From 9c160006fa079bfe9f806903731165dec1199c0d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 18 Mar 2026 09:28:58 +0200 Subject: [PATCH] docs: desktop cache architecture brainstorm and plan Cache-centric architecture for navigation persistence, mirroring Android Amethyst's pattern. Three-phase incremental migration: Phase 1 - Store events in DesktopLocalCache with LRU eviction Phase 2 - Create FeedFilter implementations Phase 3 - Migrate screens to FeedViewModel Co-Authored-By: Claude Opus 4.6 (1M context) --- ...7-desktop-cache-architecture-brainstorm.md | 151 +++++ ...sktop-cache-navigation-persistence-plan.md | 517 ++++++++++++++++++ 2 files changed, 668 insertions(+) create mode 100644 docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md create mode 100644 docs/plans/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md diff --git a/docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md b/docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md new file mode 100644 index 0000000000..5779bcfb03 --- /dev/null +++ b/docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md @@ -0,0 +1,151 @@ +# Desktop Cache Architecture — Navigation Persistence + +**Date:** 2026-03-17 +**Status:** Brainstorm +**Branch:** `feat/desktop-media` (current), will need dedicated branch + +## What We're Building + +A cache-centric data architecture for Amethyst Desktop that mirrors Android Amethyst's pattern: `DesktopLocalCache` as the single source of truth, `FeedFilter` query objects, and `FeedViewModel` for reactive UI state. This ensures loaded data (notes, metadata, reactions, zaps) survives navigation between screens. + +### The Problem + +- Feed events live in per-screen `EventCollectionState` inside `remember {}` — destroyed on navigation +- Navigating search → thread → back loses all loaded notes and search results +- Metadata is re-fetched per-screen via `loadMetadataForPubkeys()` even though `DesktopLocalCache` already holds it +- Zaps, reactions, reply counts are tracked per-screen in mutable state — lost on navigation +- UX feels broken: back navigation shows loading spinners for already-seen data + +### The Goal + +| Before | After | +|--------|-------| +| Screen creates EventCollectionState in `remember` | Screen observes FeedViewModel backed by cache | +| Events stored per-screen, lost on navigation | Events stored in DesktopLocalCache singleton | +| Metadata re-fetched per screen | Metadata cached, available immediately | +| Back navigation = full reload | Back navigation = instant (data in cache) | + +## Why This Approach + +**Mirror Android Amethyst's cache-centric design** rather than inventing a new repository pattern: + +1. **Proven pattern** — Android Amethyst handles millions of events this way +2. **Shared code** — `FeedFilter`, `FeedViewModel`, `FeedContentState` already exist in `commons/` +3. **Future merge safety** — staying aligned with upstream means less divergence +4. **Natural fit** — `DesktopLocalCache` already implements `ICacheProvider` and `ICacheEventStream` + +### Android's Architecture (what we're mirroring) + +``` +Relays → LocalCache (stores ALL events) → ICacheEventStream + ↓ + FeedViewModel subscribes + ↓ + FeedFilter.feed() queries cache + ↓ + FeedContentState (Loading/Loaded/Empty) + ↓ + UI collects StateFlow +``` + +### Desktop's Current Architecture (broken) + +``` +Relays → Screen composable (EventCollectionState in remember) + ↓ + UI renders from local state + ↓ + [navigation] → state destroyed → reload from scratch +``` + +### Desktop's Target Architecture + +``` +Relays → DesktopLocalCache (stores ALL events) → DesktopCacheEventStream + ↓ + FeedViewModel subscribes + ↓ + FeedFilter queries cache + ↓ + Screen observes FeedContentState + ↓ + [navigation] → cache persists → instant back +``` + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Architecture | Cache-centric (mirror Android) | Proven, shared code, merge-safe | +| Migration | Incremental (3 phases) | Each phase is a standalone UX improvement | +| Storage | In-memory only (no disk) | Matches Android, sufficient for navigation persistence | +| Feed queries | FeedFilter pattern from commons | Already exists, well-tested | +| State management | FeedViewModel + FeedContentState | Already in commons, handles Loading/Loaded/Empty | +| Thumbnails | Out of scope | Already survive navigation (singleton cache) | + +## Implementation Phases + +### Phase 1: Store ALL events in DesktopLocalCache + +**Goal:** Make the cache the source of truth instead of per-screen state. + +**Changes:** +- `DesktopLocalCache`: Store note events (not just metadata) when received from relays +- Relay subscription handlers: Call `localCache.consume(event)` for ALL event types +- `DesktopCacheEventStream`: Emit to `newEventBundles` / `deletedEventBundles` flows +- Zaps, reactions, reposts: Store relationship data in Note model (like Android) + +**What it fixes:** Data accumulates in a singleton — screens can query it on mount. + +### Phase 2: Create Desktop FeedFilters + +**Goal:** Query the cache instead of holding per-screen event lists. + +**Changes:** +- `DesktopGlobalFeedFilter` — queries cache for kind 1 events, sorted by createdAt +- `DesktopFollowingFeedFilter` — queries cache for events from followed users +- `DesktopThreadFilter` — queries cache for root + replies to a note ID +- `DesktopProfileFeedFilter` — queries cache for events by a specific pubkey +- `DesktopBookmarkFeedFilter` — queries cache for bookmarked event IDs + +**What it fixes:** Screens get data from cache immediately, no relay round-trip on back navigation. + +### Phase 3: Migrate Screens to FeedViewModel + +**Goal:** Replace per-screen `EventCollectionState` with shared `FeedViewModel`. + +**Changes per screen:** +- Replace `val eventState = remember { EventCollectionState(...) }` with `val viewModel = remember { FeedViewModel(filter, localCache) }` +- Replace `events by eventState.items.collectAsState()` with `feedState by viewModel.feedContent.collectAsState()` +- Remove per-screen relay subscription handlers (cache handles it) +- Remove per-screen zap/reaction/reply tracking (stored in Note model) + +**Migration order:** FeedScreen → ThreadScreen → UserProfileScreen → SearchResultsList → BookmarksScreen → ReadsScreen → NotificationsScreen + +**What it fixes:** Full navigation persistence, cleaner screen composables, shared ViewModel pattern. + +## Existing Code to Reuse + +| Component | Location | Status | +|-----------|----------|--------| +| `ICacheProvider` | `commons/model/cache/ICacheProvider.kt` | ✅ Already implemented by DesktopLocalCache | +| `ICacheEventStream` | `commons/model/cache/ICacheEventStream.kt` | ✅ Already implemented by DesktopCacheEventStream | +| `FeedFilter` | `commons/ui/feeds/FeedFilter.kt` | ✅ Ready to subclass | +| `AdditiveFeedFilter` | `commons/ui/feeds/AdditiveFeedFilter.kt` | ✅ Optimized for incremental updates | +| `FeedViewModel` | `commons/viewmodels/FeedViewModel.kt` | ⚠️ May need adaptation for desktop lifecycle | +| `FeedContentState` | `commons/ui/feeds/FeedContentState.kt` | ✅ Ready to use | +| `User` / `Note` models | `commons/model/` | ✅ Already used by DesktopLocalCache | + +## Resolved Questions + +| Question | Decision | Rationale | +|----------|----------|-----------| +| **ViewModel lifecycle** | App-level singletons | Desktop has no Activity lifecycle. Create ViewModels at startup, keep alive forever. Simple and matches desktop mental model. | +| **Cache eviction** | LRU eviction | Cap cache per type (e.g., 10k notes, 5k users). Desktop has more RAM but still finite. Defensive choice. | +| **Subscription management** | Centralized coordinator | `DesktopRelaySubscriptionsCoordinator` manages all feed subs. Screens request what they need, coordinator deduplicates. Already partially exists. | + +## Resolved Questions (continued) + +| Question | Decision | Rationale | +|----------|----------|-----------| +| **Event consumption scope** | Full port of Android's consume methods | Future-proof. Port all event kind handlers from Android LocalCache to DesktopLocalCache. | diff --git a/docs/plans/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md b/docs/plans/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md new file mode 100644 index 0000000000..25394d8e21 --- /dev/null +++ b/docs/plans/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md @@ -0,0 +1,517 @@ +--- +title: "feat: Desktop Cache-Centric Architecture for Navigation Persistence" +type: feat +status: active +date: 2026-03-18 +origin: docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md +--- + +# feat: Desktop Cache-Centric Architecture for Navigation Persistence + +## Overview + +Migrate Amethyst Desktop from per-screen event state (`EventCollectionState` in `remember {}`) to Android's cache-centric pattern where `DesktopLocalCache` is the single source of truth. Feeds query the cache via `FeedFilter`, `FeedViewModel` subscribes to the cache event stream, and data survives navigation. + +Three-phase incremental migration. Each phase is a standalone PR that improves UX. + +## Problem Statement + +| Symptom | Root Cause | +|---------|------------| +| Back navigation shows loading spinners | `EventCollectionState` destroyed when composable leaves composition | +| Metadata re-fetched per screen | Screens call `loadMetadataForPubkeys()` instead of reading cache | +| Zap/reaction counts lost on navigate | Tracked in per-screen mutable state, not in `Note` model | +| Search results vanish on thread → back | Search screen's `EventCollectionState` is gone | +| Wasted network/relay resources | Same events re-fetched on every navigation; duplicate REQ filters | + +## Proposed Solution + +Mirror Android Amethyst's architecture (see brainstorm: `docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md`): + +``` +Relays ──→ DesktopLocalCache.consume(event) + │ + ├──→ Store in maps (users, notes, addressableNotes) + ├──→ Update Note relationships (replies, reactions, zaps) + └──→ DesktopCacheEventStream.emitNewNotes(note) + │ + ▼ + FeedViewModel.collect { newNotes → + feedState.updateFeedWith(newNotes) + } + │ + ▼ + Screen observes feedState.feedContent (Loading/Loaded/Empty) +``` + +## Technical Approach + +### Phase 1: Store ALL Events in DesktopLocalCache + +**Goal:** Make `DesktopLocalCache` the source of truth. + +**Branch:** `feat/desktop-cache-phase1` + +#### 1.1 Switch cache backing to `LargeCache` + +**File:** `desktopApp/.../cache/DesktopLocalCache.kt` + +Replace `ConcurrentHashMap` with `LargeCache` from quartz — same backing store Android uses (`ConcurrentSkipListMap` on JVM), with rich query APIs (`filterIntoSet`, `mapNotNull`, range queries). Desktop keeps strong references (no `WeakReference` wrapper — that's Android-only `LargeSoftCache` for mobile memory pressure). Desktop has 2GB+ heap; we want to keep everything. + +```kotlin +// Before: +private val notes = ConcurrentHashMap() +private val users = ConcurrentHashMap() + +// After: +private val notes = LargeCache() +private val users = LargeCache() +private val addressableNotes = LargeCache() +``` + +This gives FeedFilters access to `filterIntoSet`, `mapNotNull`, etc. — matching Android's query patterns exactly. + +#### 1.2 Port consume methods from Android LocalCache + +**File:** `desktopApp/.../cache/DesktopLocalCache.kt` + +Start with 4 new event kinds (kind 9734 required for zap processing). Add remaining kinds per-screen in Phase 3. + +| Kind | Event Type | Phase | Notes | +|------|-----------|-------|-------| +| 0 | `MetadataEvent` | Done | Already exists (`consumeMetadata`) | +| 1 | `TextNoteEvent` | 1 | Core feed content | +| 7 | `ReactionEvent` | 1 | Reaction counts on Note | +| 9734 | `LnZapRequestEvent` | 1 | Required before kind 9735 can process | +| 9735 | `LnZapEvent` | 1 | Zap counts on Note | + +Each consume method follows Android's pattern. Use `event.tagsWithoutCitations()` for reply parsing (handles both NIP-10 marked and legacy positional tags, excluding inline nostr: citations): + +```kotlin +fun consume(event: TextNoteEvent, relay: NormalizedRelayUrl?): Boolean { + val note = checkGetOrCreateNote(event.id) ?: return false + if (note.event != null) return false // already have it + val author = getOrCreateUser(event.pubKey) + val repliesTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + note.loadEvent(event, author, repliesTo) + repliesTo.forEach { it.addReply(note) } + refreshObservers(note) + return true +} +``` + +For reactions, check both `e`-tags and `a`-tags (reactions to addressable events like articles): +```kotlin +fun consume(event: ReactionEvent, relay: NormalizedRelayUrl?): Boolean { + val note = checkGetOrCreateNote(event.id) ?: return false + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val reactedTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + note.loadEvent(event, author, reactedTo) + reactedTo.forEach { it.addReaction(note) } + refreshObservers(note) + return true +} +``` + +#### 1.3 Bridge relay callbacks to cache consumption + +**Problem:** Relay `onEvent` callbacks are non-suspend. `emitNewNotes()` is suspend. + +**Solution:** Use `BasicBundledInsert` from commons (already exists, battle-tested in Android) with 250ms delay (snappier than Android's 1000ms — desktop has mains power, users expect faster updates). + +**File:** `desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt` + +```kotlin +private val eventBundler = BasicBundledInsert( + delay = 250, // 250ms for desktop (Android uses 1000ms to save battery) + dispatcher = Dispatchers.IO, + scope = scope, +) + +fun consumeEvent(event: Event, relay: NormalizedRelayUrl?) { + scope.launch(Dispatchers.IO) { + val consumed = localCache.consume(event, relay) + if (consumed) { + val note = localCache.getNoteIfExists(event.id) as? Note ?: return@launch + eventBundler.invalidateList(note) { batch -> + localCache.eventStream.emitNewNotes(batch) + } + } + } +} +``` + +Keep per-screen `rememberSubscription` — just change the `onEvent` callback to route through cache. Per-screen subscriptions handle lifecycle automatically (auto-cleanup on navigate away). + +```kotlin +// Before (per-screen state): +onEvent = { event, _, _, _ -> eventState.addItem(event) } + +// After (routes to cache): +onEvent = { event, _, relay, _ -> coordinator.consumeEvent(event, relay) } +``` + +#### 1.4 Fix SharedFlow configuration + +**Problem:** `MutableSharedFlow(replay = 0)` with no buffer drops events and blocks emitters on slow collectors. Android uses `extraBufferCapacity=100, DROP_OLDEST`. + +**File:** `desktopApp/.../cache/DesktopLocalCache.kt` (DesktopCacheEventStream) + +```kotlin +class DesktopCacheEventStream : ICacheEventStream { + private val _newEventBundles = MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val _deletedEventBundles = MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + // ... +} +``` + +Dropped emissions are fine — events are already in the cache. The flow signals "something changed," not "here is the data." + +#### 1.5 Set JVM memory limit + LRU eviction + +**File:** `desktopApp/build.gradle.kts` + +```kotlin +compose.desktop.application { + jvmArgs += "-Xmx2g" +} +``` + +**LRU eviction** — wrap `LargeCache` with size caps. Desktop has more RAM than mobile but runs for hours without restart. Use `LruCache` from `androidx.collection` (confirmed available in desktopApp deps) as the backing store instead of raw `LargeCache`. 5x Android's effective limits. + +**File:** `desktopApp/.../cache/DesktopLocalCache.kt` + +```kotlin +private val notes = LruCache(MAX_NOTES) +private val users = LruCache(MAX_USERS) +private val addressableNotes = LruCache(MAX_ADDRESSABLE) + +companion object { + const val MAX_NOTES = 50_000 // ~100-150MB at ~2-3KB/note + const val MAX_USERS = 25_000 // ~25-50MB at ~1-2KB/user + const val MAX_ADDRESSABLE = 10_000 +} +``` + +**Eviction safety:** Feed lists hold strong references to `Note` objects. When LRU evicts a key, the `Note` object survives in the feed list (GC won't collect it). On next `FeedFilter.feed()` refresh, the evicted note won't appear — acceptable (feed shows most recent N items). If a user clicks an evicted note, `checkGetOrCreateNote(id)` creates a shell Note that triggers a relay re-fetch. + +Note: `LruCache` uses `synchronized` internally — fine for desktop concurrency levels. If contention becomes an issue, switch to `LargeCache` with a periodic size check. + +#### 1.6 Add cache clear on logout + +**File:** `desktopApp/.../account/AccountManager.kt` + +Cancel coordinator scope BEFORE clearing cache to prevent race between in-flight `consume()` calls and `clear()`. + +```kotlin +fun logout() { + coordinator.clear() // Stop subscriptions first + localCache.clear() // Then clear cache +} +``` + +#### Phase 1 Acceptance Criteria + +- [ ] `DesktopLocalCache` backed by `LruCache` with size caps (50k notes, 25k users, 10k addressable) +- [ ] Consume methods for kinds 1, 7, 9734, 9735 +- [ ] Relay `onEvent` callbacks route through `coordinator.consumeEvent()` +- [ ] `BasicBundledInsert(250ms)` batches events before `emitNewNotes()` +- [ ] `DesktopCacheEventStream` has `extraBufferCapacity = 64, DROP_OLDEST` +- [ ] `-Xmx2g` JVM arg set +- [ ] Cache cleared on logout (coordinator first, then cache) +- [ ] `createGlobalFeedSubscription` / `createFollowingFeedSubscription` confirmed routing through `consumeEvent` +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] `./gradlew spotlessApply` clean + +--- + +### Phase 2: Create Desktop FeedFilters + +**Goal:** Query cache instead of holding per-screen event lists. + +**Branch:** `feat/desktop-cache-phase2` + +#### 2.1 Desktop feed filters + +**Directory:** `desktopApp/.../feeds/` + +| Filter | Query Strategy | Base Class | Limit | +|--------|---------------|------------|-------| +| `DesktopGlobalFeedFilter` | `notes.filterIntoSet { kind == 1 }` | `AdditiveFeedFilter` | 2500 | +| `DesktopFollowingFeedFilter` | Same + author in account's follow list | `AdditiveFeedFilter` | 2500 | +| `DesktopThreadFilter(noteId)` | Root note + `Note.replies` graph walk | `FeedFilter` | unlimited | +| `DesktopProfileFeedFilter(pubkey)` | `notes.filterIntoSet { author == pubkey }` | `AdditiveFeedFilter` | 1000 | +| `DesktopBookmarkFeedFilter(ids)` | Direct lookup by ID set | `FeedFilter` | 2500 | +| `DesktopReadsFeedFilter` | `notes.filterIntoSet { kind == 30023 }` | `AdditiveFeedFilter` | 500 | +| `DesktopNotificationFeedFilter` | Events tagging logged-in user (reactions, zaps, replies, reposts) | `AdditiveFeedFilter` | 2500 | +| `DesktopSearchFeedFilter(query)` | Cache search + relay search results stored in cache | `AdditiveFeedFilter` | 500 | + +Limits are ~5x Android's (desktop has more screen space and RAM). Filters iterate `LruCache` values. `LruCache` doesn't have `filterIntoSet` — use `snapshot()` or iterate via `forEach`. + +The initial `feed()` scan runs only once on first load or `feedKey()` change. After that, `updateListWith()` uses `applyFilter()` + `sort()` incrementally — O(batch_size) not O(cache_size). + +Following filter reads followed pubkeys from account state (same pattern as Android's `HomeConversationsFeedFilter` which reads `account.liveHomeFollowLists`). + +Notification filter mirrors Android's `NotificationFeedFilter`: filters for events that tag the logged-in user across relevant kinds (kind 1, 6, 7, 9735), with mute list support. Simplified from Android's 20+ kinds to the kinds desktop actually displays. + +```kotlin +class DesktopGlobalFeedFilter( + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + override fun feed(): List = + cache.notes.snapshot().values + .filter { it.event?.kind == 1 } + .sortedByDescending { it.event?.createdAt ?: 0 } + .take(limit()) + + override fun applyFilter(newItems: Set): Set = + newItems.filter { it.event?.kind == 1 }.toSet() + + override fun sort(items: Set): List = + items.sortedByDescending { it.event?.createdAt ?: 0 }.take(limit()) + + override fun limit(): Int = 2500 +} +``` + +#### Phase 2 Acceptance Criteria + +- [ ] All 8 feed filters implemented and compile +- [ ] `applyFilter()` and `sort()` work for incremental updates +- [ ] Notification filter correctly identifies events tagging logged-in user +- [ ] Following filter reads follow list from account state +- [ ] Unit tests for each filter with mock cache data +- [ ] `./gradlew :desktopApp:compileKotlin` passes + +--- + +### Phase 3: Migrate Screens to FeedViewModel + +**Goal:** Replace `EventCollectionState` with `FeedViewModel` pattern across all screens. + +**Branch:** `feat/desktop-cache-phase3` + +#### 3.1 Create DesktopFeedViewModel + +`FeedViewModel.init` in commons only sets up stream collectors — it doesn't load existing cache data. Navigation back would show `Loading` forever until a new relay event arrives. Fix by calling `refreshSuspended()` on init. Consider upstreaming to base `FeedViewModel` in commons. + +**File:** `desktopApp/.../viewmodels/DesktopFeedViewModel.kt` + +```kotlin +class DesktopFeedViewModel( + filter: FeedFilter, + cacheProvider: ICacheProvider, +) : FeedViewModel(filter, cacheProvider) { + init { + viewModelScope.launch(Dispatchers.IO) { + feedState.refreshSuspended() + } + } +} +``` + +#### 3.2 ViewModel lifecycle management + +**Singleton feeds** — created in `Main.kt` alongside other app-level state (`relayManager`, `localCache`, `accountState`). Standard Kotlin JVM pattern: create at app startup, pass down as parameters. + +**File:** `desktopApp/.../Main.kt` + +```kotlin +// App-level state (created once) +val localCache = DesktopLocalCache() +val coordinator = DesktopRelaySubscriptionsCoordinator(localCache, ...) + +// Singleton ViewModels (created once, survive navigation) +val globalFeedVM = DesktopFeedViewModel(DesktopGlobalFeedFilter(localCache), localCache) +val followingFeedVM = DesktopFeedViewModel(DesktopFollowingFeedFilter(localCache, account), localCache) +val readsFeedVM = DesktopFeedViewModel(DesktopReadsFeedFilter(localCache), localCache) +val notificationsFeedVM = DesktopFeedViewModel(DesktopNotificationFeedFilter(localCache, account), localCache) +``` + +Passed to screens as parameters (not CompositionLocal). + +**Parameterized feeds** — use `remember(key)` in Compose. Data survives navigation via the cache, not the ViewModel. New VMs query cache on creation via `init { refreshSuspended() }` for instant results. + +```kotlin +@Composable +fun ThreadScreen(noteId: String, cache: DesktopLocalCache, ...) { + val vm = remember(noteId) { + DesktopFeedViewModel(DesktopThreadFilter(noteId, cache), cache) + } + DisposableEffect(vm) { + onDispose { vm.clear() } // Cancel viewModelScope + } + // ... +} +``` + +#### 3.3 Per-screen subscriptions (unchanged) + +Keep `rememberSubscription` in screens — it handles lifecycle automatically. Just change callbacks to route through cache. + +```kotlin +rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) { + when (feedMode) { + FeedMode.GLOBAL -> createGlobalFeedSubscription( + relays = configuredRelays, + onEvent = { event, _, relay, _ -> + coordinator.consumeEvent(event, relay) + }, + onEose = { _, _ -> eoseReceivedCount++ }, + ) + // ... + } +} +``` + +#### 3.4 Migrate screens with per-screen consume methods + +**Migration order and consume methods needed per screen:** + +| Screen | VM Type | Consume Kinds to Add | Notes | +|--------|---------|---------------------|-------| +| FeedScreen | Singleton (global + following) | 3 (ContactList), 6 (Repost) | Includes FeedNoteCard rewrite | +| ThreadScreen | Parameterized (noteId) | 5 (Deletion) | Deleted note indicators | +| UserProfileScreen | Parameterized (pubkey) | — | Uses existing kinds | +| SearchResultsList | Parameterized (query) | — | Uses DesktopSearchFeedFilter | +| BookmarksScreen | Singleton | 30078 (BookmarkList) | Bookmark state from events | +| ReadsScreen | Singleton | 30023 (LongTextNote) | Articles feed | +| NotificationsScreen | Singleton | — | Uses DesktopNotificationFeedFilter | + +**FeedNoteCard rewrite** — done alongside FeedScreen migration (first screen). Currently takes raw `Event` + per-screen zap/reaction counts as parameters. Must rewrite to read from `Note` model directly (`note.reactions`, `note.zaps`, `note.replies`). All subsequent screen migrations benefit from this rewrite. + +**Per-screen migration removes:** +- `val eventState = remember { EventCollectionState(...) }` +- Per-screen `zapsByEvent`, `reactionIdsByEvent`, `replyIdsByEvent`, `repostIdsByEvent` mutable state maps +- Per-screen metadata, zap, reaction, reply, repost subscription handlers + +**Per-screen migration adds:** +- `val feedState by viewModel.feedState.feedContent.collectAsState()` +- Route `onEvent` to `coordinator.consumeEvent()` +- Always use `key` in `items()`: `items(notes.list, key = { it.idHex })` + +```kotlin +when (val state = feedState) { + is FeedState.Loading -> LoadingState("Loading notes...") + is FeedState.Empty -> EmptyState(...) + is FeedState.Loaded -> { + val notes by state.feed.collectAsState() + LazyColumn { + items(notes.list, key = { it.idHex }) { note -> + FeedNoteCard(note = note, ...) + } + } + } + is FeedState.FeedError -> ErrorState(state.errorMessage) +} +``` + +#### Phase 3 Acceptance Criteria + +- [ ] `DesktopFeedViewModel` loads cache data on creation (initial `refreshSuspended()`) +- [ ] Singleton ViewModels created in `Main.kt` for global/following/reads/notifications +- [ ] `remember(key)` + `DisposableEffect` for parameterized feeds (thread, profile, search) +- [ ] `FeedNoteCard` rewritten to read from `Note` model (done with FeedScreen migration) +- [ ] Per-screen subscriptions route through `consumeEvent()` +- [ ] Consume methods added for kinds 3, 5, 6, 30023, 30078 +- [ ] All 9 screens migrated: Feed, Thread, Profile, Search, Bookmarks, Reads, Notifications +- [ ] Per-screen zap/reaction/reply state removed (stored in Note model) +- [ ] Navigation back shows instant data (no loading spinner) +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] `./gradlew spotlessApply` clean +- [ ] Manual test: Feed → Thread → Back → data persists +- [ ] Manual test: Search → Thread → Back → search results preserved + +--- + +## System-Wide Impact + +### Interaction Graph + +``` +User navigates to Feed + → FeedScreen reads globalFeedViewModel.feedState + → FeedContentState queries DesktopGlobalFeedFilter.feed() + → Filter queries DesktopLocalCache.notes.snapshot().values, filters kind==1 + → Returns cached Note objects + +Relay sends new event + → OkHttp callback → coordinator.consumeEvent(event, relay) + → scope.launch(IO) { localCache.consume(event) } + → Note created/updated in cache + → BasicBundledInsert(250ms) batches notes + → eventStream.emitNewNotes(batch) + → FeedViewModel.collect { feedState.updateFeedWith(notes) } + → Compose recomposes + +User navigates away and back + → Singleton VM: feedState already Loaded → instant render + → Parameterized VM: new VM created, init { refreshSuspended() } loads from cache → instant render +``` + +### Error Propagation + +| Error | Source | Handling | +|-------|--------|----------| +| Relay disconnect | OkHttp | Coordinator reconnects, no cache impact | +| consume() throws | Cache | Caught in consumer coroutine, logged, event skipped | +| emitNewNotes() buffer full | SharedFlow | `DROP_OLDEST` — events already in cache | +| Filter query on empty cache | FeedFilter | Returns empty list → `FeedState.Empty` | + +### State Lifecycle Risks + +| Risk | Mitigation | +|------|-----------| +| Stale data after logout | `coordinator.clear()` then `localCache.clear()` | +| Mixed-account data | ViewModels cleared + cache cleared on account switch | +| Subscription leak on app exit | Coordinator.clear() in shutdown hook | +| Cache memory pressure | `-Xmx2g` + LRU caps (50k notes, 25k users) | + +## Dependencies & Prerequisites + +| Dependency | Status | Needed For | +|------------|--------|------------| +| `LruCache` (androidx.collection) | ✅ In desktopApp deps — thread-safe, bounded | Phase 1 | +| `BasicBundledInsert` (commons) | ✅ In commons `BundledUpdate.kt` | Phase 1 | +| `ICacheProvider` / `ICacheEventStream` | ✅ In commons | Phase 1 | +| `Note.loadEvent()`, `addReply()`, `addReaction()`, `addZap()` | ✅ In commons | Phase 1 | +| `FeedFilter` / `AdditiveFeedFilter` | ✅ In commons | Phase 2 | +| `FeedViewModel` / `FeedContentState` | ✅ In commons | Phase 3 | +| `kotlinx-coroutines-swing` | ✅ In desktopApp deps | Dispatchers.Main | + +## Success Metrics + +| Metric | Before | After | +|--------|--------|-------| +| Back navigation time | 2-5s (full reload) | <100ms (cache hit) | +| Metadata re-fetch on navigate | Every screen | Never (cached) | +| Zap/reaction counts on back | Lost | Preserved | + +## Future Improvements (Deferred) + +| Improvement | Trigger | +|-------------|---------| +| Secondary indexes (by kind, by author) | `feed()` scan >50ms | +| Disk persistence (SQLite) | User requests cross-session persistence | +| Centralized subscription coordinator | Multiple screens need same relay filter | + +## Sources & References + +- **Origin:** [docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md](docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md) +- `commons/.../ui/feeds/FeedFilter.kt`, `AdditiveFeedFilter.kt`, `FeedContentState.kt` +- `commons/.../viewmodels/FeedViewModel.kt` +- `commons/.../model/cache/ICacheProvider.kt`, `ICacheEventStream.kt` +- `commons/.../model/Note.kt` — `loadEvent()`, `addReply()`, reactions, zaps +- `desktopApp/.../cache/DesktopLocalCache.kt` +- `amethyst/.../model/LocalCache.kt` — Android reference +- `docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md` — subscription stability