Files
amethyst/docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md
nrobi144 fe22de0817 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
2026-07-06 09:31:02 +03:00

702 lines
30 KiB
Markdown

---
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).