Files
amethyst/docs/plans/2026-04-23-feat-desktop-relay-config-single-source-plan.md
T
nrobi144 835947d611 feat(desktop): relay config persistence, correct counts, per-screen picker
- Persist relay list events (kinds 10050/10007/10006) as JSON to
  java.util.prefs.Preferences with per-account key isolation
- Load persisted relay configs on startup before bootstrap subscription
- Validate loaded events (kind + pubkey check), 8KB guard on writes
- Fix SearchScreen relay count: "0 of 1" not "0 of 7" — uses searchRelays
- Fix FeedScreen relay count: shows feed relay count, not all connected
- Per-screen relay picker dialogs: Dns icon on Feed and Search screens
  opens AlertDialog wrapping existing editors (Nip65RelayEditor,
  SearchRelayEditor) — no new composable files
- Fix created_at dedup: use >= for replaceable event semantics
- Fix setters: use TimeUtils.now() not Long.MAX_VALUE
- Add consumePublishedEvent() for local immediate update after publish
- Remove stale "not loaded" warnings from Search/Blocked editors
- Fix FeedHeader type: Set<NormalizedRelayUrl> not Set<Any>
- Fix picker LaunchedEffect(Unit) to not overwrite user edits

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

203 lines
9.1 KiB
Markdown

