Files
amethyst/docs/plans/2026-04-20-feat-relay-power-tools-plan.md
T
nrobi144 250bb5a1ad feat(multi-account): display names, middle-truncated npub, npub-only account fix
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>
2026-04-29 09:35:33 +03:00

608 lines
29 KiB
Markdown

---
title: "feat: Relay Power Tools — Dashboard Screen + Compose Relay Picker"
type: feat
status: active
date: 2026-04-20
origin: docs/brainstorms/2026-04-20-relay-power-tools-brainstorm.md
deepened: 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
1. **Separate metrics from RelayStatus** — avoid StateFlow churn that caused subscription issues (March 2026 brainstorm)
2. **Kill RelayMetricsStore** — session-only metrics, no persistence (YAGNI, Preferences 8KB limit)
3. **Cut to 3 relay categories** — desktop only uses NIP-65, DM, connected today
4. **Reuse existing `publish()` API** — already accepts relay set, no new `publishTo()` needed
5. **Reuse existing `Note.addRelay()`** — relay source tracking already exists in cache layer
6. **Fix Nip11Fetcher Tor regression** — must use fail-closed HTTP client, not fail-open `getHttpClient()`
7. **Block DM fallback to all relays** — security: never fall back to all connected for DMs
8. **7 files for DeckColumnType, not 5** — add PinnedNavBarState + AppDrawer `category()`
9. **New ScreenCategory.NETWORK** — semantically correct vs IDENTITY
### Files Eliminated (YAGNI)
- `RelayMetricsStore.kt` — no UI consumes historical data
- `RelaySuggestions.kt` — deferred entirely, no demand signal
- `RelayBadge.kt` — per-event tracking overhead for nice-to-have
---
## Overview
Add full relay visibility and control to Amethyst Desktop through two features:
1. **Relay Dashboard** — New `DeckColumnType.Relays` screen with live session metrics, NIP-11 info, and per-category relay list management
2. **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`. The `relayStatuses` StateFlow is used as a `remember` key in FeedScreen. Metrics change per-event (100+/sec). This creates the exact churn documented in the March 2026 brainstorm. Instead, accumulate in `ConcurrentHashMap`, emit snapshot at 1Hz.
```kotlin
// 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 — returns `directClient` when Tor is bootstrapping. NIP-11 fetch MUST use fail-closed client. Either fix `getHttpClient()` or use `currentClient()`.
>
> **Research Insight (Coroutines):** `ConcurrentHashMap.getOrPut` is NOT atomic for the lambda. Use `Mutex` per URL with double-check pattern.
>
> **Research Insight (Performance):** Limit response body to 256KB to prevent DoS from malicious relay.
```kotlin
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 `Nip11Retriever` from 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):** `DeckColumnType` is a sealed class. ALL exhaustive `when` expressions 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):** `mutableIntStateOf` is Android-only. Use `mutableStateOf(0)` or better, an enum.
```kotlin
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 `RelayMetricCard` per relay, **with `key = { it.url.url }`**
- Single-expanded-at-a-time behavior (parent tracks `expandedUrl: NormalizedRelayUrl?`)
> **Research Insight (Compose):** Apply `distinctUntilChanged()` on `relayStatuses` flow before collecting. Sort by URL for stable list ordering.
```kotlin
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 `AnimatedVisibility` for expand/collapse. Use `produceState` for NIP-11 per-card to avoid parent recomposition cascade. Use M3 colors via `RelayStatusColors` object.
```kotlin
@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://` or `ws://`
- 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 `combine` on `connectedRelays` (NOT `relayStatuses`) + category relay list into `@Immutable` data class with `distinctUntilChanged()`.
>
> **Research Insight (Security):** DM action type must NOT fall back to all connected relays. Block send if no kind 10050 configured.
```kotlin
@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 connected
- `DM` → DM relay list (kind 10050), **NO FALLBACK — block send if empty**
> **Research Insight (Nostr):** `publish(event, relays)` already exists on `RelayConnectionManager`. No new `publishTo()` 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 into `RelayPickerState`
- Add `ComposeRelayPicker` above send button
- Replace `relayManager.broadcastToAll(signedEvent)` with `relayManager.publish(signedEvent, selectedRelays)`
**11. Integrate into DM compose**
- Same `ComposeRelayPicker` with 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 `_rawMetrics` ConcurrentHashMap (no StateFlow emission)
- 1Hz snapshot coroutine → copies `_rawMetrics` to `_metricsFlow` StateFlow → dashboard recomposes at max 1fps
- `relayStatuses` StateFlow → **unchanged**, only emits on connect/disconnect/error (low frequency)
- `Nip11Fetcher.fetch()` triggered per-card via `produceState` → HTTP GET via fail-closed `currentClient()` → cached in memory
- `ComposeRelayPicker` reads `connectedRelays` + category relay list via `combine().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.Relays` registered in all 7 files, compiles
- [ ] New `ScreenCategory.NETWORK` in AppDrawer
- [ ] Relays appears in App Drawer under Network
- [ ] Can pin Relays to nav bar, open as deck column
- [ ] Added to `DEFAULT_PINNED` in PinnedNavBarState
- [ ] MenuBar: View > Relay Dashboard (Cmd+Shift+R)
- [ ] Monitor tab shows all relays with: status, ping, event count (1Hz), last event time
- [ ] Separate `relayMetrics` StateFlow (1Hz), `relayStatuses` unchanged
- [ ] 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](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.kt`
- `RelayStatus`: `desktopApp/.../network/RelayStatus.kt`
- `DesktopHttpClient.currentClient()`: `desktopApp/.../network/DesktopHttpClient.kt:168`
- `publish(event, relays)`: `desktopApp/.../network/RelayConnectionManager.kt` (already accepts relay set)
- `Note.addRelay()`: relay source tracking already in cache consume methods
- `RelayStatusCard`: `desktopApp/.../ui/relay/RelayStatusCard.kt`
- `ComposeNoteDialog`: `desktopApp/.../ui/ComposeNoteDialog.kt`
- `DeckColumnType`: `desktopApp/.../ui/deck/DeckColumnType.kt`
- `AppDrawer`: `desktopApp/.../ui/deck/AppDrawer.kt`
- `PinnedNavBarState`: `desktopApp/.../ui/deck/PinnedNavBarState.kt`
- `Nip11RelayInformation`: `quartz/.../nip11RelayInfo/Nip11RelayInformation.kt`
- FeedScreen relay subscription strategy: `docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md`
### External References
- [NIP-11 Relay Information](https://github.com/nostr-protocol/nips/blob/master/11.md)
- [NIP-65 Relay List Metadata](https://github.com/nostr-protocol/nips/blob/master/65.md)
- [NIP-17 Private Direct Messages](https://github.com/nostr-protocol/nips/blob/master/17.md)
## Unanswered Questions
1. Should `currentClient()` in DesktopHttpClient be the default for ALL non-websocket HTTP requests, or just NIP-11?
2. Should the relay config "Save" button compare `created_at` with the remote version before overwriting?
3. Should Relays be in `DEFAULT_PINNED` or just pinnable?
4. Does `DesktopHttpClient.currentClient()` support the `Accept: application/nostr+json` header routing, or does it need a separate method for HTTP GET vs WebSocket?