Files
amethyst/docs/plans/2026-04-22-feat-relay-persistence-counts-per-screen-picker-plan.md
T
nrobi144 835947d611 feat(desktop): relay config persistence, correct counts, per-screen picker
- 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>
2026-04-23 12:00:03 +03:00

8.3 KiB

title, type, status, date, origin
title type status date origin
feat: Relay Config Persistence, Correct Counts, Per-Screen Picker feat active 2026-04-22 docs/brainstorms/2026-04-22-relay-config-persistence-and-per-screen-editing-brainstorm.md

feat: Relay Config Persistence, Correct Counts, Per-Screen Picker

Overview

Three fixes to make relay management work end-to-end: persist config across restarts, show correct per-category relay counts, and add inline relay editing per screen.

Problem Statement

  1. Config lost on restart/reopen: Search/DM/blocked relay lists vanish — no Preferences persistence, NIP-51 private tags not decrypted
  2. Wrong relay counts: Search shows "0 of 7 relays responded" against all connected relays, not the 1 configured search relay
  3. No per-screen editing: Must navigate to full Dashboard to change which relays a feature uses

(see brainstorm: docs/brainstorms/2026-04-22-relay-config-persistence-and-per-screen-editing-brainstorm.md)

Technical Approach

Design Decisions (from brainstorm)

Decision Choice Rationale
Persistence format Raw event JSON per kind Preserves created_at for dedup, matches Android backupXxxRelayList pattern
Preferences keys relay_<kind>_<pubkey-prefix> Per-account isolation, avoids key collision
NIP-51 decryption Decrypt lazily when signer available, persist encrypted Don't leak private relay info to disk
Relay count source Category-specific searchRelays.size, feedRelays.size Not allRelayUrls.size
Picker type Subscribe-FROM picker (changes which relays screen uses) Different from compose picker (publish-TO)
NIP-65 picker Read/write toggles in expandable form Other categories are simple add/remove
Publish on save Immediately with loading/error state User confirmed
Connection dots Yes User confirmed

Phase 1: Persistence + NIP-51 Decryption

DesktopRelayListPersistence.kt (new):

object DesktopRelayListPersistence {
    private val prefs = Preferences.userNodeForPackage(DesktopRelayListPersistence::class.java)

    fun saveEvent(kind: Int, pubKeyHex: String, event: Event) {
        prefs.put(key(kind, pubKeyHex), event.toJson())
    }

    fun loadEvent(kind: Int, pubKeyHex: String): Event? {
        val json = prefs.get(key(kind, pubKeyHex), "")
        if (json.isBlank()) return null
        return try { Event.fromJson(json) } catch (_: Exception) { null }
    }

    private fun key(kind: Int, pubKeyHex: String) = "relay_${kind}_${pubKeyHex.take(8)}"
}

DesktopAccountRelays.kt changes:

  • Accept NostrSigner in constructor
  • Accept scope: CoroutineScope for async decryption
  • consumeIfRelevant becomes suspend — calls event.relays(signer) for NIP-51 kinds
  • On every state change → DesktopRelayListPersistence.saveEvent(kind, pubKeyHex, event)
  • loadFromPersistence() method — loads events, decrypts NIP-51 in coroutine
  • Call loadFromPersistence() in init {} block

Main.kt changes:

  • Pass signer to DesktopAccountRelays constructor
  • Bootstrap LaunchedEffect launches consumeIfRelevant in coroutine (now suspend)

Phase 2: Correct Per-Screen Relay Counts

SearchScreen.kt changes:

  • Replace state.initRelayStates(allRelayUrls) with state.initRelayStates(searchRelays)
  • searchRelays already from LocalRelayCategories.current.searchRelays.collectAsState()
  • Banner shows "0 of 1 relays responded" when 1 search relay configured

FeedScreen.kt changes:

  • Replace "${connectedRelays.size} relays connected" with "${feedRelays.size} feed relays"
  • feedRelays already from LocalRelayCategories.current.feedRelays.collectAsState()

