feat: shared index relays across Desktop and amy + settings UI

Unifies the "index relays" set (used for kind 0 profile metadata and
kind 3 follow list REQs) across the Desktop app and the `amy` CLI so
they always compute WoT scores against the same data source, and adds
a user-configurable settings section for the list.

Before this change:
- Desktop hard-coded `DefaultRelays.RELAYS` at coordinator
  construction; users could not override.
- `amy wot sync` used `outboxRelays().ifEmpty { inboxRelays() }` —
  NIP-65 write / DM inbox relays, which are semantically different
  from index relays. `amy wot get` after `amy wot sync` could return a
  different score than the Desktop UI would compute.

New `PreferencesIndexRelays` (commons/jvmMain) is a tiny class backed
by `java.util.prefs.Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")` —
the same JVM-user-scoped shared-node trick `PreferencesHashtagSpamSettings`
already relies on. Both Desktop and amy running as the same OS user
observe the same value with zero extra plumbing. App-global (not
per-account); users typically have one preferred index-relay set
regardless of which account is logged in.

Behaviour changes for users who never open the settings UI: none.
`DEFAULT_INDEX_RELAYS` is byte-for-byte identical to the four URLs in
`DefaultRelays.RELAYS`.

Wiring:
- `DesktopRelayCategories` gains a straight-through `indexRelays`
  StateFlow (no combine — index relays are a curated user choice, not
  a NIP-65-derived set) plus `setIndexRelays(new)`.
- `Main.kt` instantiates `PreferencesIndexRelays` at App() root and
  passes it into both the subscriptions-coordinator constructor and
  `DesktopRelayCategories`. Coordinator snapshots the effective set
  at construction — changes take effect on next relaunch (documented
  in the settings section explainer).
- `Context.indexRelays()` reads the same preferences node so
  `WotCommand.sync` produces identical relay batches to Desktop.
- New `IndexRelaysSection` composable in
  `desktopApp/.../ui/settings/` — list + per-row remove + add-row
  with URL normalisation. Deletion of all entries falls back to
  defaults (delete-all is the reset — no separate "Reset" button).
  Placed between the Local Relay and Content Filters sections of the
  Relays settings screen.

Tests:
- `PreferencesIndexRelaysTest` — defaults fallback, round-trip
  persistence, blank-token skipping, non-empty defaults guardrail.
- Full existing test suites remain green.

Companion PR (search-result badges) landed on `feat/wot-search-badges`
and is this branch's parent. Both remain stacked on the WoT feature
branch pending upstream review.

