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

189 lines
8.3 KiB
Markdown

---
title: "feat: Relay Config Persistence, Correct Counts, Per-Screen Picker"
type: feat
status: active
date: 2026-04-22
origin: 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):
```kotlin
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):
```kotlin
@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:
```kotlin
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](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.kt` lines 113-128
- Android AccountSettings: `amethyst/model/AccountSettings.kt` lines 196-202
- Desktop Preferences pattern: `desktopApp/.../DesktopPreferences.kt`