---
title: "feat: DesktopRelayConfig — Single Source of Truth for All Relay Categories"
type: feat
status: active
date: 2026-04-23
---
# feat: DesktopRelayConfig — Single Source of Truth for All Relay Categories
## Problem
The current relay wiring is broken. Multiple classes (`DesktopRelayCategories`, `DesktopAccountRelays`, `RelayConnectionManager`) each hold parts of relay state, leading to race conditions, empty initial values, and screens using inconsistent relay sources.
**Symptoms:**
- Feed shows "0 feed relays" on startup (race: `allConfiguredRelays` empty at `DesktopRelayCategories` construction)
- NIP-65 relay changes don't propagate to feed subscriptions
- Different screens use different relay sources (`relayStatuses.keys` vs `connectedRelays` vs `feedRelays` vs `allRelayUrls`)
- No screen resubscribes when relay config changes
## Root Cause
| Issue | Why |
|-------|-----|
| `feedRelays` starts empty | `stateIn(Eagerly, allConfiguredRelays.value)` captures empty snapshot at construction time |
| Screens use inconsistent sources | FeedScreen uses `feedRelays`, NotificationsScreen uses `relayStatuses.keys`, ReadsScreen uses `relayStatuses.keys` |
| No reconnection on config change | `rememberSubscription` keys don't include relay category flows |
| Coordinator `indexRelays` is static | Passed at construction, never updated |
## Proposed Solution
**One class: `DesktopRelayConfig`** that:
1. Holds all relay category sets as reactive `StateFlow`s
2. Is initialized from `DefaultRelays.RELAYS` immediately (never empty)
3. Updates when NIP-65/DM/search/blocked events arrive
4. Persists to Preferences
5. Every screen reads from this single source
## Technical Approach
### Delete/Replace
| Remove | Replace With |
|--------|-------------|
| `DesktopRelayCategories.kt` | `DesktopRelayConfig.kt` |
| `DesktopAccountRelays.kt` | Merged into `DesktopRelayConfig` |
| `DesktopDmRelayState.kt` | Merged into `DesktopRelayConfig` |
| `LocalRelayCategories` CompositionLocal | `LocalRelayConfig` |
| `LocalAccountRelays` CompositionLocal | Removed (merged into `LocalRelayConfig`) |
### `DesktopRelayConfig.kt` — The One Class
```kotlin
class DesktopRelayConfig(
val userPubKeyHex: HexKey,
private val relayManager: RelayConnectionManager,
private val nip65State: Nip65RelayListState,
private val scope: CoroutineScope,
) {
private val prefs = Preferences.userNodeForPackage(DesktopRelayConfig::class.java)
// === Raw category state (updated by events + persistence + UI) ===
private val _dmRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
private val _searchRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(DEFAULT_SEARCH_RELAYS)
private val _blockedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
// === Derived relay sets for subscriptions ===
/** Default relays — always populated from DefaultRelays.RELAYS, never empty */
private val defaultRelays: Set<NormalizedRelayUrl> =
DefaultRelays.RELAYS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
/** Feed relays: NIP-65 outbox → fallback to defaultRelays, minus blocked */
val feedRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
nip65State.outboxFlow,
_blockedRelays,
) { outbox, blocked ->
(outbox.ifEmpty { defaultRelays }) - blocked
}.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, defaultRelays) // NEVER empty
/** Notification relays: NIP-65 inbox → fallback to defaultRelays, minus blocked */
val notificationRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
nip65State.inboxFlow,
_blockedRelays,
) { inbox, blocked ->
(inbox.ifEmpty { defaultRelays }) - blocked
}.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
/** Search relays: kind 10007 → fallback to relay.nostr.band, minus blocked */
val searchRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
_searchRelays,
_blockedRelays,
) { search, blocked ->
search - blocked
}.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_SEARCH_RELAYS)
/** DM relays: kind 10050 → fallback to defaultRelays */
val dmRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
_dmRelays,
_blockedRelays,
) { dm, blocked ->
(dm.ifEmpty { defaultRelays }) - blocked
}.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
/** Blocked relays (public read-only) */
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>> = _blockedRelays.asStateFlow()
// Also expose raw lists for editors
val dmRelayList: StateFlow<Set<NormalizedRelayUrl>> = _dmRelays.asStateFlow()
val searchRelayList: StateFlow<Set<NormalizedRelayUrl>> = _searchRelays.asStateFlow()
val blockedRelayList: StateFlow<Set<NormalizedRelayUrl>> = _blockedRelays.asStateFlow()
init { loadFromPersistence() }
// === Persistence (relay URLs as CSV) ===
fun setDmRelays(relays: Set<NormalizedRelayUrl>) { _dmRelays.value = relays; save("dm", relays) }
fun setSearchRelays(relays: Set<NormalizedRelayUrl>) { _searchRelays.value = relays; save("search", relays) }
fun setBlockedRelays(relays: Set<NormalizedRelayUrl>) { _blockedRelays.value = relays; save("blocked", relays) }
// === Event consumption (from bootstrap + relay subscriptions) ===
fun consumeEvent(event: Event) { ... } // routes by kind, checks created_at
companion object {
val DEFAULT_SEARCH_RELAYS = setOfNotNull(RelayUrlNormalizer.normalizeOrNull("wss://relay.nostr.band"))
}
}
```
**Key design difference from current code:** `defaultRelays` is a `val` computed once from `DefaultRelays.RELAYS` — it's NEVER empty. The `stateIn` initial value is `defaultRelays`, not a snapshot of some flow that might not be populated yet.
### Screen Updates
Every screen uses `LocalRelayConfig.current` to get the right relay set:
| Screen | Current Source | New Source | Change |
|--------|---------------|------------|--------|
| **FeedScreen** (feed sub) | `feedRelays` from DesktopRelayCategories | `relayConfig.feedRelays` | Same concept, but initial value is never empty |
| **FeedScreen** (contact list) | `allRelayUrls = relayStatuses.keys` | `relayConfig.feedRelays` | Unified source |
| **FeedScreen** (metadata) | `allRelayUrls` | `relayConfig.feedRelays` | Unified |
| **FeedScreen** (interactions) | `relayStatuses.value.keys` snapshot | `relayConfig.feedRelays` | Fix snapshot issue |
| **SearchScreen** | `searchRelays` from categories | `relayConfig.searchRelays` | Same concept |
| **NotificationsScreen** | `relayStatuses.keys` | `relayConfig.notificationRelays` | Now uses NIP-65 inbox |
| **ReadsScreen** | `relayStatuses.keys` | `relayConfig.feedRelays` | Unified with feed |
| **BookmarksScreen** | `relayStatuses.keys` | `relayConfig.feedRelays` | Unified |
| **DM subscriptions** | Hardcoded empty | `relayConfig.dmRelays` | Actually works now |
### Subscription Reactivity
`rememberSubscription` already rekeys when its key params change. Each screen uses `relayConfig.feedRelays.collectAsState()` as a key — when the StateFlow emits a new set (e.g., after NIP-65 update), the subscription teardowns and recreates with the new relay set. This is already how it works; the fix is just making the initial value non-empty.
### Coordinator Fix
`DesktopRelaySubscriptionsCoordinator.indexRelays` should use `relayConfig.feedRelays.value` at construction. Or better: make it a `var` so it can be updated:
```kotlin
class DesktopRelaySubscriptionsCoordinator(
private val client: INostrClient,
private val scope: CoroutineScope,
var indexRelays: Set<NormalizedRelayUrl>, // var, not val
private val localCache: DesktopLocalCache,
)
```
Update it when relay config changes:
```kotlin
LaunchedEffect(relayConfig.feedRelays) {
relayConfig.feedRelays.collect { relays ->
subscriptionsCoordinator.indexRelays = relays
}
}
```
## Implementation Steps
1. Create `DesktopRelayConfig.kt` merging AccountRelays + DmRelayState + RelayCategories
2. Create `LocalRelayConfig` CompositionLocal, remove `LocalRelayCategories` + `LocalAccountRelays`
3. Provide in Main.kt, remove old classes
4. Update every screen to use `LocalRelayConfig.current`
5. Make coordinator `indexRelays` mutable + reactive
6. Test: feed shows 7 relays on startup, search uses configured relays, NIP-65 changes propagate
## Acceptance Criteria
- [ ] Feed shows relay count immediately on startup (never "0 feed relays")
- [ ] All screens use `LocalRelayConfig.current` — no direct `relayStatuses.keys` usage
- [ ] NIP-65 relay changes propagate to feed subscriptions (resubscribes)
- [ ] Search relay changes propagate to search subscriptions
- [ ] DM relay changes propagate to DM subscriptions
- [ ] Relay config persists across restarts
- [ ] Per-screen relay picker dialogs use `DesktopRelayConfig` setters
- [ ] `DesktopRelaySubscriptionsCoordinator.indexRelays` updates reactively
## Unanswered Questions
1. Should `defaultRelays` also include user's connected relays, or strictly `DefaultRelays.RELAYS`?
2. Should we delete `DesktopDmRelayState` or keep it as an internal implementation detail?