Plan: docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md
This commit is contained in:
nrobi144
2026-07-06 09:31:02 +03:00
parent afa1a3b652
commit fe22de0817
8 changed files with 1135 additions and 14 deletions
@@ -361,6 +361,23 @@ class Context(
/** Union of all three buckets. */
suspend fun anyRelays(): Set<NormalizedRelayUrl> = outboxRelays() + inboxRelays() + keyPackageRelays()
/**
* Index relays — the shared, app-global set used to fetch profile
* metadata (kind 0) and follow lists (kind 3). Mirrors the Desktop
* app's `LocalRelayCategories.indexRelays` by reading from the same
* `java.util.prefs` node
* (`com/vitorpamplona/amethyst/relays/index`). Falls back to the
* shipping defaults when the user hasn't configured anything.
*
* This is what `amy wot sync` uses; `outboxRelays()` /
* `inboxRelays()` remain for callers that want relay lists derived
* from NIP-65 identity semantics.
*/
fun indexRelays(): Set<NormalizedRelayUrl> =
com.vitorpamplona.amethyst.commons.relays.index
.PreferencesIndexRelays()
.effective()
/**
* Seed relays for "look up someone we know nothing about" queries —
* fetching another user's kind:10002 / 10050 / 10051 / 30443 before we
@@ -125,8 +125,13 @@ object WotCommand {
Output.emit(mapOf("synced" to 0, "detail" to "empty follow set"))
return 0
}
val relays = ctx.outboxRelays().ifEmpty { ctx.inboxRelays() }
if (relays.isEmpty()) return Output.error("no_relays", "no relays configured")
// Index relays — shared with the Desktop app via
// `java.util.prefs`. Falls back to
// `PreferencesIndexRelays.DEFAULT_INDEX_RELAYS` when the
// user hasn't configured anything, so this is never empty
// in practice.
val relays = ctx.indexRelays()
if (relays.isEmpty()) return Output.error("no_relays", "no index relays configured")
// Chunk authors into ≤100 per Filter for relays with per-filter caps.
val filters =
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relays.index
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.prefs.Preferences
/**
* User-configurable set of relays used to fetch profile metadata
* (kind 0) and follow lists (kind 3) — the "index relays" set passed
* to `FeedMetadataCoordinator` in the Desktop app and to `wot sync`
* in `amy`.
*
* Backed by [java.util.prefs.Preferences] at a fixed node
* `com/vitorpamplona/amethyst/relays/index` (JVM-user-scoped). The
* shared node means Desktop and `amy` running as the same OS user
* observe the same setting without extra plumbing — the same trick
* `PreferencesHashtagSpamSettings` uses for the hashtag-spam filter.
*
* Not per-account: users typically have a single preferred set of
* index relays regardless of which account is currently logged in.
* If per-account overrides become necessary later, layer a per-user
* key on top; this class stays the base.
*
* CSV serialisation for the persisted value matches what
* `DesktopAccountRelays` uses for its categories — no JSON dep, no
* `Serializable` contract. URLs are normalised via
* [RelayUrlNormalizer.normalizeOrNull] at both write and read time so
* malformed entries never enter the effective set.
*/
class PreferencesIndexRelays(
private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME),
) {
private val mutableRelays: MutableStateFlow<Set<NormalizedRelayUrl>> =
MutableStateFlow(parse(prefs.get(KEY_URLS, "")))
/**
* Current user override. Empty when the user has not configured
* anything — callers should route through [effective] to get the
* defaults-fallback resolved set.
*/
val relays: StateFlow<Set<NormalizedRelayUrl>> = mutableRelays.asStateFlow()
fun setRelays(new: Set<NormalizedRelayUrl>) {
mutableRelays.value = new
prefs.put(KEY_URLS, new.joinToString(",") { it.url })
}
/**
* Resolves the set the relay client should actually use — the user
* override when non-empty, otherwise [DEFAULT_INDEX_RELAYS]. Never
* returns empty (unless the caller has explicitly reset both the
* override and the defaults to empty, which would require a code
* change here).
*/
fun effective(): Set<NormalizedRelayUrl> = mutableRelays.value.ifEmpty { DEFAULT_INDEX_RELAYS }
companion object {
const val NODE_NAME = "com/vitorpamplona/amethyst/relays/index"
const val KEY_URLS = "urls"
/**
* Byte-for-byte identical to `DefaultRelays.RELAYS` at
* `desktopApp/.../network/RelayStatus.kt`. Preserves current
* behaviour for users who never open the settings UI.
*
* Note: `commons/AmethystDefaults.kt` also has
* `DefaultIndexerRelayList` (Purple Pages, Coracle …) which is
* more purpose-built for indexing. Adopting it is a separate
* ticket — see the plan's "Out of Scope" section.
*/
val DEFAULT_INDEX_RELAYS: Set<NormalizedRelayUrl> =
listOf(
"wss://nos.lol",
"wss://nostr.wine",
"wss://relay.noswhere.com",
"wss://relay.primal.net",
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
internal fun parse(csv: String): Set<NormalizedRelayUrl> =
csv
.split(",")
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relays.index
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.prefs.Preferences
class PreferencesIndexRelaysTest {
private val testNode = "com/vitorpamplona/amethyst/test/relays/index_${System.currentTimeMillis()}"
private fun prefs(): Preferences = Preferences.userRoot().node(testNode)
@Before
fun setup() {
prefs().clear()
}
@After
fun teardown() {
prefs().removeNode()
}
@Test
fun defaultsWhenPreferencesUnset() {
val store = PreferencesIndexRelays(prefs())
assertTrue(store.relays.value.isEmpty())
assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective())
}
@Test
fun setRelaysPersistsAcrossInstances() {
val store = PreferencesIndexRelays(prefs())
val urls =
listOf("wss://relay.example", "wss://index.example")
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
store.setRelays(urls)
assertEquals(urls, store.relays.value)
val reloaded = PreferencesIndexRelays(prefs())
assertEquals(urls, reloaded.relays.value)
assertEquals(urls, reloaded.effective())
}
@Test
fun effectiveFallsBackWhenOverrideCleared() {
val store = PreferencesIndexRelays(prefs())
val urls =
listOf("wss://relay.example")
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
store.setRelays(urls)
store.setRelays(emptySet())
assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective())
}
@Test
fun emptyEntriesInCsvAreSkipped() {
// Plant a URL list with empty tokens (extra commas). The
// parser should skip blanks silently.
prefs().put(PreferencesIndexRelays.KEY_URLS, "wss://good.example,,wss://also-good.example,")
val store = PreferencesIndexRelays(prefs())
// Both good URLs should be present; no blank / empty entry.
assertEquals(2, store.relays.value.size)
assertTrue(store.relays.value.none { it.url.isBlank() })
}
@Test
fun defaultSetIsNotEmpty() {
// Guardrail against a future refactor accidentally clearing the constant.
assertTrue(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS.isNotEmpty())
}
}
@@ -86,7 +86,6 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome
@@ -128,7 +127,6 @@ import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -758,6 +756,17 @@ fun App(
// node so the `amy` CLI binary observes the same toggle.
val hashtagSpamSettings = remember { PreferencesHashtagSpamSettings() }
// Index-relay preference — user-configurable set used to fetch profile
// metadata (kind 0) and follow lists (kind 3). Persisted in a shared
// java.util.prefs node so `amy wot sync` reads from the same source of
// truth. Falls back to PreferencesIndexRelays.DEFAULT_INDEX_RELAYS when
// the user hasn't configured anything.
val indexRelaysStore =
remember {
com.vitorpamplona.amethyst.commons.relays.index
.PreferencesIndexRelays()
}
// Local relay store — persists events to SQLite per account
val localRelayStore =
remember {
@@ -842,18 +851,16 @@ fun App(
}
}
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
// Subscriptions coordinator — uses the user's configured index relays
// (or PreferencesIndexRelays.DEFAULT_INDEX_RELAYS as fallback) for
// metadata + follow-list REQs. Changes made via the settings UI take
// effect on next relaunch — the coordinator snapshots the set here.
val subscriptionsCoordinator =
remember(relayManager, localCache) {
remember(relayManager, localCache, indexRelaysStore) {
DesktopRelaySubscriptionsCoordinator(
client = relayManager.client,
scope = scope,
indexRelays =
DefaultRelays.RELAYS
.mapNotNull {
RelayUrlNormalizer.normalizeOrNull(it)
}.toSet(),
indexRelays = indexRelaysStore.effective(),
localCache = localCache,
).also { it.startCleanupLoop() }
}
@@ -1108,6 +1115,7 @@ fun App(
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
indexRelaysStore = indexRelaysStore,
nip11Fetcher = nip11Fetcher,
appScope = scope,
torStatus = currentTorStatus,
@@ -1230,6 +1238,7 @@ fun MainContent(
account: AccountState.LoggedIn,
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
indexRelaysStore: com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays,
nip11Fetcher: Nip11Fetcher,
appScope: CoroutineScope,
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
@@ -1273,13 +1282,14 @@ fun MainContent(
)
}
// Aggregated relay categories (feed, notifications, search, DM)
// Aggregated relay categories (feed, notifications, search, DM, index)
val relayCategories =
remember(iAccount.nip65RelayList, accountRelays, relayManager) {
remember(iAccount.nip65RelayList, accountRelays, relayManager, indexRelaysStore) {
DesktopRelayCategories(
nip65State = iAccount.nip65RelayList,
accountRelays = accountRelays,
connectedRelays = relayManager.connectedRelays,
indexRelaysStore = indexRelaysStore,
scope = scope,
)
}
@@ -2133,6 +2143,14 @@ fun RelaySettingsScreen(
Spacer(Modifier.height(16.dp))
}
// Index Relays section — shared between Desktop and `amy`.
com.vitorpamplona.amethyst.desktop.ui.settings.IndexRelaysSection(
categories = LocalRelayCategories.current,
)
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
// Content Filters section — hashtag-spam filter and future
// content-moderation toggles.
Text(
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.model
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
@@ -47,6 +49,13 @@ class DesktopRelayCategories(
accountRelays: DesktopAccountRelays,
/** Reactive connected relay set — used as fallback when NIP-65 is empty */
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
/**
* Shared index-relay preference — app-global, backed by
* [PreferencesIndexRelays] and visible to `amy` via the same
* Preferences node. Used by [indexRelays] and by
* `Main.kt` when constructing the subscriptions coordinator.
*/
private val indexRelaysStore: PreferencesIndexRelays,
scope: CoroutineScope,
) {
/** Default relays — ALWAYS populated, used as stateIn initial value */
@@ -99,6 +108,26 @@ class DesktopRelayCategories(
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
/**
* Index relays: user override → [PreferencesIndexRelays.DEFAULT_INDEX_RELAYS].
*
* Unlike [feedRelays] / [notificationRelays] / [dmRelays] this
* category does *not* combine with connected/NIP-65 sets — it's a
* curated user choice about where to look up metadata and follow
* lists, not a "what's actually reachable right now" derived set.
* No debounce needed: writes are gated by settings-screen UI, not
* fanned in from a subscription pipeline.
*/
val indexRelays: StateFlow<Set<NormalizedRelayUrl>> =
indexRelaysStore.relays
.map { it.ifEmpty { PreferencesIndexRelays.DEFAULT_INDEX_RELAYS } }
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, indexRelaysStore.effective())
fun setIndexRelays(new: Set<NormalizedRelayUrl>) {
indexRelaysStore.setRelays(new)
}
companion object {
val DEFAULT_SEARCH_RELAYS = DefaultSearchRelayList
}
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* Settings section for the shared "index relays" — the set used by
* `FeedMetadataCoordinator` (Desktop) and `amy wot sync` (CLI) to fetch
* profile metadata (kind 0) and follow lists (kind 3).
*
* The list mutates via [DesktopRelayCategories.setIndexRelays], which
* writes through to [PreferencesIndexRelays]. Deletions land
* immediately; the running Desktop coordinator continues using its
* constructor-time snapshot until the app is relaunched (documented in
* the explainer below the title).
*/
@Composable
fun IndexRelaysSection(
categories: DesktopRelayCategories,
modifier: Modifier = Modifier,
) {
val relays by categories.indexRelays.collectAsState()
var newUrl by remember { mutableStateOf("") }
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = "Index Relays",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(4.dp))
Text(
text = "Used to fetch profile metadata and follow lists (Web-of-Trust). Changes take effect on next relaunch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
// Current relay list — each row with a remove button.
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
relays.forEach { relay ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = relay.url,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { categories.setIndexRelays(relays - relay) },
modifier = Modifier.size(28.dp),
) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = "Remove ${relay.url}",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(Modifier.height(12.dp))
// Add-row.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = newUrl,
onValueChange = { newUrl = it },
placeholder = { Text("wss://relay.example") },
singleLine = true,
modifier = Modifier.weight(1f).padding(end = 8.dp),
)
Button(
onClick = {
val normalized = RelayUrlNormalizer.normalizeOrNull(newUrl.trim())
if (normalized != null) {
categories.setIndexRelays(relays + normalized)
newUrl = ""
}
},
enabled = newUrl.isNotBlank(),
) {
Text("Add")
}
Spacer(Modifier.width(4.dp))
}
}
}
@@ -0,0 +1,701 @@
---
title: WoT follow-ups — search-result badges + shared index relays
type: feat
status: active
date: 2026-07-01
origin: docs/plans/2026-07-01-feat-desktop-wot-score-plan.md
deepened: 2026-07-01
---
# WoT follow-ups — search-result badges + shared index relays
## Enhancement Summary
**Deepened on:** 2026-07-01 (same day as plan write).
**Agents used:** code-simplicity-reviewer, targeted repo verification sweep.
### Key corrections vs first draft
1. **Split into two PRs.** Item 1 (search badges) is mechanical and has
zero coupling to Items 2+3. Ship it alone. Items 2 + 3 stay bundled
because the UI (Item 3) is the write path for the persistence
(Item 2) — reviewing them separately means reviewing dead code or a
headless feature.
2. **App-global (not per-account) index-relay override.** First draft
made this per-account to match `searchRelays` / `dmRelays`. But
`searchRelays` / `dmRelays` are per-account because they're NIP-51 /
NIP-17 identity-scoped semantics; index relays are a user preference
about where profile-metadata lookups go, and users have a single
preferred set regardless of which account they're logged into.
App-global halves the API surface and matches user mental model.
3. **`PreferencesIndexRelays` in `commons/jvmMain/`, not extending
`DesktopAccountRelays`.** Verification found `DesktopAccountRelays`
uses `Preferences.userNodeForPackage(DesktopAccountRelays::class.java)`,
which is a per-class node — **not visible to amy** running from a
different classpath. To achieve the "one truth for Desktop and amy"
goal, the shared node must be an explicit
`Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`,
which is exactly the pattern `PreferencesHashtagSpamSettings` uses.
New small class mirrors that shape.
4. **Drop `WoTBadgedSearchCard`.** Only two call sites; the 6-line
score computation inlines cleanly. New wrapper composable earns its
keep at 3+ call sites, not 2.
5. **Drop `DefaultIndexRelays.kt` in commons.** Speculative — no
Android caller. amy can duplicate the 4 URLs (they change ~never)
or read a single constant from a shared location. Extracting to
commons is architectural neatness without a consumer.
6. **`DesktopRelayCategories.indexRelays` uses `override ?: default`
only.** Not the full combine used by `searchRelays` (which
intersects with NIP-65 discovery). Index relays are a curated user
choice, not a "what's actually reachable right now" derived set. No
`debounce` / `stateIn` combine needed — a straight-through StateFlow
from the Preferences read is enough.
7. **Drop "Reset to defaults" button in Item 3.** Removing all relays
from the UI already falls back to `DefaultRelays.RELAYS`. Delete-all
IS the reset.
8. **Drop integration scenarios 2 and 6.** #2 (badge respects
exemptions) is covered by existing `WoTBadgedAvatar` tests — same
code path. #6 (empty override falls back) is a single unit test on
`PreferencesIndexRelays`, not a manual scenario.
9. **`RelaySettingsScreen` current content** was mischaracterised — it
already has 6 sections (Wallet Connect, Media Server, Image
Compression, Tor, Namecoin, Local Relay, Content Filters). Index
Relays fits between Local Relay and Content Filters (both have
dividers).
10. **Adopt-not-in-this-PR discovery: `commons/AmethystDefaults.kt`
already has `DefaultIndexerRelayList`** (Purple Pages, Coracle,
etc). Desktop today uses the wrong list (`DefaultRelays.RELAYS` =
general-purpose relays) for its index REQs. That's a real
behavioural bug worth a separate ticket — not this one — because
changing default index relays is a user-visible behaviour shift and
deserves its own review.
---
## Overview
Three small follow-ups to the just-shipped Web-of-Trust score feature
(branch `feat/desktop-wot-score`, closed for manual testing):
1. **Badges on search-result person cards.** The main NoteCard header
already renders `WoTBadgedAvatar`, but the Search screen's person
picker uses a different composable (`UserSearchCard`) that doesn't
currently accept a badge.
2. **Unify amy `wot sync` with Desktop on the same relay set.** Desktop
currently uses a hard-coded `DefaultRelays.RELAYS` list as its
`indexRelays`; amy uses whatever the user's NIP-65 outbox/inbox lists
contain. When the two disagree, `amy wot get` after `amy wot sync`
returns a different score than the Desktop UI would compute.
3. **Add an Index Relays section to the Relays settings screen** so
users can customise which relays back both surfaces from one place.
**Shipping plan:** two PRs.
- **PR A — Search badges (Item 1).** ~40 LOC, one commons param
addition, two Desktop call-site inline changes. Independent of the
other work. Ships first.
- **PR B — Shared index relays (Items 2 + 3).** Introduces a small
Preferences-backed class in `commons/jvmMain/`, wires the coordinator
to read from it, adds a settings-screen section, and updates
`amy wot sync` to read the same node. ~300 LOC. Ships second.
## Problem Statement
Three concrete regressions/gaps from the manual-testing pass of the WoT
PR:
- **Item 1.** When searching for a person in the Desktop search screen,
their result card is a stranger 90% of the time (that's the point of
searching), but there's no trust cue on the card. Users who find WoT
badges useful on feed avatars want the same signal here.
- **Item 2.** amy's `wot sync` uses `ctx.outboxRelays()` (NIP-65 write
list) with a fall-back to inbox. Those are legitimate relays for
publishing / receiving events, but they are *not* what Desktop uses
to fetch profile metadata and follow lists — Desktop hits a
hard-coded `indexRelays` set (nos.lol, nostr.wine,
relay.noswhere.com, relay.primal.net today). Result: `amy wot get`
after a fresh `amy wot sync` can produce a score that lags or
diverges from the Desktop UI for the same account.
- **Item 3.** The Relays settings screen already contains six
sections; there's no UI to inspect or change which relays are
considered "index relays" — the values live only in the hard-coded
default list in `RelayStatus.kt`.
## Proposed Solution
### PR A — Item 1: Badge slot on `UserSearchCard`
`UserSearchCard` in
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt`
gets an optional `badge` slot that forwards to its embedded
`UserAvatar` (which already has the slot from the WoT PR):
```kotlin
@Composable
fun UserSearchCard(
user: User,
onClick: () -> Unit,
modifier: Modifier = Modifier,
badge: @Composable (BoxScope.() -> Unit)? = null,
) {
// Existing layout, unchanged, except:
UserAvatar(
userHex = user.pubkeyHex,
pictureUrl = user.profilePicture(),
size = 40.dp,
contentDescription = stringResource(Res.string.accessibility_user_avatar),
badge = badge,
)
// …rest of the Row unchanged
}
```
Backward compatible — default `null` means no visual change for callers
that don't opt in. The layout impact is zero: `UserAvatar` handles the
badge's `Box` overlay itself; the badge lives on the avatar's bottom-
right corner, and the `ArrowForward` icon at the row's trailing edge
doesn't collide with it.
Two Desktop call sites in
`desktopApp/.../ui/search/SearchResultsList.kt:116,126` inline the score
computation directly at the call:
```kotlin
val service = LocalWoTService.current
val ready = LocalWoTReady.current
val exempt = LocalSpamExemptKeys.current
val score = if (service != null && ready && user.pubkeyHex !in exempt) {
service.scores[user.pubkeyHex] ?: 0
} else 0
UserSearchCard(
user = user,
onClick = { },
badge = if (score > 0) {
{ WoTBadge(count = score, modifier = Modifier.align(Alignment.BottomEnd)) }
} else null,
)
```
The two sites are 4 lines apart; a small local `remember` block above
them can factor the read if we want (optional micro-cleanup — not
required).
**Not migrated in PR A:**
- `desktopApp/.../ui/chats/NewDmDialog.kt` (three sites) — the DM
recipient picker. Same rationale as before: when picking a DM
recipient you're already committing to messaging that person; a
trust badge is more noise than signal. Add later if testing calls
for it.
### PR B — Item 2: Shared `indexRelays` between Desktop and amy
#### Persist via `java.util.prefs`, node shared with amy
`Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`.
Same JVM-user-wide `java.util.prefs` trick the hashtag-spam PR (#3431)
uses — Desktop and amy running as the same OS user see the same node.
**Not stored per-account.** Users have a single preferred set of index
relays regardless of which account is logged in. Halves the API
surface and matches user intuition. If a user with two accounts
genuinely needs separate index relays per account, we add per-account
overlay later on demand — YAGNI now.
**Not persisted via `DesktopAccountRelays`.** That class uses
`Preferences.userNodeForPackage(DesktopAccountRelays::class.java)`,
which resolves to a per-class node that `cli/` running from a different
classpath **would not see**. Extending it would give us Desktop-local
config with no amy visibility — the opposite of what we want.
#### New shared class
`commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt`
(new file, mirrors `PreferencesHashtagSpamSettings` shape):
```kotlin
class PreferencesIndexRelays(
private val prefs: Preferences =
Preferences.userRoot().node(NODE_NAME),
) {
private val _relays =
MutableStateFlow(parse(prefs.get(KEY_URLS, "")))
val relays: StateFlow<Set<NormalizedRelayUrl>> = _relays.asStateFlow()
fun setRelays(new: Set<NormalizedRelayUrl>) {
_relays.value = new
prefs.put(KEY_URLS, new.joinToString(",") { it.url })
}
/** Resolves the effective set — user override if non-empty, else defaults. */
fun effective(): Set<NormalizedRelayUrl> =
_relays.value.ifEmpty { DEFAULT_INDEX_RELAYS }
companion object {
const val NODE_NAME = "com/vitorpamplona/amethyst/relays/index"
const val KEY_URLS = "urls"
/**
* Byte-for-byte identical to `DefaultRelays.RELAYS` at
* `desktopApp/.../network/RelayStatus.kt`. Preserves current
* behaviour for users who never open the settings UI.
*
* Note: `commons/AmethystDefaults.kt` also has
* `DefaultIndexerRelayList` (Purple Pages, Coracle, …) which
* is more purpose-built. Adopting it is a separate ticket —
* see Out of Scope.
*/
val DEFAULT_INDEX_RELAYS: Set<NormalizedRelayUrl> = setOf(
"wss://nos.lol",
"wss://nostr.wine",
"wss://relay.noswhere.com",
"wss://relay.primal.net",
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
private fun parse(csv: String): Set<NormalizedRelayUrl> =
csv.split(",")
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
}
}
```
CSV serialisation matches what `DesktopAccountRelays` uses for its
categories (`prefs.put(key, relays.joinToString(",") { it.url })`) — no
JSON, no `Serializable`, no dependencies beyond `RelayUrlNormalizer`.
#### Desktop wiring
Add `indexRelays: StateFlow<Set<NormalizedRelayUrl>>` to
`DesktopRelayCategories`, backed by the new class. Simple straight-
through, no combine:
```kotlin
class DesktopRelayCategories(
// existing params
private val indexRelaysStore: PreferencesIndexRelays,
) {
// existing categories…
val indexRelays: StateFlow<Set<NormalizedRelayUrl>> =
indexRelaysStore.relays
.map { it.ifEmpty { PreferencesIndexRelays.DEFAULT_INDEX_RELAYS } }
.stateIn(scope, SharingStarted.Eagerly, indexRelaysStore.effective())
fun setIndexRelays(new: Set<NormalizedRelayUrl>) = indexRelaysStore.setRelays(new)
}
```
`Main.kt` — the constructor at
`desktopApp/.../Main.kt:847-859` swaps the hard-coded literal for the
current effective set:
```kotlin
// before:
indexRelays = DefaultRelays.RELAYS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet(),
// after:
indexRelays = indexRelaysStore.effective(),
```
`indexRelaysStore` is instantiated once at App() root (before the
coordinator) and provided into `DesktopRelayCategories`. UI reads from
`LocalRelayCategories.current.indexRelays`.
**Changes take effect on next relaunch.** Documented in the settings
section's help text. The existing coordinator has no re-target API for
`indexRelays`; teaching it one is out of scope. Rationale: index-relay
churn is expected to be rare, and users who edit the list generally
expect to restart anyway.
#### amy wiring
New helper on `cli/.../Context.kt`:
```kotlin
fun indexRelays(): Set<NormalizedRelayUrl> {
val prefs = Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")
val csv = prefs.get("urls", "")
val user = csv.split(",")
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
return user.ifEmpty {
// Same defaults as PreferencesIndexRelays.DEFAULT_INDEX_RELAYS
// Duplicated here (4 URLs) — they change ~never.
setOf(
"wss://nos.lol", "wss://nostr.wine",
"wss://relay.noswhere.com", "wss://relay.primal.net",
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
}
}
```
`WotCommand.sync` swaps:
```kotlin
// before:
val relays = ctx.outboxRelays().ifEmpty { ctx.inboxRelays() }
// after:
val relays = ctx.indexRelays()
```
The 4-URL duplication is fine per the simplicity review — the list
changes ~never; a single shared commons constant would be architectural
neatness with no material win. Adding a whole
`commons/defaults/DefaultIndexRelays.kt` for a 4-line constant fails
YAGNI on a plan we're specifically told to keep small.
#### Optional: `amy relay index …` verbs — deferred
v1 configuration lives in the Desktop settings section. If someone
running amy headless wants to seed the Preferences node, they can do
so with a five-line JVM one-liner:
```
java -cp … -e 'Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index").put("urls","wss://foo,wss://bar")'
```
CLI verbs are a follow-up ticket if demand appears.
### PR B — Item 3: Index Relays section in `RelaySettingsScreen`
Insert a new section in `RelaySettingsScreen`
(`desktopApp/.../Main.kt` line 1797 onward). Current sections in order:
1. Wallet Connect (NWC)
2. Media Server Settings
3. Image Compression Settings
4. Tor Settings
5. Namecoin Settings
6. Local Relay (conditional)
7. Content Filters (hashtag-spam)
Insert **between Local Relay and Content Filters** — both already have
a `HorizontalDivider` around them.
Section renders:
- Title: "Index Relays"
- One-line explainer: "Used to fetch profile metadata and follow lists
(Web-of-Trust). Changes take effect on next relaunch."
- `LazyColumn` of `Text(relay.url) + IconButton(Icons.Default.Close, onClick = onRemove)` — 30 LOC ballpark.
- Add-row: `OutlinedTextField + Button("Add")`. Normalises input via
`RelayUrlNormalizer.normalizeOrNull`; ignores nulls silently (or
surfaces "invalid relay URL" if trivial).
- No "Reset to defaults" button — removing all entries falls back to
defaults automatically (delete-all is the reset).
Reads:
```kotlin
val indexRelays by LocalRelayCategories.current.indexRelays.collectAsState()
val categories = LocalRelayCategories.current
// then in add/remove handlers:
categories.setIndexRelays(indexRelays + newUrl)
categories.setIndexRelays(indexRelays - existingUrl)
```
## Technical Considerations
### Recomposition + reactivity (Item 1)
Inlining the score computation at each `UserSearchCard` call still gets
per-key snapshot tracking — `service.scores[pubkey]` is a
`SnapshotStateMap` read that Compose tracks per-key. Only the row for
the changed pubkey recomposes when its score updates. Identical
behaviour to what we shipped in `WoTBadgedAvatar`; the wrapper
composable would have added a subscriber node with no gain.
### Live re-targeting of the coordinator (Item 2)
Existing `DesktopRelaySubscriptionsCoordinator` reads `indexRelays`
once at construction and holds it. Teaching it to swap
`indexRelays` mid-flight is a real refactor (in-flight subscription
state, cross-EOSE semantics). Ship "changes take effect on next
relaunch" for v1; add live re-targeting in a follow-up if users notice.
### Preferences node identity across Desktop and amy (Item 2)
Both processes use
`Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`.
Because `java.util.prefs.Preferences` is JVM-user-scoped
(per OS user, per prefs backend — plist on macOS, dconf on Linux,
registry on Windows), both processes end up looking at the same
physical store. `PreferencesHashtagSpamSettings` already relies on this
guarantee in shipped code.
### CSV vs JSON serialisation (Item 2)
CSV (`joinToString(",") { it.url }`) matches what `DesktopAccountRelays`
does for its categories. No dependency on Jackson or Serialisation at
the storage boundary. Trade-off: URLs cannot contain commas (they
can't per RFC anyway — commas are reserved). We normalise through
`RelayUrlNormalizer.normalizeOrNull` at both write time (in
`setRelays`) and read time (in `parse` / `Context.indexRelays()`), so
persisted CSV never contains an invalid URL.
### Default list ergonomics (deferred)
Verification surfaced a real bug: `commons/AmethystDefaults.kt`
already contains `DefaultIndexerRelayList` (Purple Pages, Coracle,
etc.) — a purpose-built index-relay set — but Desktop currently uses
`DefaultRelays.RELAYS` (nos.lol, nostr.wine, relay.noswhere.com,
relay.primal.net), which are general-purpose. That default mismatch is
a real behaviour improvement to be made, but it's a user-visible
behavioural change that deserves its own PR + review. **This plan
preserves byte-parity with today's default** and flags the improvement
in Out of Scope.
## System-Wide Impact
### Interaction graph
```
PR A (Item 1):
User opens Search column → types query
→ SearchResultsList renders LazyColumn of user results
→ Each result inlines: read LocalWoTService.scores, gate on ready/exempt
→ pass a WoTBadge lambda to UserSearchCard(badge=...)
→ UserSearchCard forwards to UserAvatar(badge=...)
→ UserAvatar renders Box overlay with WoTBadge chip
PR B (Items 2+3):
User opens Relays settings → Index Relays section
→ List rendered from LocalRelayCategories.indexRelays
→ User adds / removes a relay
→ categories.setIndexRelays(newSet)
→ indexRelaysStore.setRelays(newSet)
→ prefs.put("urls", csv) at
com/vitorpamplona/amethyst/relays/index
→ indexRelays StateFlow emits new value
amy wot sync (later):
→ ctx.indexRelays() reads the same prefs node
→ identical relay set — Desktop and amy compute the same score
Desktop app next launch:
→ indexRelaysStore.effective() returns user set (or defaults)
→ coordinator constructed with that set
```
### Error & failure propagation
- **Empty override set** (user removed all entries): fall back to
defaults at both `indexRelaysStore.effective()` and
`ctx.indexRelays()`. Never allow an empty batch REQ — WoT would
silently break.
- **Malformed URL entry** (e.g. old persisted CSV with a URL that no
longer normalises): filter through
`RelayUrlNormalizer.normalizeOrNull` at read time, drop nulls.
- **Preferences read failure** (`BackingStoreException`): treat as
"unset → use defaults". Log at debug, do not surface to the user.
### State lifecycle risks
- **Cross-account leak:** App-global preference, no per-account
identity in the key — by design.
- **Coordinator using stale set after user changes indexRelays:** Yes,
in v1 the coordinator keeps its constructor-time set until relaunch.
Documented in the UI. Not a data-integrity risk — just a UX quirk.
### API surface parity
- **PR A:** `UserSearchCard` badge slot — commonMain, backward-
compatible. Android call sites (if any exist post-merge) unchanged;
badge slot stays null.
- **PR B, new:** `PreferencesIndexRelays` class in `commons/jvmMain/`.
- **PR B, modified:** `DesktopRelayCategories` gains an `indexRelays`
StateFlow + `setIndexRelays(...)`. `Main.kt` coordinator
construction. `Context.kt` gains `indexRelays()`.
`WotCommand.sync` swaps its relay source. `RelaySettingsScreen`
gains an "Index Relays" section.
- **Nothing** in `amethyst/` (Android) is touched — this is Desktop +
amy only.
### Integration test scenarios
1. **Search badge shows.** Load a search result for a stranger scored
≥ 1 in the WoT map — the badge renders bottom-right of the avatar.
2. **Index Relays default state.** Fresh install → open Relays
settings → Index Relays section lists the four
`DEFAULT_INDEX_RELAYS` entries as read-only (or marked "(default)").
3. **Index Relays override persists.** Add a new relay → close the
app → relaunch → new relay still present. Remove one → close →
relaunch → still gone.
4. **amy sees the same override.** After the Desktop override above,
`amy wot sync` uses the new relay set. Verify by observing which
relays receive the kind-3 REQ (packet capture or a debug print
inside `WotCommand.sync`).
5. **Bad URL doesn't crash.** Manually plant an invalid entry in the
Preferences node → app restart → invalid entries filtered out, UI
shows only valid entries.
## Acceptance Criteria
### PR A — Functional (Item 1)
- [ ] `UserSearchCard` in commons accepts optional
`badge: @Composable (BoxScope.() -> Unit)? = null` and forwards
it to its `UserAvatar` call.
- [ ] Both call sites in
`desktopApp/.../ui/search/SearchResultsList.kt` (currently at
lines ~116 and ~126) inline the WoT-score computation and pass a
`WoTBadge` lambda when score > 0 and pubkey not in
`LocalSpamExemptKeys`.
- [ ] `NewDmDialog` call sites remain unchanged.
### PR B — Functional (Items 2+3)
- [ ] `PreferencesIndexRelays` created at
`commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt`.
Persists to `Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`
key `urls` as CSV. Exposes
`relays: StateFlow<Set<NormalizedRelayUrl>>`,
`setRelays(new)`, `effective(): Set<NormalizedRelayUrl>`, and
`DEFAULT_INDEX_RELAYS` constant that matches `DefaultRelays.RELAYS`
byte-for-byte.
- [ ] `DesktopRelayCategories.indexRelays: StateFlow<Set<NormalizedRelayUrl>>`
exposed — straight-through from `PreferencesIndexRelays.relays`,
empty falls back to defaults. `setIndexRelays(new)` delegates.
- [ ] `Main.kt:847-859` constructs
`DesktopRelaySubscriptionsCoordinator` with
`indexRelays = indexRelaysStore.effective()` instead of the
hard-coded `DefaultRelays.RELAYS.mapNotNull { … }.toSet()`.
Behaviour on fresh install identical to today.
- [ ] `cli/.../Context.kt` gains `indexRelays(): Set<NormalizedRelayUrl>`
reading the same Preferences node, falling back to the same 4
defaults inline.
- [ ] `WotCommand.sync` uses `ctx.indexRelays()` instead of
`outboxRelays()/inboxRelays()`.
- [ ] `RelaySettingsScreen` has an "Index Relays" section between
Local Relay and Content Filters, with:
- Title + one-line explainer including "Changes take effect on
next relaunch."
- List of current relays with per-row remove button.
- Add-row: URL input + Add button, normalises via
`RelayUrlNormalizer.normalizeOrNull`, silently drops nulls.
- No "Reset to defaults" button (remove-all is the reset).
### Non-functional (both PRs)
- [ ] `./gradlew spotlessApply` — no diff.
- [ ] `./gradlew :commons:compileKotlinJvm :desktopApp:compileKotlin
:cli:compileKotlin` — clean.
- [ ] `./gradlew test` — full suite passes.
- [ ] No new `Preferences` writes on any render path — only on
settings-screen mutations.
### Quality gates
- [ ] **PR A:** manual smoke — open Search, type a query, verify
badges appear on results scored ≥ 1 in the WoT map; none on
follows/self.
- [ ] **PR B:** unit test for `PreferencesIndexRelays` round-trip
(write set → new instance → same set out).
- [ ] **PR B:** unit test for `PreferencesIndexRelays.effective()`
fallback when Preferences is unset.
- [ ] **PR B:** unit test for `ctx.indexRelays()` fallback behaviour
when Preferences is unset.
- [ ] **PR B:** three new manual scenarios added to the WoT testing
sheet — search-badge visibility, Preferences override
persistence across restart, amy sync uses override (packet
capture or debug log).
## Success Metrics
- Search results have the same at-a-glance trust cue as feed cards.
- `amy wot get <target>` after `amy wot sync` returns a score identical
to the Desktop UI within ~2 s of the same relay set having been
configured.
- Users who add / remove index relays in the settings UI see their
change reflected on next relaunch, verified via a debug log line.
## Dependencies & Risks
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| Coordinator snapshot of `indexRelays` leaks stale set until relaunch | high (accepted v1) | Document as "changes on relaunch" in the UI; follow-up ticket for live re-targeting. |
| Empty override silently kills WoT | medium | Fallback-to-defaults guard at *both* `indexRelaysStore.effective()` and `ctx.indexRelays()`. Unit-tested. |
| CSV serialisation confuses a user who hand-edits the prefs file | low | Documented as internal; users are expected to use the UI. Hand-edit path stays functional as long as URLs don't contain commas (they can't per RFC). |
| Two Desktop and cli defaults drift out of sync (4 URLs duplicated in two places) | low | Comment in both files pointing to each other. If the list ever needs to change, both places must update. Realistically the list changes ~never. |
| `commons/AmethystDefaults.DefaultIndexerRelayList` continues to be the "correct" index-relay set while we're shipping the "general-purpose" defaults | ok (deferred) | Out-of-scope. Separate ticket to adopt as default. |
## Out of Scope (deferred)
- **`amy relay index add / remove / list` verbs.** Defer until there's
demand from headless workflows.
- **Live re-targeting of index-relay subscriptions** without app
relaunch. Separate coordinator refactor.
- **NIP-51 kind 30002 based index-relay list** for cross-Nostr-client
portability.
- **DM-recipient-picker badges** in `NewDmDialog`. Ship only if manual
testing complains.
- **Adopting `commons/AmethystDefaults.DefaultIndexerRelayList` as the
Desktop / amy default.** Real improvement, but a user-visible
behavioural change. Standalone ticket + review.
- **Per-account index-relay overrides.** YAGNI now — single-user
preference dominates. Add later if demand shows up.
## Sources & References
### Origin
- **WoT PR plan:** `docs/plans/2026-07-01-feat-desktop-wot-score-plan.md`
- **Manual testing sheet:**
`desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md`
### Internal references
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt:51-108`
— target for badge slot (Item 1).
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt:82`
— badge slot already exists here (from the WoT PR).
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt:116,126`
— the two person-result call sites to migrate.
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:847-859`
— where the hard-coded `indexRelays` is passed to the coordinator.
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt:40-47`
— `DefaultRelays.RELAYS` (byte-parity target for
`DEFAULT_INDEX_RELAYS`).
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt:80-89`
— `searchRelays` pattern (reference; index relays uses a *simpler*
shape).
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt`
— per-class Preferences node pattern **we're deliberately not
reusing** (would not be visible to amy).
- `commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettings.kt`
— pattern for shared Preferences node used across Desktop + amy;
`PreferencesIndexRelays` mirrors this shape.
- `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt:329-362`
— where `outboxRelays()` / `inboxRelays()` live.
- `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt`
— swap `sync` to `ctx.indexRelays()`.
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/AmethystDefaults.kt:62-63`
— `DefaultIndexerRelayList` (Purple Pages, Coracle …) — flagged as
future default adoption, **not touched** in this plan.
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt`
— `indexRelays` constructor param (already exists, no change needed).
### Skill references
- `relay-client` — DesktopRelayCategories composition + StateFlow
patterns.
- `compose-expert` — CompositionLocal readers + badge slot forwarding.
- `amy-expert` — Context helper pattern (`outboxRelays()` etc.), CLI
verb shape, shared JVM `Preferences` node semantics.
- `kotlin-flow-state-event-modeling` — StateFlow<Set<…>> straight-
through vs combine semantics.
### Related work
- WoT PR branch: `feat/desktop-wot-score` (closed for manual testing).
- Hashtag-spam PR: https://github.com/vitorpamplona/amethyst/pull/3431
(merged) — the `java.util.prefs` shared-node pattern
`PreferencesIndexRelays` mirrors.
- Feature backlog:
`desktopApp/plans/_desktop-feature-backlog.md` item #2 (parent WoT
feature).