AdvancedSearchBarState.kt changes:

  • initRelayStates takes Set<NormalizedRelayUrl> instead of Set<Any>

Phase 3: Per-Screen Relay Picker Dialog

RelayPickerDialog.kt (new):

@Composable
fun RelayPickerDialog(
    title: String,                    // "Search Relays", "Feed Relays", etc.
    currentRelays: List<NormalizedRelayUrl>,  // or List<AdvertisedRelayInfo> for NIP-65
    connectedRelays: Set<NormalizedRelayUrl>,
    signer: NostrSigner,
    isNip65: Boolean = false,         // show read/write toggles
    onSave: suspend (List<NormalizedRelayUrl>) -> Event,  // returns signed event
    onPublish: (Event) -> Unit,
    onDismiss: () -> Unit,
)

UI:

  • Modal dialog with category title
  • Relay list with connection status dots (green/gray)
  • Add relay input with validation
  • NIP-65 mode: expandable read/write/both toggles per relay
  • Save button with loading spinner + error text
  • Flow: Save → isLoading = trueonSave()onPublish() → persist → isLoading = falseonDismiss()

Screen integration — relay icon buttons:

Screen Location Category Picker type
SearchScreen Next to search bar Search relays Simple add/remove
FeedScreen Next to "X feed relays" text NIP-65 outbox Read/write toggles
DM screen Header area DM relays Simple add/remove

Each screen:

var showRelayPicker by remember { mutableStateOf(false) }
// Relay icon button
IconButton(onClick = { showRelayPicker = true }) { Icon(Icons.Default.Dns, ...) }
// Dialog
if (showRelayPicker) {
    RelayPickerDialog(
        title = "Search Relays",
        currentRelays = searchRelays.toList(),
        connectedRelays = connectedRelays,
        signer = signer,
        onSave = { relays -> SearchRelayListEvent.create(relays, signer) },
        onPublish = { event -> relayManager.broadcastToAll(event); accountRelays.setSearchRelays(relays) },
        onDismiss = { showRelayPicker = false },
    )
}

Files Modified/Created

File Change
DesktopRelayListPersistence.kt NEW: save/load event JSON per kind per account
DesktopAccountRelays.kt Add signer, suspend consumeIfRelevant, persistence, NIP-51 decrypt
RelayPickerDialog.kt NEW: modal per-screen relay editor with loading/error
SearchScreen.kt Fix relay count init, add relay picker icon
FeedScreen.kt Fix relay count text, add relay picker icon
Main.kt Pass signer to accountRelays, update bootstrap for suspend
AdvancedSearchBarState.kt initRelayStates takes typed Set

Acceptance Criteria

Phase 1: Persistence

  • Relay configs survive app restart (save to Preferences, load on startup)
  • NIP-51 events decrypted via signer when available
  • Bootstrap overwrites persisted data only if newer (created_at)
  • Per-account key isolation (pubkey prefix)
  • Graceful fallback on corrupt/missing persistence data

Phase 2: Relay Counts

  • SearchScreen shows "X of Y" against search relay set size, not all connected
  • FeedScreen shows feed relay count, not connected relay count
  • Counts update when relay set changes

Phase 3: Per-Screen Picker

  • Relay icon on SearchScreen, FeedScreen, DM screen opens picker dialog
  • Picker shows current category relays with connection status dots
  • Add/remove with validation (domain check, wss:// required)
  • NIP-65 picker has expandable read/write/both toggles
  • Save publishes immediately with loading spinner
  • Error handling: signing failure, publish failure shown in dialog
  • Screen resubscribes after picker save

Dependencies & Risks

Risk Mitigation
Preferences 8KB limit per key Relay list JSON is typically <2KB — safe. Monitor for large lists
NIP-46 signer timeout on decrypt Persist encrypted event, decrypt lazily. Show relays as "loading..."
Picker save echoes back via bootstrap Dedup by created_at — same or newer event is no-op
consumeIfRelevant now suspend Bootstrap already runs in coroutine scope

Sources