835947d611
- 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>
203 lines
9.1 KiB
Markdown
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?
|