250bb5a1ad
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>
608 lines
29 KiB
Markdown
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?
|