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>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
---
|
||||
title: "feat: Relay Config Parity — All Desktop-Relevant Categories"
|
||||
type: feat
|
||||
status: draft
|
||||
date: 2026-04-21
|
||||
parent: docs/plans/2026-04-20-feat-relay-power-tools-plan.md
|
||||
---
|
||||
|
||||
# feat: Relay Config Parity — All Desktop-Relevant Categories
|
||||
|
||||
## Context
|
||||
|
||||
Phase 1-2 of Relay Power Tools shipped a dashboard with Monitor tab, a Configure tab with only Connected Relays (NIP-65/DM as placeholders), and a compose relay picker. This plan fills in all relay categories that desktop features actually consume.
|
||||
|
||||
## Scope: Feature-Driven Categories
|
||||
|
||||
Only categories where desktop has a feature that reads/writes them:
|
||||
|
||||
| Category | Kind | Desktop Feature | State Class | Priority |
|
||||
|----------|------|----------------|-------------|----------|
|
||||
| **NIP-65 Inbox/Outbox** | 10002 | Feeds, notifications, outbox publishing | `Nip65RelayListState` (commons) | P0 |
|
||||
| **DM Relays** | 10050 | DMs (DesktopMessagesScreen) | `DesktopAccountRelays._dmRelayList` | P0 |
|
||||
| **Search Relays** | 10007 | Search (SearchScreen, NIP-50) | — (new) | P1 |
|
||||
| **Blocked Relays** | 10006 | Privacy/moderation | — (new) | P2 |
|
||||
| **Connected Relays** | runtime | Everything (fallback pool) | `RelayConnectionManager` | ✅ Done |
|
||||
|
||||
### Explicitly Out of Scope
|
||||
|
||||
| Category | Kind | Why |
|
||||
|----------|------|-----|
|
||||
| Private Outbox | 10013 | Desktop drafts are local-only (`DesktopDraftStore`) |
|
||||
| Indexer Relays | 10086 | Desktop uses connected relays for indexing |
|
||||
| Proxy Relays | 10087 | No proxy relay feature on desktop |
|
||||
| Broadcast Relays | 10088 | Compose picker already handles per-action relay selection |
|
||||
| Trusted Relays | 10089 | No trust-scoring UI on desktop |
|
||||
| Key Package | MIP-00 | No MLS/group messaging on desktop |
|
||||
| Relay Sets | 30002 | No custom grouping UI |
|
||||
| Wiki Relays | 10102 | No wiki feature |
|
||||
| Relay Feeds | 10012 | No relay feeds feature |
|
||||
| Local Relays | custom | No local relay feature |
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Existing (Phase 1-2):
|
||||
├── RelayConfigTab.kt # MODIFY: replace placeholders with real editors
|
||||
├── RelayListEditor.kt # REUSE: generic add/remove/validate
|
||||
├── DesktopAccountRelays.kt # MODIFY: add NIP-65 + search relay state
|
||||
├── DesktopIAccount.kt # Already has Nip65RelayListState
|
||||
|
||||
New:
|
||||
├── Nip65RelayEditor.kt # NEW: inbox/outbox editor with read/write toggles
|
||||
├── DmRelayEditor.kt # NEW: DM relay editor with block-send-if-empty
|
||||
├── SearchRelayEditor.kt # NEW: search relay editor
|
||||
├── BlockedRelayEditor.kt # NEW: blocked relay editor
|
||||
└── DesktopSearchRelayState.kt # NEW: search relay state (kind 10007)
|
||||
```
|
||||
|
||||
### Phase 3a: NIP-65 Inbox/Outbox Editor (P0)
|
||||
|
||||
**Why P0:** Feeds, notifications, and publishing all depend on NIP-65. Without this, desktop uses hardcoded default relays.
|
||||
|
||||
**State:** `Nip65RelayListState` already exists in commons and is instantiated in `DesktopIAccount`. Desktop already has `outboxFlow` and `inboxFlow`.
|
||||
|
||||
**UI: `Nip65RelayEditor.kt`**
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Nip65RelayEditor(
|
||||
nip65State: Nip65RelayListState,
|
||||
relayManager: RelayConnectionManager,
|
||||
onPublish: (AdvertisedRelayListEvent) -> Unit,
|
||||
)
|
||||
```
|
||||
|
||||
- Two-column or tagged list: each relay has Read/Write/Both toggle
|
||||
- Uses `AdvertisedRelayInfo` with `type` (READ, WRITE, BOTH)
|
||||
- "Save" button calls `nip65State.saveRelayList(relays)` → returns signed event → `onPublish` broadcasts
|
||||
- Shows "Published to X of Y relays" confirmation
|
||||
- "Reset to defaults" option restores `defaultOutboxRelays`/`defaultInboxRelays`
|
||||
|
||||
**Data flow:**
|
||||
1. Read current: `nip65State.getNIP65RelayList()?.relays()` → list of `AdvertisedRelayInfo`
|
||||
2. User edits in mutable local state
|
||||
3. Save: `nip65State.saveRelayList(editedRelays)` → signed `AdvertisedRelayListEvent`
|
||||
4. Broadcast: `relayManager.publish(event, connectedRelays)`
|
||||
|
||||
**Integration:**
|
||||
- Replace "NIP-65 Inbox/Outbox — coming soon" placeholder in `RelayConfigTab`
|
||||
- Thread `nip65State` from `DesktopIAccount` through to `RelayConfigTab`
|
||||
|
||||
### Phase 3b: DM Relay Editor (P0)
|
||||
|
||||
**Why P0:** Desktop has full DM support. DM relays control where encrypted messages are sent/received.
|
||||
|
||||
**State:** `DesktopAccountRelays._dmRelayList` already tracks kind 10050.
|
||||
|
||||
**UI: `DmRelayEditor.kt`**
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DmRelayEditor(
|
||||
dmRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
signer: NostrSigner,
|
||||
onPublish: (Event) -> Unit,
|
||||
)
|
||||
```
|
||||
|
||||
- Simple relay list (no read/write split — DM relays are all-or-nothing)
|
||||
- Warning banner if empty: "No DM relays configured — DMs will use connected relays as fallback"
|
||||
- "Save" builds `ChatMessageRelayListEvent` → sign → publish
|
||||
- Security: highlight that these relays see your DM metadata
|
||||
|
||||
**Data flow:**
|
||||
1. Read current: `accountRelays.dmRelayList` StateFlow
|
||||
2. Save: build `ChatMessageRelayListEvent.create(relays, signer)` → publish
|
||||
|
||||
**Integration:**
|
||||
- Replace "DM Relays — coming soon" placeholder in `RelayConfigTab`
|
||||
- Thread `accountRelays` + `signer` through to `RelayConfigTab`
|
||||
|
||||
### Phase 3c: Search Relay Editor (P1)
|
||||
|
||||
**Why P1:** Desktop has a full search screen with NIP-50 support but currently searches all connected relays. Dedicated search relays improve result quality.
|
||||
|
||||
**State:** New `DesktopSearchRelayState` needed — simple `StateFlow<Set<NormalizedRelayUrl>>` backed by kind 10007 events.
|
||||
|
||||
**UI: `SearchRelayEditor.kt`**
|
||||
|
||||
- Same pattern as DM relay editor
|
||||
- Explain to user: "These relays support NIP-50 full-text search"
|
||||
- Default suggestion: `wss://relay.nostr.band` (common NIP-50 relay)
|
||||
- "Save" builds `SearchRelayListEvent` → sign → publish
|
||||
|
||||
**Integration:**
|
||||
- New section in `RelayConfigTab` after DM Relays
|
||||
- Wire search relay state into `DesktopRelaySubscriptionsCoordinator` for search queries
|
||||
|
||||
### Phase 3d: Blocked Relay Editor (P2)
|
||||
|
||||
**Why P2:** Privacy feature — user can maintain a list of relays they don't want to connect to. Lower priority but simple to implement since pattern is identical.
|
||||
|
||||
**State:** New — kind 10006 `BlockedRelayListEvent`.
|
||||
|
||||
**UI: `BlockedRelayEditor.kt`**
|
||||
|
||||
- List of blocked relay URLs
|
||||
- "Save" publishes kind 10006 event
|
||||
- Integration: filter blocked relays from connection pool (future)
|
||||
|
||||
### Implementation Order
|
||||
|
||||
| Phase | What | Files | Depends On |
|
||||
|-------|------|-------|------------|
|
||||
| 3a | NIP-65 editor | `Nip65RelayEditor.kt`, modify `RelayConfigTab`, modify `DeckColumnContainer` (thread state) | `Nip65RelayListState` (exists) |
|
||||
| 3b | DM relay editor | `DmRelayEditor.kt`, modify `RelayConfigTab` | `DesktopAccountRelays` (exists) |
|
||||
| 3c | Search relay editor | `SearchRelayEditor.kt`, `DesktopSearchRelayState.kt`, modify `RelayConfigTab`, modify subscriptions coordinator | New state class |
|
||||
| 3d | Blocked relay editor | `BlockedRelayEditor.kt`, modify `RelayConfigTab` | `BlockedRelayListEvent` (exists in quartz) |
|
||||
|
||||
### State Threading
|
||||
|
||||
`RelayConfigTab` currently receives only `relayManager`. It needs:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayConfigTab(
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
nip65State: Nip65RelayListState, // Phase 3a
|
||||
accountRelays: DesktopAccountRelays, // Phase 3b (DM relays)
|
||||
signer: NostrSigner, // For signing relay list events
|
||||
onPublish: (Event) -> Unit, // Broadcast signed events
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
```
|
||||
|
||||
Thread through: `Main.kt` → `MainContent` → `DeckLayout`/`SinglePaneLayout` → `DeckColumnContainer` → `RootContent` → `RelayDashboardScreen` → `RelayConfigTab`
|
||||
|
||||
Alternative: pass `DesktopIAccount` (already threaded) which has `nip65State` + `signer`, and `accountRelays` (already created alongside).
|
||||
|
||||
### Shared `RelayListEditor` Pattern
|
||||
|
||||
All editors follow the same pattern — reuse `RelayListEditor` for the add/remove/validate part. Each editor wraps it with:
|
||||
1. Category-specific header + description
|
||||
2. Optional per-relay toggles (read/write for NIP-65)
|
||||
3. Save button that builds the right event kind
|
||||
4. Publish feedback
|
||||
|
||||
### Event Kind Reference (from quartz)
|
||||
|
||||
| Kind | Event Class | Tag Format |
|
||||
|------|------------|------------|
|
||||
| 10002 | `AdvertisedRelayListEvent` | `["r", "wss://...", "read"\|"write"]` |
|
||||
| 10050 | `ChatMessageRelayListEvent` | `["relay", "wss://..."]` |
|
||||
| 10007 | `SearchRelayListEvent` | `["relay", "wss://..."]` |
|
||||
| 10006 | `BlockedRelayListEvent` | `["relay", "wss://..."]` |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Phase 3a: NIP-65
|
||||
- [ ] NIP-65 section shows current inbox/outbox relays from user's kind 10002
|
||||
- [ ] Each relay has Read/Write/Both toggle
|
||||
- [ ] Can add/remove relays
|
||||
- [ ] Save signs + publishes AdvertisedRelayListEvent
|
||||
- [ ] Shows "Published to X of Y" confirmation
|
||||
- [ ] Reset to defaults option
|
||||
|
||||
### Phase 3b: DM Relays
|
||||
- [ ] DM section shows current kind 10050 relays
|
||||
- [ ] Warning if empty
|
||||
- [ ] Save signs + publishes ChatMessageRelayListEvent
|
||||
- [ ] Shows publish confirmation
|
||||
|
||||
### Phase 3c: Search Relays
|
||||
- [ ] Search section shows current kind 10007 relays
|
||||
- [ ] Suggests relay.nostr.band if empty
|
||||
- [ ] Save signs + publishes SearchRelayListEvent
|
||||
- [ ] Search screen uses configured search relays
|
||||
|
||||
### Phase 3d: Blocked Relays
|
||||
- [ ] Blocked section shows kind 10006 relays
|
||||
- [ ] Save signs + publishes BlockedRelayListEvent
|
||||
|
||||
## Unanswered Questions
|
||||
|
||||
1. Should NIP-65 editor show relays from the user's existing event, or from `defaultOutboxRelays`/`defaultInboxRelays` if no event exists?
|
||||
2. Should DM relay editor block fallback to connected relays (per original plan security decision) or just warn?
|
||||
3. For search relays — should we auto-detect NIP-50 support via NIP-11 `supported_nips` field and suggest capable relays from the connected pool?
|
||||
4. Should publishing relay list events use `connectedRelays` or the NIP-65 outbox relays? (Bootstrap problem if NIP-65 is empty.)
|
||||
5. How to handle the threading of 4+ state objects through the composable tree — pass `DesktopIAccount` directly or keep explicit params?
|
||||
@@ -0,0 +1,329 @@
|
||||
---
|
||||
title: "feat: Wire Relay Config Categories into Desktop Subscriptions"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-04-21
|
||||
origin: docs/plans/2026-04-21-feat-relay-config-parity-plan.md
|
||||
---
|
||||
|
||||
# feat: Wire Relay Config Categories into Desktop Subscriptions
|
||||
|
||||
## Overview
|
||||
|
||||
Desktop's relay config UI publishes relay list events (kinds 10002, 10050, 10007, 10006) but the app itself uses hardcoded defaults or all connected relays for every subscription. This plan wires each relay category into the features that consume it.
|
||||
|
||||
## 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) |
|
||||
|
||||
**Root causes:**
|
||||
1. `DesktopAccountRelays` is dead code — never instantiated in Main.kt
|
||||
2. `DesktopDmRelayState` in Main.kt uses hardcoded `MutableStateFlow(emptySet())`
|
||||
3. No desktop state holders for search or blocked relay lists
|
||||
4. No bootstrap subscription fetches user's own relay config events at login
|
||||
5. Screens pass `allRelayUrls` to all `rememberSubscription` calls
|
||||
6. `DesktopRelaySubscriptionsCoordinator.indexRelays` is static
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
4 phases, each independently shippable:
|
||||
1. Bootstrap subscription + wire existing state objects
|
||||
2. Create missing state holders + aggregate relay categories
|
||||
3. Update screens to consume category relay sets
|
||||
4. Persist relay lists to Preferences
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
New/Modified:
|
||||
├── Main.kt # MODIFY: instantiate DesktopAccountRelays, bootstrap sub
|
||||
├── model/
|
||||
│ ├── DesktopAccountRelays.kt # MODIFY: add search + blocked relay tracking
|
||||
│ ├── DesktopRelayCategories.kt # NEW: aggregator with fallback logic
|
||||
│ └── DesktopDmRelayState.kt # UNCHANGED (already correct)
|
||||
├── subscriptions/
|
||||
│ ├── DesktopRelaySubscriptionsCoordinator.kt # MODIFY: accept reactive relay sets
|
||||
│ ├── BootstrapSubscription.kt # NEW: fetch user's kind 10002/10050/10007/10006
|
||||
│ └── SubscriptionUtils.kt # MODIFY: rememberSubscription keys
|
||||
├── ui/
|
||||
│ ├── FeedScreen.kt # MODIFY: use NIP-65 outbox relays
|
||||
│ ├── SearchScreen.kt # MODIFY: use search relays
|
||||
│ └── NotificationsScreen.kt # MODIFY: use NIP-65 inbox relays
|
||||
```
|
||||
|
||||
### Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Outbox model | v1: all filters to all outbox relays | True per-relay filter maps requires SubscriptionConfig redesign — defer |
|
||||
| Search fallback | Hardcoded NIP-50 relays (relay.nostr.band) | Most connected relays don't support NIP-50 |
|
||||
| DM fallback | Connected relays (existing behavior) | Blocking send is too aggressive for desktop UX |
|
||||
| Blocked subtraction | Utility function at call site | Centralized in `RelayConnectionManager.subscribe` is opaque and affects all callers |
|
||||
| Relay set change → resub | Debounce 1s via `distinctUntilChanged()` on StateFlows | Prevents thrashing during startup when multiple kind events arrive |
|
||||
| Auto-connect category relays | Yes, add to relay pool if not present | DM/search relays from events may not be in connected set |
|
||||
| Persist relay lists | java.util.prefs.Preferences | Consistent with existing DesktopPreferences pattern |
|
||||
| FeedMetadataCoordinator.indexRelays | Keep static, recreate coordinator when relays change | Avoids commons API change |
|
||||
|
||||
### Phase 1: Bootstrap + Wire Existing State
|
||||
|
||||
**Goal:** Fetch user's relay config on login. Wire `DesktopAccountRelays` into Main.kt so kind 10050 events actually update DM relay state.
|
||||
|
||||
**1a. Create `BootstrapSubscription.kt`**
|
||||
|
||||
Fetches user's own replaceable events (kinds 10002, 10050, 10007, 10006) from connected relays on login.
|
||||
|
||||
```kotlin
|
||||
class BootstrapSubscription(
|
||||
private val relayManager: RelayConnectionManager,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
fun subscribe(
|
||||
userPubKeyHex: HexKey,
|
||||
onEvent: (Event) -> Unit,
|
||||
) {
|
||||
val filter = Filter(
|
||||
kinds = listOf(10002, 10050, 10007, 10006),
|
||||
authors = listOf(userPubKeyHex),
|
||||
limit = 4,
|
||||
)
|
||||
relayManager.subscribe(
|
||||
subId = "bootstrap-relay-config",
|
||||
filters = listOf(filter),
|
||||
listener = object : SubscriptionListener {
|
||||
override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
|
||||
onEvent(event)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**1b. Instantiate `DesktopAccountRelays` in Main.kt**
|
||||
|
||||
Replace the standalone `DesktopDmRelayState(MutableStateFlow(emptySet()), ...)` with `DesktopAccountRelays`:
|
||||
|
||||
```kotlin
|
||||
// In Main.kt logged-in section (replace lines 924-929)
|
||||
val accountRelays = remember(account, relayManager, scope) {
|
||||
DesktopAccountRelays(account.pubKeyHex, relayManager, scope)
|
||||
}
|
||||
|
||||
// Bootstrap: fetch user's relay config events
|
||||
LaunchedEffect(accountRelays) {
|
||||
relayManager.connectedRelays.first { it.isNotEmpty() }
|
||||
BootstrapSubscription(relayManager, scope).subscribe(account.pubKeyHex) { event ->
|
||||
accountRelays.consumeIfRelevant(event) // routes to appropriate handler
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**1c. Expand `DesktopAccountRelays` to route all relay config events**
|
||||
|
||||
Add methods to consume kinds 10002, 10007, 10006 (currently only handles 10050):
|
||||
|
||||
```kotlin
|
||||
// In DesktopAccountRelays
|
||||
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()
|
||||
|
||||
fun consumeIfRelevant(event: Event): Boolean {
|
||||
return when (event.kind) {
|
||||
ChatMessageRelayListEvent.KIND -> { consumeDmRelayList(event as ChatMessageRelayListEvent); true }
|
||||
SearchRelayListEvent.KIND -> { consumeSearchRelayList(event); true }
|
||||
BlockedRelayListEvent.KIND -> { consumeBlockedRelayList(event); true }
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**1d. Thread `accountRelays` to MainContent and layouts**
|
||||
|
||||
Single instance created in Main.kt, passed through composable tree. Replace the per-column `DesktopAccountRelays` created in `DeckColumnContainer`.
|
||||
|
||||
### Phase 2: Relay Category Aggregator
|
||||
|
||||
**Goal:** Single source of truth for "which relays should feature X use?" with fallback logic.
|
||||
|
||||
**Create `DesktopRelayCategories.kt`**
|
||||
|
||||
```kotlin
|
||||
class DesktopRelayCategories(
|
||||
private val nip65State: Nip65RelayListState,
|
||||
private val accountRelays: DesktopAccountRelays,
|
||||
private val connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
/** Relays for home feed / publishing. NIP-65 write → fallback to connected */
|
||||
val feedRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||
nip65State.outboxFlow,
|
||||
connectedRelays,
|
||||
) { outbox, connected -> outbox.ifEmpty { connected } }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
|
||||
|
||||
/** Relays for receiving notifications. NIP-65 read → fallback to connected */
|
||||
val notificationRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||
nip65State.inboxFlow,
|
||||
connectedRelays,
|
||||
) { inbox, connected -> inbox.ifEmpty { connected } }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
|
||||
|
||||
/** Relays for NIP-50 search. Search list → fallback to DEFAULT_SEARCH_RELAYS */
|
||||
val searchRelays: StateFlow<Set<NormalizedRelayUrl>> = accountRelays.searchRelayList
|
||||
.map { it.ifEmpty { DEFAULT_SEARCH_RELAYS } }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_SEARCH_RELAYS)
|
||||
|
||||
/** DM relays — already handled by DesktopDmRelayState with fallback */
|
||||
val dmRelays: StateFlow<Set<NormalizedRelayUrl>> = accountRelays.dmRelays.flow
|
||||
|
||||
/** Blocked relays to exclude from all subscriptions */
|
||||
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>> = accountRelays.blockedRelayList
|
||||
|
||||
companion object {
|
||||
val DEFAULT_SEARCH_RELAYS = setOf(
|
||||
NormalizedRelayUrl("wss://relay.nostr.band/"),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Update Screens
|
||||
|
||||
**3a. SearchScreen.kt**
|
||||
|
||||
Replace `allRelayUrls` with `relayCategories.searchRelays`:
|
||||
|
||||
```kotlin
|
||||
// Current (line 175):
|
||||
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
|
||||
// relays = allRelayUrls
|
||||
|
||||
// Target:
|
||||
val searchRelays by relayCategories.searchRelays.collectAsState()
|
||||
rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) {
|
||||
// relays = searchRelays
|
||||
```
|
||||
|
||||
Thread `relayCategories` to SearchScreen via RootContent params.
|
||||
|
||||
**3b. FeedScreen.kt**
|
||||
|
||||
Replace `allRelayUrls` with `relayCategories.feedRelays`:
|
||||
|
||||
```kotlin
|
||||
val feedRelays by relayCategories.feedRelays.collectAsState()
|
||||
rememberSubscription(feedRelays, ..., relayManager = relayManager) {
|
||||
// relays = feedRelays
|
||||
```
|
||||
|
||||
**3c. NotificationsScreen.kt**
|
||||
|
||||
Use `relayCategories.notificationRelays` for incoming notification subscriptions.
|
||||
|
||||
**3d. Blocked relay subtraction**
|
||||
|
||||
Add utility:
|
||||
|
||||
```kotlin
|
||||
// In RelayValidation.kt or new file
|
||||
fun Set<NormalizedRelayUrl>.minusBlocked(
|
||||
blocked: Set<NormalizedRelayUrl>
|
||||
): Set<NormalizedRelayUrl> = this - blocked
|
||||
```
|
||||
|
||||
Apply at each subscription site:
|
||||
|
||||
```kotlin
|
||||
val effectiveRelays = feedRelays.minusBlocked(blockedRelays)
|
||||
```
|
||||
|
||||
**3e. Make DM subscriptions reactive**
|
||||
|
||||
In Main.kt, replace one-shot `subscribeToDms()` with a collector:
|
||||
|
||||
```kotlin
|
||||
LaunchedEffect(accountRelays) {
|
||||
accountRelays.dmRelays.flow.collect { dmRelaySet ->
|
||||
subscriptionsCoordinator.resubscribeToDms(account.pubKeyHex, accountRelays.dmRelays, onDmEvent)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Persistence
|
||||
|
||||
**Goal:** Relay lists survive app restart without waiting for bootstrap fetch.
|
||||
|
||||
Save last-known relay lists to `DesktopPreferences`:
|
||||
|
||||
```kotlin
|
||||
// On every relay list update
|
||||
DesktopPreferences.nip65RelayList = mapper.writeValueAsString(outboxRelays)
|
||||
DesktopPreferences.dmRelayList = mapper.writeValueAsString(dmRelays)
|
||||
DesktopPreferences.searchRelayList = mapper.writeValueAsString(searchRelays)
|
||||
DesktopPreferences.blockedRelayList = mapper.writeValueAsString(blockedRelays)
|
||||
|
||||
// On startup, before bootstrap fetch completes
|
||||
val savedDmRelays = mapper.readValue<Set<String>>(DesktopPreferences.dmRelayList)
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
accountRelays.setDmRelays(savedDmRelays)
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Phase 1: Bootstrap + Wire
|
||||
- [ ] `DesktopAccountRelays` instantiated once in Main.kt, threaded to all screens
|
||||
- [ ] Bootstrap subscription fetches kinds 10002, 10050, 10007, 10006 on login
|
||||
- [ ] Kind 10050 events update `accountRelays.dmRelayList` (no longer hardcoded empty)
|
||||
- [ ] Kind 10007 events populate `accountRelays.searchRelayList`
|
||||
- [ ] Kind 10006 events populate `accountRelays.blockedRelayList`
|
||||
- [ ] Remove per-column `DesktopAccountRelays` from DeckColumnContainer
|
||||
|
||||
### Phase 2: Aggregator
|
||||
- [ ] `DesktopRelayCategories` exposes `feedRelays`, `notificationRelays`, `searchRelays`, `dmRelays`, `blockedRelays`
|
||||
- [ ] Each StateFlow has appropriate fallback (connected, default search relays)
|
||||
- [ ] `distinctUntilChanged()` on all flows to prevent subscription thrashing
|
||||
|
||||
### Phase 3: Screen Wiring
|
||||
- [ ] SearchScreen uses `searchRelays` for NIP-50 subscriptions
|
||||
- [ ] FeedScreen uses `feedRelays` for feed subscriptions
|
||||
- [ ] NotificationsScreen uses `notificationRelays`
|
||||
- [ ] Blocked relays subtracted from all subscription relay sets
|
||||
- [ ] DM subscriptions reactive — resubscribe when dmRelays flow changes
|
||||
- [ ] `rememberSubscription` keys on category relay sets
|
||||
|
||||
### Phase 4: Persistence
|
||||
- [ ] Relay lists saved to Preferences on every update
|
||||
- [ ] Loaded from Preferences on startup before bootstrap completes
|
||||
- [ ] Bootstrap fetch overwrites saved data with fresh data from relays
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| Subscription thrashing during startup | Medium | Medium | `distinctUntilChanged()` + 1s debounce on relay set changes |
|
||||
| Bootstrap subscription returns stale events | Low | Low | Replaceable events — latest `created_at` wins |
|
||||
| DM relays not in connected set | Medium | Medium | Auto-add to relay pool on relay set change |
|
||||
| Blocked relay decryption fails (NIP-44) | Low | Low | Graceful fallback — empty blocked set |
|
||||
| Coordinator recreation on relay change | Medium | Low | Debounce, only recreate when relay set structurally changes |
|
||||
|
||||
## Unanswered Questions
|
||||
|
||||
1. Should bootstrap subscription unsubscribe after receiving all 4 kinds, or stay open for live updates?
|
||||
2. Should `DesktopRelayCategories` auto-add category relays to the relay pool (so they connect)?
|
||||
3. For blocked relay decryption — can we use `signer.decrypt()` directly or need async handling for NIP-46?
|
||||
4. Should we show a UI indicator when operating on fallback relays ("No NIP-65 published — using defaults")?
|
||||
5. True outbox model (per-relay filter maps) — defer to separate plan or include as Phase 5?
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: "feat: Wire Relay Config Categories into Desktop Subscriptions"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-04-22
|
||||
origin: docs/plans/2026-04-21-feat-relay-config-parity-plan.md
|
||||
deepened: 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 timeout** — `withTimeoutOrNull(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:
|
||||
|
||||
```kotlin
|
||||
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
|
||||
|
||||
```kotlin
|
||||
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)
|
||||
|
||||
```kotlin
|
||||
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)
|
||||
Reference in New Issue
Block a user