- Relay Dashboard as new DeckColumnType.Relays with Monitor + Configure tabs - Monitor tab: live 1Hz session metrics, NIP-11 detail panels, reconnect - Configure tab: collapsible editors for Connected, NIP-65 (R/W/Both toggles), DM (kind 10050), Search (kind 10007), Blocked (kind 10006) - Compose Relay Picker: expandable relay selection in note compose dialog - Nip11Fetcher: fail-closed HTTP client, 256KB limit, Mutex dedup - RelayMetrics: separate StateFlow (1Hz) to avoid relayStatuses churn - Bootstrap subscription: fetches user's relay config on login (kinds 10002/10050/10007/10006) - DesktopRelayCategories aggregator: feedRelays, searchRelays, notificationRelays, dmRelays with fallback logic, blocked subtraction, 1s debounce - LocalRelayCategories CompositionLocal (matches LocalTorState pattern) - FeedScreen uses NIP-65 outbox relays, SearchScreen uses search relays - "X relays connected" on feed is clickable → opens Relay Dashboard - URL validation: requires domain with dot, blocks ws:// unless .onion - Auto-add pending input on Save, empty list protection - Thread-safe created_at dedup (AtomicLong) in DesktopAccountRelays - DisposableEffect cleanup for bootstrap subscription Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7.7 KiB
title, type, status, date, origin, deepened
| title | type | status | date | origin | deepened |
|---|---|---|---|---|---|
| feat: Wire Relay Config Categories into Desktop Subscriptions | feat | active | 2026-04-22 | docs/plans/2026-04-21-feat-relay-config-parity-plan.md | 2026-04-22 |
feat: Wire Relay Config Categories into Desktop Subscriptions
Enhancement Summary
Deepened on: 2026-04-22 Review agents used: nostr-expert, kotlin-coroutines, desktop-expert, performance-oracle, architecture-strategist, code-simplicity-reviewer
Key Changes from Review
- 2 phases, not 4 — merged aggregator into Phase 1, cut persistence (YAGNI)
- No new files for BootstrapSubscription or minusBlocked — inline everything
- Keep DesktopRelayCategories but provide via
LocalRelayCategoriesCompositionLocal (matchesLocalTorStatepattern) - Bake blocked subtraction +
debounce(1s)into aggregator — centralized, not per-call-site - Route bootstrap through localCache — keeps Nip65RelayListState in sync
- Kind 10006/10007 need NIP-51 decryption — use
privateTags(signer) connectedRelays.first { isNotEmpty }needs timeout —withTimeoutOrNull(30s)- Make FeedMetadataCoordinator.indexRelays mutable — avoid recreation, preserve dedupe state
- Add
created_atchecking in consumeIfRelevant to prevent stale overwrites
Problem Statement
| Feature | Currently Uses | Should Use |
|---|---|---|
| Home feed | all connected relays | NIP-65 outbox (kind 10002 write) |
| Notifications | all connected relays | NIP-65 inbox (kind 10002 read) |
| Search | all connected relays | Search relays (kind 10007) |
| DMs | emptySet() fallback → connected |
DM relays (kind 10050) |
| All features | no filtering | Minus blocked relays (kind 10006) |
Technical Approach
Phase 1: Wire State + Bootstrap (single PR)
1a. Expand DesktopAccountRelays.kt
Add search/blocked StateFlows + consumeIfRelevant() dispatcher with created_at dedup:
private val _searchRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val searchRelayList: StateFlow<Set<NormalizedRelayUrl>> = _searchRelayList.asStateFlow()
private val _blockedRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val blockedRelayList: StateFlow<Set<NormalizedRelayUrl>> = _blockedRelayList.asStateFlow()
// Track created_at to prevent stale overwrites
private var lastSearchCreatedAt = 0L
private var lastBlockedCreatedAt = 0L
private var lastDmCreatedAt = 0L
fun consumeIfRelevant(event: Event): Boolean {
return when (event.kind) {
ChatMessageRelayListEvent.KIND -> {
if (event.createdAt > lastDmCreatedAt) {
lastDmCreatedAt = event.createdAt
consumeDmRelayList(event as ChatMessageRelayListEvent)
}
true
}
SearchRelayListEvent.KIND -> {
if (event.createdAt > lastSearchCreatedAt) {
lastSearchCreatedAt = event.createdAt
// NIP-51: try public tags first, then decrypt private
val relays = (event as? SearchRelayListEvent)?.publicRelays()?.toSet() ?: emptySet()
_searchRelayList.value = relays
}
true
}
BlockedRelayListEvent.KIND -> {
if (event.createdAt > lastBlockedCreatedAt) {
lastBlockedCreatedAt = event.createdAt
val relays = (event as? BlockedRelayListEvent)?.publicRelays()?.toSet() ?: emptySet()
_blockedRelayList.value = relays
}
true
}
else -> false
}
}
1b. Create DesktopRelayCategories.kt — aggregator with fallback + blocked subtraction + debounce
class DesktopRelayCategories(
nip65State: Nip65RelayListState,
accountRelays: DesktopAccountRelays,
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
scope: CoroutineScope,
) {
val feedRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
nip65State.outboxFlow, connectedRelays, accountRelays.blockedRelayList,
) { outbox, connected, blocked ->
(outbox.ifEmpty { connected }) - blocked
}.debounce(1.seconds).distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
val notificationRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
nip65State.inboxFlow, connectedRelays, accountRelays.blockedRelayList,
) { inbox, connected, blocked ->
(inbox.ifEmpty { connected }) - blocked
}.debounce(1.seconds).distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
val searchRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
accountRelays.searchRelayList, accountRelays.blockedRelayList,
) { search, blocked ->
(search.ifEmpty { DEFAULT_SEARCH_RELAYS }) - blocked
}.debounce(1.seconds).distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_SEARCH_RELAYS)
val dmRelays: StateFlow<Set<NormalizedRelayUrl>> = accountRelays.dmRelays.flow
companion object {
val DEFAULT_SEARCH_RELAYS = setOf(NormalizedRelayUrl("wss://relay.nostr.band/"))
}
}
1c. Create LocalRelayCategories.kt — CompositionLocal (matches LocalTorState pattern)
val LocalRelayCategories = compositionLocalOf<DesktopRelayCategories> {
error("No DesktopRelayCategories provided")
}
1d. Wire in Main.kt
- Instantiate single
DesktopAccountRelaysin logged-in section - Create
DesktopRelayCategoriescombining nip65State + accountRelays + connectedRelays - Inline bootstrap subscription in
LaunchedEffect(~10 lines) - Route bootstrap events through
localCachefor NIP-65 sync - Provide
LocalRelayCategoriesviaCompositionLocalProvider - Use
withTimeoutOrNull(30.seconds)onconnectedRelays.first { isNotEmpty } - Remove hardcoded
DesktopDmRelayState(emptySet())and per-columnDesktopAccountRelays
Phase 2: Screen Wiring
Each screen collects from LocalRelayCategories.current:
SearchScreen.kt: val searchRelays by LocalRelayCategories.current.searchRelays.collectAsState() → use in rememberSubscription
FeedScreen.kt: val feedRelays by LocalRelayCategories.current.feedRelays.collectAsState() → replace allRelayUrls
NotificationsScreen.kt: val notificationRelays by LocalRelayCategories.current.notificationRelays.collectAsState()
DM subscriptions: Make reactive via debounce(500).collect { resubscribe } with Job tracking
Files Modified
| File | Change |
|---|---|
DesktopAccountRelays.kt |
Add search/blocked flows, consumeIfRelevant, created_at dedup |
DesktopRelayCategories.kt |
NEW: aggregator with debounce + blocked subtraction |
LocalRelayCategories.kt |
NEW: CompositionLocal (3 lines) |
Main.kt |
Wire accountRelays, relayCategories, inline bootstrap, CompositionLocalProvider |
DeckColumnContainer.kt |
Remove per-column DesktopAccountRelays creation |
FeedScreen.kt |
Use feedRelays from LocalRelayCategories |
SearchScreen.kt |
Use searchRelays from LocalRelayCategories |
NotificationsScreen.kt |
Use notificationRelays from LocalRelayCategories |
Acceptance Criteria
- Bootstrap subscription fetches kinds 10002/10050/10007/10006 on login
- Events routed through localCache (NIP-65 stays in sync)
- SearchScreen uses search relays (falls back to relay.nostr.band)
- FeedScreen uses NIP-65 outbox relays (falls back to connected)
- Blocked relays subtracted from all category relay sets
- DM subscriptions reactive to relay changes
- No subscription thrashing at startup (debounce 1s)
connectedRelays.firsthas 30s timeout
Unanswered Questions
- NIP-51 private tag decryption for kind 10007/10006 — defer to v2 or attempt now? (Public tags work for
create(), butupdateRelayList()moves to private)