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

9.1 KiB

title, type, status, date
title type status date
feat: DesktopRelayConfig — Single Source of Truth for All Relay Categories feat active 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 StateFlows
  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

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:

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:

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?