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
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:
allConfiguredRelaysempty atDesktopRelayCategoriesconstruction) - NIP-65 relay changes don't propagate to feed subscriptions
- Different screens use different relay sources (
relayStatuses.keysvsconnectedRelaysvsfeedRelaysvsallRelayUrls) - 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:
- Holds all relay category sets as reactive
StateFlows - Is initialized from
DefaultRelays.RELAYSimmediately (never empty) - Updates when NIP-65/DM/search/blocked events arrive
- Persists to Preferences
- 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
- Create
DesktopRelayConfig.ktmerging AccountRelays + DmRelayState + RelayCategories - Create
LocalRelayConfigCompositionLocal, removeLocalRelayCategories+LocalAccountRelays - Provide in Main.kt, remove old classes
- Update every screen to use
LocalRelayConfig.current - Make coordinator
indexRelaysmutable + reactive - 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 directrelayStatuses.keysusage - 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
DesktopRelayConfigsetters DesktopRelaySubscriptionsCoordinator.indexRelaysupdates reactively
Unanswered Questions
- Should
defaultRelaysalso include user's connected relays, or strictlyDefaultRelays.RELAYS? - Should we delete
DesktopDmRelayStateor keep it as an internal implementation detail?