- 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>
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:
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?