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
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
- 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.
- App-global (not per-account) index-relay override. First draft
made this per-account to match
searchRelays/dmRelays. ButsearchRelays/dmRelaysare 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. PreferencesIndexRelaysincommons/jvmMain/, not extendingDesktopAccountRelays. Verification foundDesktopAccountRelaysusesPreferences.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 explicitPreferences.userRoot().node("com/vitorpamplona/amethyst/relays/index"), which is exactly the patternPreferencesHashtagSpamSettingsuses. New small class mirrors that shape.- 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. - Drop
DefaultIndexRelays.ktin 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. DesktopRelayCategories.indexRelaysusesoverride ?: defaultonly. Not the full combine used bysearchRelays(which intersects with NIP-65 discovery). Index relays are a curated user choice, not a "what's actually reachable right now" derived set. Nodebounce/stateIncombine needed — a straight-through StateFlow from the Preferences read is enough.- 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. - Drop integration scenarios 2 and 6. #2 (badge respects
exemptions) is covered by existing
WoTBadgedAvatartests — same code path. #6 (empty override falls back) is a single unit test onPreferencesIndexRelays, not a manual scenario. RelaySettingsScreencurrent 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).- Adopt-not-in-this-PR discovery:
commons/AmethystDefaults.ktalready hasDefaultIndexerRelayList(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):
- 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. - Unify amy
wot syncwith Desktop on the same relay set. Desktop currently uses a hard-codedDefaultRelays.RELAYSlist as itsindexRelays; amy uses whatever the user's NIP-65 outbox/inbox lists contain. When the two disagree,amy wot getafteramy wot syncreturns a different score than the Desktop UI would compute. - 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 updatesamy wot syncto 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 syncusesctx.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-codedindexRelaysset (nos.lol, nostr.wine, relay.noswhere.com, relay.primal.net today). Result:amy wot getafter a freshamy wot synccan 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:
- Wallet Connect (NWC)
- Media Server Settings
- Image Compression Settings
- Tor Settings
- Namecoin Settings
- Local Relay (conditional)
- 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."
LazyColumnofText(relay.url) + IconButton(Icons.Default.Close, onClick = onRemove)— 30 LOC ballpark.- Add-row:
OutlinedTextField + Button("Add"). Normalises input viaRelayUrlNormalizer.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()andctx.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.normalizeOrNullat 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:
UserSearchCardbadge slot — commonMain, backward- compatible. Android call sites (if any exist post-merge) unchanged; badge slot stays null. - PR B, new:
PreferencesIndexRelaysclass incommons/jvmMain/. - PR B, modified:
DesktopRelayCategoriesgains anindexRelaysStateFlow +setIndexRelays(...).Main.ktcoordinator construction.Context.ktgainsindexRelays().WotCommand.syncswaps its relay source.RelaySettingsScreengains an "Index Relays" section. - Nothing in
amethyst/(Android) is touched — this is Desktop + amy only.
Integration test scenarios
- Search badge shows. Load a search result for a stranger scored ≥ 1 in the WoT map — the badge renders bottom-right of the avatar.
- Index Relays default state. Fresh install → open Relays
settings → Index Relays section lists the four
DEFAULT_INDEX_RELAYSentries as read-only (or marked "(default)"). - Index Relays override persists. Add a new relay → close the app → relaunch → new relay still present. Remove one → close → relaunch → still gone.
- amy sees the same override. After the Desktop override above,
amy wot syncuses the new relay set. Verify by observing which relays receive the kind-3 REQ (packet capture or a debug print insideWotCommand.sync). - 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)
UserSearchCardin commons accepts optionalbadge: @Composable (BoxScope.() -> Unit)? = nulland forwards it to itsUserAvatarcall.- Both call sites in
desktopApp/.../ui/search/SearchResultsList.kt(currently at lines ~116 and ~126) inline the WoT-score computation and pass aWoTBadgelambda when score > 0 and pubkey not inLocalSpamExemptKeys. NewDmDialogcall sites remain unchanged.
PR B — Functional (Items 2+3)
PreferencesIndexRelayscreated atcommons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt. Persists toPreferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")keyurlsas CSV. Exposesrelays: StateFlow<Set<NormalizedRelayUrl>>,setRelays(new),effective(): Set<NormalizedRelayUrl>, andDEFAULT_INDEX_RELAYSconstant that matchesDefaultRelays.RELAYSbyte-for-byte.DesktopRelayCategories.indexRelays: StateFlow<Set<NormalizedRelayUrl>>exposed — straight-through fromPreferencesIndexRelays.relays, empty falls back to defaults.setIndexRelays(new)delegates.Main.kt:847-859constructsDesktopRelaySubscriptionsCoordinatorwithindexRelays = indexRelaysStore.effective()instead of the hard-codedDefaultRelays.RELAYS.mapNotNull { … }.toSet(). Behaviour on fresh install identical to today.cli/.../Context.ktgainsindexRelays(): Set<NormalizedRelayUrl>reading the same Preferences node, falling back to the same 4 defaults inline.WotCommand.syncusesctx.indexRelays()instead ofoutboxRelays()/inboxRelays().RelaySettingsScreenhas 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 viaRelayUrlNormalizer.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
Preferenceswrites 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
PreferencesIndexRelaysround-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>afteramy wot syncreturns 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 / listverbs. 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.DefaultIndexerRelayListas 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-codedindexRelaysis passed to the coordinator.desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt:40-47—DefaultRelays.RELAYS(byte-parity target forDEFAULT_INDEX_RELAYS).desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt:80-89—searchRelayspattern (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;PreferencesIndexRelaysmirrors this shape.cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt:329-362— whereoutboxRelays()/inboxRelays()live.cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt— swapsynctoctx.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—indexRelaysconstructor 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 JVMPreferencesnode 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.prefsshared-node patternPreferencesIndexRelaysmirrors. - Feature backlog:
desktopApp/plans/_desktop-feature-backlog.mditem #2 (parent WoT feature).