835947d611
- 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>
8.3 KiB
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
- Config lost on restart/reopen: Search/DM/blocked relay lists vanish — no Preferences persistence, NIP-51 private tags not decrypted
- Wrong relay counts: Search shows "0 of 7 relays responded" against all connected relays, not the 1 configured search relay
- 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
NostrSignerin constructor - Accept
scope: CoroutineScopefor async decryption consumeIfRelevantbecomessuspend— callsevent.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()ininit {}block
Main.kt changes:
- Pass
signertoDesktopAccountRelaysconstructor - Bootstrap
LaunchedEffectlaunchesconsumeIfRelevantin coroutine (now suspend)
Phase 2: Correct Per-Screen Relay Counts
SearchScreen.kt changes:
- Replace
state.initRelayStates(allRelayUrls)withstate.initRelayStates(searchRelays) searchRelaysalready fromLocalRelayCategories.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" feedRelaysalready fromLocalRelayCategories.current.feedRelays.collectAsState()
AdvancedSearchBarState.kt changes:
initRelayStatestakesSet<NormalizedRelayUrl>instead ofSet<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 = true→onSave()→onPublish()→ persist →isLoading = false→onDismiss()
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
- Origin brainstorm: docs/brainstorms/2026-04-22-relay-config-persistence-and-per-screen-editing-brainstorm.md — Key decisions: persist raw event JSON, decrypt NIP-51 lazily, publish immediately from picker
- Android persistence pattern:
amethyst/LocalPreferences.ktlines 113-128 - Android AccountSettings:
amethyst/model/AccountSettings.ktlines 196-202 - Desktop Preferences pattern:
desktopApp/.../DesktopPreferences.kt