Files
amethyst/docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md
T
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

30 KiB

title, type, status, date, origin, deepened
title type status date origin deepened
WoT follow-ups — search-result badges + shared index relays feat active 2026-07-01 docs/plans/2026-07-01-feat-desktop-wot-score-plan.md 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):

@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:

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

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:

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:

// 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:

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:

// 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:

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