Files
amethyst/docs/plans/2026-04-23-feat-desktop-relay-config-single-source-plan.md
Claude 6579b5f656 docs: audit, status-stamp, and index all module plans
Audited all 143 plan files across the 10 plans/ folders. Each plan now
carries a Status header (shipped | in-progress | queued | abandoned)
backed by codebase evidence, and every folder has a README.md index
grouping plans by status.

Shipped plans were moved into a per-folder plans/archive/ (via git mv,
history preserved) so each plans/ folder surfaces only live work:

  shipped (archived): 122   in-progress: 8   queued: 7   abandoned: 4

docs/plans/ is the frozen legacy folder; its plans were stamped and
indexed in place (48 of 52 archived) but it remains closed to new plans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
2026-06-30 15:35:38 +00:00

9.3 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

Status: abandoned — Superseded — the proposed single DesktopRelayConfig class was never built; relay state landed as DesktopRelayCategories/LocalRelayCategories instead. Audited 2026-06-30.

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?