mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<T>` | `commons/ui/feeds/FeedFilter.kt` | ✅ Ready to subclass |
|
||||
| `AdditiveFeedFilter<T>` | `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. |
|
||||
@@ -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<HexKey, Note>()
|
||||
private val users = ConcurrentHashMap<HexKey, User>()
|
||||
|
||||
// After:
|
||||
private val notes = LargeCache<HexKey, Note>()
|
||||
private val users = LargeCache<HexKey, User>()
|
||||
private val addressableNotes = LargeCache<String, AddressableNote>()
|
||||
```
|
||||
|
||||
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<Note>(
|
||||
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<Set<Note>>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
private val _deletedEventBundles = MutableSharedFlow<Set<Note>>(
|
||||
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<HexKey, Note>(MAX_NOTES)
|
||||
private val users = LruCache<HexKey, User>(MAX_USERS)
|
||||
private val addressableNotes = LruCache<String, AddressableNote>(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<Note>` | 2500 |
|
||||
| `DesktopFollowingFeedFilter` | Same + author in account's follow list | `AdditiveFeedFilter<Note>` | 2500 |
|
||||
| `DesktopThreadFilter(noteId)` | Root note + `Note.replies` graph walk | `FeedFilter<Note>` | unlimited |
|
||||
| `DesktopProfileFeedFilter(pubkey)` | `notes.filterIntoSet { author == pubkey }` | `AdditiveFeedFilter<Note>` | 1000 |
|
||||
| `DesktopBookmarkFeedFilter(ids)` | Direct lookup by ID set | `FeedFilter<Note>` | 2500 |
|
||||
| `DesktopReadsFeedFilter` | `notes.filterIntoSet { kind == 30023 }` | `AdditiveFeedFilter<Note>` | 500 |
|
||||
| `DesktopNotificationFeedFilter` | Events tagging logged-in user (reactions, zaps, replies, reposts) | `AdditiveFeedFilter<Note>` | 2500 |
|
||||
| `DesktopSearchFeedFilter(query)` | Cache search + relay search results stored in cache | `AdditiveFeedFilter<Note>` | 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<Note>() {
|
||||
override fun feed(): List<Note> =
|
||||
cache.notes.snapshot().values
|
||||
.filter { it.event?.kind == 1 }
|
||||
.sortedByDescending { it.event?.createdAt ?: 0 }
|
||||
.take(limit())
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> =
|
||||
newItems.filter { it.event?.kind == 1 }.toSet()
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> =
|
||||
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<Note>,
|
||||
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<Event>(...) }`
|
||||
- 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<T>` / `AdditiveFeedFilter<T>` | ✅ 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
|
||||
Reference in New Issue
Block a user