From 9c160006fa079bfe9f806903731165dec1199c0d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 18 Mar 2026 09:28:58 +0200 Subject: [PATCH 01/26] 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 000000000..5779bcfb0 --- /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 000000000..25394d8e2 --- /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 From 61119e691643a75f04f8a079ab7c2469a1e63908 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 19 Mar 2026 13:35:51 +0200 Subject: [PATCH 02/26] =?UTF-8?q?docs:=20deepen=20cache=20plan=20=E2=80=94?= =?UTF-8?q?=20BoundedLargeCache,=20FeedNoteCard=20rewrite=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve LruCache vs LargeCache: use LargeCache (lock-free ConcurrentSkipListMap) with BoundedLargeCache wrapper for size enforcement on put() - Confirm LargeCache available on desktop via quartz jvmAndroid source set - Detail FeedNoteCard rewrite: Note model field mapping, 5 subscription removals - Fix filter examples to use filterIntoSet (LargeCache API) Co-Authored-By: Claude Opus 4.6 (1M context) --- ...sktop-cache-navigation-persistence-plan.md | 128 ++++++++++++------ 1 file changed, 90 insertions(+), 38 deletions(-) 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 index 25394d8e2..f1fb2340d 100644 --- 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 @@ -52,24 +52,62 @@ Relays ──→ DesktopLocalCache.consume(event) **Branch:** `feat/desktop-cache-phase1` -#### 1.1 Switch cache backing to `LargeCache` +#### 1.1 Switch cache backing to `LargeCache` with size enforcement **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. +Replace `ConcurrentHashMap` with `LargeCache` from quartz — same backing store Android uses (`ConcurrentSkipListMap` on JVM), lock-free reads (CAS-based), rich query APIs (`filterIntoSet`, `mapNotNull`, range queries). Desktop keeps strong references (no `WeakReference` wrapper — that's Android-only `LargeSoftCache` for mobile memory pressure). + +`LargeCache` chosen over `LruCache` because: +- **Lock-free reads** — `ConcurrentSkipListMap` vs `synchronized` on every `get()`. Critical at 1000 events/sec. +- **Rich query API** — `filterIntoSet`, `mapNotNull` match Android's filter patterns exactly. +- **No snapshot overhead** — `LruCache.snapshot()` copies the entire map; `LargeCache` iterates in-place. + +Add size enforcement via a `BoundedLargeCache` wrapper that checks size on `put()` and evicts oldest entries when cap is exceeded: ```kotlin -// Before: -private val notes = ConcurrentHashMap() -private val users = ConcurrentHashMap() +class BoundedLargeCache, V>( + private val maxSize: Int, + private val evictPercent: Float = 0.1f, // Remove 10% when cap hit +) { + private val inner = LargeCache() -// After: -private val notes = LargeCache() -private val users = LargeCache() -private val addressableNotes = LargeCache() + fun get(key: K): V? = inner.get(key) + fun put(key: K, value: V) { + inner.put(key, value) + enforceSize() + } + fun getOrCreate(key: K, builder: (K) -> V): V = inner.getOrCreate(key, builder).also { enforceSize() } + fun remove(key: K): V? = inner.remove(key) + fun clear() = inner.clear() + fun size(): Int = inner.size() + fun values(): Iterable = inner.values() + fun filterIntoSet(consumer: CacheCollectors.BiFilter): Set = inner.filterIntoSet(consumer) + // ... delegate other LargeCache methods as needed + + private fun enforceSize() { + if (inner.size() > maxSize) { + val toRemove = (maxSize * evictPercent).toInt().coerceAtLeast(1) + // ConcurrentSkipListMap keys are sorted — first N keys are "oldest" by insertion order + val keys = inner.keys().take(toRemove) + keys.forEach { inner.remove(it) } + } + } +} + +// Usage: +private val notes = BoundedLargeCache(MAX_NOTES) +private val users = BoundedLargeCache(MAX_USERS) +private val addressableNotes = BoundedLargeCache(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 +} ``` -This gives FeedFilters access to `filterIntoSet`, `mapNotNull`, etc. — matching Android's query patterns exactly. +Note: `ConcurrentSkipListMap` keys are sorted, so `keys().take(N)` removes the lexicographically smallest hex keys — not strictly "oldest by time." For true time-based eviction, the `enforceSize()` could sort by `note.event?.createdAt` instead, but the simple key-based approach is cheaper and good enough (hex keys from Nostr events are effectively random, so eviction is approximately random). #### 1.2 Port consume methods from Android LocalCache @@ -177,7 +215,7 @@ class DesktopCacheEventStream : ICacheEventStream { 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 +#### 1.5 Set JVM memory limit **File:** `desktopApp/build.gradle.kts` @@ -187,25 +225,9 @@ compose.desktop.application { } ``` -**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. +Size enforcement is handled by `BoundedLargeCache` (section 1.1). `-Xmx2g` is a safety net for the JVM heap overall. -**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. +**Eviction safety:** Feed lists hold strong references to `Note` objects. When `BoundedLargeCache` 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. #### 1.6 Add cache clear on logout @@ -222,7 +244,7 @@ fun logout() { #### Phase 1 Acceptance Criteria -- [ ] `DesktopLocalCache` backed by `LruCache` with size caps (50k notes, 25k users, 10k addressable) +- [ ] `DesktopLocalCache` backed by `BoundedLargeCache` 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()` @@ -256,7 +278,7 @@ fun logout() { | `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`. +Limits are ~5x Android's (desktop has more screen space and RAM). Filters use `BoundedLargeCache.filterIntoSet` — same API as Android's `LocalCache`. 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). @@ -269,8 +291,7 @@ class DesktopGlobalFeedFilter( private val cache: DesktopLocalCache, ) : AdditiveFeedFilter() { override fun feed(): List = - cache.notes.snapshot().values - .filter { it.event?.kind == 1 } + cache.notes.filterIntoSet { _, note -> note.event?.kind == 1 } .sortedByDescending { it.event?.createdAt ?: 0 } .take(limit()) @@ -388,12 +409,43 @@ rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = r | 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. +**FeedNoteCard rewrite** — done alongside FeedScreen migration (first screen). All subsequent screen migrations benefit. + +Current `FeedNoteCard` takes raw `Event` + per-screen counts: +```kotlin +// CURRENT: 6 per-screen state params +FeedNoteCard(event, ..., zapReceipts, reactionCount, replyCount, repostCount, ...) +``` + +New `FeedNoteCard` takes `Note` from cache — reads counts directly from model: +```kotlin +// NEW: Note replaces all per-screen count params +FeedNoteCard(note, ...) // inside: note.zaps.size, note.countReactions(), note.replies.size, note.boosts.size +``` + +**Field mapping (per-screen state → Note model):** + +| Per-Screen State Map | Note Model Replacement | +|---------------------|----------------------| +| `zapsByEvent[id]` → `List` | `note.zaps` → `Map` | +| `zapReceipts.sumOf { it.amountSats }` | `note.zapsAmount` (BigDecimal) | +| `reactionIdsByEvent[id]` → count | `note.countReactions()` | +| `replyIdsByEvent[id]` → count | `note.replies.size` | +| `repostIdsByEvent[id]` → count | `note.boosts.size` | + +**FeedScreen subscriptions removed** (5 subscriptions, ~130 lines): +- `createZapsSubscription` + `zapsByEvent` state map +- `createReactionsSubscription` + `reactionIdsByEvent` state map +- `createRepliesSubscription` + `replyIdsByEvent` state map +- `createRepostsSubscription` + `repostIdsByEvent` state map +- `createBatchMetadataSubscription` for zap senders + +These are replaced by `cache.consume()` which populates Note model relationships automatically. **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 +- 5 per-screen subscription handlers (zaps, reactions, replies, reposts, metadata) **Per-screen migration adds:** - `val feedState by viewModel.feedState.feedContent.collectAsState()` @@ -442,7 +494,7 @@ when (val state = feedState) { User navigates to Feed → FeedScreen reads globalFeedViewModel.feedState → FeedContentState queries DesktopGlobalFeedFilter.feed() - → Filter queries DesktopLocalCache.notes.snapshot().values, filters kind==1 + → Filter calls cache.notes.filterIntoSet { kind==1 } → Returns cached Note objects Relay sends new event @@ -475,13 +527,13 @@ User navigates away and back | 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) | +| Cache memory pressure | `-Xmx2g` + BoundedLargeCache caps (50k notes, 25k users) | ## Dependencies & Prerequisites | Dependency | Status | Needed For | |------------|--------|------------| -| `LruCache` (androidx.collection) | ✅ In desktopApp deps — thread-safe, bounded | Phase 1 | +| `LargeCache` (quartz) | ✅ In quartz jvmAndroid — `ConcurrentSkipListMap`, lock-free | 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 | From 543f7a329c7a864b8d8f907329ea7161fe7e6d98 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 19 Mar 2026 13:55:04 +0200 Subject: [PATCH 03/26] =?UTF-8?q?feat(cache):=20Phase=201=20=E2=80=94=20ca?= =?UTF-8?q?che-centric=20architecture=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BoundedLargeCache: LargeCache wrapper with size enforcement (50k notes, 25k users, 10k addressable). Lock-free reads via ConcurrentSkipListMap, evicts 10% when cap exceeded. - Switch DesktopLocalCache from ConcurrentHashMap to BoundedLargeCache. Exposes filterIntoSet, values, etc. matching Android's query patterns. - Add consume methods for kinds 1 (TextNote), 7 (Reaction), 9734 (ZapRequest), 9735 (Zap). Uses tagsWithoutCitations() for reply parsing, originalPost()+taggedAddresses() for reactions, zappedPost() for zaps. Follows Android LocalCache pattern. - Add consumeEvent() bridge in coordinator with BasicBundledInsert (250ms batching). Routes relay onEvent callbacks to cache. - Fix SharedFlow: extraBufferCapacity=64, DROP_OLDEST. Prevents blocking emitters and dropped events. - Set -Xmx2g JVM arg for long desktop sessions. - Clear cache on logout (coordinator first, then cache). Co-Authored-By: Claude Opus 4.6 (1M context) --- desktopApp/build.gradle.kts | 2 + .../vitorpamplona/amethyst/desktop/Main.kt | 8 + .../desktop/cache/BoundedLargeCache.kt | 93 +++++++++ .../desktop/cache/DesktopLocalCache.kt | 182 ++++++++++++++++-- .../DesktopRelaySubscriptionsCoordinator.kt | 31 +++ 5 files changed, 300 insertions(+), 16 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 1c6264985..0ea2cde8f 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -80,6 +80,8 @@ compose.desktop { mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED" + jvmArgs += "-Xmx2g" + nativeDistributions { appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index dcc369138..9d659b67f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -460,6 +460,14 @@ fun App( ) } + // Clear cache and subscriptions on logout + LaunchedEffect(accountState) { + if (accountState is AccountState.LoggedOut) { + subscriptionsCoordinator.clear() + localCache.clear() + } + } + // Try to load saved account on startup DisposableEffect(Unit) { relayManager.addDefaultRelays() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt new file mode 100644 index 000000000..576b50ae1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.cache + +import com.vitorpamplona.quartz.utils.cache.CacheCollectors +import com.vitorpamplona.quartz.utils.cache.LargeCache + +/** + * A bounded wrapper around [LargeCache] that enforces a maximum size. + * + * When the cache exceeds [maxSize], the oldest entries (by key order) are evicted. + * Uses [LargeCache] (ConcurrentSkipListMap) for lock-free reads and rich query APIs + * (filterIntoSet, mapNotNull, etc.) matching Android's LocalCache patterns. + * + * Chosen over LruCache because: + * - Lock-free reads via ConcurrentSkipListMap (vs synchronized on every get()) + * - Rich query API matching Android's filter patterns + * - No snapshot copy overhead for iteration + */ +class BoundedLargeCache, V>( + private val maxSize: Int, + private val evictPercent: Float = 0.1f, +) { + private val inner = LargeCache() + + fun get(key: K): V? = inner.get(key) + + fun put( + key: K, + value: V, + ) { + inner.put(key, value) + enforceSize() + } + + fun getOrCreate( + key: K, + builder: (K) -> V, + ): V { + val result = inner.getOrCreate(key, builder) + enforceSize() + return result + } + + fun remove(key: K): V? = inner.remove(key) + + fun containsKey(key: K): Boolean = inner.containsKey(key) + + fun size(): Int = inner.size() + + fun isEmpty(): Boolean = inner.isEmpty() + + fun clear() = inner.clear() + + fun keys(): Set = inner.keys() + + fun values(): Iterable = inner.values() + + fun filterIntoSet(consumer: CacheCollectors.BiFilter): Set = inner.filterIntoSet(consumer) + + fun mapNotNull(consumer: CacheCollectors.BiMapper): List = inner.mapNotNull(consumer) + + fun forEach(consumer: java.util.function.BiConsumer) = inner.forEach(consumer) + + fun count(consumer: CacheCollectors.BiFilter): Int = inner.count(consumer) + + private fun enforceSize() { + val currentSize = inner.size() + if (currentSize > maxSize) { + val toRemove = (maxSize * evictPercent).toInt().coerceAtLeast(1) + val keys = inner.keys().take(toRemove) + keys.forEach { inner.remove(it) } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index b41bda8de..367b128e6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -32,12 +32,18 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch @@ -50,28 +56,33 @@ import java.util.concurrent.ConcurrentHashMap * Supports searching users by name prefix for the search functionality. */ class DesktopLocalCache : ICacheProvider { - private val users = ConcurrentHashMap() - private val notes = ConcurrentHashMap() - private val addressableNotes = ConcurrentHashMap() + val users = BoundedLargeCache(MAX_USERS) + val notes = BoundedLargeCache(MAX_NOTES) + val addressableNotes = BoundedLargeCache(MAX_ADDRESSABLE) private val deletedEvents = ConcurrentHashMap.newKeySet() - private val eventStream = DesktopCacheEventStream() + val eventStream = DesktopCacheEventStream() + + companion object { + const val MAX_NOTES = 50_000 + const val MAX_USERS = 25_000 + const val MAX_ADDRESSABLE = 10_000 + } val paymentTracker = NwcPaymentTracker() // ----- User operations ----- - override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey] + override fun getUserIfExists(pubkey: HexKey): User? = users.get(pubkey) override fun getOrCreateUser(pubkey: HexKey): User = - users.getOrPut(pubkey) { - // Create placeholder notes for relay lists + users.getOrCreate(pubkey) { val nip65Note = getOrCreateNote("nip65:$pubkey") val dmNote = getOrCreateNote("dm:$pubkey") User(pubkey, nip65Note, dmNote) } - override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { (key, user) -> predicate(key, user) } + override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { key, user -> predicate(key, user) } override fun findUsersStartingWith( prefix: String, @@ -92,7 +103,8 @@ class DesktopLocalCache : ICacheProvider { ) // Search by name/displayName/nip05/lud16 - return users.values + return users + .values() .filter { user -> val metadata = user.metadataOrNull() if (metadata == null) { @@ -128,6 +140,134 @@ class DesktopLocalCache : ICacheProvider { } } + // ----- Event consumption (mirrors Android LocalCache pattern) ----- + + /** + * Routes an event to the appropriate consume method. + * Returns true if the event was consumed (new), false if already seen. + */ + fun consume( + event: Event, + relay: NormalizedRelayUrl?, + ): Boolean = + when (event) { + is MetadataEvent -> { + consumeMetadata(event) + true + } + + is TextNoteEvent -> { + consumeTextNote(event, relay) + } + + is ReactionEvent -> { + consumeReaction(event, relay) + } + + is LnZapRequestEvent -> { + consumeZapRequest(event, relay) + } + + is LnZapEvent -> { + consumeZap(event, relay) + } + + else -> { + false + } + } + + /** + * Consumes a kind 1 text note event. + * Creates/updates Note in cache and links reply relationships. + */ + private fun consumeTextNote( + event: TextNoteEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val repliesTo = event.tagsWithoutCitations().mapNotNull { getNoteIfExists(it) } + note.loadEvent(event, author, repliesTo) + relay?.let { note.addRelay(it) } + repliesTo.forEach { it.addReply(note) } + return true + } + + /** + * Consumes a kind 7 reaction event. + * Links reaction to target notes via e-tags and a-tags. + */ + private fun consumeReaction( + event: ReactionEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val reactedTo = + event.originalPost().mapNotNull { getNoteIfExists(it) } + + event.taggedAddresses().mapNotNull { addressableNotes.get(it.toValue()) } + note.loadEvent(event, author, reactedTo) + relay?.let { note.addRelay(it) } + reactedTo.forEach { it.addReaction(note) } + return true + } + + /** + * Consumes a kind 9734 zap request event. + * Must be consumed before the corresponding LnZapEvent (kind 9735). + */ + private fun consumeZapRequest( + event: LnZapRequestEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + note.loadEvent(event, author, emptyList()) + relay?.let { note.addRelay(it) } + return true + } + + /** + * Consumes a kind 9735 zap receipt event. + * Links zap to target notes via the embedded zap request. + */ + private fun consumeZap( + event: LnZapEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + + // Get or consume the embedded zap request + val zapRequestEvent = event.zapRequest + val zapRequestNote = + if (zapRequestEvent != null) { + consumeZapRequest(zapRequestEvent, relay) + getOrCreateNote(zapRequestEvent.id) + } else { + null + } + + val zappedNotes = + event.zappedPost().mapNotNull { getNoteIfExists(it) } + + event.taggedAddresses().mapNotNull { addressableNotes.get(it.toValue()) } + + note.loadEvent(event, author, zappedNotes) + relay?.let { note.addRelay(it) } + + // Link zap to target notes + if (zapRequestNote != null) { + zappedNotes.forEach { it.addZap(zapRequestNote, note) } + } + + return true + } + // ----- NWC Payment operations ----- /** @@ -199,17 +339,17 @@ class DesktopLocalCache : ICacheProvider { // ----- Note operations ----- - override fun getNoteIfExists(hexKey: HexKey): Note? = notes[hexKey] + override fun getNoteIfExists(hexKey: HexKey): Note? = notes.get(hexKey) override fun checkGetOrCreateNote(hexKey: HexKey): Note = getOrCreateNote(hexKey) fun getOrCreateNote(hexKey: HexKey): Note = - notes.getOrPut(hexKey) { + notes.getOrCreate(hexKey) { Note(hexKey) } override fun getOrCreateAddressableNote(key: Address): AddressableNote = - addressableNotes.getOrPut(key.toValue()) { + addressableNotes.getOrCreate(key.toValue()) { AddressableNote(key) } @@ -260,9 +400,9 @@ class DesktopLocalCache : ICacheProvider { // ----- Stats ----- - fun userCount(): Int = users.size + fun userCount(): Int = users.size() - fun noteCount(): Int = notes.size + fun noteCount(): Int = notes.size() fun clear() { users.clear() @@ -276,8 +416,18 @@ class DesktopLocalCache : ICacheProvider { * Desktop implementation of ICacheEventStream. */ class DesktopCacheEventStream : ICacheEventStream { - private val _newEventBundles = MutableSharedFlow>(replay = 0) - private val _deletedEventBundles = MutableSharedFlow>(replay = 0) + private val _newEventBundles = + MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val _deletedEventBundles = + MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) override val newEventBundles: SharedFlow> = _newEventBundles override val deletedEventBundles: SharedFlow> = _deletedEventBundles diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index e47b44169..7fe7e489f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoordinator import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter +import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.quartz.nip01Core.core.Event @@ -34,6 +35,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * Desktop-specific relay subscriptions coordinator. @@ -86,6 +89,34 @@ class DesktopRelaySubscriptionsCoordinator( }, ) + // Event bundler: batches consumed notes before emitting to SharedFlow + // 250ms for desktop (Android uses 1000ms to save battery) + private val eventBundler = + BasicBundledInsert( + delay = 250, + dispatcher = Dispatchers.IO, + scope = scope, + ) + + /** + * Central event router — consumes an event into the cache and emits to event stream. + * Called from relay onEvent callbacks. Non-blocking (launches on IO dispatcher). + */ + 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) + } + } + } + } + /** * Start the coordinator. * Call once when app starts or user logs in. From c198df57fd5ca536e9f66c61e85204018bc18517 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 19 Mar 2026 14:06:44 +0200 Subject: [PATCH 04/26] =?UTF-8?q?feat(cache):=20Phase=202+3=20foundation?= =?UTF-8?q?=20=E2=80=94=20FeedFilters=20+=20DesktopFeedViewModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2: 8 FeedFilter implementations: - DesktopGlobalFeedFilter (kind 1, limit 2500) - DesktopFollowingFeedFilter (kind 1 + followed pubkeys) - DesktopThreadFilter (root + reply graph walk) - DesktopProfileFeedFilter (notes by pubkey, limit 1000) - DesktopBookmarkFeedFilter (notes by ID set) - DesktopReadsFeedFilter (kind 30023, limit 500) - DesktopNotificationFeedFilter (events tagging user) - DesktopSearchFeedFilter (content text search) All use AdditiveFeedFilter for incremental updates (O(batch) after initial load). Limits are 5x Android's. Phase 3 foundation: DesktopFeedViewModel subclass that calls refreshSuspended() on init to load existing cache data — fixes navigation persistence (back shows instant data). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/feeds/DesktopFeedFilters.kt | 253 ++++++++++++++++++ .../viewmodels/DesktopFeedViewModel.kt | 48 ++++ 2 files changed, 301 insertions(+) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt new file mode 100644 index 000000000..073dcad31 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.feeds + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter +import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent + +/** + * Global feed: all kind 1 text notes, sorted by createdAt desc. + */ +class DesktopGlobalFeedFilter( + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "global" + + override fun feed(): List = + cache.notes + .filterIntoSet { _, note -> note.event is TextNoteEvent } + .sortedWith(DefaultFeedOrder) + .take(limit()) + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is TextNoteEvent } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 2500 +} + +/** + * Following feed: kind 1 text notes from followed pubkeys. + */ +class DesktopFollowingFeedFilter( + private val cache: DesktopLocalCache, + private val followedPubkeys: () -> Set, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "following-${followedPubkeys().hashCode()}" + + override fun feed(): List { + val follows = followedPubkeys() + return cache.notes + .filterIntoSet { _, note -> + note.event is TextNoteEvent && note.author?.pubkeyHex in follows + }.sortedWith(DefaultFeedOrder) + .take(limit()) + } + + override fun applyFilter(newItems: Set): Set { + val follows = followedPubkeys() + return newItems.filterTo(HashSet()) { + it.event is TextNoteEvent && it.author?.pubkeyHex in follows + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 2500 +} + +/** + * Thread feed: root note + all replies (graph walk via Note.replies). + */ +class DesktopThreadFilter( + private val noteId: HexKey, + private val cache: DesktopLocalCache, +) : FeedFilter() { + override fun feedKey(): String = "thread-$noteId" + + override fun feed(): List { + val root = cache.getNoteIfExists(noteId) ?: return emptyList() + val result = mutableListOf(root) + collectReplies(root, result) + return result.sortedWith(compareBy { it.createdAt() ?: 0 }) + } + + private fun collectReplies( + note: Note, + result: MutableList, + ) { + for (reply in note.replies) { + if (reply !in result) { + result.add(reply) + collectReplies(reply, result) + } + } + } + + override fun limit(): Int = Int.MAX_VALUE +} + +/** + * Profile feed: all kind 1 notes by a specific pubkey. + */ +class DesktopProfileFeedFilter( + private val pubkey: HexKey, + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "profile-$pubkey" + + override fun feed(): List = + cache.notes + .filterIntoSet { _, note -> + note.event is TextNoteEvent && note.author?.pubkeyHex == pubkey + }.sortedWith(DefaultFeedOrder) + .take(limit()) + + override fun applyFilter(newItems: Set): Set = + newItems.filterTo(HashSet()) { + it.event is TextNoteEvent && it.author?.pubkeyHex == pubkey + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 1000 +} + +/** + * Bookmark feed: notes by ID set (from BookmarkListEvent). + */ +class DesktopBookmarkFeedFilter( + private val bookmarkedIds: () -> Set, + private val cache: DesktopLocalCache, +) : FeedFilter() { + override fun feedKey(): String = "bookmarks-${bookmarkedIds().hashCode()}" + + override fun feed(): List = + bookmarkedIds() + .mapNotNull { cache.getNoteIfExists(it) } + .filter { it.event != null } + .sortedWith(DefaultFeedOrder) + .take(limit()) + + override fun limit(): Int = 2500 +} + +/** + * Reads feed: kind 30023 long-form content. + */ +class DesktopReadsFeedFilter( + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "reads" + + override fun feed(): List = + cache.notes + .filterIntoSet { _, note -> note.event is LongTextNoteEvent } + .sortedWith(DefaultFeedOrder) + .take(limit()) + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is LongTextNoteEvent } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 500 +} + +/** + * Notification feed: events that tag the logged-in user. + * Includes reactions, zaps, replies, reposts targeting the user's notes. + */ +class DesktopNotificationFeedFilter( + private val userPubKeyHex: HexKey, + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + companion object { + val NOTIFICATION_KINDS = + setOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + LnZapEvent.KIND, + ) + } + + override fun feedKey(): String = "notifications-$userPubKeyHex" + + override fun feed(): List = + cache.notes + .filterIntoSet { _, note -> isNotificationForUser(note) } + .sortedWith(DefaultFeedOrder) + .take(limit()) + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isNotificationForUser(it) } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 2500 + + private fun isNotificationForUser(note: Note): Boolean { + val event = note.event ?: return false + return event.kind in NOTIFICATION_KINDS && + event.pubKey != userPubKeyHex && + event.isTaggedUser(userPubKeyHex) + } +} + +/** + * Search feed: notes matching a text query (content search). + * Results are populated by relay search subscriptions that route through cache. + */ +class DesktopSearchFeedFilter( + private val query: String, + private val cache: DesktopLocalCache, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "search-$query" + + override fun feed(): List { + val lowerQuery = query.lowercase() + return cache.notes + .filterIntoSet { _, note -> + val event = note.event ?: return@filterIntoSet false + event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) + }.sortedWith(DefaultFeedOrder) + .take(limit()) + } + + override fun applyFilter(newItems: Set): Set { + val lowerQuery = query.lowercase() + return newItems.filterTo(HashSet()) { + val event = it.event + event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + override fun limit(): Int = 500 +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt new file mode 100644 index 000000000..a19d3ef59 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.viewmodels + +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter +import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Desktop-specific FeedViewModel that loads existing cache data on creation. + * + * The base FeedViewModel only sets up event stream collectors — it doesn't + * load data already in cache. This means navigating back to a screen would + * show Loading forever until a new relay event arrives. This subclass fixes + * that by calling refreshSuspended() on init. + */ +class DesktopFeedViewModel( + filter: FeedFilter, + cacheProvider: ICacheProvider, +) : FeedViewModel(filter, cacheProvider) { + init { + viewModelScope.launch(Dispatchers.IO) { + feedState.refreshSuspended() + } + } +} From e80473a7d900d36979edacdc5b499b865857df97 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 19 Mar 2026 15:22:38 +0200 Subject: [PATCH 05/26] =?UTF-8?q?feat(cache):=20Phase=203=20=E2=80=94=20ro?= =?UTF-8?q?ute=20all=20screen=20events=20through=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All screens now pipe relay events into DesktopLocalCache via coordinator.consumeEvent(). Existing per-screen EventCollectionState kept for rendering (incremental migration), but data also enters cache for persistence across navigation. - FeedScreen: route global + following feed events - ThreadScreen: route root note + reply events - UserProfileScreen: route posts + add cache hydration on mount (LaunchedEffect queries cache for existing posts → instant display) - BookmarksScreen: route public + private bookmark events - ReadsScreen: route long-form feed events (add coordinator param) - NotificationsScreen: route notification events Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/BookmarksScreen.kt | 6 ++++-- .../amethyst/desktop/ui/FeedScreen.kt | 3 ++- .../amethyst/desktop/ui/NotificationsScreen.kt | 3 ++- .../amethyst/desktop/ui/ReadsScreen.kt | 5 ++++- .../amethyst/desktop/ui/ThreadScreen.kt | 6 ++++-- .../amethyst/desktop/ui/UserProfileScreen.kt | 18 ++++++++++++++++-- 6 files changed, 32 insertions(+), 9 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index f08682e56..ac56815dc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -188,7 +188,8 @@ fun BookmarksScreen( FilterBuilders.byIds(publicBookmarkIds), ), relays = connectedRelays, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) publicEventState.addItem(event) }, onEose = { _, _ -> }, @@ -209,7 +210,8 @@ fun BookmarksScreen( FilterBuilders.byIds(privateBookmarkIds), ), relays = connectedRelays, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) privateEventState.addItem(event) }, onEose = { _, _ -> }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index b8fee1344..c493247a3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -279,7 +279,8 @@ fun FeedScreen( FeedMode.GLOBAL -> { createGlobalFeedSubscription( relays = configuredRelays, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) // Store metadata events in cache if (event is MetadataEvent) { localCache.consumeMetadata(event) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index e1be49646..1fe3d31fd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -143,7 +143,8 @@ fun NotificationsScreen( createNotificationsSubscription( relays = connectedRelays, pubKeyHex = account.pubKeyHex, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) // Skip events from the user themselves (except zaps) if (event.pubKey == account.pubKeyHex && event !is LnZapEvent) { return@createNotificationsSubscription diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 2d5aba286..c1e6d94bb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingLongFormFeedSubscription @@ -180,6 +181,7 @@ fun ReadsScreen( localCache: DesktopLocalCache, account: AccountState.LoggedIn? = null, nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onNavigateToProfile: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, @@ -239,7 +241,8 @@ fun ReadsScreen( FeedMode.GLOBAL -> { createLongFormFeedSubscription( relays = connectedRelays, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) if (event is LongTextNoteEvent) { eventState.addItem(event) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index d4368f984..c9ab39e30 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -203,7 +203,8 @@ fun ThreadScreen( createNoteSubscription( relays = connectedRelays, noteId = noteId, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) if (event.id == noteId) { rootNote = event levelCache[event.id] = 0 @@ -224,7 +225,8 @@ fun ThreadScreen( createThreadRepliesSubscription( relays = connectedRelays, noteId = noteId, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) replyEventState.addItem(event) }, onEose = { _, _ -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index c79b4e368..15ab92d4a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -206,13 +206,26 @@ fun UserProfileScreen( } } - // Clear posts when profile changes + // Clear posts when profile changes, then hydrate from cache remember(pubKeyHex, retryTrigger) { eventState.clear() postsLoading = true postsError = null } + // Hydrate from cache — show previously loaded posts instantly + LaunchedEffect(pubKeyHex) { + val cachedNotes = + localCache.notes.filterIntoSet { _, note -> + note.event?.kind == 1 && note.author?.pubkeyHex == pubKeyHex + } + if (cachedNotes.isNotEmpty()) { + val events = cachedNotes.mapNotNull { it.event } + eventState.addItems(events) + postsLoading = false + } + } + // Subscribe to user metadata rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { @@ -308,7 +321,8 @@ fun UserProfileScreen( createUserPostsSubscription( relays = connectedRelays, pubKeyHex = pubKeyHex, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) eventState.addItem(event) }, onEose = { _, _ -> From e834dbd1e59f9a064d7c98d9cad901646a96e19f Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 20 Mar 2026 06:48:28 +0200 Subject: [PATCH 06/26] fix(ui): show display names instead of npubs in feed toNoteDisplayData() now reads User.toBestDisplayName() from cache instead of always showing npub. Falls back to npub if no metadata. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/EventExtensions.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt index 1f9628423..cd76024c0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt @@ -31,19 +31,22 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub * Extension to convert Event to NoteDisplayData for the shared NoteCard. */ fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData { - val npub = - try { - pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." - } catch (e: Exception) { - pubKey.take(16) + "..." - } + val user = (cache?.getUserIfExists(pubKey) as? User) - val pictureUrl = (cache?.getUserIfExists(pubKey) as? User)?.profilePicture() + val displayName = + user?.toBestDisplayName() + ?: try { + pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." + } catch (e: Exception) { + pubKey.take(16) + "..." + } + + val pictureUrl = user?.profilePicture() return NoteDisplayData( id = id, pubKeyHex = pubKey, - pubKeyDisplay = npub, + pubKeyDisplay = displayName, profilePictureUrl = pictureUrl, content = content, createdAt = createdAt, From 684ba158abe1180ca5083dbe09acd58d825e2b8a Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 08:26:45 +0200 Subject: [PATCH 07/26] feat(cache): kind-based consumer registry + new event types Replace when-dispatch with HashMap for O(1) event routing. Add consume methods for: RepostEvent (kind 6), ContactListEvent (kind 3), LongTextNoteEvent (kind 30023), BookmarkListEvent (kind 30001). Replace @Volatile followedUsers with MutableStateFlow for thread safety and Compose observability. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/cache/DesktopLocalCache.kt | 130 ++++++++++++++---- 1 file changed, 100 insertions(+), 30 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 367b128e6..b382d4be2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -33,11 +33,15 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.utils.DualCase @@ -45,7 +49,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap @@ -63,6 +70,10 @@ class DesktopLocalCache : ICacheProvider { val eventStream = DesktopCacheEventStream() + /** Cached follow set for the logged-in user. Thread-safe + Compose-observable. */ + private val _followedUsers = MutableStateFlow>(emptySet()) + val followedUsers: StateFlow> = _followedUsers.asStateFlow() + companion object { const val MAX_NOTES = 50_000 const val MAX_USERS = 25_000 @@ -71,6 +82,25 @@ class DesktopLocalCache : ICacheProvider { val paymentTracker = NwcPaymentTracker() + // ----- Kind-based event consumer registry ----- + + private val consumers = HashMap Boolean>() + + init { + consumers[MetadataEvent.KIND] = { e, _ -> + 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[LnZapRequestEvent.KIND] = { e, r -> consumeZapRequest(e as LnZapRequestEvent, r) } + consumers[LnZapEvent.KIND] = { e, r -> consumeZap(e as LnZapEvent, r) } + consumers[RepostEvent.KIND] = { e, r -> consumeRepost(e as RepostEvent, r) } + consumers[ContactListEvent.KIND] = { e, _ -> consumeContactList(e as ContactListEvent) } + consumers[LongTextNoteEvent.KIND] = { e, r -> consumeLongTextNote(e as LongTextNoteEvent, r) } + consumers[BookmarkListEvent.KIND] = { e, _ -> consumeBookmarkList(e as BookmarkListEvent) } + } + // ----- User operations ----- override fun getUserIfExists(pubkey: HexKey): User? = users.get(pubkey) @@ -140,42 +170,16 @@ class DesktopLocalCache : ICacheProvider { } } - // ----- Event consumption (mirrors Android LocalCache pattern) ----- + // ----- Event consumption (kind-based registry) ----- /** - * Routes an event to the appropriate consume method. - * Returns true if the event was consumed (new), false if already seen. + * Routes an event to the appropriate consume method via kind-based registry. + * O(1) dispatch. Returns true if the event was consumed (new), false if already seen. */ fun consume( event: Event, relay: NormalizedRelayUrl?, - ): Boolean = - when (event) { - is MetadataEvent -> { - consumeMetadata(event) - true - } - - is TextNoteEvent -> { - consumeTextNote(event, relay) - } - - is ReactionEvent -> { - consumeReaction(event, relay) - } - - is LnZapRequestEvent -> { - consumeZapRequest(event, relay) - } - - is LnZapEvent -> { - consumeZap(event, relay) - } - - else -> { - false - } - } + ): Boolean = consumers[event.kind]?.invoke(event, relay) ?: false /** * Consumes a kind 1 text note event. @@ -268,6 +272,71 @@ class DesktopLocalCache : ICacheProvider { return true } + /** + * Consumes a kind 6 repost event. + * Links repost to target note via e-tag. + */ + private fun consumeRepost( + event: RepostEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val boostedNote = event.boostedEventId()?.let { getNoteIfExists(it) } + val repliesTo = listOfNotNull(boostedNote) + note.loadEvent(event, author, repliesTo) + relay?.let { note.addRelay(it) } + boostedNote?.addBoost(note) + return true + } + + /** + * Consumes a kind 3 contact list event (replaceable). + * Updates the cached followedUsers set. + */ + private fun consumeContactList(event: ContactListEvent): Boolean { + val currentFollows = _followedUsers.value + val newFollows = event.verifiedFollowKeySet() + if (newFollows != currentFollows) { + _followedUsers.value = newFollows + } + return true + } + + /** + * Consumes a kind 30023 long-form text note event. + * Creates Note in cache like TextNoteEvent. + */ + private fun consumeLongTextNote( + event: LongTextNoteEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + note.loadEvent(event, author, emptyList()) + relay?.let { note.addRelay(it) } + return true + } + + /** + * Consumes a kind 30001 bookmark list event (addressable/replaceable). + * Stores in addressableNotes cache. + */ + private fun consumeBookmarkList(event: BookmarkListEvent): Boolean { + val address = event.address() + val addressableNote = getOrCreateAddressableNote(address) + val author = getOrCreateUser(event.pubKey) + + // Only update if newer + val existingEvent = addressableNote.event + if (existingEvent != null && existingEvent.createdAt >= event.createdAt) return false + + addressableNote.loadEvent(event, author, emptyList()) + return true + } + // ----- NWC Payment operations ----- /** @@ -409,6 +478,7 @@ class DesktopLocalCache : ICacheProvider { notes.clear() addressableNotes.clear() deletedEvents.clear() + _followedUsers.value = emptySet() } } From e8ede21c1782ba2946a96aca7f2421fadf92ba0f Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 08:28:49 +0200 Subject: [PATCH 08/26] feat(cache): Coordinator error handling + interaction subscriptions Add try-catch in consumeEvent() so one bad event doesn't kill the pipeline. Add requestInteractions()/releaseInteractions() with Job tracking via ConcurrentHashMap for screen-scoped subscription lifecycle. Add SubscriptionHealth tracking (lastEventReceivedAt, eoseReceived) exposed as StateFlow for UI consumption. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DesktopRelaySubscriptionsCoordinator.kt | 135 +++++++++++++++++- 1 file changed, 130 insertions(+), 5 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 7fe7e489f..1f267a177 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -36,7 +36,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * Desktop-specific relay subscriptions coordinator. @@ -62,6 +67,11 @@ import kotlinx.coroutines.launch * } * ``` */ +data class SubscriptionHealth( + val lastEventReceivedAt: Long? = null, + val eoseReceived: Boolean = false, +) + class DesktopRelaySubscriptionsCoordinator( private val client: INostrClient, private val scope: CoroutineScope, @@ -98,25 +108,132 @@ class DesktopRelaySubscriptionsCoordinator( scope = scope, ) + // Screen-triggered subscription Jobs — keyed by subId for proper cancellation + private val screenSubscriptions = ConcurrentHashMap() + + // Subscription health tracking + private val _subscriptionHealth = MutableStateFlow>(emptyMap()) + val subscriptionHealth: StateFlow> = _subscriptionHealth.asStateFlow() + + private fun updateHealth( + subId: String, + lastEventReceivedAt: Long? = null, + eoseReceived: Boolean? = null, + ) { + _subscriptionHealth.value = + _subscriptionHealth.value.toMutableMap().apply { + val current = this[subId] ?: SubscriptionHealth() + this[subId] = + current.copy( + lastEventReceivedAt = lastEventReceivedAt ?: current.lastEventReceivedAt, + eoseReceived = eoseReceived ?: current.eoseReceived, + ) + } + } + /** * Central event router — consumes an event into the cache and emits to event stream. * Called from relay onEvent callbacks. Non-blocking (launches on IO dispatcher). + * Try-catch per event ensures one bad event doesn't kill the pipeline. */ 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) + 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) { + println("Coordinator: failed to consume kind ${event.kind}: ${e.message}") } } } + /** + * Request a consolidated interaction subscription for the given note IDs. + * Subscribes to kinds 7 (reactions), 9735 (zaps), 6 (reposts), and 1 (replies) + * targeting these notes. Returns a subId for cleanup via [releaseInteractions]. + */ + fun requestInteractions( + noteIds: List, + relays: Set, + ): String { + val subId = generateSubId("interactions-${noteIds.hashCode()}") + + // Cancel any existing subscription with this ID + screenSubscriptions.remove(subId)?.cancel() + client.close(subId) + + if (noteIds.isEmpty() || relays.isEmpty()) return subId + + val filters = + listOf( + // Reactions (kind 7) targeting these notes + Filter( + kinds = listOf(com.vitorpamplona.quartz.nip25Reactions.ReactionEvent.KIND), + tags = mapOf("e" to noteIds), + ), + // Zap receipts (kind 9735) targeting these notes + Filter( + kinds = listOf(com.vitorpamplona.quartz.nip57Zaps.LnZapEvent.KIND), + tags = mapOf("e" to noteIds), + ), + // Reposts (kind 6) targeting these notes + Filter( + kinds = listOf(com.vitorpamplona.quartz.nip18Reposts.RepostEvent.KIND), + tags = mapOf("e" to noteIds), + ), + ) + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + consumeEvent(event, relay) + updateHealth(subId, lastEventReceivedAt = System.currentTimeMillis()) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + updateHealth(subId, eoseReceived = true) + } + } + + val job = + scope.launch { + client.openReqSubscription( + subId = subId, + filters = relays.associateWith { filters }, + listener = listener, + ) + } + screenSubscriptions[subId] = job + + return subId + } + + /** + * Release a screen-triggered interaction subscription. + */ + fun releaseInteractions(subId: String) { + screenSubscriptions.remove(subId)?.cancel() + client.close(subId) + _subscriptionHealth.value = + _subscriptionHealth.value.toMutableMap().apply { remove(subId) } + } + /** * Start the coordinator. * Call once when app starts or user logs in. @@ -265,6 +382,14 @@ class DesktopRelaySubscriptionsCoordinator( * Call when switching accounts or during cleanup. */ fun clear() { + // Clean up screen-triggered subscriptions + screenSubscriptions.forEach { (subId, job) -> + job.cancel() + client.close(subId) + } + screenSubscriptions.clear() + _subscriptionHealth.value = emptyMap() + unsubscribeFromDms() feedMetadata.clear() rateLimiter.reset() From c63ace1d9cd6164865120bbcf82dc15ea48a2731 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 08:32:59 +0200 Subject: [PATCH 09/26] =?UTF-8?q?feat(cache):=20FeedScreen=20=E2=86=92=20D?= =?UTF-8?q?esktopFeedViewModel=20+=20Note-based=20FeedNoteCard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite FeedScreen from inline EventCollectionState + 8 rememberSubscription() calls to cache-centric DesktopFeedViewModel pattern: - ViewModel keyed on feedMode with DisposableEffect cleanup (destroy()) - FeedState pattern matching (Loading/Loaded/Empty/Error) - FeedNoteCard takes Note instead of Event, reads counts from Note.flowSet - DisposableEffect for note.clearFlow() cleanup in LazyColumn - DisposableEffect for Coordinator interaction subscription lifecycle - LaunchedEffect for rate-limited metadata loading via Coordinator - Extract FeedHeader into separate composable - Add destroy() to DesktopFeedViewModel (ViewModel.clear() is internal in KMP) - Update UserProfileScreen to look up Note from cache for FeedNoteCard Removes ~350 lines of inline state management (EventCollectionState, zapsByEvent, reactionIdsByEvent, replyIdsByEvent, repostIdsByEvent, followedUsers, bookmarkList, 8 rememberSubscription blocks). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 727 ++++++------------ .../amethyst/desktop/ui/UserProfileScreen.kt | 42 +- .../viewmodels/DesktopFeedViewModel.kt | 9 + 3 files changed, 263 insertions(+), 515 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index c493247a3..1b0b8063f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -45,49 +45,32 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.richtext.UrlParser -import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode -import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders -import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig -import com.vitorpamplona.amethyst.desktop.subscriptions.createBatchMetadataSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard -import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys +import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map data class LightboxState( val urls: List, @@ -97,11 +80,12 @@ data class LightboxState( ) /** - * Note card with action buttons. + * Note card that reads counts from the Note model (cache-backed). + * Event is extracted from Note for signing operations in NoteActionsRow. */ @Composable fun FeedNoteCard( - event: Event, + note: Note, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn?, @@ -112,15 +96,25 @@ fun FeedNoteCard( onNavigateToThread: (String) -> Unit = {}, onImageClick: ((List, Int) -> Unit)? = null, onMediaClick: ((List, Int, Float) -> Unit)? = null, - zapReceipts: List = emptyList(), - reactionCount: Int = 0, - replyCount: Int = 0, - repostCount: Int = 0, - bookmarkList: BookmarkListEvent? = null, - isBookmarked: Boolean = false, - onBookmarkChanged: (BookmarkListEvent) -> Unit = {}, ) { - val zapAmountSats = zapReceipts.sumOf { it.amountSats } + val event = note.event ?: return + + // Observe Note.flowSet for live count updates + 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() + + // Read counts from Note model (re-read on each stateFlow emission) + val reactionCount = note.countReactions() + val replyCount = note.replies.size + val repostCount = note.boosts.size + val zapAmount = note.zapsAmount + + // Clean up flowSet when card leaves composition + DisposableEffect(note) { + onDispose { note.clearFlow() } + } Column { NoteCard( @@ -144,15 +138,12 @@ fun FeedNoteCard( onReplyClick = onReply, onZapFeedback = onZapFeedback, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = zapReceipts.size, - zapAmountSats = zapAmountSats, - zapReceipts = zapReceipts, + zapCount = note.zaps.size, + zapAmountSats = zapAmount.toLong(), + zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps reactionCount = reactionCount, replyCount = replyCount, repostCount = repostCount, - bookmarkList = bookmarkList, - isBookmarked = isBookmarked, - onBookmarkChanged = onBookmarkChanged, ) } } @@ -172,488 +163,146 @@ fun FeedScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() - // Configured relay URLs only — stabilized with distinctUntilChanged() to prevent - // subscription churn from relay status changes (pings, connect/disconnect). - // openReqSubscription connects relays on demand; no need to wait for connectedRelays. - val configuredRelays by remember { - relayManager.relayStatuses - .map { it.keys } - .distinctUntilChanged() - }.collectAsState(emptySet()) - val scope = rememberCoroutineScope() - val eventState = - remember { - EventCollectionState( - getId = { it.id }, - sortComparator = compareByDescending { it.createdAt }, - maxSize = 200, - scope = scope, - ) - } - val events by eventState.items.collectAsState() + val followedUsers by localCache.followedUsers.collectAsState() + var replyToEvent by remember { mutableStateOf(null) } var lightboxState by remember { mutableStateOf(null) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } - var followedUsers by remember { mutableStateOf>(emptySet()) } - var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } - // Track reaction event IDs per target event to deduplicate - var reactionIdsByEvent by remember { mutableStateOf>>(emptyMap()) } - val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size } - // Track reply/repost event IDs per target event to deduplicate - var replyIdsByEvent by remember { mutableStateOf>>(emptyMap()) } - val repliesByEvent = replyIdsByEvent.mapValues { it.value.size } - var repostIdsByEvent by remember { mutableStateOf>>(emptyMap()) } - val repostsByEvent = repostIdsByEvent.mapValues { it.value.size } - var bookmarkList by remember { mutableStateOf(null) } - var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } - // Track EOSE to know when initial load is complete - var eoseReceivedCount by remember { mutableStateOf(0) } - val initialLoadComplete = eoseReceivedCount > 0 - - // Load followed users for Following feed mode - rememberSubscription(configuredRelays, account, feedMode, relayManager = relayManager) { - if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { - createContactListSubscription( - relays = configuredRelays, - pubKeyHex = account.pubKeyHex, - onEvent = { event, _, relay, _ -> - if (event is ContactListEvent) { - val follows = event.verifiedFollowKeySet() - followedUsers = follows + // DesktopFeedViewModel keyed on feedMode — recreated on mode switch + val viewModel = + remember(feedMode) { + val filter = + when (feedMode) { + FeedMode.GLOBAL -> { + DesktopGlobalFeedFilter(localCache) } - }, - ) - } else { - null - } - } - // Load user's bookmark list - rememberSubscription(configuredRelays, account, relayManager = relayManager) { - if (configuredRelays.isNotEmpty() && account != null) { - SubscriptionConfig( - subId = "bookmarks-${account.pubKeyHex.take(8)}", - filters = - listOf( - FilterBuilders.byAuthors( - authors = listOf(account.pubKeyHex), - kinds = listOf(BookmarkListEvent.KIND), - limit = 1, - ), - ), - relays = configuredRelays, - onEvent = { event, _, _, _ -> - if (event is BookmarkListEvent) { - bookmarkList = event - // Extract public bookmarked event IDs - val pubIds = - event - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds + FeedMode.FOLLOWING -> { + DesktopFollowingFeedFilter(localCache) { + localCache.followedUsers.value + } } - }, - onEose = { _, _ -> }, - ) - } else { - null - } - } - - // Clear events and reset EOSE when feed mode changes - remember(feedMode) { - eventState.clear() - eoseReceivedCount = 0 - } - - // Subscribe to feed based on mode - rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) { - if (configuredRelays.isEmpty()) { - return@rememberSubscription null - } - - when (feedMode) { - FeedMode.GLOBAL -> { - createGlobalFeedSubscription( - relays = configuredRelays, - onEvent = { event, _, relay, _ -> - subscriptionsCoordinator?.consumeEvent(event, relay) - // Store metadata events in cache - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - eventState.addItem(event) - }, - onEose = { _, _ -> - eoseReceivedCount++ - }, - ) - } - - FeedMode.FOLLOWING -> { - if (followedUsers.isNotEmpty()) { - createFollowingFeedSubscription( - relays = configuredRelays, - followedUsers = followedUsers.toList(), - onEvent = { event, _, _, _ -> - // Store metadata events in cache - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - eventState.addItem(event) - }, - onEose = { _, _ -> - eoseReceivedCount++ - }, - ) - } else { - null } + DesktopFeedViewModel(filter, localCache) + } + + // Cancel old ViewModel's viewModelScope on recreation + DisposableEffect(viewModel) { + onDispose { viewModel.destroy() } + } + + val feedState by viewModel.feedState.feedContent.collectAsState() + + // Load metadata for visible notes via Coordinator (rate-limited) + LaunchedEffect(feedState, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) { + val notes = viewModel.feedState.visibleNotes() + if (notes.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForNotes(notes) } } } - // Subscribe to zaps for visible events - val eventIds = events.map { it.id } - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null - } + // Request interaction subscriptions (zaps, reactions, reposts) for visible notes + DisposableEffect(feedState, subscriptionsCoordinator) { + val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} + val notes = viewModel.feedState.visibleNotes() + val noteIds = notes.mapNotNull { it.event?.id } + if (noteIds.isEmpty()) return@DisposableEffect onDispose {} - createZapsSubscription( - relays = configuredRelays, - eventIds = eventIds, - onEvent = { event, _, _, _ -> - if (event is LnZapEvent) { - val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription - val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription - zapsByEvent = - zapsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptyList() - if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { - this[targetEventId] = existing + receipt - } - } - } - }, - ) - } - - // Subscribe to metadata for zap senders (to show display names) - val zapSenderPubkeys = - zapsByEvent.values - .flatten() - .map { it.senderPubKey } - .distinct() - rememberSubscription(configuredRelays, zapSenderPubkeys, relayManager = relayManager) { - if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) { - return@rememberSubscription null - } - - // Only fetch metadata for users we don't have yet - val missingPubkeys = - zapSenderPubkeys.filter { pubkey -> - localCache - .getUserIfExists(pubkey) - ?.metadataOrNull() - ?.flow - ?.value == null - } - if (missingPubkeys.isEmpty()) { - return@rememberSubscription null - } - - createBatchMetadataSubscription( - relays = configuredRelays, - pubKeyHexList = missingPubkeys, - onEvent = { event, _, _, _ -> - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - }, - ) - } - - // Subscribe to reactions for visible events - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null - } - - createReactionsSubscription( - relays = configuredRelays, - eventIds = eventIds, - onEvent = { event, _, _, _ -> - if (event is ReactionEvent) { - val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription - reactionIdsByEvent = - reactionIdsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptySet() - this[targetEventId] = existing + event.id - } - } - }, - ) - } - - // Subscribe to replies for visible events - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null - } - - createRepliesSubscription( - relays = configuredRelays, - eventIds = eventIds, - onEvent = { event, _, _, _ -> - // Find the event this is replying to - val replyToId = - event.tags - .filter { it.size >= 2 && it[0] == "e" } - .lastOrNull() - ?.get(1) ?: return@createRepliesSubscription - if (replyToId in eventIds) { - replyIdsByEvent = - replyIdsByEvent.toMutableMap().apply { - val existing = this[replyToId] ?: emptySet() - this[replyToId] = existing + event.id - } - } - }, - ) - } - - // Subscribe to reposts for visible events - rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { - if (configuredRelays.isEmpty() || eventIds.isEmpty()) { - return@rememberSubscription null - } - - createRepostsSubscription( - relays = configuredRelays, - eventIds = eventIds, - onEvent = { event, _, _, _ -> - if (event is RepostEvent) { - val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription - repostIdsByEvent = - repostIdsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptySet() - this[targetEventId] = existing + event.id - } - } - }, - ) - } - - // Subscribe to metadata for note authors + mentioned users - val authorPubkeys = events.map { it.pubKey }.distinct() - val mentionedPubkeys = - remember(events) { - val parser = UrlParser() - events - .flatMap { event -> - val urls = parser.parseValidUrls(event.content) - extractMentionedPubkeys(urls.bech32s) - }.distinct() - } - val allPubkeys = remember(authorPubkeys, mentionedPubkeys) { (authorPubkeys + mentionedPubkeys).distinct() } - - // Use coordinator for rate-limited metadata loading (preferred) - LaunchedEffect(allPubkeys, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null && allPubkeys.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataForPubkeys(allPubkeys) - } - } - - // Fallback subscription if coordinator not available - rememberSubscription(configuredRelays, allPubkeys, subscriptionsCoordinator, relayManager = relayManager) { - // Skip if using coordinator - if (subscriptionsCoordinator != null) { - return@rememberSubscription null - } - - if (configuredRelays.isEmpty() || allPubkeys.isEmpty()) { - return@rememberSubscription null - } - - // Only fetch metadata for users we don't have yet - val missingPubkeys = - allPubkeys.filter { pubkey -> - localCache - .getUserIfExists(pubkey) - ?.metadataOrNull() - ?.flow - ?.value == null - } - if (missingPubkeys.isEmpty()) { - return@rememberSubscription null - } - - createBatchMetadataSubscription( - relays = configuredRelays, - pubKeyHexList = missingPubkeys, - onEvent = { event, _, _, _ -> - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } - }, - ) + val relays = + relayManager.relayStatuses.value.keys + val subId = coordinator.requestInteractions(noteIds, relays) + onDispose { coordinator.releaseInteractions(subId) } } @OptIn(ExperimentalLayoutApi::class) Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { - // Header with compose button — wraps on narrow columns - FlowRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column { - FlowRow( - verticalArrangement = Arrangement.Center, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - - // Feed mode selector - if (account != null) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - FilterChip( - selected = feedMode == FeedMode.GLOBAL, - onClick = { - feedMode = FeedMode.GLOBAL - DesktopPreferences.feedMode = FeedMode.GLOBAL - }, - label = { Text("Global") }, - ) - FilterChip( - selected = feedMode == FeedMode.FOLLOWING, - onClick = { - feedMode = FeedMode.FOLLOWING - DesktopPreferences.feedMode = FeedMode.FOLLOWING - }, - label = { Text("Following") }, - ) - } - } - } - - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "${connectedRelays.size} relays connected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (feedMode == FeedMode.FOLLOWING) { - Text( - " • ${followedUsers.size} followed", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(8.dp)) - IconButton( - onClick = { relayManager.connect() }, - modifier = Modifier.size(24.dp), - ) { - Icon( - Icons.Default.Refresh, - contentDescription = "Refresh", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - } - } - - // New Post button (primary action) - Button( - onClick = onCompose, - enabled = account != null && !account.isReadOnly, - ) { - Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("New Post") - } - } + // Header with compose button + FeedHeader( + feedMode = feedMode, + account = account, + connectedRelays = connectedRelays, + followedUsersCount = followedUsers.size, + onFeedModeChange = { mode -> + feedMode = mode + DesktopPreferences.feedMode = mode + }, + onRefresh = { relayManager.connect() }, + onCompose = onCompose, + ) Spacer(Modifier.height(8.dp)) - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { - LoadingState("Loading followed users...") - } else if (events.isEmpty() && !initialLoadComplete) { - LoadingState("Loading notes...") - } else if (events.isEmpty() && initialLoadComplete) { - EmptyState( - title = - if (feedMode == FeedMode.FOLLOWING) { - "No notes from followed users" - } else { - "No notes found" - }, - description = - if (feedMode == FeedMode.FOLLOWING) { - "Notes from people you follow will appear here" - } else { - "Notes from the network will appear here" - }, - onRefresh = { relayManager.connect() }, - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Use distinctBy to prevent duplicate key crashes from events with same ID - items(events.distinctBy { it.id }, key = { it.id }) { event -> - FeedNoteCard( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = { replyToEvent = event }, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, - onMediaClick = { urls, index, seekPos -> - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .playVideo(urls[index], seekPos) - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .toggleFullscreen() + // Feed content based on FeedState + when (val state = feedState) { + is FeedState.Loading -> { + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else { + LoadingState("Loading notes...") + } + } + + is FeedState.Empty -> { + EmptyState( + title = + if (feedMode == FeedMode.FOLLOWING) { + "No notes from followed users" + } else { + "No notes found" }, - zapReceipts = zapsByEvent[event.id] ?: emptyList(), - reactionCount = reactionsByEvent[event.id] ?: 0, - replyCount = repliesByEvent[event.id] ?: 0, - repostCount = repostsByEvent[event.id] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(event.id), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds + description = + if (feedMode == FeedMode.FOLLOWING) { + "Notes from people you follow will appear here" + } else { + "Notes from the network will appear here" }, - ) + onRefresh = { relayManager.connect() }, + ) + } + + is FeedState.FeedError -> { + EmptyState( + title = "Error loading feed", + description = state.errorMessage, + onRefresh = { relayManager.connect() }, + ) + } + + is FeedState.Loaded -> { + val loadedState by state.feed.collectAsState() + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(loadedState.list, key = { it.idHex }) { note -> + FeedNoteCard( + note = note, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = { replyToEvent = note.event }, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> + lightboxState = LightboxState(urls, index) + }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + ) + } } } } - } // end Column + } // Reply dialog if (replyToEvent != null && account != null) { @@ -675,5 +324,91 @@ fun FeedScreen( onDismiss = { lightboxState = null }, ) } - } // end Box + } +} + +/** + * Feed header with title, mode selector, relay count, and compose button. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun FeedHeader( + feedMode: FeedMode, + account: AccountState.LoggedIn?, + connectedRelays: Set, + followedUsersCount: Int, + onFeedModeChange: (FeedMode) -> Unit, + onRefresh: () -> Unit, + onCompose: () -> Unit, +) { + FlowRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column { + FlowRow( + verticalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + if (account != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { onFeedModeChange(FeedMode.GLOBAL) }, + label = { Text("Global") }, + ) + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { onFeedModeChange(FeedMode.FOLLOWING) }, + label = { Text("Following") }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${connectedRelays.size} relays connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (feedMode == FeedMode.FOLLOWING) { + Text( + " \u2022 $followedUsersCount followed", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = onRefresh, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + } + + Button( + onClick = onCompose, + enabled = account != null && !account.isReadOnly, + ) { + Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("New Post") + } + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 15ab92d4a..36b769f0e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -786,25 +786,29 @@ fun UserProfileScreen( else -> { items(events.distinctBy { it.id }, key = { it.id }) { event -> - FeedNoteCard( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = onCompose, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, - onImageClick = { urls, index -> - lightboxState = LightboxState(urls, index) - }, - onMediaClick = { urls, index, seekPos -> - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .playVideo(urls[index], seekPos) - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .toggleFullscreen() - }, - ) + // Look up Note from cache for the new FeedNoteCard API + val note = localCache.getNoteIfExists(event.id) + if (note != null) { + FeedNoteCard( + note = note, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onCompose, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onImageClick = { urls, index -> + lightboxState = LightboxState(urls, index) + }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt index a19d3ef59..b094bcc79 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch /** @@ -45,4 +46,12 @@ class DesktopFeedViewModel( feedState.refreshSuspended() } } + + /** + * Cancel viewModelScope. ViewModel.clear() is internal in lifecycle KMP, + * so Desktop composables use this for cleanup via DisposableEffect. + */ + fun destroy() { + viewModelScope.cancel() + } } From 50eb03bc3ed4ea9131313aa37f5a7bc081ef1d22 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 08:57:55 +0200 Subject: [PATCH 10/26] =?UTF-8?q?feat(cache):=20UserProfileScreen=20notes?= =?UTF-8?q?=20feed=20=E2=86=92=20DesktopFeedViewModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace EventCollectionState + createUserPostsSubscription + manual cache hydration with DesktopFeedViewModel + DesktopProfileFeedFilter. Profile header subscriptions (metadata, contact list, followers) kept as-is since they're not feed concerns. DisposableEffect cleanup on profile switch. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/UserProfileScreen.kt | 183 +++++++----------- 1 file changed, 72 insertions(+), 111 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 36b769f0e..79dce41fb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -59,6 +59,7 @@ import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -74,24 +75,24 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastBanner import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus -import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -141,19 +142,25 @@ fun UserProfileScreen( val scope = rememberCoroutineScope() - // User's posts - val eventState = - remember { - EventCollectionState( - getId = { it.id }, - sortComparator = compareByDescending { it.createdAt }, - maxSize = 200, - scope = scope, + // User's posts — cache-backed via DesktopFeedViewModel + val profileViewModel = + remember(pubKeyHex) { + DesktopFeedViewModel( + DesktopProfileFeedFilter(pubKeyHex, localCache), + localCache, ) } - val events by eventState.items.collectAsState() - var postsLoading by remember { mutableStateOf(true) } - var postsError by remember { mutableStateOf(null) } + DisposableEffect(profileViewModel) { + onDispose { profileViewModel.destroy() } + } + val profileFeedState by profileViewModel.feedState.feedContent.collectAsState() + val profileLoadedNotes = + if (profileFeedState is FeedState.Loaded) { + val loaded by (profileFeedState as FeedState.Loaded).feed.collectAsState() + loaded.list + } else { + kotlinx.collections.immutable.persistentListOf() + } var retryTrigger by remember { mutableStateOf(0) } // Tab and gallery state @@ -206,26 +213,6 @@ fun UserProfileScreen( } } - // Clear posts when profile changes, then hydrate from cache - remember(pubKeyHex, retryTrigger) { - eventState.clear() - postsLoading = true - postsError = null - } - - // Hydrate from cache — show previously loaded posts instantly - LaunchedEffect(pubKeyHex) { - val cachedNotes = - localCache.notes.filterIntoSet { _, note -> - note.event?.kind == 1 && note.author?.pubkeyHex == pubKeyHex - } - if (cachedNotes.isNotEmpty()) { - val events = cachedNotes.mapNotNull { it.event } - eventState.addItems(events) - postsLoading = false - } - } - // Subscribe to user metadata rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { @@ -313,29 +300,6 @@ fun UserProfileScreen( } } - // Subscribe to user posts - rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { - if (connectedRelays.isNotEmpty()) { - postsLoading = true - postsError = null - createUserPostsSubscription( - relays = connectedRelays, - pubKeyHex = pubKeyHex, - onEvent = { event, _, relay, _ -> - subscriptionsCoordinator?.consumeEvent(event, relay) - eventState.addItem(event) - }, - onEose = { _, _ -> - postsLoading = false - }, - ) - } else { - postsLoading = false - postsError = "No relays configured" - null - } - } - // Subscribe to picture events (kind 20) for gallery tab rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { @@ -722,35 +686,8 @@ fun UserProfileScreen( // Tab content when (selectedTab) { 0 -> { - when { - postsError != null -> { - item(key = "error") { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - "Failed to load posts", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - Spacer(Modifier.height(8.dp)) - Text( - postsError!!, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - OutlinedButton(onClick = { retryTrigger++ }) { - Text("Retry") - } - } - } - } - } - - postsLoading -> { + when (profileFeedState) { + is FeedState.Loading -> { item(key = "loading") { Box( modifier = Modifier.fillMaxWidth().padding(32.dp), @@ -769,7 +706,7 @@ fun UserProfileScreen( } } - events.isEmpty() -> { + is FeedState.Empty -> { item(key = "empty") { Box( modifier = Modifier.fillMaxWidth().padding(32.dp), @@ -784,33 +721,57 @@ fun UserProfileScreen( } } - else -> { - items(events.distinctBy { it.id }, key = { it.id }) { event -> - // Look up Note from cache for the new FeedNoteCard API - val note = localCache.getNoteIfExists(event.id) - if (note != null) { - FeedNoteCard( - note = note, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = onCompose, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, - onImageClick = { urls, index -> - lightboxState = LightboxState(urls, index) - }, - onMediaClick = { urls, index, seekPos -> - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .playVideo(urls[index], seekPos) - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .toggleFullscreen() - }, - ) + is FeedState.FeedError -> { + item(key = "error") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Failed to load posts", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + Text( + (profileFeedState as FeedState.FeedError).errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = { retryTrigger++ }) { + Text("Retry") + } + } } } } + + is FeedState.Loaded -> { + // loadedNotes collected outside LazyColumn in profileLoadedNotes + items(profileLoadedNotes, key = { it.idHex }) { note -> + FeedNoteCard( + note = note, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onCompose, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onImageClick = { urls, index -> + lightboxState = LightboxState(urls, index) + }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + ) + } + } } } From 8c1e54e147e36952593dc12ce567b2c19bff4a16 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 09:00:34 +0200 Subject: [PATCH 11/26] feat(ui): relay health indicator in NavigationRail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RelayHealthIndicator component — shows elapsed time since last relay event. Hidden when <30s (healthy), shows "45s ago" / "3m ago" when stale. Placed above BunkerHeartbeatIndicator in NavigationRail. Reads from Coordinator's subscriptionHealth StateFlow. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 2 + .../ui/components/RelayHealthIndicator.kt | 76 +++++++++++++++++++ .../desktop/ui/deck/SinglePaneLayout.kt | 13 ++++ 3 files changed, 91 insertions(+) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/RelayHealthIndicator.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 9d659b67f..f8a9bd57b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -736,6 +736,7 @@ fun MainContent( Row(Modifier.fillMaxSize().weight(1f)) { when (layoutMode) { LayoutMode.SINGLE_PANE -> { + val healthMap by subscriptionsCoordinator.subscriptionHealth.collectAsState() SinglePaneLayout( relayManager = relayManager, localCache = localCache, @@ -752,6 +753,7 @@ fun MainContent( onZapFeedback = onZapFeedback, signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, + subscriptionHealth = healthMap, modifier = Modifier.weight(1f), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/RelayHealthIndicator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/RelayHealthIndicator.kt new file mode 100644 index 000000000..16249abb2 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/RelayHealthIndicator.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +/** + * Displays relay health as elapsed time since last event. + * Hidden when < 30s (healthy). Shows "45s" or "3m" when stale. + */ +@Composable +fun RelayHealthIndicator( + lastEventReceivedAt: Long?, + modifier: Modifier = Modifier, +) { + if (lastEventReceivedAt == null) return + + // Tick every 5s to update elapsed display + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (true) { + delay(5_000) + now = System.currentTimeMillis() + } + } + + val elapsedMs = now - lastEventReceivedAt + if (elapsedMs < 30_000) return // healthy, don't show + + val text = formatElapsed(elapsedMs) + + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(horizontal = 8.dp), + ) +} + +private fun formatElapsed(elapsedMs: Long): String { + val seconds = elapsedMs / 1000 + return when { + seconds < 60 -> "${seconds}s ago" + seconds < 3600 -> "${seconds / 60}m ago" + else -> "${seconds / 3600}h ago" + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 7afabc73a..489645088 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -65,7 +65,9 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionHealth import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.components.RelayHealthIndicator import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope @@ -108,6 +110,7 @@ fun SinglePaneLayout( onZapFeedback: (ZapFeedback) -> Unit, signerConnectionState: SignerConnectionState, lastPingTimeSec: Long?, + subscriptionHealth: Map = emptyMap(), modifier: Modifier = Modifier, ) { var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } @@ -150,6 +153,16 @@ fun SinglePaneLayout( Spacer(Modifier.weight(1f)) + // Relay health — shows elapsed time since last event (hidden when <30s) + val latestEvent = + subscriptionHealth.values + .mapNotNull { it.lastEventReceivedAt } + .maxOrNull() + RelayHealthIndicator( + lastEventReceivedAt = latestEvent, + modifier = Modifier.padding(bottom = 4.dp), + ) + BunkerHeartbeatIndicator( signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, From c598c6135c413fb1b42f9fa47de4ac8062820445 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 09:07:16 +0200 Subject: [PATCH 12/26] =?UTF-8?q?feat(cache):=20ThreadScreen=20=E2=86=92?= =?UTF-8?q?=20DesktopFeedViewModel=20+=20DesktopThreadFilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace EventCollectionState + 7 rememberSubscription blocks + inline count maps with DesktopFeedViewModel + DesktopThreadFilter (graph walk). Keep root note + replies relay subscriptions to populate cache. Use FeedNoteCard for both root and reply notes (reads counts from Note.flowSet). Remove ~200 lines of inline state management (zapsByEvent, reactionIdsByEvent, replyIdsByEvent, repostIdsByEvent, bookmarkList, bookmark subscription). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/ThreadScreen.kt | 496 +++++------------- 1 file changed, 141 insertions(+), 355 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index c9ab39e30..b3c3a1192 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -42,48 +42,38 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.richtext.UrlParser -import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator -import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders -import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createNoteSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription -import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay -import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard -import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys +import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent /** * Desktop Thread Screen - displays a note and all its replies in a thread view. * - * Uses the shared drawReplyLevel modifier from commons to display reply nesting. + * Uses DesktopFeedViewModel + DesktopThreadFilter for cache-backed display. + * Keeps relay subscriptions to populate cache with thread data. */ @Composable fun ThreadScreen( @@ -100,104 +90,37 @@ fun ThreadScreen( onReply: (Event) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() - val scope = rememberCoroutineScope() - - // State for the root note - var rootNote by remember { mutableStateOf(null) } - - // State for reply events - val replyEventState = - remember(noteId) { - EventCollectionState( - getId = { it.id }, - sortComparator = compareBy { it.createdAt }, - maxSize = 500, - scope = scope, - ) - } - val replyEvents by replyEventState.items.collectAsState() - - // Cache for calculating reply levels - val levelCache = remember(noteId) { mutableMapOf() } - - // Track EOSE to know when initial load is complete - var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) } - var repliesEoseReceived by remember(noteId) { mutableStateOf(false) } - - // Track zaps per event - var zapsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } - // Track reaction event IDs per target event to deduplicate - var reactionIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } - val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size } - // Track reply/repost event IDs per target event to deduplicate - var replyIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } - val repliesByEvent = replyIdsByEvent.mapValues { it.value.size } - var repostIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } - val repostsByEvent = repostIdsByEvent.mapValues { it.value.size } - - // Bookmark state - var bookmarkList by remember { mutableStateOf(null) } - var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } // Lightbox state var lightboxState by remember { mutableStateOf(null) } - // Load metadata for thread authors + mentioned users via coordinator - LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null) { - val pubkeys = mutableListOf() - rootNote?.let { pubkeys.add(it.pubKey) } - pubkeys.addAll(replyEvents.map { it.pubKey }) + // Track EOSE for root note subscription + var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) } - // Also load metadata for users mentioned in note content - val parser = UrlParser() - val allEvents = listOfNotNull(rootNote) + replyEvents - val mentionedPubkeys = - allEvents.flatMap { event -> - extractMentionedPubkeys(parser.parseValidUrls(event.content).bech32s) - } - pubkeys.addAll(mentionedPubkeys) - - if (pubkeys.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct()) - } - } - } - - // Subscribe to user's bookmark list - rememberSubscription(connectedRelays, account, relayManager = relayManager) { - if (connectedRelays.isNotEmpty() && account != null) { - SubscriptionConfig( - subId = "thread-bookmarks-${account.pubKeyHex.take(8)}", - filters = - listOf( - FilterBuilders.byAuthors( - authors = listOf(account.pubKeyHex), - kinds = listOf(BookmarkListEvent.KIND), - limit = 1, - ), - ), - relays = connectedRelays, - onEvent = { event, _, _, _ -> - if (event is BookmarkListEvent) { - bookmarkList = event - val pubIds = - event - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds - } - }, - onEose = { _, _ -> }, + // DesktopFeedViewModel reads thread from cache (root + replies via graph walk) + val threadViewModel = + remember(noteId) { + DesktopFeedViewModel( + DesktopThreadFilter(noteId, localCache), + localCache, ) - } else { - null } + DisposableEffect(threadViewModel) { + onDispose { threadViewModel.destroy() } } + val feedState by threadViewModel.feedState.feedContent.collectAsState() + val threadNotes = + if (feedState is FeedState.Loaded) { + val loaded by (feedState as FeedState.Loaded).feed.collectAsState() + loaded.list + } else { + kotlinx.collections.immutable.persistentListOf() + } - // Subscribe to the root note + // Level cache for reply nesting + val levelCache = remember(noteId) { mutableMapOf() } + + // Keep relay subscriptions to populate cache — root note rememberSubscription(connectedRelays, noteId, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { createNoteSubscription( @@ -205,10 +128,7 @@ fun ThreadScreen( noteId = noteId, onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) - if (event.id == noteId) { - rootNote = event - levelCache[event.id] = 0 - } + levelCache[event.id] = 0 }, onEose = { _, _ -> rootNoteEoseReceived = true @@ -219,7 +139,7 @@ fun ThreadScreen( } } - // Subscribe to replies + // Keep relay subscription for replies rememberSubscription(connectedRelays, noteId, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { createThreadRepliesSubscription( @@ -227,122 +147,41 @@ fun ThreadScreen( noteId = noteId, onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) - replyEventState.addItem(event) - }, - onEose = { _, _ -> - repliesEoseReceived = true }, + onEose = { _, _ -> }, ) } else { null } } - // Subscribe to zaps for thread events - val allEventIds = listOf(noteId) + replyEvents.map { it.id } - rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { - return@rememberSubscription null - } + // Request interaction data (zaps, reactions, reposts) for visible thread notes + DisposableEffect(threadNotes, subscriptionsCoordinator) { + val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} + val noteIds = threadNotes.mapNotNull { it.event?.id } + if (noteIds.isEmpty()) return@DisposableEffect onDispose {} - createZapsSubscription( - relays = connectedRelays, - eventIds = allEventIds, - onEvent = { event, _, _, _ -> - if (event is LnZapEvent) { - val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription - val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription - zapsByEvent = - zapsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptyList() - if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { - this[targetEventId] = existing + receipt - } - } - } - }, - ) + val relays = relayManager.relayStatuses.value.keys + val subId = coordinator.requestInteractions(noteIds, relays) + onDispose { coordinator.releaseInteractions(subId) } } - // Subscribe to reactions for thread events - rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { - return@rememberSubscription null + // Load metadata for thread authors via coordinator + LaunchedEffect(threadNotes, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && threadNotes.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForNotes(threadNotes) } - - createReactionsSubscription( - relays = connectedRelays, - eventIds = allEventIds, - onEvent = { event, _, _, _ -> - if (event is ReactionEvent) { - val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription - reactionIdsByEvent = - reactionIdsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptySet() - this[targetEventId] = existing + event.id - } - } - }, - ) } - // Subscribe to replies for thread events (for counts) - rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { - return@rememberSubscription null - } - - createRepliesSubscription( - relays = connectedRelays, - eventIds = allEventIds, - onEvent = { event, _, _, _ -> - val replyToId = - event.tags - .filter { it.size >= 2 && it[0] == "e" } - .lastOrNull() - ?.get(1) ?: return@createRepliesSubscription - if (replyToId in allEventIds) { - replyIdsByEvent = - replyIdsByEvent.toMutableMap().apply { - val existing = this[replyToId] ?: emptySet() - this[replyToId] = existing + event.id - } - } - }, - ) - } - - // Subscribe to reposts for thread events - rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { - return@rememberSubscription null - } - - createRepostsSubscription( - relays = connectedRelays, - eventIds = allEventIds, - onEvent = { event, _, _, _ -> - if (event is RepostEvent) { - val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription - repostIdsByEvent = - repostIdsByEvent.toMutableMap().apply { - val existing = this[targetEventId] ?: emptySet() - this[targetEventId] = existing + event.id - } - } - }, - ) - } - - // Calculate reply level for an event based on e-tags - fun calculateLevel(event: Event): Int { + // Calculate reply level for a note based on e-tags + fun calculateLevel(note: Note): Int { + val event = note.event ?: return 1 levelCache[event.id]?.let { return it } - // Find the event this is replying to (last e-tag or marked reply/root) val replyToId = findReplyToId(event) val level = if (replyToId == null || replyToId == noteId) { - 1 // Direct reply to root + 1 } else { (levelCache[replyToId] ?: 0) + 1 } @@ -350,6 +189,9 @@ fun ThreadScreen( return level } + val rootNote = threadNotes.firstOrNull { it.idHex == noteId } + val replyNotes = threadNotes.filter { it.idHex != noteId } + Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { // Header with back button @@ -372,165 +214,111 @@ fun ThreadScreen( ) } - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else if (rootNote == null && !rootNoteEoseReceived) { - LoadingState("Loading thread...") - } else if (rootNote == null && rootNoteEoseReceived) { - EmptyState( - title = "Note not found", - description = "This note may have been deleted or is not available from connected relays", - onRefresh = onBack, - refreshLabel = "Go back", - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - // Root note (no reply level indicator) - item(key = noteId) { - Column { - NoteCard( - note = rootNote!!.toNoteDisplayData(localCache), - localCache = localCache, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, - onMediaClick = { urls, index, seekPos -> - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .playVideo(urls[index], seekPos) - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .toggleFullscreen() - }, - ) - if (account != null) { - val rootZaps = zapsByEvent[noteId] ?: emptyList() - NoteActionsRow( - event = rootNote!!, + when { + connectedRelays.isEmpty() -> { + LoadingState("Connecting to relays...") + } + + feedState is FeedState.Loading && !rootNoteEoseReceived -> { + LoadingState("Loading thread...") + } + + rootNote == null && rootNoteEoseReceived -> { + EmptyState( + title = "Note not found", + description = "This note may have been deleted or is not available from connected relays", + onRefresh = onBack, + refreshLabel = "Go back", + ) + } + + else -> { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + // Root note + if (rootNote != null) { + item(key = noteId) { + Column { + FeedNoteCard( + note = rootNote, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = { rootNote.event?.let { onReply(it) } }, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> + lightboxState = LightboxState(urls, index) + }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + ) + } + HorizontalDivider(thickness = 1.dp) + } + } + + // Reply notes with level indicators + items(replyNotes, key = { it.idHex }) { note -> + val level = calculateLevel(note) + Column( + modifier = + Modifier + .drawReplyLevel( + level = level, + color = MaterialTheme.colorScheme.outlineVariant, + selected = MaterialTheme.colorScheme.outlineVariant, + ).clickable { + note.event?.let { onNavigateToThread(it.id) } + }, + ) { + FeedNoteCard( + note = note, relayManager = relayManager, localCache = localCache, account = account, nwcConnection = nwcConnection, - onReplyClick = { onReply(rootNote!!) }, + onReply = { note.event?.let { onReply(it) } }, onZapFeedback = onZapFeedback, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = rootZaps.size, - zapAmountSats = rootZaps.sumOf { it.amountSats }, - zapReceipts = rootZaps, - reactionCount = reactionsByEvent[noteId] ?: 0, - replyCount = repliesByEvent[noteId] ?: 0, - repostCount = repostsByEvent[noteId] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(noteId), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> + lightboxState = LightboxState(urls, index) + }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() }, ) } + HorizontalDivider(thickness = 1.dp) } - HorizontalDivider(thickness = 1.dp) - } - // Reply notes with level indicators - items(replyEvents.distinctBy { it.id }, key = { it.id }) { event -> - val level = calculateLevel(event) - - Column( - modifier = - Modifier - .drawReplyLevel( - level = level, - color = MaterialTheme.colorScheme.outlineVariant, - selected = - if (event.id == noteId) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outlineVariant - }, - ).clickable { - onNavigateToThread(event.id) - }, - ) { - NoteCard( - note = event.toNoteDisplayData(localCache), - localCache = localCache, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, - onMediaClick = { urls, index, seekPos -> - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .playVideo(urls[index], seekPos) - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .toggleFullscreen() - }, - ) - if (account != null) { - val eventZaps = zapsByEvent[event.id] ?: emptyList() - NoteActionsRow( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReplyClick = { onReply(event) }, - onZapFeedback = onZapFeedback, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = eventZaps.size, - zapAmountSats = eventZaps.sumOf { it.amountSats }, - zapReceipts = eventZaps, - reactionCount = reactionsByEvent[event.id] ?: 0, - replyCount = repliesByEvent[event.id] ?: 0, - repostCount = repostsByEvent[event.id] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(event.id), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds - }, + // Empty/loading state for replies + if (replyNotes.isEmpty()) { + item { + Spacer(Modifier.height(32.dp)) + Text( + "No replies yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), ) } } - HorizontalDivider(thickness = 1.dp) - } - - // Empty state for no replies - if (replyEvents.isEmpty() && repliesEoseReceived) { - item { - Spacer(Modifier.height(32.dp)) - Text( - "No replies yet", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - ) - } - } else if (replyEvents.isEmpty() && !repliesEoseReceived) { - item { - Spacer(Modifier.height(32.dp)) - Text( - "Loading replies...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - ) - } } } } - } // end Column + } // Lightbox overlay val lb = lightboxState @@ -543,7 +331,7 @@ fun ThreadScreen( onDismiss = { lightboxState = null }, ) } - } // end Box + } } /** @@ -554,13 +342,11 @@ private fun findReplyToId(event: Event): String? { val eTags = event.tags.filter { it.size >= 2 && it[0] == "e" } if (eTags.isEmpty()) return null - // Check for NIP-10 marked tags first val replyTag = eTags.find { it.size >= 4 && it[3] == "reply" } if (replyTag != null) return replyTag[1] val rootTag = eTags.find { it.size >= 4 && it[3] == "root" } if (rootTag != null && eTags.size == 1) return rootTag[1] - // Fall back to positional (last e-tag is the reply-to) return eTags.lastOrNull()?.get(1) } From 4f5665a2bd4b0c76618164f6b8850a01527b0e3b Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 09:16:51 +0200 Subject: [PATCH 13/26] =?UTF-8?q?fix(cache):=20address=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20subscription=20churn,=20O(n)=20size,=20atomicity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 fixes from compound engineering review: - Fix subscription churn flooding relays: DisposableEffect keyed on feedMode/noteId (stable) instead of feedState (changes every 250ms) - BoundedLargeCache: AtomicInteger counter replaces O(n) ConcurrentSkipListMap.size() — eliminates 50K traversal per event - Non-atomic updateHealth(): use MutableStateFlow.update{} for atomic compare-and-set instead of read-modify-write race P2 fixes: - DesktopThreadFilter: LinkedHashSet for O(1) containment checks, down from O(R²) with MutableList - consumeContactList: createdAt guard ensures newer events always win - consumeEvent error logging: include event ID and relay URL Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/cache/BoundedLargeCache.kt | 43 +++++++++++++------ .../desktop/cache/DesktopLocalCache.kt | 11 ++--- .../desktop/feeds/DesktopFeedFilters.kt | 15 ++++--- .../DesktopRelaySubscriptionsCoordinator.kt | 19 ++++---- .../amethyst/desktop/ui/FeedScreen.kt | 22 +++++----- .../amethyst/desktop/ui/ThreadScreen.kt | 15 ++++--- 6 files changed, 75 insertions(+), 50 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt index 576b50ae1..46b3acc1c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt @@ -22,24 +22,22 @@ package com.vitorpamplona.amethyst.desktop.cache import com.vitorpamplona.quartz.utils.cache.CacheCollectors import com.vitorpamplona.quartz.utils.cache.LargeCache +import java.util.concurrent.atomic.AtomicInteger /** * A bounded wrapper around [LargeCache] that enforces a maximum size. * - * When the cache exceeds [maxSize], the oldest entries (by key order) are evicted. - * Uses [LargeCache] (ConcurrentSkipListMap) for lock-free reads and rich query APIs - * (filterIntoSet, mapNotNull, etc.) matching Android's LocalCache patterns. + * When the cache exceeds [maxSize], entries are evicted by key order. + * Uses [LargeCache] (ConcurrentSkipListMap) for lock-free reads and rich query APIs. * - * Chosen over LruCache because: - * - Lock-free reads via ConcurrentSkipListMap (vs synchronized on every get()) - * - Rich query API matching Android's filter patterns - * - No snapshot copy overhead for iteration + * Size tracking uses AtomicInteger (O(1)) instead of ConcurrentSkipListMap.size() (O(n)). */ class BoundedLargeCache, V>( private val maxSize: Int, private val evictPercent: Float = 0.1f, ) { private val inner = LargeCache() + private val sizeCounter = AtomicInteger(0) fun get(key: K): V? = inner.get(key) @@ -47,7 +45,9 @@ class BoundedLargeCache, V>( key: K, value: V, ) { + val existing = inner.get(key) inner.put(key, value) + if (existing == null) sizeCounter.incrementAndGet() enforceSize() } @@ -55,20 +55,33 @@ class BoundedLargeCache, V>( key: K, builder: (K) -> V, ): V { + val existing = inner.get(key) + if (existing != null) return existing val result = inner.getOrCreate(key, builder) + // Increment if we were the ones who created it (not a concurrent insert) + if (inner.get(key) === result) { + sizeCounter.incrementAndGet() + } enforceSize() return result } - fun remove(key: K): V? = inner.remove(key) + fun remove(key: K): V? { + val removed = inner.remove(key) + if (removed != null) sizeCounter.decrementAndGet() + return removed + } fun containsKey(key: K): Boolean = inner.containsKey(key) - fun size(): Int = inner.size() + fun size(): Int = sizeCounter.get() - fun isEmpty(): Boolean = inner.isEmpty() + fun isEmpty(): Boolean = sizeCounter.get() == 0 - fun clear() = inner.clear() + fun clear() { + inner.clear() + sizeCounter.set(0) + } fun keys(): Set = inner.keys() @@ -83,11 +96,15 @@ class BoundedLargeCache, V>( fun count(consumer: CacheCollectors.BiFilter): Int = inner.count(consumer) private fun enforceSize() { - val currentSize = inner.size() + val currentSize = sizeCounter.get() if (currentSize > maxSize) { val toRemove = (maxSize * evictPercent).toInt().coerceAtLeast(1) val keys = inner.keys().take(toRemove) - keys.forEach { inner.remove(it) } + keys.forEach { + if (inner.remove(it) != null) { + sizeCounter.decrementAndGet() + } + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index b382d4be2..a83a864cd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -295,12 +295,13 @@ class DesktopLocalCache : ICacheProvider { * Consumes a kind 3 contact list event (replaceable). * Updates the cached followedUsers set. */ + private var lastContactListCreatedAt = 0L + private fun consumeContactList(event: ContactListEvent): Boolean { - val currentFollows = _followedUsers.value - val newFollows = event.verifiedFollowKeySet() - if (newFollows != currentFollows) { - _followedUsers.value = newFollows - } + // Replaceable event — only accept newer contact lists + if (event.createdAt <= lastContactListCreatedAt) return false + lastContactListCreatedAt = event.createdAt + _followedUsers.value = event.verifiedFollowKeySet() return true } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index 073dcad31..8f87d901a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -94,19 +94,20 @@ class DesktopThreadFilter( override fun feed(): List { val root = cache.getNoteIfExists(noteId) ?: return emptyList() - val result = mutableListOf(root) - collectReplies(root, result) - return result.sortedWith(compareBy { it.createdAt() ?: 0 }) + // Use LinkedHashSet for O(1) containment checks (was O(R) with MutableList) + val seen = LinkedHashSet() + seen.add(root) + collectReplies(root, seen) + return seen.sortedWith(compareBy { it.createdAt() ?: 0 }) } private fun collectReplies( note: Note, - result: MutableList, + seen: LinkedHashSet, ) { for (reply in note.replies) { - if (reply !in result) { - result.add(reply) - collectReplies(reply, result) + if (seen.add(reply)) { + collectReplies(reply, seen) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 1f267a177..70e5dceee 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap @@ -120,15 +121,16 @@ class DesktopRelaySubscriptionsCoordinator( lastEventReceivedAt: Long? = null, eoseReceived: Boolean? = null, ) { - _subscriptionHealth.value = - _subscriptionHealth.value.toMutableMap().apply { - val current = this[subId] ?: SubscriptionHealth() + _subscriptionHealth.update { current -> + current.toMutableMap().apply { + val existing = this[subId] ?: SubscriptionHealth() this[subId] = - current.copy( - lastEventReceivedAt = lastEventReceivedAt ?: current.lastEventReceivedAt, - eoseReceived = eoseReceived ?: current.eoseReceived, + existing.copy( + lastEventReceivedAt = lastEventReceivedAt ?: existing.lastEventReceivedAt, + eoseReceived = eoseReceived ?: existing.eoseReceived, ) } + } } /** @@ -150,7 +152,7 @@ class DesktopRelaySubscriptionsCoordinator( } } } catch (e: Exception) { - println("Coordinator: failed to consume kind ${event.kind}: ${e.message}") + println("Coordinator: failed to consume kind=${event.kind} id=${event.id} relay=$relay: ${e.message}") } } } @@ -230,8 +232,7 @@ class DesktopRelaySubscriptionsCoordinator( fun releaseInteractions(subId: String) { screenSubscriptions.remove(subId)?.cancel() client.close(subId) - _subscriptionHealth.value = - _subscriptionHealth.value.toMutableMap().apply { remove(subId) } + _subscriptionHealth.update { it - subId } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 1b0b8063f..64c02825d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -204,17 +204,19 @@ fun FeedScreen( } } - // Request interaction subscriptions (zaps, reactions, reposts) for visible notes - DisposableEffect(feedState, subscriptionsCoordinator) { + // Request interaction subscriptions — keyed on feedMode (stable), not feedState (changes every 250ms) + DisposableEffect(feedMode, subscriptionsCoordinator) { val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} - val notes = viewModel.feedState.visibleNotes() - val noteIds = notes.mapNotNull { it.event?.id } - if (noteIds.isEmpty()) return@DisposableEffect onDispose {} - - val relays = - relayManager.relayStatuses.value.keys - val subId = coordinator.requestInteractions(noteIds, relays) - onDispose { coordinator.releaseInteractions(subId) } + val relays = relayManager.relayStatuses.value.keys + // Initial subscription with whatever notes are visible now + val noteIds = viewModel.feedState.visibleNotes().mapNotNull { it.event?.id } + val subId = + if (noteIds.isNotEmpty()) { + coordinator.requestInteractions(noteIds, relays) + } else { + null + } + onDispose { subId?.let { coordinator.releaseInteractions(it) } } } @OptIn(ExperimentalLayoutApi::class) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index b3c3a1192..4b6962c24 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -155,15 +155,18 @@ fun ThreadScreen( } } - // Request interaction data (zaps, reactions, reposts) for visible thread notes - DisposableEffect(threadNotes, subscriptionsCoordinator) { + // Request interaction data — keyed on noteId (stable), not threadNotes (changes on every bundle) + DisposableEffect(noteId, subscriptionsCoordinator) { val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} val noteIds = threadNotes.mapNotNull { it.event?.id } - if (noteIds.isEmpty()) return@DisposableEffect onDispose {} - val relays = relayManager.relayStatuses.value.keys - val subId = coordinator.requestInteractions(noteIds, relays) - onDispose { coordinator.releaseInteractions(subId) } + val subId = + if (noteIds.isNotEmpty()) { + coordinator.requestInteractions(noteIds, relays) + } else { + null + } + onDispose { subId?.let { coordinator.releaseInteractions(it) } } } // Load metadata for thread authors via coordinator From 0621e8f7c2383ed2100621e696c601b3defe5f4a Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 12:07:22 +0200 Subject: [PATCH 14/26] =?UTF-8?q?refactor(cache):=20P3=20simplifications?= =?UTF-8?q?=20=E2=80=94=20remove=20dead=20code,=20reduce=20abstractions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace SubscriptionHealth map + data class with single lastEventAt Long? timestamp (only consumer was RelayHealthIndicator taking max()) - Replace consumer HashMap registry with when block (safe casts, compiler-checked, 9 kinds doesn't benefit from O(1) lookup) - Remove dead loadReactionsForNotes() (superseded by requestInteractions) - Remove unused BoundedLargeCache methods: containsKey, isEmpty, mapNotNull, forEach (zero external callers) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 4 +- .../desktop/cache/BoundedLargeCache.kt | 8 --- .../desktop/cache/DesktopLocalCache.kt | 69 ++++++++++++------- .../DesktopRelaySubscriptionsCoordinator.kt | 48 ++----------- .../desktop/ui/deck/SinglePaneLayout.kt | 9 +-- 5 files changed, 55 insertions(+), 83 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index f8a9bd57b..0dfaa05c0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -736,7 +736,7 @@ fun MainContent( Row(Modifier.fillMaxSize().weight(1f)) { when (layoutMode) { LayoutMode.SINGLE_PANE -> { - val healthMap by subscriptionsCoordinator.subscriptionHealth.collectAsState() + val lastRelayEvent by subscriptionsCoordinator.lastEventAt.collectAsState() SinglePaneLayout( relayManager = relayManager, localCache = localCache, @@ -753,7 +753,7 @@ fun MainContent( onZapFeedback = onZapFeedback, signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, - subscriptionHealth = healthMap, + lastRelayEventAt = lastRelayEvent, modifier = Modifier.weight(1f), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt index 46b3acc1c..4b71abe10 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt @@ -72,12 +72,8 @@ class BoundedLargeCache, V>( return removed } - fun containsKey(key: K): Boolean = inner.containsKey(key) - fun size(): Int = sizeCounter.get() - fun isEmpty(): Boolean = sizeCounter.get() == 0 - fun clear() { inner.clear() sizeCounter.set(0) @@ -89,10 +85,6 @@ class BoundedLargeCache, V>( fun filterIntoSet(consumer: CacheCollectors.BiFilter): Set = inner.filterIntoSet(consumer) - fun mapNotNull(consumer: CacheCollectors.BiMapper): List = inner.mapNotNull(consumer) - - fun forEach(consumer: java.util.function.BiConsumer) = inner.forEach(consumer) - fun count(consumer: CacheCollectors.BiFilter): Int = inner.count(consumer) private fun enforceSize() { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index a83a864cd..78d7f2e7e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -82,25 +82,6 @@ class DesktopLocalCache : ICacheProvider { val paymentTracker = NwcPaymentTracker() - // ----- Kind-based event consumer registry ----- - - private val consumers = HashMap Boolean>() - - init { - consumers[MetadataEvent.KIND] = { e, _ -> - 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[LnZapRequestEvent.KIND] = { e, r -> consumeZapRequest(e as LnZapRequestEvent, r) } - consumers[LnZapEvent.KIND] = { e, r -> consumeZap(e as LnZapEvent, r) } - consumers[RepostEvent.KIND] = { e, r -> consumeRepost(e as RepostEvent, r) } - consumers[ContactListEvent.KIND] = { e, _ -> consumeContactList(e as ContactListEvent) } - consumers[LongTextNoteEvent.KIND] = { e, r -> consumeLongTextNote(e as LongTextNoteEvent, r) } - consumers[BookmarkListEvent.KIND] = { e, _ -> consumeBookmarkList(e as BookmarkListEvent) } - } - // ----- User operations ----- override fun getUserIfExists(pubkey: HexKey): User? = users.get(pubkey) @@ -170,16 +151,58 @@ class DesktopLocalCache : ICacheProvider { } } - // ----- Event consumption (kind-based registry) ----- + // ----- Event consumption ----- /** - * Routes an event to the appropriate consume method via kind-based registry. - * O(1) dispatch. Returns true if the event was consumed (new), false if already seen. + * Routes an event to the appropriate consume method. + * Returns true if the event was consumed (new), false if already seen. */ fun consume( event: Event, relay: NormalizedRelayUrl?, - ): Boolean = consumers[event.kind]?.invoke(event, relay) ?: false + ): Boolean = + when (event) { + is MetadataEvent -> { + consumeMetadata(event) + true + } + + is TextNoteEvent -> { + consumeTextNote(event, relay) + } + + is ReactionEvent -> { + consumeReaction(event, relay) + } + + is LnZapRequestEvent -> { + consumeZapRequest(event, relay) + } + + is LnZapEvent -> { + consumeZap(event, relay) + } + + is RepostEvent -> { + consumeRepost(event, relay) + } + + is ContactListEvent -> { + consumeContactList(event) + } + + is LongTextNoteEvent -> { + consumeLongTextNote(event, relay) + } + + is BookmarkListEvent -> { + consumeBookmarkList(event) + } + + else -> { + false + } + } /** * Consumes a kind 1 text note event. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 70e5dceee..29121e4b1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -40,7 +40,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap @@ -68,11 +67,6 @@ import java.util.concurrent.ConcurrentHashMap * } * ``` */ -data class SubscriptionHealth( - val lastEventReceivedAt: Long? = null, - val eoseReceived: Boolean = false, -) - class DesktopRelaySubscriptionsCoordinator( private val client: INostrClient, private val scope: CoroutineScope, @@ -112,26 +106,9 @@ class DesktopRelaySubscriptionsCoordinator( // Screen-triggered subscription Jobs — keyed by subId for proper cancellation private val screenSubscriptions = ConcurrentHashMap() - // Subscription health tracking - private val _subscriptionHealth = MutableStateFlow>(emptyMap()) - val subscriptionHealth: StateFlow> = _subscriptionHealth.asStateFlow() - - private fun updateHealth( - subId: String, - lastEventReceivedAt: Long? = null, - eoseReceived: Boolean? = null, - ) { - _subscriptionHealth.update { current -> - current.toMutableMap().apply { - val existing = this[subId] ?: SubscriptionHealth() - this[subId] = - existing.copy( - lastEventReceivedAt = lastEventReceivedAt ?: existing.lastEventReceivedAt, - eoseReceived = eoseReceived ?: existing.eoseReceived, - ) - } - } - } + // Last event received from any subscription — drives RelayHealthIndicator + private val _lastEventAt = MutableStateFlow(null) + val lastEventAt: StateFlow = _lastEventAt.asStateFlow() /** * Central event router — consumes an event into the cache and emits to event stream. @@ -146,6 +123,7 @@ class DesktopRelaySubscriptionsCoordinator( try { val consumed = localCache.consume(event, relay) if (consumed) { + _lastEventAt.value = System.currentTimeMillis() val note = localCache.getNoteIfExists(event.id) ?: return@launch eventBundler.invalidateList(note) { batch -> localCache.eventStream.emitNewNotes(batch) @@ -202,14 +180,6 @@ class DesktopRelaySubscriptionsCoordinator( forFilters: List?, ) { consumeEvent(event, relay) - updateHealth(subId, lastEventReceivedAt = System.currentTimeMillis()) - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - updateHealth(subId, eoseReceived = true) } } @@ -232,7 +202,6 @@ class DesktopRelaySubscriptionsCoordinator( fun releaseInteractions(subId: String) { screenSubscriptions.remove(subId)?.cancel() client.close(subId) - _subscriptionHealth.update { it - subId } } /** @@ -276,13 +245,6 @@ class DesktopRelaySubscriptionsCoordinator( feedMetadata.loadMetadataForPubkeys(pubkeys) } - /** - * Load reactions for specific notes. - */ - fun loadReactionsForNotes(noteIds: List) { - feedMetadata.loadReactionsForNotes(noteIds) - } - // -- DM Subscription Support -- /** Active DM subscription IDs for cleanup */ @@ -389,7 +351,7 @@ class DesktopRelaySubscriptionsCoordinator( client.close(subId) } screenSubscriptions.clear() - _subscriptionHealth.value = emptyMap() + _lastEventAt.value = null unsubscribeFromDms() feedMetadata.clear() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 489645088..63adf3f56 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -65,7 +65,6 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator -import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionHealth import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.components.RelayHealthIndicator import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen @@ -110,7 +109,7 @@ fun SinglePaneLayout( onZapFeedback: (ZapFeedback) -> Unit, signerConnectionState: SignerConnectionState, lastPingTimeSec: Long?, - subscriptionHealth: Map = emptyMap(), + lastRelayEventAt: Long? = null, modifier: Modifier = Modifier, ) { var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } @@ -154,12 +153,8 @@ fun SinglePaneLayout( Spacer(Modifier.weight(1f)) // Relay health — shows elapsed time since last event (hidden when <30s) - val latestEvent = - subscriptionHealth.values - .mapNotNull { it.lastEventReceivedAt } - .maxOrNull() RelayHealthIndicator( - lastEventReceivedAt = latestEvent, + lastEventReceivedAt = lastRelayEventAt, modifier = Modifier.padding(bottom = 4.dp), ) From 1e1bc1b172691b2ab91774c792094f50da988637 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 13:46:27 +0200 Subject: [PATCH 15/26] fix(cache): add missing relay subscriptions for FeedScreen and UserProfileScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FeedScreen and UserProfileScreen used DesktopFeedViewModel (cache-backed) but never subscribed to relays for kind 1/kind 3 events, causing 0 notes, 0 followed, and 0 relays on the home feed. Adds rememberSubscription calls for: - Contact list (kind 3) in FeedScreen → populates followedUsers - Global/following feed (kind 1) in FeedScreen → populates cache - Profile notes (kind 1) in UserProfileScreen → populates cache Also adds 33 integration tests covering the full cache pipeline: - DesktopCachePipelineTest: cache → filter → ViewModel (25 tests) - CoordinatorPipelineTest: coordinator → cache → ViewModel (8 tests) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 50 ++ .../amethyst/desktop/ui/UserProfileScreen.kt | 23 + .../desktop/cache/CoordinatorPipelineTest.kt | 402 ++++++++++++ .../desktop/cache/DesktopCachePipelineTest.kt | 571 ++++++++++++++++++ 4 files changed, 1046 insertions(+) create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 64c02825d..961d61fae 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -67,6 +67,10 @@ import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel @@ -169,6 +173,52 @@ fun FeedScreen( var lightboxState by remember { mutableStateOf(null) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } + // Subscribe to contact list (kind 3) — populates localCache.followedUsers + rememberSubscription(connectedRelays, account, relayManager = relayManager) { + if (connectedRelays.isNotEmpty() && account != null) { + createContactListSubscription( + relays = connectedRelays, + pubKeyHex = account.pubKeyHex, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } else { + null + } + } + + // Subscribe to feed events (kind 1) — populates cache via coordinator + rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) { + if (connectedRelays.isEmpty()) return@rememberSubscription null + + when (feedMode) { + FeedMode.GLOBAL -> { + createGlobalFeedSubscription( + relays = connectedRelays, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } + + FeedMode.FOLLOWING -> { + val follows = followedUsers.toList() + if (follows.isNotEmpty()) { + createFollowingFeedSubscription( + relays = connectedRelays, + followedUsers = follows, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } else { + null + } + } + } + } + // DesktopFeedViewModel keyed on feedMode — recreated on mode switch val viewModel = remember(feedMode) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 79dce41fb..e772eda71 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -163,6 +163,29 @@ fun UserProfileScreen( } var retryTrigger by remember { mutableStateOf(0) } + // Subscribe to profile user's text notes (kind 1) — populates cache for DesktopFeedViewModel + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + SubscriptionConfig( + subId = generateSubId("profile-notes-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.textNotesFromAuthors( + authors = listOf(pubKeyHex), + limit = 200, + ), + ), + relays = connectedRelays, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Tab and gallery state var selectedTab by remember { mutableStateOf(0) } var lightboxState by remember { mutableStateOf(null) } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt new file mode 100644 index 000000000..eb3d04558 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt @@ -0,0 +1,402 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.cache + +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Integration tests for the Coordinator → Cache → ViewModel pipeline. + * + * These tests use a stub INostrClient (no real relay connections) and exercise + * the full event consumption path through DesktopRelaySubscriptionsCoordinator. + * + * Key invariant being tested: when coordinator.consumeEvent() is called, + * the event should flow through cache → eventStream → ViewModel.feedState. + */ +class CoordinatorPipelineTest { + private val userPubKey = "a".repeat(64) + private val followedPubKey = "b".repeat(64) + private val dummySig = "0".repeat(128) + private val relayUrl = NormalizedRelayUrl("wss://relay.test/") + + private suspend fun waitForBundler() = delay(600) + + /** + * Stub INostrClient — records subscription calls but doesn't connect to any relay. + * This lets us test the coordinator's event routing without network dependencies. + */ + private class StubNostrClient : INostrClient { + val openedSubs = mutableMapOf>, IRequestListener?>>() + + override fun connectedRelaysFlow(): StateFlow> = MutableStateFlow(emptySet()) + + override fun availableRelaysFlow(): StateFlow> = MutableStateFlow(emptySet()) + + override fun connect() {} + + override fun disconnect() {} + + override fun reconnect( + onlyIfChanged: Boolean, + ignoreRetryDelays: Boolean, + ) {} + + override fun isActive() = false + + override fun renewFilters(relay: IRelayClient) {} + + override fun openReqSubscription( + subId: String, + filters: Map>, + listener: IRequestListener?, + ) { + openedSubs[subId] = filters to listener + } + + override fun queryCount( + subId: String, + filters: Map>, + ) {} + + override fun close(subId: String) { + openedSubs.remove(subId) + } + + override fun send( + event: Event, + relayList: Set, + ) {} + + override fun subscribe(listener: IRelayClientListener) {} + + override fun unsubscribe(listener: IRelayClientListener) {} + + override fun getReqFiltersOrNull(subId: String): Map>? = null + + override fun getCountFiltersOrNull(subId: String): Map>? = null + + override fun activeRequests(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + } + + private fun createCoordinator( + cache: DesktopLocalCache, + scope: CoroutineScope, + ): Pair { + val client = StubNostrClient() + val coordinator = + DesktopRelaySubscriptionsCoordinator( + client = client, + scope = scope, + indexRelays = setOf(relayUrl), + localCache = cache, + ) + return coordinator to client + } + + // ----------------------------------------------------------------------- + // 1. Coordinator → Cache → ViewModel flow + // ----------------------------------------------------------------------- + + @Test + fun `consumeEvent routes text note into cache and triggers ViewModel update`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + waitForBundler() + assertIs(vm.feedState.feedContent.value) + + // Simulate relay event arriving through coordinator + val event = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "Hello from relay", + sig = dummySig, + ) + coordinator.consumeEvent(event, relayUrl) + + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs( + state, + "ViewModel should be Loaded after coordinator.consumeEvent()", + ) + assertTrue( + vm.feedState.visibleNotes().any { it.idHex == event.id }, + "Note should appear in feed", + ) + + vm.destroy() + scope.cancel() + } + + @Test + fun `consumeEvent updates lastEventAt timestamp`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + assertTrue(coordinator.lastEventAt.value == null, "lastEventAt should be null initially") + + val event = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "test", + sig = dummySig, + ) + coordinator.consumeEvent(event, relayUrl) + waitForBundler() + + assertTrue(coordinator.lastEventAt.value != null, "lastEventAt should be set after consumeEvent") + + scope.cancel() + } + + @Test + fun `contact list consumed via coordinator updates followedUsers`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + val contactEvent = + ContactListEvent( + id = "cl1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = arrayOf(arrayOf("p", followedPubKey)), + content = "", + sig = dummySig, + ) + coordinator.consumeEvent(contactEvent, relayUrl) + waitForBundler() + + assertTrue( + cache.followedUsers.value.contains(followedPubKey), + "followedUsers should contain the followed pubkey after contact list consumption", + ) + + scope.cancel() + } + + @Test + fun `following feed shows notes after contact list and text notes arrive via coordinator`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + // Step 1: Contact list arrives + val contactEvent = + ContactListEvent( + id = "cl1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = arrayOf(arrayOf("p", followedPubKey)), + content = "", + sig = dummySig, + ) + coordinator.consumeEvent(contactEvent, relayUrl) + waitForBundler() + + // Step 2: Create following feed ViewModel + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + assertIs(vm.feedState.feedContent.value) + + // Step 3: Text note from followed user arrives + val textEvent = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = followedPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "Note from followed user", + sig = dummySig, + ) + coordinator.consumeEvent(textEvent, relayUrl) + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs( + state, + "Following feed should show notes from followed users", + ) + assertTrue(vm.feedState.visibleNotes().size == 1) + + vm.destroy() + scope.cancel() + } + + @Test + fun `following feed remains empty when no contact list has been consumed`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + // No contact list consumed — followedUsers is empty + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + + // Text note arrives but not from a followed user (because no follows) + val textEvent = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = followedPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "Note that won't show", + sig = dummySig, + ) + coordinator.consumeEvent(textEvent, relayUrl) + waitForBundler() + + assertIs( + vm.feedState.feedContent.value, + "Following feed should be empty when no contact list loaded — " + + "this is the bug the user sees (0 notes, 0 followed)", + ) + + vm.destroy() + scope.cancel() + } + + // ----------------------------------------------------------------------- + // 2. Duplicate event handling + // ----------------------------------------------------------------------- + + @Test + fun `duplicate events are not double-counted in feed`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + waitForBundler() + + val event = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "test", + sig = dummySig, + ) + + // Consume same event twice (can happen with multiple relays) + coordinator.consumeEvent(event, relayUrl) + coordinator.consumeEvent(event, NormalizedRelayUrl("wss://relay2.test/")) + waitForBundler() + + assertTrue( + vm.feedState.visibleNotes().size == 1, + "Same event from multiple relays should appear only once", + ) + + vm.destroy() + scope.cancel() + } + + // ----------------------------------------------------------------------- + // 3. Interaction subscriptions + // ----------------------------------------------------------------------- + + @Test + fun `requestInteractions opens subscription on client`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, client) = createCoordinator(cache, scope) + + val noteIds = listOf("n1".padEnd(64, '0')) + val subId = coordinator.requestInteractions(noteIds, setOf(relayUrl)) + + // openReqSubscription is launched in scope — wait for it + delay(200) + + assertTrue(client.openedSubs.containsKey(subId), "Interaction subscription should be opened") + + coordinator.releaseInteractions(subId) + assertTrue(!client.openedSubs.containsKey(subId), "Subscription should be closed after release") + + scope.cancel() + } + + @Test + fun `requestInteractions with empty noteIds returns without opening subscription`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, client) = createCoordinator(cache, scope) + + coordinator.requestInteractions(emptyList(), setOf(relayUrl)) + delay(200) + + assertTrue(client.openedSubs.isEmpty(), "Should not open subscription for empty noteIds") + + scope.cancel() + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt new file mode 100644 index 000000000..9c92e54f3 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt @@ -0,0 +1,571 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.cache + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopNotificationFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter +import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Integration tests for the Desktop cache → filter → ViewModel pipeline. + * + * These tests verify that events consumed into DesktopLocalCache flow through + * feed filters and into DesktopFeedViewModel's FeedState correctly. + * + * The test structure mirrors how the app works: + * 1. Events arrive from relays + * 2. DesktopLocalCache.consume() stores them + emits via eventStream + * 3. DesktopFeedViewModel collects eventStream and updates FeedState + * 4. FeedFilter determines which notes appear in which feed + */ +class DesktopCachePipelineTest { + // Deterministic test keys + private val userPubKey = "a".repeat(64) + private val followedPubKey = "b".repeat(64) + private val unfollowedPubKey = "c".repeat(64) + private val dummySig = "0".repeat(128) + private val relayUrl = + com.vitorpamplona.quartz.nip01Core.relay.normalizer + .NormalizedRelayUrl("wss://relay.test/") + + /** Wait for async bundling (250ms bundler + margin) */ + private suspend fun waitForBundler() = delay(500) + + private fun textNote( + id: String, + pubKey: String, + content: String = "Hello world", + createdAt: Long = System.currentTimeMillis() / 1000, + replyToId: String? = null, + ): TextNoteEvent { + val tags = + if (replyToId != null) { + arrayOf(arrayOf("e", replyToId, "", "reply")) + } else { + emptyArray() + } + return TextNoteEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = tags, + content = content, + sig = dummySig, + ) + } + + private fun contactList( + id: String, + pubKey: String, + follows: List, + createdAt: Long = System.currentTimeMillis() / 1000, + ): ContactListEvent = + ContactListEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = follows.map { arrayOf("p", it) }.toTypedArray(), + content = "", + sig = dummySig, + ) + + private fun reaction( + id: String, + pubKey: String, + targetNoteId: String, + createdAt: Long = System.currentTimeMillis() / 1000, + ): ReactionEvent = + ReactionEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = arrayOf(arrayOf("e", targetNoteId)), + content = "+", + sig = dummySig, + ) + + // ----------------------------------------------------------------------- + // 1. Cache consumption basics + // ----------------------------------------------------------------------- + + @Test + fun `consume text note creates Note in cache`() { + val cache = DesktopLocalCache() + val event = textNote("note1".padEnd(64, '0'), userPubKey) + + val consumed = cache.consume(event, relayUrl) + + assertTrue(consumed, "First consume should return true") + val note = cache.getNoteIfExists("note1".padEnd(64, '0')) + assertTrue(note != null, "Note should exist in cache after consume") + assertEquals(event.id, note.event?.id) + } + + @Test + fun `consume same note twice returns false`() { + val cache = DesktopLocalCache() + val event = textNote("note1".padEnd(64, '0'), userPubKey) + + cache.consume(event, relayUrl) + val secondConsume = cache.consume(event, relayUrl) + + assertTrue(!secondConsume, "Second consume of same event should return false") + } + + @Test + fun `consume contact list updates followedUsers`() { + val cache = DesktopLocalCache() + val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey)) + + cache.consume(event, relayUrl) + + assertEquals(setOf(followedPubKey), cache.followedUsers.value) + } + + @Test + fun `newer contact list replaces older`() { + val cache = DesktopLocalCache() + val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100) + val newer = + contactList( + "cl2".padEnd(64, '0'), + userPubKey, + listOf(followedPubKey, unfollowedPubKey), + createdAt = 200, + ) + + cache.consume(old, relayUrl) + cache.consume(newer, relayUrl) + + assertEquals(setOf(followedPubKey, unfollowedPubKey), cache.followedUsers.value) + } + + @Test + fun `older contact list is rejected`() { + val cache = DesktopLocalCache() + val newer = contactList("cl2".padEnd(64, '0'), userPubKey, listOf(followedPubKey, unfollowedPubKey), createdAt = 200) + val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100) + + cache.consume(newer, relayUrl) + cache.consume(old, relayUrl) + + assertEquals( + setOf(followedPubKey, unfollowedPubKey), + cache.followedUsers.value, + "Older contact list should not overwrite newer", + ) + } + + @Test + fun `consume reaction links to target note`() { + val cache = DesktopLocalCache() + val noteId = "note1".padEnd(64, '0') + val note = textNote(noteId, userPubKey) + val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId) + + cache.consume(note, relayUrl) + cache.consume(react, relayUrl) + + val cachedNote = cache.getNoteIfExists(noteId)!! + assertTrue(cachedNote.countReactions() > 0, "Note should have reactions after consuming reaction event") + } + + // ----------------------------------------------------------------------- + // 2. Event stream emission + // ----------------------------------------------------------------------- + + @Test + fun `consume emits to eventStream`() = + runBlocking { + val cache = DesktopLocalCache() + val collected = mutableListOf>() + + val job = + launch(Dispatchers.IO) { + cache.eventStream.newEventBundles.collect { collected.add(it) } + } + + // Give collector time to start + delay(50) + + val event = textNote("note1".padEnd(64, '0'), userPubKey) + cache.consume(event, relayUrl) + val note = cache.getNoteIfExists(event.id)!! + cache.emitNewNotes(setOf(note)) + + delay(100) + job.cancel() + + assertTrue(collected.isNotEmpty(), "EventStream should emit after consume + emitNewNotes") + assertTrue(collected.any { batch -> batch.any { it.idHex == event.id } }) + } + + // ----------------------------------------------------------------------- + // 3. Filter logic + // ----------------------------------------------------------------------- + + @Test + fun `GlobalFeedFilter includes all text notes`() { + val cache = DesktopLocalCache() + val filter = DesktopGlobalFeedFilter(cache) + + // Add notes from different authors + cache.consume(textNote("n1".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("n2".padEnd(64, '0'), followedPubKey, createdAt = 200), relayUrl) + cache.consume(textNote("n3".padEnd(64, '0'), unfollowedPubKey, createdAt = 300), relayUrl) + + val feed = filter.feed() + assertEquals(3, feed.size, "Global feed should contain all text notes") + } + + @Test + fun `FollowingFeedFilter only includes notes from followed users`() { + val cache = DesktopLocalCache() + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val feed = filter.feed() + + assertEquals(1, feed.size, "Following feed should only contain notes from followed users") + assertEquals("n1".padEnd(64, '0'), feed[0].idHex) + } + + @Test + fun `FollowingFeedFilter returns empty when no follows`() { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { emptySet() } + val feed = filter.feed() + + assertTrue(feed.isEmpty(), "Following feed should be empty when no follows") + } + + @Test + fun `ProfileFeedFilter only shows notes from target pubkey`() { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl) + + val filter = DesktopProfileFeedFilter(followedPubKey, cache) + val feed = filter.feed() + + assertEquals(1, feed.size) + assertEquals(followedPubKey, feed[0].author?.pubkeyHex) + } + + @Test + fun `ThreadFilter returns root and replies`() { + val cache = DesktopLocalCache() + val rootId = "root".padEnd(64, '0') + val replyId = "reply".padEnd(64, '0') + + cache.consume(textNote(rootId, userPubKey, createdAt = 100), relayUrl) + cache.consume(textNote(replyId, followedPubKey, createdAt = 200, replyToId = rootId), relayUrl) + + val filter = DesktopThreadFilter(rootId, cache) + val feed = filter.feed() + + assertEquals(2, feed.size, "Thread should contain root + reply") + } + + @Test + fun `NotificationFeedFilter shows events tagging user`() { + val cache = DesktopLocalCache() + val noteId = "note1".padEnd(64, '0') + cache.consume(textNote(noteId, userPubKey, createdAt = 100), relayUrl) + + // Reaction from someone else targeting user's note + val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId, createdAt = 200) + cache.consume(react, relayUrl) + + val filter = DesktopNotificationFeedFilter(userPubKey, cache) + val feed = filter.feed() + + // ReactionEvent tags "e" not "p" — notification filter requires isTaggedUser + // This test documents the current behavior + val reactNote = cache.getNoteIfExists("react1".padEnd(64, '0')) + val reactEvent = reactNote?.event + assertTrue(reactEvent != null, "Reaction event should exist in cache") + } + + // ----------------------------------------------------------------------- + // 4. ViewModel integration + // ----------------------------------------------------------------------- + + @Test + fun `ViewModel starts in Loading then transitions to Loaded after refresh`() = + runBlocking { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl) + + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + + // Wait for init refresh + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs(state, "After consuming notes and refreshing, state should be Loaded") + + val notes = vm.feedState.visibleNotes() + assertEquals(1, notes.size) + vm.destroy() + } + + @Test + fun `ViewModel shows Empty when cache has no matching notes`() = + runBlocking { + val cache = DesktopLocalCache() + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs(state, "ViewModel should show Empty when no notes in cache") + vm.destroy() + } + + @Test + fun `ViewModel updates when new notes arrive via eventStream`() = + runBlocking { + val cache = DesktopLocalCache() + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + + waitForBundler() + assertIs(vm.feedState.feedContent.value) + + // Simulate relay event arriving + val event = textNote("n1".padEnd(64, '0'), userPubKey) + cache.consume(event, relayUrl) + val note = cache.getNoteIfExists(event.id)!! + cache.emitNewNotes(setOf(note)) + + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs(state, "ViewModel should transition to Loaded after new notes arrive") + assertEquals(1, vm.feedState.visibleNotes().size) + vm.destroy() + } + + @Test + fun `Following ViewModel only shows followed users notes via eventStream`() = + runBlocking { + val cache = DesktopLocalCache() + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + + // Add followed user's note + val e1 = textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100) + cache.consume(e1, relayUrl) + val note1 = cache.getNoteIfExists(e1.id)!! + cache.emitNewNotes(setOf(note1)) + waitForBundler() + + assertEquals(1, vm.feedState.visibleNotes().size, "Should show followed user's note") + + // Add unfollowed user's note + val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200) + cache.consume(e2, relayUrl) + val note2 = cache.getNoteIfExists(e2.id)!! + cache.emitNewNotes(setOf(note2)) + waitForBundler() + + assertEquals(1, vm.feedState.visibleNotes().size, "Should NOT show unfollowed user's note") + vm.destroy() + } + + @Test + fun `Following ViewModel feed is empty when followedUsers is empty`() = + runBlocking { + val cache = DesktopLocalCache() + // No contact list consumed — followedUsers remains empty + + val e1 = textNote("n1".padEnd(64, '0'), followedPubKey) + cache.consume(e1, relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + + assertIs( + vm.feedState.feedContent.value, + "Following feed should be empty when no contact list loaded", + ) + vm.destroy() + } + + // ----------------------------------------------------------------------- + // 5. Cache clear + // ----------------------------------------------------------------------- + + @Test + fun `clear resets all cache state`() { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl) + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + cache.clear() + + assertEquals(0, cache.noteCount()) + assertEquals(0, cache.userCount()) + assertTrue(cache.followedUsers.value.isEmpty()) + } + + // ----------------------------------------------------------------------- + // 6. Feed ordering + // ----------------------------------------------------------------------- + + @Test + fun `global feed is sorted newest first`() { + val cache = DesktopLocalCache() + cache.consume(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl) + cache.consume(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl) + + val filter = DesktopGlobalFeedFilter(cache) + val feed = filter.feed() + + assertEquals("new".padEnd(64, '0'), feed[0].idHex, "Newest note should be first") + assertEquals("old".padEnd(64, '0'), feed[2].idHex, "Oldest note should be last") + } + + // ----------------------------------------------------------------------- + // 7. Metadata consumption + // ----------------------------------------------------------------------- + + @Test + fun `consumeMetadata updates user info`() { + val cache = DesktopLocalCache() + val metadata = + com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent( + id = "meta1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = """{"name":"TestUser","display_name":"Test User","about":"A test user"}""", + sig = dummySig, + ) + + cache.consume(metadata, relayUrl) + + val user = cache.getUserIfExists(userPubKey) + assertTrue(user != null, "User should exist after metadata consumption") + // Metadata parsing may vary, but user object should be created + assertEquals(userPubKey, user.pubkeyHex) + } + + // ----------------------------------------------------------------------- + // 8. BoundedLargeCache eviction + // ----------------------------------------------------------------------- + + @Test + fun `BoundedLargeCache evicts when over capacity`() { + val cache = BoundedLargeCache(10, evictPercent = 0.5f) + + repeat(15) { i -> + cache.put("key_${i.toString().padStart(3, '0')}", "value_$i") + } + + assertTrue(cache.size() <= 10, "Cache should not exceed max size, got ${cache.size()}") + } + + @Test + fun `BoundedLargeCache get returns null for evicted entries`() { + val cache = BoundedLargeCache(5, evictPercent = 0.5f) + + repeat(10) { i -> + cache.put("key_${i.toString().padStart(3, '0')}", "value_$i") + } + + // Some early entries should have been evicted + val size = cache.size() + assertTrue(size <= 5, "Cache should be at or below max size") + } + + // ----------------------------------------------------------------------- + // 9. Additive filter incremental updates + // ----------------------------------------------------------------------- + + @Test + fun `GlobalFeedFilter applyFilter only accepts TextNoteEvents`() { + val cache = DesktopLocalCache() + val filter = DesktopGlobalFeedFilter(cache) + + // Create a text note + val textEvent = textNote("t1".padEnd(64, '0'), userPubKey) + cache.consume(textEvent, relayUrl) + val textNote = cache.getNoteIfExists(textEvent.id)!! + + // Create a reaction (not a text note) + val reactEvent = reaction("r1".padEnd(64, '0'), userPubKey, "t1".padEnd(64, '0')) + cache.consume(reactEvent, relayUrl) + val reactNote = cache.getNoteIfExists(reactEvent.id)!! + + val filtered = filter.applyFilter(setOf(textNote, reactNote)) + + assertEquals(1, filtered.size, "applyFilter should only pass TextNoteEvents") + assertTrue(filtered.first().event is TextNoteEvent) + } + + @Test + fun `FollowingFeedFilter applyFilter respects follow set`() { + val cache = DesktopLocalCache() + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + + val e1 = textNote("n1".padEnd(64, '0'), followedPubKey) + cache.consume(e1, relayUrl) + val note1 = cache.getNoteIfExists(e1.id)!! + + val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey) + cache.consume(e2, relayUrl) + val note2 = cache.getNoteIfExists(e2.id)!! + + val filtered = filter.applyFilter(setOf(note1, note2)) + + assertEquals(1, filtered.size, "applyFilter should only include followed users") + assertEquals(followedPubKey, filtered.first().author?.pubkeyHex) + } +} From 3e41bab5d53e6d191358a26a61e6ced79713f776 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 13:50:14 +0200 Subject: [PATCH 16/26] fix(cache): use relayStatuses (available) instead of connectedRelays for subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "0 relays, 0 notes, 0 follows": all screens used connectedRelays as the relay set for subscriptions, but NostrClient uses relay-on-demand — relays only connect when openReqSubscription is called with their URL. This created a deadlock: screens waited for connected relays, but relays only connect when screens subscribe. Fix: use relayStatuses.keys (registered/available relays) which are populated by addDefaultRelays() at startup. openReqSubscription triggers connection on-demand. Affected screens: FeedScreen, UserProfileScreen, ThreadScreen, NotificationsScreen, BookmarksScreen, ReadsScreen, NewDmDialog. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/BookmarksScreen.kt | 3 ++- .../amethyst/desktop/ui/FeedScreen.kt | 18 +++++++++++------- .../amethyst/desktop/ui/NotificationsScreen.kt | 3 ++- .../amethyst/desktop/ui/ReadsScreen.kt | 3 ++- .../amethyst/desktop/ui/ThreadScreen.kt | 3 ++- .../amethyst/desktop/ui/UserProfileScreen.kt | 3 ++- .../amethyst/desktop/ui/chats/NewDmDialog.kt | 3 ++- 7 files changed, 23 insertions(+), 13 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index ac56815dc..9ecbc6b9c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -76,7 +76,8 @@ fun BookmarksScreen( onNavigateToThread: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { - val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } val scope = rememberCoroutineScope() // Tab state diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 961d61fae..9f12a8163 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -166,18 +166,22 @@ fun FeedScreen( onNavigateToThread: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { + val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() val followedUsers by localCache.followedUsers.collectAsState() + // Available relay URLs — openReqSubscription triggers connection on-demand + val allRelayUrls = remember(relayStatuses) { relayStatuses.keys } + var replyToEvent by remember { mutableStateOf(null) } var lightboxState by remember { mutableStateOf(null) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } // Subscribe to contact list (kind 3) — populates localCache.followedUsers - rememberSubscription(connectedRelays, account, relayManager = relayManager) { - if (connectedRelays.isNotEmpty() && account != null) { + rememberSubscription(allRelayUrls, account, relayManager = relayManager) { + if (allRelayUrls.isNotEmpty() && account != null) { createContactListSubscription( - relays = connectedRelays, + relays = allRelayUrls, pubKeyHex = account.pubKeyHex, onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) @@ -189,13 +193,13 @@ fun FeedScreen( } // Subscribe to feed events (kind 1) — populates cache via coordinator - rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) { - if (connectedRelays.isEmpty()) return@rememberSubscription null + rememberSubscription(allRelayUrls, feedMode, followedUsers, relayManager = relayManager) { + if (allRelayUrls.isEmpty()) return@rememberSubscription null when (feedMode) { FeedMode.GLOBAL -> { createGlobalFeedSubscription( - relays = connectedRelays, + relays = allRelayUrls, onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) }, @@ -206,7 +210,7 @@ fun FeedScreen( val follows = followedUsers.toList() if (follows.isNotEmpty()) { createFollowingFeedSubscription( - relays = connectedRelays, + relays = allRelayUrls, followedUsers = follows, onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 1fe3d31fd..6fa7f9225 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -112,7 +112,8 @@ fun NotificationsScreen( account: AccountState.LoggedIn, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, ) { - val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } val scope = rememberCoroutineScope() val notificationState = remember { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index c1e6d94bb..78274fbb2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -187,7 +187,8 @@ fun ReadsScreen( onNavigateToThread: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { - val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } val scope = rememberCoroutineScope() val eventState = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 4b6962c24..ff54ca15b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -89,7 +89,8 @@ fun ThreadScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, onReply: (Event) -> Unit = {}, ) { - val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } // Lightbox state var lightboxState by remember { mutableStateOf(null) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index e772eda71..80a9919cf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -124,7 +124,8 @@ fun UserProfileScreen( onNavigateToArticle: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { - val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } // User metadata var displayName by remember { mutableStateOf(null) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt index dd220a06b..d50cbbb33 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -80,7 +80,8 @@ fun NewDmDialog( val cachedUsers by searchState.cachedUserResults.collectAsState() val relaySearchResults by searchState.relaySearchResults.collectAsState() val isSearchingRelays by searchState.isSearchingRelays.collectAsState() - val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = remember(relayStatuses) { relayStatuses.keys } val focusRequester = remember { FocusRequester() } // NIP-50 relay search when local cache has few/no results From f2169e0236bf801a07e1a91ab78ec7b9dc34a05a Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 14:28:40 +0200 Subject: [PATCH 17/26] feat(cache): cache profile metadata and follower/following counts Profile screen now reads initial values from cache so navigating back shows data instantly instead of re-fetching everything from scratch. - Seed displayName/about/picture from User.metadataOrNull() on compose - Add followerCounts/followingCounts maps to DesktopLocalCache - Write counts on each update, read on profile open - Follower count still ticks up live (good UX) but starts from cached value - 4 new tests for profile count caching + metadata cache reads Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/cache/DesktopLocalCache.kt | 25 +++++++ .../amethyst/desktop/ui/UserProfileScreen.kt | 31 ++++++--- .../desktop/cache/DesktopCachePipelineTest.kt | 65 +++++++++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 78d7f2e7e..7952ff5c2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -491,6 +491,29 @@ class DesktopLocalCache : ICacheProvider { eventStream.emitDeletedNotes(notes) } + // ----- Profile count cache ----- + + private val followerCounts = ConcurrentHashMap() + private val followingCounts = ConcurrentHashMap() + + fun getCachedFollowerCount(pubkey: HexKey): Int = followerCounts[pubkey] ?: 0 + + fun getCachedFollowingCount(pubkey: HexKey): Int = followingCounts[pubkey] ?: 0 + + fun cacheFollowerCount( + pubkey: HexKey, + count: Int, + ) { + followerCounts[pubkey] = count + } + + fun cacheFollowingCount( + pubkey: HexKey, + count: Int, + ) { + followingCounts[pubkey] = count + } + // ----- Stats ----- fun userCount(): Int = users.size() @@ -503,6 +526,8 @@ class DesktopLocalCache : ICacheProvider { addressableNotes.clear() deletedEvents.clear() _followedUsers.value = emptySet() + followerCounts.clear() + followingCounts.clear() } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 80a9919cf..32ea06258 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -127,12 +127,22 @@ fun UserProfileScreen( val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays = remember(relayStatuses) { relayStatuses.keys } - // User metadata - var displayName by remember { mutableStateOf(null) } - var about by remember { mutableStateOf(null) } - var picture by remember { mutableStateOf(null) } - var followersCount by remember { mutableStateOf(0) } - var followingCount by remember { mutableStateOf(0) } + // User metadata — seed from cache so returning to profile is instant + val cachedUser = remember(pubKeyHex) { localCache.getUserIfExists(pubKeyHex) } + val cachedMetadata = remember(pubKeyHex) { cachedUser?.metadataOrNull() } + var displayName by remember { mutableStateOf(cachedMetadata?.bestName()) } + var about by remember { + mutableStateOf( + cachedMetadata + ?.flow + ?.value + ?.info + ?.about, + ) + } + var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) } + var followersCount by remember { mutableStateOf(localCache.getCachedFollowerCount(pubKeyHex)) } + var followingCount by remember { mutableStateOf(localCache.getCachedFollowingCount(pubKeyHex)) } // Profile editing state (only for own profile) val isOwnProfile = account != null && pubKeyHex == account.pubKeyHex @@ -279,8 +289,9 @@ fun UserProfileScreen( pubKeyHex = pubKeyHex, onEvent = { event, _, _, _ -> if (event is ContactListEvent) { - // Count the number of people this user follows - followingCount = event.verifiedFollowKeySet().size + val count = event.verifiedFollowKeySet().size + followingCount = count + localCache.cacheFollowingCount(pubKeyHex, count) } }, onEose = { _, _ -> }, @@ -314,7 +325,9 @@ fun UserProfileScreen( onEvent = { event, _, _, _ -> // Count unique authors who follow this user if (followerAuthors.add(event.pubKey)) { - followersCount = followerAuthors.size + val count = followerAuthors.size + followersCount = count + localCache.cacheFollowerCount(pubKeyHex, count) } }, onEose = { _, _ -> }, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt index 9c92e54f3..e3f2e77ea 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt @@ -568,4 +568,69 @@ class DesktopCachePipelineTest { assertEquals(1, filtered.size, "applyFilter should only include followed users") assertEquals(followedPubKey, filtered.first().author?.pubkeyHex) } + + // ----------------------------------------------------------------------- + // 10. Profile count caching + // ----------------------------------------------------------------------- + + @Test + fun `profile follower count is cached and survives clear of note cache`() { + val cache = DesktopLocalCache() + + assertEquals(0, cache.getCachedFollowerCount(userPubKey)) + + cache.cacheFollowerCount(userPubKey, 42) + assertEquals(42, cache.getCachedFollowerCount(userPubKey)) + + // Updating again overwrites + cache.cacheFollowerCount(userPubKey, 100) + assertEquals(100, cache.getCachedFollowerCount(userPubKey)) + } + + @Test + fun `profile following count is cached`() { + val cache = DesktopLocalCache() + + cache.cacheFollowingCount(userPubKey, 150) + assertEquals(150, cache.getCachedFollowingCount(userPubKey)) + } + + @Test + fun `clear resets profile count caches`() { + val cache = DesktopLocalCache() + cache.cacheFollowerCount(userPubKey, 42) + cache.cacheFollowingCount(userPubKey, 150) + + cache.clear() + + assertEquals(0, cache.getCachedFollowerCount(userPubKey)) + assertEquals(0, cache.getCachedFollowingCount(userPubKey)) + } + + @Test + fun `metadata is available from cache after consumption`() { + val cache = DesktopLocalCache() + val metadata = + com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent( + id = "meta1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = """{"name":"TestUser","display_name":"Test User","about":"A test user"}""", + sig = dummySig, + ) + + cache.consume(metadata, relayUrl) + + val user = cache.getUserIfExists(userPubKey)!! + val cached = user.metadataOrNull() + assertTrue(cached != null, "Metadata should be cached after consumption") + assertEquals("Test User", cached.bestName()) + assertEquals( + "A test user", + cached.flow.value + ?.info + ?.about, + ) + } } From f27c8811d579de97328246e150437a5f35e234aa Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 14:31:59 +0200 Subject: [PATCH 18/26] fix(cache): don't reset followersCount to 0 on subscription restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cached value now stays visible until relay data exceeds it, preventing the count jumping from cached→0→ticking back up on each navigation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 32ea06258..0c9582151 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -307,9 +307,8 @@ fun UserProfileScreen( // Subscribe to followers (contact lists that tag this user) rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { - // Clear previous followers when subscription restarts + // Clear dedup set but keep cached followersCount visible until new data arrives followerAuthors.clear() - followersCount = 0 SubscriptionConfig( subId = "followers-${pubKeyHex.take(8)}-${System.currentTimeMillis()}", From 3b489fb3cb8832c89e8cfb52bac2a4408bd161bb Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 14:36:09 +0200 Subject: [PATCH 19/26] feat(cache): seed Reads and Bookmarks screens from cache on compose Both screens now show cached data instantly on navigation instead of showing loading states while re-fetching from relays. - BookmarksScreen: reads cached BookmarkListEvent from addressableNotes, seeds public bookmark events from notes cache - ReadsScreen: seeds long-form notes (kind 30023) from notes cache Relay subscriptions still run to fetch fresh data in parallel. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/BookmarksScreen.kt | 21 +++++++++++++++++++ .../amethyst/desktop/ui/ReadsScreen.kt | 13 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index 9ecbc6b9c..e692b6470 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -121,6 +121,27 @@ fun BookmarksScreen( } } + // Seed from cache — bookmark list event may already be in addressableNotes + LaunchedEffect(account.pubKeyHex) { + val address = BookmarkListEvent.createBookmarkAddress(account.pubKeyHex) + val cachedNote = localCache.getOrCreateAddressableNote(address) + val cachedEvent = cachedNote.event as? BookmarkListEvent + if (cachedEvent != null) { + bookmarkList = cachedEvent + publicBookmarkIds = + cachedEvent + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + // Seed public events from cache + publicBookmarkIds.forEach { id -> + val note = localCache.getNoteIfExists(id) + val event = note?.event + if (event != null) publicEventState.addItem(event) + } + } + } + // Subscribe to user's bookmark list (kind 30001) rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) { if (connectedRelays.isNotEmpty()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 78274fbb2..80438b7b0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -207,6 +208,18 @@ fun ReadsScreen( var eoseReceivedCount by remember { mutableStateOf(0) } val initialLoadComplete = eoseReceivedCount > 0 + // Seed from cache — long-form notes already consumed are in cache + LaunchedEffect(Unit) { + val cached = + localCache.notes.filterIntoSet { _, note -> + note.event is LongTextNoteEvent + } + cached.forEach { note -> + (note.event as? LongTextNoteEvent)?.let { eventState.addItem(it) } + } + if (cached.isNotEmpty()) eoseReceivedCount++ + } + // Load followed users for Following feed mode rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) { val connectedRelays = connectedRelays From 00b06a0e2a78687d0e1917dccb1036991b3025ec Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 24 Mar 2026 09:09:29 +0200 Subject: [PATCH 20/26] refactor(cache): move LargeSoftCache to commons/jvmAndroid, fix ICacheProvider types - Move LargeSoftCache from amethyst/model to commons/jvmAndroid/model/cache so both Android and Desktop share the same WeakReference cache implementation - Change ICacheProvider return types from Any? to proper types (User?, Note?) since these model classes already live in commons - Remove unnecessary casts in ThreadAssembler, ChatNewMessageState, SearchBarState - Improve LargeSoftCache.cleanUp() to use single-pass iterator (zero allocation) - Update Android imports in LocalCache, LargeSoftCacheAddressExt, and test Per Vitor's feedback on PR #1905: BoundedLargeCache evicts arbitrarily. LargeSoftCache uses WeakReferences so GC respects the reference graph. --- .../model/LargeSoftCacheAddressExt.kt | 1 + .../amethyst/model/LocalCache.kt | 1 + .../model/LargeCacheAddressableFilterTest.kt | 1 + .../amethyst/commons/model/ThreadAssembler.kt | 2 +- .../commons/model/cache/ICacheProvider.kt | 8 ++++---- .../commons/viewmodels/ChatNewMessageState.kt | 5 ++--- .../commons/viewmodels/SearchBarState.kt | 3 +-- .../commons/model/cache}/LargeSoftCache.kt | 19 ++++++++----------- 8 files changed, 19 insertions(+), 21 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/model => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache}/LargeSoftCache.kt (89%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt index 717289460..ff65202ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.cache.CacheCollectors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d792bcb3a..16ae2b961 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LargeCacheAddressableFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LargeCacheAddressableFilterTest.kt index 965f5ba0c..984f1daac 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LargeCacheAddressableFilterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LargeCacheAddressableFilterTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import junit.framework.TestCase.assertEquals diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt index 9fbd7c2fd..657922e00 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt @@ -52,7 +52,7 @@ class ThreadAssembler( ?.getOrNull(1) if (markedAsRoot != null) { // Check to see if there is an error in the tag and the root has replies - val rootNote = cache.getNoteIfExists(markedAsRoot) as? Note + val rootNote = cache.getNoteIfExists(markedAsRoot) if (rootNote?.replyTo?.isEmpty() == true) { return cache.checkGetOrCreateNote(markedAsRoot) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt index 2634196aa..e4b3ccd9a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt @@ -57,7 +57,7 @@ interface ICacheProvider { * @param pubkey The user's public key in hex format * @return The User if exists in cache, null otherwise */ - fun getUserIfExists(pubkey: HexKey): Any? + fun getUserIfExists(pubkey: HexKey): User? /** * Counts users matching a predicate. @@ -75,7 +75,7 @@ interface ICacheProvider { * @param hexKey The note's ID in hex format * @return The Note if exists in cache, null otherwise */ - fun getNoteIfExists(hexKey: HexKey): Any? + fun getNoteIfExists(hexKey: HexKey): Note? /** * Gets an existing Note or creates a new one if it doesn't exist. @@ -123,7 +123,7 @@ interface ICacheProvider { fun findUsersStartingWith( prefix: String, limit: Int = 50, - ): List = emptyList() + ): List = emptyList() /** * Gets or creates a User by public key hex. @@ -132,7 +132,7 @@ interface ICacheProvider { * @param pubkey The user's public key in hex format * @return The User (existing or newly created) */ - fun getOrCreateUser(pubkey: HexKey): Any? + fun getOrCreateUser(pubkey: HexKey): User? fun justConsumeMyOwnEvent(event: Event): Boolean } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt index f9b1086bb..f308bf5b8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.text.input.TextFieldValue import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.references.references @@ -95,7 +94,7 @@ class ChatNewMessageState( if (currentRoom != null) { _recipientsMissingDmRelays.value = currentRoom.users.any { hexKey -> - val user = cache.getOrCreateUser(hexKey) as? User + val user = cache.getOrCreateUser(hexKey) user?.dmInboxRelays().isNullOrEmpty() } } else { @@ -141,7 +140,7 @@ class ChatNewMessageState( ) { val pTags = room.users.mapNotNull { hexKey -> - (cache.getOrCreateUser(hexKey) as? User)?.toPTag() + cache.getOrCreateUser(hexKey)?.toPTag() } val replyHint = _replyTo.value?.toEventHint() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt index cdbf2e0d3..657758c47 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt @@ -88,8 +88,7 @@ class SearchBarState( .debounce(debounceMs) .onEach { query -> if (query.length >= 2 && _bech32Results.value.isEmpty()) { - @Suppress("UNCHECKED_CAST") - _cachedUserResults.value = cache.findUsersStartingWith(query, 20) as List + _cachedUserResults.value = cache.findUsersStartingWith(query, 20) } else { _cachedUserResults.value = emptyList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCache.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCache.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.kt index bc7c6cce3..71bd3ba9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCache.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/cache/LargeSoftCache.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model +package com.vitorpamplona.amethyst.commons.model.cache import com.vitorpamplona.quartz.utils.cache.CacheOperations import java.lang.ref.WeakReference @@ -59,7 +59,7 @@ class LargeSoftCache : CacheOperations { /** * Puts an object into the cache with a specified key. - * The object is stored as a SoftReference. + * The object is stored as a WeakReference. * * @param key The key to associate with the object. * @param value The object to cache. @@ -105,19 +105,16 @@ class LargeSoftCache : CacheOperations { /** * Proactively cleans up the cache by removing entries whose weakly referenced - * objects have been garbage collected. While `get` handles cleanup on access, - * this method can be called periodically or when memory pressure is high. + * objects have been garbage collected. Single-pass iterator for efficiency. */ fun cleanUp() { - val keysToRemove = mutableMapOf>() - cache.forEach { key, softRef -> - if (softRef.get() == null) { - keysToRemove.put(key, softRef) + val iter = cache.entries.iterator() + while (iter.hasNext()) { + val entry = iter.next() + if (entry.value.get() == null) { + iter.remove() } } - keysToRemove.forEach { key, value -> - cache.remove(key, value) - } } override fun forEach(consumer: BiConsumer) { From 3dbbc039b3ab3188ae33fa8b0539b681ee333cbc Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 24 Mar 2026 09:14:02 +0200 Subject: [PATCH 21/26] feat(cache): replace BoundedLargeCache with LargeSoftCache on Desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DesktopLocalCache now uses LargeSoftCache (WeakReference-based, GC-driven) instead of BoundedLargeCache (strong refs, 50k cap, arbitrary eviction) - Delete BoundedLargeCache.kt — no longer needed - Rewrite findUsersStartingWith to use forEach instead of values() - Remove BoundedLargeCache eviction tests (no longer applicable) - Notes now only disappear when nothing references them, not arbitrarily Per Vitor's feedback: "this maximum size approach might not work well, as things will just disappear" --- .../desktop/cache/BoundedLargeCache.kt | 102 ------------------ .../desktop/cache/DesktopLocalCache.kt | 23 ++-- .../desktop/cache/DesktopCachePipelineTest.kt | 30 +----- 3 files changed, 13 insertions(+), 142 deletions(-) delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt deleted file mode 100644 index 4b71abe10..000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.desktop.cache - -import com.vitorpamplona.quartz.utils.cache.CacheCollectors -import com.vitorpamplona.quartz.utils.cache.LargeCache -import java.util.concurrent.atomic.AtomicInteger - -/** - * A bounded wrapper around [LargeCache] that enforces a maximum size. - * - * When the cache exceeds [maxSize], entries are evicted by key order. - * Uses [LargeCache] (ConcurrentSkipListMap) for lock-free reads and rich query APIs. - * - * Size tracking uses AtomicInteger (O(1)) instead of ConcurrentSkipListMap.size() (O(n)). - */ -class BoundedLargeCache, V>( - private val maxSize: Int, - private val evictPercent: Float = 0.1f, -) { - private val inner = LargeCache() - private val sizeCounter = AtomicInteger(0) - - fun get(key: K): V? = inner.get(key) - - fun put( - key: K, - value: V, - ) { - val existing = inner.get(key) - inner.put(key, value) - if (existing == null) sizeCounter.incrementAndGet() - enforceSize() - } - - fun getOrCreate( - key: K, - builder: (K) -> V, - ): V { - val existing = inner.get(key) - if (existing != null) return existing - val result = inner.getOrCreate(key, builder) - // Increment if we were the ones who created it (not a concurrent insert) - if (inner.get(key) === result) { - sizeCounter.incrementAndGet() - } - enforceSize() - return result - } - - fun remove(key: K): V? { - val removed = inner.remove(key) - if (removed != null) sizeCounter.decrementAndGet() - return removed - } - - fun size(): Int = sizeCounter.get() - - fun clear() { - inner.clear() - sizeCounter.set(0) - } - - fun keys(): Set = inner.keys() - - fun values(): Iterable = inner.values() - - fun filterIntoSet(consumer: CacheCollectors.BiFilter): Set = inner.filterIntoSet(consumer) - - fun count(consumer: CacheCollectors.BiFilter): Int = inner.count(consumer) - - private fun enforceSize() { - val currentSize = sizeCounter.get() - if (currentSize > maxSize) { - val toRemove = (maxSize * evictPercent).toInt().coerceAtLeast(1) - val keys = inner.keys().take(toRemove) - keys.forEach { - if (inner.remove(it) != null) { - sizeCounter.decrementAndGet() - } - } - } - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 7952ff5c2..ca08c471c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event @@ -63,9 +64,9 @@ import java.util.concurrent.ConcurrentHashMap * Supports searching users by name prefix for the search functionality. */ class DesktopLocalCache : ICacheProvider { - val users = BoundedLargeCache(MAX_USERS) - val notes = BoundedLargeCache(MAX_NOTES) - val addressableNotes = BoundedLargeCache(MAX_ADDRESSABLE) + val users = LargeSoftCache() + val notes = LargeSoftCache() + val addressableNotes = LargeSoftCache() private val deletedEvents = ConcurrentHashMap.newKeySet() val eventStream = DesktopCacheEventStream() @@ -75,9 +76,6 @@ class DesktopLocalCache : ICacheProvider { val followedUsers: StateFlow> = _followedUsers.asStateFlow() companion object { - const val MAX_NOTES = 50_000 - const val MAX_USERS = 25_000 - const val MAX_ADDRESSABLE = 10_000 } val paymentTracker = NwcPaymentTracker() @@ -114,10 +112,10 @@ class DesktopLocalCache : ICacheProvider { ) // Search by name/displayName/nip05/lud16 - return users - .values() - .filter { user -> - val metadata = user.metadataOrNull() + val results = mutableListOf() + users.forEach { _, user -> + val metadata = user.metadataOrNull() + val matches = if (metadata == null) { user.pubkeyHex.startsWith(prefix, true) || user.pubkeyNpub().startsWith(prefix, true) @@ -126,7 +124,10 @@ class DesktopLocalCache : ICacheProvider { user.pubkeyHex.startsWith(prefix, true) || user.pubkeyNpub().startsWith(prefix, true) } - }.sortedWith( + if (matches) results.add(user) + } + return results + .sortedWith( compareBy( { it.metadataOrNull()?.anyNameStartsWith(dualCase) == false }, { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == false }, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt index e3f2e77ea..20c1b2bcd 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt @@ -496,35 +496,7 @@ class DesktopCachePipelineTest { } // ----------------------------------------------------------------------- - // 8. BoundedLargeCache eviction - // ----------------------------------------------------------------------- - - @Test - fun `BoundedLargeCache evicts when over capacity`() { - val cache = BoundedLargeCache(10, evictPercent = 0.5f) - - repeat(15) { i -> - cache.put("key_${i.toString().padStart(3, '0')}", "value_$i") - } - - assertTrue(cache.size() <= 10, "Cache should not exceed max size, got ${cache.size()}") - } - - @Test - fun `BoundedLargeCache get returns null for evicted entries`() { - val cache = BoundedLargeCache(5, evictPercent = 0.5f) - - repeat(10) { i -> - cache.put("key_${i.toString().padStart(3, '0')}", "value_$i") - } - - // Some early entries should have been evicted - val size = cache.size() - assertTrue(size <= 5, "Cache should be at or below max size") - } - - // ----------------------------------------------------------------------- - // 9. Additive filter incremental updates + // 8. Additive filter incremental updates // ----------------------------------------------------------------------- @Test From cf6a74b162196860eb9c81a228c9ff25fbfd0df2 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 24 Mar 2026 09:20:01 +0200 Subject: [PATCH 22/26] feat(cache): add periodic memory cleanup for Desktop - Add cleanMemory() to DesktopLocalCache (sweeps stale WeakRef entries) - Add startCleanupLoop() to Coordinator with heap monitoring (MemoryMXBean, checks every 30s, cleans at >75% heap or every 5min) - cleanObservers() clears unused NoteFlowSets on notes + addressables - Try-catch per operation so one failure doesn't skip the rest - 2-minute startup grace period before first cleanup - Cleanup job cancelled on clear() (account switch / logout) --- .../vitorpamplona/amethyst/desktop/Main.kt | 3 +- .../desktop/cache/DesktopLocalCache.kt | 8 +++ .../DesktopRelaySubscriptionsCoordinator.kt | 57 +++++++++++++++++++ .../desktop/ui/deck/DeckColumnContainer.kt | 53 +++-------------- 4 files changed, 73 insertions(+), 48 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 0dfaa05c0..23616a926 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -153,7 +153,6 @@ sealed class DesktopScreen { ) : DesktopScreen() data object Drafts : DesktopScreen() - data object Settings : DesktopScreen() } @@ -457,7 +456,7 @@ fun App( RelayUrlNormalizer.normalizeOrNull(it) }.toSet(), localCache = localCache, - ) + ).also { it.startCleanupLoop() } } // Clear cache and subscriptions on logout diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index ca08c471c..319ec94db 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -515,6 +515,14 @@ class DesktopLocalCache : ICacheProvider { followingCounts[pubkey] = count } + // ----- Memory Cleanup ----- + + fun cleanMemory() { + notes.cleanUp() + addressableNotes.cleanUp() + users.cleanUp() + } + // ----- Stats ----- fun userCount(): Int = users.size() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 29121e4b1..f6dd01b7c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -37,11 +37,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.lang.management.ManagementFactory import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds /** * Desktop-specific relay subscriptions coordinator. @@ -356,5 +361,57 @@ class DesktopRelaySubscriptionsCoordinator( unsubscribeFromDms() feedMetadata.clear() rateLimiter.reset() + cleanupJob?.cancel() + } + + // ----- Memory Cleanup ----- + + private val memoryBean = ManagementFactory.getMemoryMXBean() + private var lastCleanupTime = 0L + private var cleanupJob: Job? = null + + /** + * Starts a periodic memory cleanup coroutine. + * Checks heap usage every 30s, runs cleanup at >75% heap or every 5 minutes. + */ + fun startCleanupLoop() { + cleanupJob = + scope.launch(Dispatchers.Default) { + delay(2.minutes) + while (isActive) { + delay(30.seconds) + val heapPct = heapUsagePercent() + val elapsed = System.currentTimeMillis() - lastCleanupTime + if (heapPct > 0.75 || elapsed > 5.minutes.inWholeMilliseconds) { + runCleanup() + } + } + } + } + + private suspend fun runCleanup() { + val ops = + listOf Unit>>( + "cleanMemory" to { localCache.cleanMemory() }, + "cleanObservers" to { cleanObservers() }, + ) + ops.forEach { (name, op) -> + try { + op() + } catch (e: Exception) { + println("Cleanup $name failed: ${e.message}") + } + } + lastCleanupTime = System.currentTimeMillis() + } + + private fun cleanObservers() { + localCache.notes.forEach { _, note -> note.clearFlow() } + localCache.addressableNotes.forEach { _, note -> note.clearFlow() } + } + + private fun heapUsagePercent(): Double { + val heap = memoryBean.heapMemoryUsage + return heap.used.toDouble() / heap.max.toDouble() } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 1da11dbd6..9a71b8bb8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -45,7 +45,6 @@ import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore -import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen @@ -97,8 +96,6 @@ fun DeckColumnContainer( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, - highlightStore: DesktopHighlightStore, - draftStore: DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -139,8 +136,6 @@ fun DeckColumnContainer( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, - draftStore = draftStore, appScope = appScope, compactMode = true, onShowComposeDialog = onShowComposeDialog, @@ -149,7 +144,6 @@ fun DeckColumnContainer( onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, - onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, ) if (currentOverlay != null) { Surface( @@ -163,14 +157,11 @@ fun DeckColumnContainer( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, - draftStore = draftStore, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onBack = { navState.pop() }, ) } @@ -189,8 +180,6 @@ internal fun RootContent( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, - highlightStore: DesktopHighlightStore? = null, - draftStore: DesktopDraftStore? = null, appScope: CoroutineScope, compactMode: Boolean = false, onShowComposeDialog: () -> Unit, @@ -199,7 +188,6 @@ internal fun RootContent( onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, onNavigateToArticle: (String) -> Unit = {}, - onNavigateToEditor: (String?) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -249,11 +237,8 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, - nwcConnection = nwcConnection, onNavigateToProfile = onNavigateToProfile, onNavigateToArticle = onNavigateToArticle, - onNavigateToThread = onNavigateToThread, - onZapFeedback = onZapFeedback, ) } @@ -296,7 +281,6 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, - onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -352,16 +336,16 @@ internal fun RootContent( localCache = localCache, account = account, subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, onBack = {}, onNavigateToProfile = onNavigateToProfile, ) } - is DeckColumnType.Editor -> { + DeckColumnType.Editor -> { + val draftStore = remember { DesktopDraftStore(scope) } ArticleEditorScreen( - draftSlug = columnType.draftSlug, - draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, + draftSlug = null, + draftStore = draftStore, account = account, relayManager = relayManager, onBack = {}, @@ -370,16 +354,10 @@ internal fun RootContent( } DeckColumnType.Drafts -> { + val draftStore = remember { DesktopDraftStore(scope) } DraftsScreen( - draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, - onOpenEditor = { slug -> onNavigateToEditor(slug) }, - ) - } - - DeckColumnType.MyHighlights -> { - com.vitorpamplona.amethyst.desktop.ui.highlights.MyHighlightsScreen( - highlightStore = highlightStore ?: remember { DesktopHighlightStore(scope) }, - onNavigateToArticle = onNavigateToArticle, + draftStore = draftStore, + onOpenEditor = {}, ) } @@ -404,14 +382,11 @@ internal fun OverlayContent( account: AccountState.LoggedIn, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, - highlightStore: DesktopHighlightStore? = null, - draftStore: DesktopDraftStore? = null, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, - onNavigateToArticle: (String) -> Unit = {}, onBack: () -> Unit, ) { when (screen) { @@ -426,7 +401,6 @@ internal fun OverlayContent( onBack = onBack, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, - onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -454,24 +428,11 @@ internal fun OverlayContent( localCache = localCache, account = account, subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, onBack = onBack, onNavigateToProfile = onNavigateToProfile, ) } - is DesktopScreen.Editor -> { - val overlayScope = androidx.compose.runtime.rememberCoroutineScope() - ArticleEditorScreen( - draftSlug = screen.draftSlug, - draftStore = draftStore ?: remember { DesktopDraftStore(overlayScope) }, - account = account, - relayManager = relayManager, - onBack = onBack, - onPublished = onBack, - ) - } - else -> { androidx.compose.material3.Text( "Unsupported screen type", From 7151d3052ae3d47816bbe3a14c3fe944bf065d31 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 24 Mar 2026 09:26:06 +0200 Subject: [PATCH 23/26] feat(cache): extract BookmarkListState to commons/commonMain - Move BookmarkListState from amethyst/ to commons/commonMain/ - Change cache param from LocalCache to ICacheProvider - Android file becomes typealias to commons version - No settings or decryptionCache dependencies (simplest State class) - Pins bookmarkList AddressableNote via strong ref for GC retention --- .../model/nip51Lists/BookmarkListState.kt | 280 +--------------- .../model/nip51Lists/BookmarkListState.kt | 301 ++++++++++++++++++ 2 files changed, 302 insertions(+), 279 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt index 9a438a880..db9004b75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt @@ -20,282 +20,4 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists -import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.NoteState -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combineTransform -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn - -@Stable -class BookmarkListState( - val signer: NostrSigner, - val cache: LocalCache, - val scope: CoroutineScope, -) { - class BookmarkList( - val public: List = emptyList(), - val private: List = emptyList(), - ) - - // Creates a long-term reference for this note so that the GC doesn't collect the note it self - val bookmarkList = cache.getOrCreateAddressableNote(getBookmarkListAddress()) - - fun getBookmarkListAddress() = BookmarkListEvent.createBookmarkAddress(signer.pubKey) - - fun getBookmarkListFlow(): StateFlow = bookmarkList.flow().metadata.stateFlow - - fun getBookmarkList(): BookmarkListEvent? = bookmarkList.event as? BookmarkListEvent - - fun publicBookmarks(note: Note): List { - val noteEvent = note.event as? BookmarkListEvent - return noteEvent?.publicBookmarks() ?: emptyList() - } - - suspend fun privateBookmarks(note: Note): List { - val noteEvent = note.event as? BookmarkListEvent - return noteEvent?.privateBookmarks(signer) ?: emptyList() - } - - @OptIn(FlowPreview::class) - val publicBookmarks: StateFlow> = - getBookmarkListFlow() - .map { noteState -> - publicBookmarks(noteState.note) - }.onStart { - emit(publicBookmarks(bookmarkList)) - }.debounce(100) - .flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - @OptIn(FlowPreview::class) - val privateBookmarks: StateFlow> = - getBookmarkListFlow() - .map { noteState -> - privateBookmarks(noteState.note) - }.onStart { - emit(privateBookmarks(bookmarkList)) - }.debounce(100) - .flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - val publicBookmarkEventIdSet = - publicBookmarks - .map { bookmark -> - bookmark - .mapNotNull { - if (it is EventBookmark) it.eventId else null - }.toSet() - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - val publicBookmarkAddressIdSet = - publicBookmarks - .map { bookmark -> - bookmark - .mapNotNull { - if (it is AddressBookmark) it.address else null - }.toSet() - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - val privateBookmarkEventIdSet = - privateBookmarks - .map { bookmark -> - bookmark - .mapNotNull { - if (it is EventBookmark) it.eventId else null - }.toSet() - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - val privateBookmarkAddressIdSet = - privateBookmarks - .map { bookmark -> - bookmark - .mapNotNull { - if (it is AddressBookmark) it.address else null - }.toSet() - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - fun bookmarkList( - privateBookmarks: List, - publicBookmarks: List, - ): BookmarkList = - BookmarkList( - public = - publicBookmarks - .mapNotNull { - when (it) { - is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) - is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) - } - }.reversed(), - private = - privateBookmarks - .mapNotNull { - when (it) { - is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) - is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) - } - }.reversed(), - ) - - @OptIn(FlowPreview::class) - val bookmarks: StateFlow = - combineTransform(privateBookmarks, publicBookmarks) { private, public -> - emit(bookmarkList(private, public)) - }.onStart { - emit(bookmarkList(privateBookmarks.value, publicBookmarks.value)) - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - BookmarkList(), - ) - - fun isInPrivateBookmarks(note: Note): Boolean { - if (!signer.isWriteable()) return false - - return if (note is AddressableNote) { - privateBookmarkAddressIdSet.value.contains(note.address) - } else { - privateBookmarkEventIdSet.value.contains(note.idHex) - } - } - - fun isInPublicBookmarks(note: Note): Boolean = - if (note is AddressableNote) { - publicBookmarkAddressIdSet.value.contains(note.address) - } else { - publicBookmarkEventIdSet.value.contains(note.idHex) - } - - suspend fun addBookmark( - note: Note, - isPrivate: Boolean, - ): BookmarkListEvent { - val bookmarkList = getBookmarkList() - - return if (bookmarkList == null) { - if (note is AddressableNote) { - BookmarkListEvent.create( - bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } else { - BookmarkListEvent.create( - bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } - } else { - if (note is AddressableNote) { - BookmarkListEvent.add( - earlierVersion = bookmarkList, - bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } else { - BookmarkListEvent.add( - earlierVersion = bookmarkList, - bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } - } - } - - suspend fun removeBookmark( - note: Note, - isPrivate: Boolean, - ): BookmarkListEvent? { - val bookmarkList = getBookmarkList() - - return if (bookmarkList != null) { - if (note is AddressableNote) { - BookmarkListEvent.remove( - earlierVersion = bookmarkList, - bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } else { - BookmarkListEvent.remove( - earlierVersion = bookmarkList, - bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), - isPrivate = isPrivate, - signer = signer, - ) - } - } else { - null - } - } - - suspend fun removeBookmark(note: Note): BookmarkListEvent? { - val bookmarkList = getBookmarkList() - - return if (bookmarkList != null) { - if (note is AddressableNote) { - BookmarkListEvent.remove( - earlierVersion = bookmarkList, - bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), - signer = signer, - ) - } else { - BookmarkListEvent.remove( - earlierVersion = bookmarkList, - bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), - signer = signer, - ) - } - } else { - null - } - } -} +typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt new file mode 100644 index 000000000..c3fdee824 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip51Lists + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +@Stable +class BookmarkListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, +) { + class BookmarkList( + val public: List = emptyList(), + val private: List = emptyList(), + ) + + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val bookmarkList = cache.getOrCreateAddressableNote(getBookmarkListAddress()) + + fun getBookmarkListAddress() = BookmarkListEvent.createBookmarkAddress(signer.pubKey) + + fun getBookmarkListFlow(): StateFlow = bookmarkList.flow().metadata.stateFlow + + fun getBookmarkList(): BookmarkListEvent? = bookmarkList.event as? BookmarkListEvent + + fun publicBookmarks(note: Note): List { + val noteEvent = note.event as? BookmarkListEvent + return noteEvent?.publicBookmarks() ?: emptyList() + } + + suspend fun privateBookmarks(note: Note): List { + val noteEvent = note.event as? BookmarkListEvent + return noteEvent?.privateBookmarks(signer) ?: emptyList() + } + + @OptIn(FlowPreview::class) + val publicBookmarks: StateFlow> = + getBookmarkListFlow() + .map { noteState -> + publicBookmarks(noteState.note) + }.onStart { + emit(publicBookmarks(bookmarkList)) + }.debounce(100) + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + @OptIn(FlowPreview::class) + val privateBookmarks: StateFlow> = + getBookmarkListFlow() + .map { noteState -> + privateBookmarks(noteState.note) + }.onStart { + emit(privateBookmarks(bookmarkList)) + }.debounce(100) + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val publicBookmarkEventIdSet = + publicBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is EventBookmark) it.eventId else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val publicBookmarkAddressIdSet = + publicBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is AddressBookmark) it.address else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val privateBookmarkEventIdSet = + privateBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is EventBookmark) it.eventId else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val privateBookmarkAddressIdSet = + privateBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is AddressBookmark) it.address else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + fun bookmarkList( + privateBookmarks: List, + publicBookmarks: List, + ): BookmarkList = + BookmarkList( + public = + publicBookmarks + .mapNotNull { + when (it) { + is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) + is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) + } + }.reversed(), + private = + privateBookmarks + .mapNotNull { + when (it) { + is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) + is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) + } + }.reversed(), + ) + + @OptIn(FlowPreview::class) + val bookmarks: StateFlow = + combineTransform(privateBookmarks, publicBookmarks) { private, public -> + emit(bookmarkList(private, public)) + }.onStart { + emit(bookmarkList(privateBookmarks.value, publicBookmarks.value)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + BookmarkList(), + ) + + fun isInPrivateBookmarks(note: Note): Boolean { + if (!signer.isWriteable()) return false + + return if (note is AddressableNote) { + privateBookmarkAddressIdSet.value.contains(note.address) + } else { + privateBookmarkEventIdSet.value.contains(note.idHex) + } + } + + fun isInPublicBookmarks(note: Note): Boolean = + if (note is AddressableNote) { + publicBookmarkAddressIdSet.value.contains(note.address) + } else { + publicBookmarkEventIdSet.value.contains(note.idHex) + } + + suspend fun addBookmark( + note: Note, + isPrivate: Boolean, + ): BookmarkListEvent { + val bookmarkList = getBookmarkList() + + return if (bookmarkList == null) { + if (note is AddressableNote) { + BookmarkListEvent.create( + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } else { + BookmarkListEvent.create( + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } + } else { + if (note is AddressableNote) { + BookmarkListEvent.add( + earlierVersion = bookmarkList, + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } else { + BookmarkListEvent.add( + earlierVersion = bookmarkList, + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } + } + } + + suspend fun removeBookmark( + note: Note, + isPrivate: Boolean, + ): BookmarkListEvent? { + val bookmarkList = getBookmarkList() + + return if (bookmarkList != null) { + if (note is AddressableNote) { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } else { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + isPrivate = isPrivate, + signer = signer, + ) + } + } else { + null + } + } + + suspend fun removeBookmark(note: Note): BookmarkListEvent? { + val bookmarkList = getBookmarkList() + + return if (bookmarkList != null) { + if (note is AddressableNote) { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + signer = signer, + ) + } else { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + signer = signer, + ) + } + } else { + null + } + } +} From 2b95cc013a70f0b03934ae44fe60f3e7e4862d55 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 24 Mar 2026 09:36:28 +0200 Subject: [PATCH 24/26] feat(cache): extract Kind3FollowListState + Nip65RelayListState to commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Kind3FollowListState to commons/commonMain with ICacheProvider + Kind3FollowListRepository interface for settings needs - Add Nip65RelayListState to commons/commonMain with ICacheProvider + Nip65RelayListRepository interface with default relay sets - Per-feature repository interfaces match existing EphemeralChatRepository / PublicChatListRepository pattern in commons - Android originals unchanged — Desktop will use commons versions - Static LocalCache.justConsumeMyOwnEvent calls replaced with cache param --- .../Kind3FollowListRepository.kt | 33 ++++ .../nip02FollowList/Kind3FollowListState.kt | 181 ++++++++++++++++++ .../Nip65RelayListRepository.kt | 40 ++++ .../nip65RelayList/Nip65RelayListState.kt | 160 ++++++++++++++++ 4 files changed, 414 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListRepository.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListRepository.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListRepository.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListRepository.kt new file mode 100644 index 000000000..da4c6e981 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListRepository.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip02FollowList + +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent + +/** + * Narrow repository interface for Kind3FollowListState's settings needs. + * Follows the established pattern of EphemeralChatRepository / PublicChatListRepository. + */ +interface Kind3FollowListRepository { + val backupContactList: ContactListEvent? + + fun updateContactListTo(event: ContactListEvent) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt new file mode 100644 index 000000000..c7e3b6066 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip02FollowList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class Kind3FollowListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, + val settings: Kind3FollowListRepository, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val user = cache.getOrCreateUser(signer.pubKey) + + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val note = cache.getOrCreateAddressableNote(getFollowListAddress()) + + fun getFollowListAddress() = ContactListEvent.createAddress(signer.pubKey) + + fun getFollowListFlow(): StateFlow = note.flow().metadata.stateFlow + + fun getFollowListEvent(): ContactListEvent? = note.event as? ContactListEvent + + @OptIn(ExperimentalCoroutinesApi::class) + private val innerFlow: Flow = + getFollowListFlow().transformLatest { + emit(buildKind3Follows(it.note.event as? ContactListEvent ?: settings.backupContactList)) + } + + val flow = + innerFlow + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + // this has priority. + buildKind3Follows(getFollowListEvent() ?: settings.backupContactList), + ) + + // Creates a long-term reference for all follows of a user + val userList = + flow + .map { kind3Follows -> + kind3Follows.authors.mapNotNull { + runCatching { cache.getOrCreateUser(it) }.getOrNull() + } + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + // this has priority. + flow.value.authors.mapNotNull { + runCatching { cache.getOrCreateUser(it) }.getOrNull() + }, + ) + + /** + This contains a big OR of everything the user wants to see in the a single feed. + */ + @Immutable + class Kind3Follows( + val authors: Set = emptySet(), + val authorsPlusMe: Set, + ) + + fun buildKind3Follows(latestContactList: ContactListEvent?): Kind3Follows { + // makes sure the output include only valid p tags + val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() + + return Kind3Follows( + authors = verifiedFollowingUsers, + authorsPlusMe = verifiedFollowingUsers + signer.pubKey, + ) + } + + suspend fun follow(users: List): ContactListEvent { + val contactList = getFollowListEvent() + + val contacts = + users.map { + ContactTag(it.pubkeyHex, it.bestRelayHint(), null) + } + + return if (contactList != null) { + ContactListEvent.followUsers(contactList, contacts, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = contacts, + relayUse = emptyMap(), + signer = signer, + ) + } + } + + suspend fun follow(user: User): ContactListEvent { + val contactList = getFollowListEvent() + + return if (contactList != null) { + ContactListEvent.followUser(contactList, user.pubkeyHex, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), + relayUse = emptyMap(), + signer = signer, + ) + } + } + + suspend fun unfollow(user: User): ContactListEvent? { + val contactList = getFollowListEvent() + + return if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowUser( + contactList, + user.pubkeyHex, + signer, + ) + } else { + null + } + } + + init { + settings.backupContactList?.let { + Log.d("AccountRegisterObservers", "Loading saved ${it.tags.size} contacts") + + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + // saves contact list for the next time. + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "Kind 3 Collector Start") + getFollowListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}") + (it.note.event as? ContactListEvent)?.let { + settings.updateContactListTo(it) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListRepository.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListRepository.kt new file mode 100644 index 000000000..6b5889074 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListRepository.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip65RelayList + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * Narrow repository interface for Nip65RelayListState's settings needs. + * Follows the established pattern of EphemeralChatRepository / PublicChatListRepository. + */ +interface Nip65RelayListRepository { + val backupNIP65RelayList: AdvertisedRelayListEvent? + + fun updateNIP65RelayList(event: AdvertisedRelayListEvent) + + /** Default relay set when no NIP-65 list is available (write relays). */ + val defaultOutboxRelays: Set + + /** Default relay set when no NIP-65 list is available (read relays). */ + val defaultInboxRelays: Set +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt new file mode 100644 index 000000000..b93f17d53 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip65RelayList + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class Nip65RelayListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, + val settings: Nip65RelayListRepository, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val nip65ListNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress()) + + fun getNIP65RelayListAddress() = AdvertisedRelayListEvent.createAddress(signer.pubKey) + + fun getNIP65RelayListFlow(): StateFlow = nip65ListNote.flow().metadata.stateFlow + + fun getNIP65RelayList(): AdvertisedRelayListEvent? = nip65ListNote.event as? AdvertisedRelayListEvent + + fun nip65Event(note: Note) = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + + fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: settings.defaultOutboxRelays + + fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set = nip65Event(note)?.readRelaysNorm()?.toSet() ?: settings.defaultInboxRelays + + fun normalizeNIP65WriteRelayListNoDefaults(note: Note): Set = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: emptySet() + + fun normalizeNIP65ReadRelayListNoDefaults(note: Note): Set = nip65Event(note)?.readRelaysNorm()?.toSet() ?: emptySet() + + fun normalizeNIP65AllRelayListWithBackup(note: Note): Set = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: settings.defaultOutboxRelays + + fun normalizeNIP65AllRelayListWithBackupNoDefaults(note: Note): Set = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: emptySet() + + val outboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65WriteRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65WriteRelayListWithBackup(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val inboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65ReadRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListWithBackup(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val outboxFlowNoDefaults = + getNIP65RelayListFlow() + .map { normalizeNIP65WriteRelayListNoDefaults(it.note) } + .onStart { emit(normalizeNIP65WriteRelayListNoDefaults(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val inboxFlowNoDefaults = + getNIP65RelayListFlow() + .map { normalizeNIP65ReadRelayListNoDefaults(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListNoDefaults(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val allFlowNoDefaults = + getNIP65RelayListFlow() + .map { normalizeNIP65AllRelayListWithBackupNoDefaults(it.note) } + .onStart { emit(normalizeNIP65AllRelayListWithBackupNoDefaults(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(relays: List): AdvertisedRelayListEvent { + val nip65RelayList = getNIP65RelayList() + + return if (nip65RelayList != null) { + AdvertisedRelayListEvent.replaceRelayListWith( + earlierVersion = nip65RelayList, + newRelays = relays, + signer = signer, + ) + } else { + AdvertisedRelayListEvent.createFromScratch( + relays = relays, + signer = signer, + ) + } + } + + init { + settings.backupNIP65RelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") + getNIP65RelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${signer.pubKey}") + (it.note.event as? AdvertisedRelayListEvent)?.let { + settings.updateNIP65RelayList(it) + } + } + } + } +} From 072e437277c3cd9b7eac847743a0b0919da91265 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 24 Mar 2026 09:42:59 +0200 Subject: [PATCH 25/26] feat(cache): wire State classes on DesktopIAccount for GC retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BookmarkListState, Kind3FollowListState, Nip65RelayListState to DesktopIAccount — pins important AddressableNotes via strong refs - Create stub Kind3FollowListRepository + Nip65RelayListRepository (no persistence yet, null backups) - Update followingKeySet() to read from Kind3FollowListState.flow - Revert accidental long-form branch changes in DeckColumnContainer --- .../vitorpamplona/amethyst/desktop/Main.kt | 1 + .../amethyst/desktop/model/DesktopIAccount.kt | 42 ++++++++++++++- .../desktop/ui/deck/DeckColumnContainer.kt | 53 ++++++++++++++++--- .../desktop/cache/CoordinatorPipelineTest.kt | 2 + 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 23616a926..4c73e002b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -153,6 +153,7 @@ sealed class DesktopScreen { ) : DesktopScreen() data object Drafts : DesktopScreen() + data object Settings : DesktopScreen() } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 6f432f058..9efb98238 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -24,6 +24,11 @@ import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.INwcSignerState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.nip02FollowList.Kind3FollowListRepository +import com.vitorpamplona.amethyst.commons.model.nip02FollowList.Kind3FollowListState +import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState +import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRepository +import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache @@ -31,6 +36,7 @@ import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -42,6 +48,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope @@ -67,6 +74,39 @@ class DesktopIAccount( override val pubKey: String = accountState.pubKeyHex + // ----- State Classes (pin important notes via strong refs for GC retention) ----- + + val bookmarkState = BookmarkListState(signer, localCache, scope) + + val kind3FollowList = + Kind3FollowListState( + signer, + localCache, + scope, + object : Kind3FollowListRepository { + override val backupContactList: ContactListEvent? = null + + override fun updateContactListTo(event: ContactListEvent) { /* no persistence yet */ } + }, + ) + + val nip65RelayList = + Nip65RelayListState( + signer, + localCache, + scope, + object : Nip65RelayListRepository { + override val backupNIP65RelayList: AdvertisedRelayListEvent? = null + + override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) { /* no persistence yet */ } + + override val defaultOutboxRelays = relayManager.connectedRelays.value + override val defaultInboxRelays = relayManager.connectedRelays.value + }, + ) + + // --------------------------------------------------------------------------------- + override val showSensitiveContent: Boolean? = null override val hiddenWordsCase: List = emptyList() @@ -97,7 +137,7 @@ class DesktopIAccount( override fun isWriteable(): Boolean = !accountState.isReadOnly - override fun followingKeySet(): Set = emptySet() + override fun followingKeySet(): Set = kind3FollowList.flow.value.authors override fun isHidden(user: User): Boolean = false diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 9a71b8bb8..1da11dbd6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen @@ -96,6 +97,8 @@ fun DeckColumnContainer( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore, + draftStore: DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -136,6 +139,8 @@ fun DeckColumnContainer( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, compactMode = true, onShowComposeDialog = onShowComposeDialog, @@ -144,6 +149,7 @@ fun DeckColumnContainer( onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, ) if (currentOverlay != null) { Surface( @@ -157,11 +163,14 @@ fun DeckColumnContainer( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onBack = { navState.pop() }, ) } @@ -180,6 +189,8 @@ internal fun RootContent( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore? = null, + draftStore: DesktopDraftStore? = null, appScope: CoroutineScope, compactMode: Boolean = false, onShowComposeDialog: () -> Unit, @@ -188,6 +199,7 @@ internal fun RootContent( onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, onNavigateToArticle: (String) -> Unit = {}, + onNavigateToEditor: (String?) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -237,8 +249,11 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, + nwcConnection = nwcConnection, onNavigateToProfile = onNavigateToProfile, onNavigateToArticle = onNavigateToArticle, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, ) } @@ -281,6 +296,7 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -336,16 +352,16 @@ internal fun RootContent( localCache = localCache, account = account, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, onBack = {}, onNavigateToProfile = onNavigateToProfile, ) } - DeckColumnType.Editor -> { - val draftStore = remember { DesktopDraftStore(scope) } + is DeckColumnType.Editor -> { ArticleEditorScreen( - draftSlug = null, - draftStore = draftStore, + draftSlug = columnType.draftSlug, + draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, account = account, relayManager = relayManager, onBack = {}, @@ -354,10 +370,16 @@ internal fun RootContent( } DeckColumnType.Drafts -> { - val draftStore = remember { DesktopDraftStore(scope) } DraftsScreen( - draftStore = draftStore, - onOpenEditor = {}, + draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, + onOpenEditor = { slug -> onNavigateToEditor(slug) }, + ) + } + + DeckColumnType.MyHighlights -> { + com.vitorpamplona.amethyst.desktop.ui.highlights.MyHighlightsScreen( + highlightStore = highlightStore ?: remember { DesktopHighlightStore(scope) }, + onNavigateToArticle = onNavigateToArticle, ) } @@ -382,11 +404,14 @@ internal fun OverlayContent( account: AccountState.LoggedIn, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore? = null, + draftStore: DesktopDraftStore? = null, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, + onNavigateToArticle: (String) -> Unit = {}, onBack: () -> Unit, ) { when (screen) { @@ -401,6 +426,7 @@ internal fun OverlayContent( onBack = onBack, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -428,11 +454,24 @@ internal fun OverlayContent( localCache = localCache, account = account, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, onBack = onBack, onNavigateToProfile = onNavigateToProfile, ) } + is DesktopScreen.Editor -> { + val overlayScope = androidx.compose.runtime.rememberCoroutineScope() + ArticleEditorScreen( + draftSlug = screen.draftSlug, + draftStore = draftStore ?: remember { DesktopDraftStore(overlayScope) }, + account = account, + relayManager = relayManager, + onBack = onBack, + onPublished = onBack, + ) + } + else -> { androidx.compose.material3.Text( "Unsupported screen type", diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt index eb3d04558..b47fa330f 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt @@ -79,6 +79,8 @@ class CoordinatorPipelineTest { override fun disconnect() {} + override fun close() {} + override fun reconnect( onlyIfChanged: Boolean, ignoreRetryDelays: Boolean, From 9c9c7d3f241a0080bfa2431de9323320a433087a Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 26 Mar 2026 07:20:22 +0200 Subject: [PATCH 26/26] feat(cache): seed NotificationsScreen from cache on compose - Add localCache param to NotificationsScreen - Seed reactions and zaps from cache on initial compose - Notifications now persist across tab navigation - All screens now have cache seeding (Feed, Thread, Profile, Bookmarks, Reads, Notifications) --- .../desktop/ui/NotificationsScreen.kt | 34 +++++++++++++++++++ .../desktop/ui/deck/DeckColumnContainer.kt | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 6fa7f9225..edb2e43d8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.createNotificationsSubscription @@ -109,6 +110,7 @@ sealed class NotificationItem( @Composable fun NotificationsScreen( relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, ) { @@ -126,6 +128,38 @@ fun NotificationsScreen( } val notifications by notificationState.items.collectAsState() + // Seed from cache — reactions, zaps already consumed are in cache + LaunchedEffect(Unit) { + val myPubKey = account.pubKeyHex + val cached = + localCache.notes.filterIntoSet { _, note -> + val event = note.event ?: return@filterIntoSet false + when (event) { + is ReactionEvent -> event.pubKey != myPubKey + is LnZapEvent -> true + else -> false + } + } + cached.forEach { note -> + val event = note.event ?: return@forEach + val notification = + when (event) { + is ReactionEvent -> { + NotificationItem.Reaction(event, event.createdAt, event.content) + } + + is LnZapEvent -> { + NotificationItem.Zap(event, event.createdAt, event.amount?.toLong()) + } + + else -> { + null + } + } + notification?.let { notificationState.addItem(it) } + } + } + // Load metadata for notification authors via coordinator LaunchedEffect(notifications, subscriptionsCoordinator) { if (subscriptionsCoordinator != null && notifications.isNotEmpty()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 1da11dbd6..8d8f01805 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -220,7 +220,7 @@ internal fun RootContent( } DeckColumnType.Notifications -> { - NotificationsScreen(relayManager, account, subscriptionsCoordinator) + NotificationsScreen(relayManager, localCache, account, subscriptionsCoordinator) } DeckColumnType.Messages -> {