Files
amethyst/docs/plans/2026-04-22-feat-relay-subscription-wiring-plan.md
T
nrobi144 66962d360d feat(desktop): relay power tools — dashboard, config editors, subscription wiring
- 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>
2026-04-22 10:15:44 +03:00

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

  1. 2 phases, not 4 — merged aggregator into Phase 1, cut persistence (YAGNI)
  2. No new files for BootstrapSubscription or minusBlocked — inline everything
  3. Keep DesktopRelayCategories but provide via LocalRelayCategories CompositionLocal (matches LocalTorState pattern)
  4. Bake blocked subtraction + debounce(1s) into aggregator — centralized, not per-call-site
  5. Route bootstrap through localCache — keeps Nip65RelayListState in sync
  6. Kind 10006/10007 need NIP-51 decryption — use privateTags(signer)
  7. connectedRelays.first { isNotEmpty } needs timeoutwithTimeoutOrNull(30s)
  8. Make FeedMetadataCoordinator.indexRelays mutable — avoid recreation, preserve dedupe state
  9. Add created_at checking 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 DesktopAccountRelays in logged-in section
  • Create DesktopRelayCategories combining nip65State + accountRelays + connectedRelays
  • Inline bootstrap subscription in LaunchedEffect (~10 lines)
  • Route bootstrap events through localCache for NIP-65 sync
  • Provide LocalRelayCategories via CompositionLocalProvider
  • Use withTimeoutOrNull(30.seconds) on connectedRelays.first { isNotEmpty }
  • Remove hardcoded DesktopDmRelayState(emptySet()) and per-column DesktopAccountRelays

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.first has 30s timeout

Unanswered Questions

  1. NIP-51 private tag decryption for kind 10007/10006 — defer to v2 or attempt now? (Public tags work for create(), but updateRelayList() moves to private)