Account switcher dropdown improvements: - Two-row display: Display Name on top, npub (middle-truncated) below e.g. 'Alice' / 'npub1abc...wxyz · Bunker' - Middle-truncation for npub: shows first 10 + last 6 chars - Resolves display names from DesktopLocalCache user metadata - Confirmation dialog also shows display name - npub-only (view-only) accounts now persist to encrypted storage (ensureCurrentAccountInStorage called in onLoginSuccess) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
29 KiB
title, type, status, date, origin, deepened
| title | type | status | date | origin | deepened |
|---|---|---|---|---|---|
| feat: Relay Power Tools — Dashboard Screen + Compose Relay Picker | feat | active | 2026-04-20 | docs/brainstorms/2026-04-20-relay-power-tools-brainstorm.md | 2026-04-20 |
feat: Relay Power Tools — Dashboard Screen + Compose Relay Picker
Enhancement Summary
Deepened on: 2026-04-20 Review agents used: compose-expert, desktop-expert, kotlin-coroutines, nostr-expert, performance-oracle, security-sentinel, architecture-strategist, code-simplicity-reviewer
Key Improvements from Review
- Separate metrics from RelayStatus — avoid StateFlow churn that caused subscription issues (March 2026 brainstorm)
- Kill RelayMetricsStore — session-only metrics, no persistence (YAGNI, Preferences 8KB limit)
- Cut to 3 relay categories — desktop only uses NIP-65, DM, connected today
- Reuse existing
publish()API — already accepts relay set, no newpublishTo()needed - Reuse existing
Note.addRelay()— relay source tracking already exists in cache layer - Fix Nip11Fetcher Tor regression — must use fail-closed HTTP client, not fail-open
getHttpClient() - Block DM fallback to all relays — security: never fall back to all connected for DMs
- 7 files for DeckColumnType, not 5 — add PinnedNavBarState + AppDrawer
category() - New ScreenCategory.NETWORK — semantically correct vs IDENTITY
Files Eliminated (YAGNI)
RelayMetricsStore.kt— no UI consumes historical dataRelaySuggestions.kt— deferred entirely, no demand signalRelayBadge.kt— per-event tracking overhead for nice-to-have
Overview
Add full relay visibility and control to Amethyst Desktop through two features:
- Relay Dashboard — New
DeckColumnType.Relaysscreen with live session metrics, NIP-11 info, and per-category relay list management - Compose Relay Picker — Expandable relay section in compose dialogs for per-action relay selection
Problem Statement / Motivation
Desktop currently has a minimal relay settings section (~70 lines in Main.kt) that only supports add/remove/reconnect with basic status cards. Users cannot:
- Monitor relay health (event counts, latency, NIP support)
- Configure per-category relay lists (NIP-65, DM)
- Choose which relays to publish to when composing notes
Gossip and noStrudel lead in relay management UX — this feature brings Amethyst Desktop ahead of both.
Proposed Solution
Dashboard-first build order (see brainstorm: Key Decisions). Dashboard establishes shared state layer (session metrics, NIP-11, category management) that compose picker reuses.
Technical Approach
Architecture
New/Modified Files:
├── desktopApp/src/jvmMain/.../
│ ├── network/
│ │ ├── RelayStatus.kt # UNCHANGED — connection state only
│ │ ├── RelayConnectionManager.kt # MODIFY: add relayMetrics flow, 1Hz snapshot
│ │ └── Nip11Fetcher.kt # NEW: Mutex-based HTTP GET + session cache
│ ├── ui/
│ │ ├── relay/
│ │ │ ├── RelayDashboardScreen.kt # NEW: main dashboard screen
│ │ │ ├── RelayMetricsTab.kt # NEW: live metrics tab
│ │ │ ├── RelayConfigTab.kt # NEW: per-category editor tab
│ │ │ ├── RelayDetailPanel.kt # NEW: NIP-11 detail expansion
│ │ │ ├── RelayMetricCard.kt # NEW: enhanced status card w/ metrics
│ │ │ ├── RelayListEditor.kt # NEW: add/remove for a category
│ │ │ └── RelayStatusCard.kt # MODIFY: M3 colors
│ │ └── compose/
│ │ └── ComposeRelayPicker.kt # NEW: expandable relay section
│ ├── ui/deck/
│ │ ├── DeckColumnType.kt # MODIFY: add Relays
│ │ ├── ColumnHeader.kt # MODIFY: add icon
│ │ ├── DeckColumnContainer.kt # MODIFY: add routing + thread Nip11Fetcher
│ │ ├── AppDrawer.kt # MODIFY: add to LAUNCHABLE_SCREENS + category()
│ │ ├── DeckState.kt # MODIFY: add parse
│ │ └── PinnedNavBarState.kt # MODIFY: add to DEFAULT_PINNED
│ ├── Main.kt # MODIFY: create Nip11Fetcher, MenuBar item
│ └── ComposeNoteDialog.kt # MODIFY: integrate ComposeRelayPicker
Design Decisions (consolidated from SpecFlow + all reviews)
| Decision | Choice | Rationale |
|---|---|---|
| Metrics vs status | Separate relayMetrics StateFlow |
Avoids relayStatuses churn (March brainstorm). Metrics update 1Hz, status updates on connect/disconnect |
| Metrics persistence | Session-only (no disk) | YAGNI: no UI for historical data. Preferences has 8KB limit. Add SQLite later if needed |
| Config categories | 3: NIP-65, DM, Connected | Desktop doesn't use search/trusted/proxy/indexer/key-package relays yet |
| Save semantics | Explicit "Save" button per tab | Avoid event spam from immediate publish on every toggle |
| Compose picker default | NIP-65 write relays, fallback to all connected | Correct event visibility for followers |
| DM picker fallback | Block send if no kind 10050 | Security: never fall back to all relays for DMs (metadata leak) |
| Selective publish | Use existing publish(event, relays) |
API already exists on RelayConnectionManager |
| Relay source tracking | Use existing Note.addRelay() |
Already populated in cache consume methods |
| NIP-11 fetch client | Fail-closed via currentClient() |
getHttpClient() fails open during Tor bootstrap |
| NIP-11 response | Limit body to 256KB | Prevent DoS from malicious relay |
| Drag-to-reorder | Cut | Order is display-only, no functional value |
| Relay order | Alphabetical sort | Simple, deterministic |
| AppDrawer category | New ScreenCategory.NETWORK |
IDENTITY is wrong (user-centric), NETWORK is infrastructure |
| Tab identity | Enum DashboardTab |
Not magic int (CMP compatible) |
| Status colors | M3 colorScheme.primary/error/outline |
Not hardcoded Color.Green/Red |
| Card expansion | Hoisted isExpanded + AnimatedVisibility |
Enables single-expanded-at-a-time, smooth animation |
| Collapsed chips | LazyRow with keys |
Not Row with horizontalScroll |
| NIP-11 per card | produceState per card |
Prevents parent recomposition cascade |
| NIP-11 dedup | Mutex per URL, double-check pattern |
Not ConcurrentHashMap.getOrPut (not atomic for lambda) |
| Picker state | Fully hoisted to parent | Not split between internal mutableState + callback |
Implementation Phases
Phase 1: State Layer + Dashboard Screen
Goal: Relay Dashboard as new DeckColumnType with live session metrics and NIP-11 info.
Phase 1a: Extend Relay Infrastructure
1. Add separate relayMetrics StateFlow (RelayConnectionManager.kt)
Research Insight (Performance/Coroutines): DO NOT add eventCount/lastEventAt to
RelayStatus. TherelayStatusesStateFlow is used as arememberkey in FeedScreen. Metrics change per-event (100+/sec). This creates the exact churn documented in the March 2026 brainstorm. Instead, accumulate inConcurrentHashMap, emit snapshot at 1Hz.
// In RelayConnectionManager
@Immutable // Compose skip optimization
data class RelayMetrics(
val eventCount: Long = 0,
val lastEventAt: Long? = null,
)
// Hot metrics — written on every event, no StateFlow emission
private val _rawMetrics = ConcurrentHashMap<NormalizedRelayUrl, RelayMetrics>()
// Throttled snapshot — 1Hz emission for UI
private val _metricsFlow = MutableStateFlow<Map<NormalizedRelayUrl, RelayMetrics>>(emptyMap())
val relayMetrics: StateFlow<Map<NormalizedRelayUrl, RelayMetrics>> = _metricsFlow.asStateFlow()
// In listener callback — atomic, no StateFlow emission
override fun onEvent(relay: IRelayClient, event: Event) {
_rawMetrics.compute(relay.url) { _, m ->
RelayMetrics(
eventCount = (m?.eventCount ?: 0) + 1,
lastEventAt = System.currentTimeMillis()
)
}
}
// 1Hz snapshot coroutine — started in init or connect()
private fun startMetricsSnapshot(scope: CoroutineScope) {
scope.launch {
while (isActive) {
delay(1_000)
_metricsFlow.value = HashMap(_rawMetrics)
}
}
}
RelayStatus stays unchanged — connection-only state.
2. Create Nip11Fetcher (Nip11Fetcher.kt)
Research Insight (Security):
getHttpClient()has a fail-open fallback — returnsdirectClientwhen Tor is bootstrapping. NIP-11 fetch MUST use fail-closed client. Either fixgetHttpClient()or usecurrentClient().Research Insight (Coroutines):
ConcurrentHashMap.getOrPutis NOT atomic for the lambda. UseMutexper URL with double-check pattern.Research Insight (Performance): Limit response body to 256KB to prevent DoS from malicious relay.
class Nip11Fetcher(
private val httpClient: DesktopHttpClient,
private val scope: CoroutineScope,
) {
private val cache = ConcurrentHashMap<NormalizedRelayUrl, Nip11RelayInformation>()
private val locks = ConcurrentHashMap<NormalizedRelayUrl, Mutex>()
companion object {
private const val MAX_RESPONSE_BYTES = 256 * 1024L // 256KB
private val SEMAPHORE = Semaphore(5) // max 5 concurrent fetches
}
suspend fun fetch(url: NormalizedRelayUrl): Nip11RelayInformation? {
cache[url]?.let { return it }
val mutex = locks.getOrPut(url) { Mutex() }
return mutex.withLock {
cache[url]?.let { return it } // double-check
SEMAPHORE.withPermit {
withContext(Dispatchers.IO) {
fetchFromNetwork(url)
}
}?.also { info ->
cache[url] = info
locks.remove(url)
}
}
}
private fun fetchFromNetwork(url: NormalizedRelayUrl): Nip11RelayInformation? {
// FAIL-CLOSED: use currentClient() not getHttpClient()
val client = httpClient.currentClient() ?: return null
val httpUrl = url.toHttp()
val request = Request.Builder()
.url(httpUrl)
.header("Accept", "application/nostr+json")
.build()
return try {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.source()
?.readUtf8(MAX_RESPONSE_BYTES) ?: return null
Nip11RelayInformation.fromJson(body)
} else null
}
} catch (_: Exception) { null }
}
fun getCached(url: NormalizedRelayUrl): Nip11RelayInformation? = cache[url]
fun clearCache() { cache.clear(); locks.clear() }
}
Research Insight (Architecture): Consider extracting
Nip11Retrieverfrom Android to commons later. For now, desktop-specific is fine since the caching layer differs (Android LruCache vs ConcurrentHashMap).
Phase 1b: Register DeckColumnType
7 files to modify (desktop-expert caught 2 missing):
| File | Change |
|---|---|
DeckColumnType.kt |
Add object Relays : DeckColumnType(), add to title() = "Relays" and typeKey() = "relays" |
ColumnHeader.kt |
Add DeckColumnType.Relays -> Icons.Default.Dns in icon() |
DeckColumnContainer.kt |
Add DeckColumnType.Relays -> RelayDashboardScreen(...) in RootContent() |
AppDrawer.kt |
Add new ScreenCategory.NETWORK("Network", Icons.Default.Dns) enum value. Add DeckColumnType.Relays to LAUNCHABLE_SCREENS. Add to category() → NETWORK |
DeckState.kt |
Add "relays" -> DeckColumnType.Relays in parseColumnType() |
PinnedNavBarState.kt |
Add DeckColumnType.Relays to DEFAULT_PINNED list |
Main.kt |
Create Nip11Fetcher at app level. Thread through DeckColumnContainer. Add MenuBar item: View > Relay Dashboard (Cmd+Shift+R) |
Research Insight (Desktop):
DeckColumnTypeis a sealed class. ALL exhaustivewhenexpressions MUST be updated or compilation fails. The exhaustive ones:title(),typeKey(),icon(),category(),RootContent().
Phase 1c: Dashboard UI
3. Create RelayDashboardScreen (RelayDashboardScreen.kt)
Research Insight (Compose):
mutableIntStateOfis Android-only. UsemutableStateOf(0)or better, an enum.
enum class DashboardTab(val label: String) {
MONITOR("Monitor"),
CONFIGURE("Configure"),
}
@Composable
fun RelayDashboardScreen(
relayManager: DesktopRelayConnectionManager,
nip11Fetcher: Nip11Fetcher,
accountRelays: DesktopAccountRelays,
) {
var selectedTab by remember { mutableStateOf(DashboardTab.MONITOR) }
Column {
TabRow(selectedTabIndex = DashboardTab.entries.indexOf(selectedTab)) {
DashboardTab.entries.forEach { tab ->
Tab(
selected = selectedTab == tab,
onClick = { selectedTab = tab },
text = { Text(tab.label) },
)
}
}
when (selectedTab) {
DashboardTab.MONITOR -> RelayMetricsTab(relayManager, nip11Fetcher)
DashboardTab.CONFIGURE -> RelayConfigTab(relayManager, accountRelays)
}
}
}
4. Create RelayMetricsTab (RelayMetricsTab.kt)
- Summary header: "X of Y connected" + global reconnect button
- LazyColumn of
RelayMetricCardper relay, withkey = { it.url.url } - Single-expanded-at-a-time behavior (parent tracks
expandedUrl: NormalizedRelayUrl?)
Research Insight (Compose): Apply
distinctUntilChanged()onrelayStatusesflow before collecting. Sort by URL for stable list ordering.
val statuses by relayManager.relayStatuses
.map { it.values.toList().sortedBy { s -> s.url.url } }
.distinctUntilChanged()
.collectAsState(emptyList())
val metrics by relayManager.relayMetrics
.collectAsState() // already throttled to 1Hz
5. Create RelayMetricCard (RelayMetricCard.kt)
Research Insight (Compose): Hoist expansion state. Use
AnimatedVisibilityfor expand/collapse. UseproduceStatefor NIP-11 per-card to avoid parent recomposition cascade. Use M3 colors viaRelayStatusColorsobject.
@Composable
fun RelayMetricCard(
status: RelayStatus,
metrics: RelayMetrics?,
isExpanded: Boolean,
onToggleExpand: () -> Unit,
nip11Fetcher: Nip11Fetcher,
modifier: Modifier = Modifier,
) {
// NIP-11 fetched per-card, no parent recomposition
val nip11 by produceState<Nip11RelayInformation?>(null, status.url) {
value = nip11Fetcher.fetch(status.url)
}
Card(modifier) {
// Connection status icon (M3 colors)
// Relay URL + display name from NIP-11
// Ping with Tor badge if .onion
// Events count (session) from metrics
// Last event relative time from metrics
Row(Modifier.clickable { onToggleExpand() }) { /* ... */ }
AnimatedVisibility(isExpanded) {
RelayDetailPanel(nip11)
}
}
}
object RelayStatusColors {
@Composable fun connected() = MaterialTheme.colorScheme.primary
@Composable fun error() = MaterialTheme.colorScheme.error
@Composable fun disconnected() = MaterialTheme.colorScheme.outline
}
Research Insight (Desktop): Add right-click context menu on relay rows: Copy URL, Open in Browser, Disconnect, Remove. Add tooltips on all icon buttons. Support double-click to expand.
6. Create RelayDetailPanel (RelayDetailPanel.kt)
Simplified NIP-11 display (per simplicity review):
- Name + description
- Software + version
- Supported NIPs (comma-separated text, not clickable chips)
- Payment: free/paid badge
Research Insight (Simplicity): Cut: geo, countries, languages, contact email, retention, fees. These are relay-operator info, not end-user info.
7. Create RelayConfigTab (RelayConfigTab.kt)
Research Insight (Simplicity): Cut from 8 categories to 3. Desktop doesn't use search/trusted/proxy/indexer/key-package relay lists yet.
Collapsible sections:
- NIP-65 Inbox/Outbox (kind 10002) — with read/write toggle per relay
- DM Relays (kind 10050)
- Connected Relays — the flat list that already exists (add/remove/reconnect)
Each section uses RelayListEditor.
8. Create RelayListEditor (RelayListEditor.kt)
- Relay URL input field with validation (wss://, .onion allowed)
- Add button (Enter to add)
- LazyColumn of relay items with key, alphabetically sorted:
- Relay URL + NIP-11 name
- Connection status dot
- Remove button (X)
- Read/write toggle (NIP-65 only)
- "Save" button — publishes updated relay list event
- "Reset to defaults" option
Research Insight (Simplicity): No drag-to-reorder. Order is display-only. Alphabetical sort is deterministic and simple.
URL validation:
- Must start with
wss://orws:// - Normalize via
RelayUrlNormalizer - Reject duplicates within same category
Research Insight (Nostr): NIP-65 uses
"r"tags. DM (10050) uses"relay"tags. Different tag formats — use the appropriate event builder from quartz.Research Insight (Nostr): After publishing, show "Published to X of Y relays" not just "Saved". Warn if fewer than 2 relays confirm.
Phase 2: Compose Relay Picker
Goal: Expandable relay section in note compose and DM compose dialogs.
9. Create ComposeRelayPicker (ComposeRelayPicker.kt)
Research Insight (Compose): Fully hoist state to parent. Use
combineonconnectedRelays(NOTrelayStatuses) + category relay list into@Immutabledata class withdistinctUntilChanged().Research Insight (Security): DM action type must NOT fall back to all connected relays. Block send if no kind 10050 configured.
@Immutable
data class RelayPickerState(
val allRelays: Set<NormalizedRelayUrl>,
val connectedRelays: Set<NormalizedRelayUrl>,
) {
companion object { val EMPTY = RelayPickerState(emptySet(), emptySet()) }
}
enum class RelayActionType { NOTE, DM }
@Composable
fun ComposeRelayPicker(
pickerState: RelayPickerState, // hoisted — parent combines flows
selectedRelays: Set<NormalizedRelayUrl>, // hoisted
onToggleRelay: (NormalizedRelayUrl) -> Unit,
onSaveAsDefault: () -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
// Collapsed: "▸ Relays (3 of 5)" with chips
Row(Modifier.clickable { expanded = !expanded }) {
Icon(if (expanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight)
Text("Relays (${selectedRelays.size} of ${pickerState.allRelays.size})")
}
if (!expanded) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
items(selectedRelays.toList(), key = { it.url }) { url ->
AssistChip(onClick = {}, label = { Text(url.displayUrl()) })
}
}
}
AnimatedVisibility(expanded) {
Column {
// Connected relays: checkboxes
pickerState.allRelays.sortedBy { it.url }.forEach { url ->
val connected = url in pickerState.connectedRelays
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = url in selectedRelays,
onCheckedChange = { onToggleRelay(url) },
enabled = connected,
)
Text(
url.displayUrl(),
color = if (connected) LocalContentColor.current
else MaterialTheme.colorScheme.outline,
)
if (!connected) Text(" (disconnected)", style = MaterialTheme.typography.labelSmall)
}
}
TextButton(onClick = onSaveAsDefault) { Text("Save as default") }
}
}
}
Default relay population per action type:
NOTE→ NIP-65 outbox relays, fallback to all connectedDM→ DM relay list (kind 10050), NO FALLBACK — block send if empty
Research Insight (Nostr):
publish(event, relays)already exists onRelayConnectionManager. No newpublishTo()method needed. For non-DM events, always include NIP-65 write relays in the set to maintain outbox model compliance.
10. Integrate into ComposeNoteDialog (ComposeNoteDialog.kt)
- Parent combines
connectedRelays+ NIP-65 outbox intoRelayPickerState - Add
ComposeRelayPickerabove send button - Replace
relayManager.broadcastToAll(signedEvent)withrelayManager.publish(signedEvent, selectedRelays)
11. Integrate into DM compose
- Same
ComposeRelayPickerwith DM relay list as source - If no DM relay list (kind 10050): show warning "Configure DM relays first", disable Send
Phase 3: Future (Deferred — not in this PR)
Research Insight (Simplicity): These are all nice-to-haves without demand signal. Defer entirely. If demand emerges, create separate feature tickets.
- Relay source badges on notes (Note.addRelay already tracks this)
- Relay suggestions from contacts' NIP-65 lists
- Additional relay categories (search, trusted, proxy, indexer, key package)
- Historical metrics with SQLite persistence
- Keyboard navigation refinements (Tab/arrows in relay lists)
System-Wide Impact
Interaction Graph
RelayConnectionManager.onEvent()→ atomic increment on_rawMetricsConcurrentHashMap (no StateFlow emission)- 1Hz snapshot coroutine → copies
_rawMetricsto_metricsFlowStateFlow → dashboard recomposes at max 1fps relayStatusesStateFlow → unchanged, only emits on connect/disconnect/error (low frequency)Nip11Fetcher.fetch()triggered per-card viaproduceState→ HTTP GET via fail-closedcurrentClient()→ cached in memoryComposeRelayPickerreadsconnectedRelays+ category relay list viacombine().distinctUntilChanged()→ user toggles →publish(event, selectedRelays)called- Relay list save → signs event via signer → publishes to relays → shows "Published to X of Y"
Error Propagation
| Error | Source | Handling |
|---|---|---|
| NIP-11 fetch fails | HTTP timeout/404/parse/Tor not ready | Show "Info unavailable" in detail panel |
| NIP-11 body too large | Malicious relay sends >256KB | Truncated by readUtf8(MAX_RESPONSE_BYTES) |
| Relay list publish rejected | Auth required, rate limited | Snackbar "Published to X of Y relays" with warning |
| Signer timeout (NIP-46) | Remote bunker unresponsive | "Signing timed out" dialog, offer retry |
| All relays disconnected at compose | Network failure | Warning banner in picker, disable Send |
| No DM relay list configured | User never published kind 10050 | Block DM send, show "Configure DM relays" warning |
| Tor bootstrapping when NIP-11 fetched | Tor not ready yet | currentClient() returns null → fetch returns null → "Info unavailable" |
State Lifecycle Risks
- Relay list conflict: User edits on desktop while mobile publishes newer version → desktop overwrites. Mitigation: show "last updated" timestamp, warn if remote version is newer.
- NIP-11 cache stale within session: Relay updates NIP-11 info after initial fetch → acceptable, session cache is intentional.
- Metrics lost on restart: Session-only by design. No persistence to corrupt.
Acceptance Criteria
Phase 1: Dashboard
DeckColumnType.Relaysregistered in all 7 files, compiles- New
ScreenCategory.NETWORKin AppDrawer - Relays appears in App Drawer under Network
- Can pin Relays to nav bar, open as deck column
- Added to
DEFAULT_PINNEDin PinnedNavBarState - MenuBar: View > Relay Dashboard (Cmd+Shift+R)
- Monitor tab shows all relays with: status, ping, event count (1Hz), last event time
- Separate
relayMetricsStateFlow (1Hz),relayStatusesunchanged - NIP-11 detail panel shows: name, software, supported NIPs, paid/free
- NIP-11 fetched on-demand per-card via
produceState, Mutex dedup, fail-closed Tor - NIP-11 response limited to 256KB
- Configure tab shows 3 categories: NIP-65 inbox/outbox, DM, Connected
- Save button per category publishes appropriate event kind
- After save, shows "Published to X of Y relays"
- URL input validates wss:// format, rejects duplicates
- Relay list sorted alphabetically (no drag reorder)
- Tor relays show .onion badge next to latency
- Right-click context menu on relay rows (Copy URL, Remove)
- M3 colors for status indicators (not hardcoded Color.Green/Red)
- Card expansion hoisted + AnimatedVisibility
Phase 2: Compose Picker
- Expandable "Relays (N of M)" section in ComposeNoteDialog
- Collapsed shows relay name chips via LazyRow, expanded shows checkboxes
- Default: NIP-65 outbox for notes
- DM: kind 10050 relays only, no fallback — block send if empty
- Disconnected relays shown greyed out, not checkable
- "Save as default" persists to appropriate relay list event
- Per-action selection resets on dialog reopen
- Uses existing
publish(event, relays)API (no new method) - State fully hoisted, combined via
combine().distinctUntilChanged()
Dependencies & Prerequisites
| Dependency | Status | Notes |
|---|---|---|
Nip11RelayInformation (quartz) |
✅ Exists | Full data model with fromJson() |
RelayConnectionManager |
✅ Exists | Add relayMetrics flow + 1Hz snapshot |
RelayStatusCard |
✅ Exists | Enhance with M3 colors |
DesktopAccountRelays |
✅ Exists | DM relay state |
Nip65RelayListState (commons) |
✅ Exists | Inbox/outbox state |
DesktopHttpClient.currentClient() |
✅ Exists | Fail-closed Tor-aware client |
publish(event, relays) |
✅ Exists | Already on RelayConnectionManager |
Note.addRelay() |
✅ Exists | Relay source tracking in cache |
| Relay list event types (quartz) | ✅ Exists | Kind 10002, 10050 |
Nip11Fetcher |
🆕 New | HTTP + session cache + Mutex dedup |
Risk Analysis & Mitigation
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| NIP-11 fetch leaks IP during Tor bootstrap | Medium | High | Use currentClient() (fail-closed), not getHttpClient() (fail-open) |
| DM published to non-DM relays | Low | High | Block DM send when no kind 10050 configured, never fall back to all relays |
| NIP-11 DoS via large response | Low | Medium | 256KB body limit via readUtf8(MAX_RESPONSE_BYTES) |
| Dashboard recomposition churn | Medium | Medium | Separate relayMetrics flow at 1Hz, relayStatuses unchanged |
| Relay list publish partially accepted | Low | Medium | Show "Published to X of Y relays", warn if < 2 confirm |
| Relay list conflict with mobile | Low | Medium | Show "last updated" timestamp |
| Metrics persistence when Tor active leaks patterns | N/A | N/A | Eliminated — session-only metrics, nothing persisted to disk |
Sources & References
Origin
- Brainstorm document: docs/brainstorms/2026-04-20-relay-power-tools-brainstorm.md — Key decisions: dashboard-first build order, expandable compose picker UX, per-session NIP-11 cache
Internal References
RelayConnectionManager:desktopApp/.../network/RelayConnectionManager.ktRelayStatus:desktopApp/.../network/RelayStatus.ktDesktopHttpClient.currentClient():desktopApp/.../network/DesktopHttpClient.kt:168publish(event, relays):desktopApp/.../network/RelayConnectionManager.kt(already accepts relay set)Note.addRelay(): relay source tracking already in cache consume methodsRelayStatusCard:desktopApp/.../ui/relay/RelayStatusCard.ktComposeNoteDialog:desktopApp/.../ui/ComposeNoteDialog.ktDeckColumnType:desktopApp/.../ui/deck/DeckColumnType.ktAppDrawer:desktopApp/.../ui/deck/AppDrawer.ktPinnedNavBarState:desktopApp/.../ui/deck/PinnedNavBarState.ktNip11RelayInformation:quartz/.../nip11RelayInfo/Nip11RelayInformation.kt- FeedScreen relay subscription strategy:
docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md
External References
Unanswered Questions
- Should
currentClient()in DesktopHttpClient be the default for ALL non-websocket HTTP requests, or just NIP-11? - Should the relay config "Save" button compare
created_atwith the remote version before overwriting? - Should Relays be in
DEFAULT_PINNEDor just pinnable? - Does
DesktopHttpClient.currentClient()support theAccept: application/nostr+jsonheader routing, or does it need a separate method for HTTP GET vs WebSocket?