Merge pull request #3483 from nrobi144/feat/wot-shared-index-relays

feat(desktop): Web-of-Trust score badges + shared index relays + amy wot verbs
This commit is contained in:
Vitor Pamplona
2026-07-08 12:53:08 -04:00
committed by GitHub
35 changed files with 5958 additions and 77 deletions
@@ -448,6 +448,23 @@ class Context(
/** Union of all three buckets. */
suspend fun anyRelays(): Set<NormalizedRelayUrl> = outboxRelays() + inboxRelays() + keyPackageRelays()
/**
* Index relays — the shared, app-global set used to fetch profile
* metadata (kind 0) and follow lists (kind 3). Mirrors the Desktop
* app's `LocalRelayCategories.indexRelays` by reading from the same
* `java.util.prefs` node
* (`com/vitorpamplona/amethyst/relays/index`). Falls back to the
* shipping defaults when the user hasn't configured anything.
*
* This is what `amy wot sync` uses; `outboxRelays()` /
* `inboxRelays()` remain for callers that want relay lists derived
* from NIP-65 identity semantics.
*/
fun indexRelays(): Set<NormalizedRelayUrl> =
com.vitorpamplona.amethyst.commons.relays.index
.PreferencesIndexRelays()
.effective()
/**
* Seed relays for "look up someone we know nothing about" queries —
* fetching another user's kind:10002 / 10050 / 10051 / 30443 before we
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand
import com.vitorpamplona.amethyst.cli.commands.SyncCommand
import com.vitorpamplona.amethyst.cli.commands.UseCommand
import com.vitorpamplona.amethyst.cli.commands.VerifyCommand
import com.vitorpamplona.amethyst.cli.commands.WotCommand
import com.vitorpamplona.amethyst.cli.commands.ZapCommand
import com.vitorpamplona.amethyst.cli.commands.cashu.CashuCommands
import com.vitorpamplona.amethyst.cli.commands.cashu.CashuMintCommands
@@ -287,6 +288,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"podcast" -> PodcastCommands.dispatch(dataDir, tail)
"podcast20" -> Podcast20Commands.dispatch(dataDir, tail)
"bunker" -> BunkerCommand.run(dataDir, tail)
"wot" -> WotCommand.dispatch(dataDir, tail)
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
@@ -0,0 +1,225 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.wot.OutboxCacheGateway
import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher
import com.vitorpamplona.amethyst.commons.wot.WoTService
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import java.util.Collections
/**
* `amy wot <get|list|sync>` — Web-of-Trust score queries.
*
* The score for a pubkey X is the count of accounts in the active user's
* kind-3 follow set who also follow X. `get` and `list` are read-only —
* they hydrate the score map from whatever kind-3 events already live in
* the local event store. `sync` pulls fresh kind-3 events from the
* configured relay pool so the next `get` / `list` is up to date.
*/
object WotCommand {
suspend fun dispatch(
dataDir: DataDir,
rest: Array<String>,
): Int {
val head = rest.firstOrNull() ?: return usage()
val tail = rest.drop(1).toTypedArray()
return when (head) {
"get" -> get(dataDir, tail)
"list" -> list(dataDir, tail)
"sync" -> sync(dataDir, tail)
else -> usage()
}
}
private fun usage(): Int = Output.error("bad_args", "wot <get|list|sync>")
private suspend fun get(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "wot get <pubkey|npub>")
val userArg = rest[0]
Context.open(dataDir).use { ctx ->
ctx.prepare()
val target = ctx.requireUserHex(userArg)
val (svc, scope) = buildHydratedService(ctx)
try {
val score = svc.scoresSnapshot()[target] ?: 0
Output.emit(mapOf("pubkey" to target, "score" to score))
return 0
} finally {
scope.cancel()
}
}
}
private suspend fun list(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val threshold = args.flag("threshold")?.toIntOrNull() ?: 1
val limit = args.flag("limit")?.toIntOrNull() ?: 50
Context.open(dataDir).use { ctx ->
ctx.prepare()
val (svc, scope) = buildHydratedService(ctx)
try {
val entries =
svc
.scoresSnapshot()
.entries
.asSequence()
.filter { it.value >= threshold }
.sortedByDescending { it.value }
.take(limit)
.map { mapOf("pubkey" to it.key, "score" to it.value) }
.toList()
Output.emit(mapOf("count" to entries.size, "entries" to entries))
return 0
} finally {
scope.cancel()
}
}
}
private suspend fun sync(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
// Overall timeout; per-relay budget is set by OutboxDispatcher's
// default (4s). `--timeout N` overrides the overall cap.
val overallTimeoutMs = args.flag("timeout")?.toLongOrNull()?.times(1000) ?: 8_000L
Context.open(dataDir).use { ctx ->
ctx.prepare()
val self = ctx.identity.pubKeyHex
val myKind3 = ctx.contactsOf(self)
val follows =
myKind3?.verifiedFollowKeySet()?.toSet()
?: return Output.error("no_follows", "no kind-3 in local store; run `amy follow` first")
if (follows.isEmpty()) {
Output.emit(mapOf("synced" to 0, "detail" to "empty follow set"))
return 0
}
val relays = ctx.indexRelays()
if (relays.isEmpty()) return Output.error("no_relays", "no index relays configured")
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
try {
// Buffer discovered events; persist synchronously after
// the fetch. `store.insert` is suspending so we can't call
// it from the non-suspending gateway callbacks. This also
// keeps `insert` errors surfaceable in a single log line
// rather than swallowed into a race.
val buffered = Collections.synchronizedList(mutableListOf<Event>())
val gateway =
object : OutboxCacheGateway {
override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? =
// Amy's store lookup is suspending; can't do
// it here. The dispatcher then falls through
// to Phase 1 discovery for every author, which
// matches the old `amy wot sync` behaviour of
// always re-asking. A future optimisation
// could pre-populate a `Map<HexKey,
// AdvertisedRelayListEvent>` before dispatch.
null
override fun onOutboxDiscovered(
event: AdvertisedRelayListEvent,
relay: NormalizedRelayUrl,
) {
buffered.add(event)
}
override fun onDiscoveredEvent(
event: Event,
relay: NormalizedRelayUrl,
) {
buffered.add(event)
}
}
val dispatcher =
OutboxDispatcher(
client = ctx.client,
scope = scope,
indexRelays = { relays },
gateway = gateway,
overallTimeoutMs = overallTimeoutMs,
)
val result = dispatcher.fetchKind3Only(follows)
// Persist to store so future `get` / `list` see them.
val eventsToPersist = synchronized(buffered) { buffered.toList() }
eventsToPersist.forEach { runCatching { ctx.store.insert(it) } }
Output.emit(
mapOf(
"followers" to follows.size,
"authors_requested" to result.authorsRequested,
"kind10002_received" to result.kind10002Received,
"kind3_received" to result.kind3Received,
"outbox_covered_authors" to result.outboxCoveredAuthors,
"fallback_authors" to result.fallbackAuthors,
"persisted" to eventsToPersist.size,
),
)
return 0
} finally {
scope.cancel()
}
}
}
/**
* Build a [WoTService], populate it from the local event store, then
* return the (service, backing scope). Caller must cancel the scope
* when done.
*/
private suspend fun buildHydratedService(ctx: Context): Pair<WoTService, CoroutineScope> {
val self = ctx.identity.pubKeyHex
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val svc = WoTService(scope, writerDispatcher = Dispatchers.Unconfined)
val myKind3 = ctx.contactsOf(self)
val follows: Set<HexKey> = myKind3?.verifiedFollowKeySet() ?: emptySet()
svc.onFollowSetChange(follows, self)
// Pull each follower's kind-3 from the store and feed into the service.
follows.forEach { follower ->
val followerKind3 = ctx.contactsOf(follower) ?: return@forEach
svc.applyKind3(follower, followerKind3.verifiedFollowKeySet())
}
svc.markReadyOnce()
return svc to scope
}
}
@@ -0,0 +1,695 @@
---
title: WoT fetch via outbox model + PR #3483 review fixes
type: fix
status: completed
date: 2026-07-06
origin: PR https://github.com/vitorpamplona/amethyst/pull/3483 review comments (Vitor Pamplona, davotoula)
---
# WoT fetch via outbox model + PR #3483 review fixes
## Overview
PR #3483 (branch `feat/wot-shared-index-relays`) adds Web-of-Trust badges + shared
Index Relays + `amy wot` verbs. Two reviewers flagged issues:
- **Vitor** (owner): stop broadcasting kind-0/kind-3 REQs to a static index-relay
list. Use the outbox model: index relays discover each author's kind-10002,
then kind-0/kind-3 REQs go to each author's declared write relays.
- **davotoula**: six correctness / perf / lifecycle bugs across
`DesktopLocalCache`, `WoTService`, and `FeedMetadataCoordinator` — some
Desktop-scoped, most in `commons/commonMain` so Android inherits them the
moment WoT gets wired there.
This plan lands **both** in a single PR revision:
- The outbox-model refactor for kind-0 / kind-3 fetching (Vitor's ask).
- All six correctness/perf/lifecycle fixes (davotoula's ask).
- A sweep confirming no production default references the dying
`relay.damus.io`.
The scope is intentionally larger than a normal review-fix cycle because the
outbox refactor changes the same seams the bug-fixes touch — separating them
would produce a churny diff.
## Problem Statement
### 1. Index-relay broadcast is architecturally wrong for kind-0 / kind-3
Current flow (this branch):
```
Login (~350 follows) ─▶ Main.kt:1315
└── FeedMetadataCoordinator.loadKind3Batched(follows)
└── REQ kinds=[3] authors=[follows chunked/100]
to *every* index relay in
PreferencesIndexRelays.effective()
```
Semantics:
- Every kind-3 event is fetched from index relays whether or not the author
publishes there.
- Users who *only* publish to their own outbox (increasingly common on modern
Nostr) return no kind-3 — WoT signal is wrong (undercount).
- Index-relay operators absorb the entire follow set's worth of REQ authors,
even when other relays hold the data.
- The same anti-pattern exists for kind-0 profile metadata (via
`loadMetadataBatched`) and inside `amy wot sync` (which reimplements the same
broadcast in `WotCommand.sync`).
Vitor's directive (quoting review):
> Kind 0 and 3 must be downloaded from the outbox relay (10002, write) of each
> user. Basically, find all 10002 events via index relays (purple pages, etc),
> then parse them all to find a list of relays per author, invert the map to
> get a list of authors per relay, then use that list to download posts, kind
> 0 and contact lists from each author.
### 2. Six correctness / perf / lifecycle bugs
| # | File:line | Symptom | Severity |
|---|-----------|---------|----------|
| 1 | `DesktopLocalCache.kt:509-530` | `accountPubkey` race → self kind-3 stamped `lastContactListByAuthor` before self-check; later relay retry rejected by `createdAt <= prev`; empty follow view; `FollowAction.follow` calls `createFromScratch(...)` and **wipes real follow list** | **P0 (data loss)** |
| 2 | `commons/wot/WoTService.kt:162-190` | `handleFollowSet` sets `myFollows` before the `MAX_FOLLOWS` guard, guard doesn't return `myFollows` to empty, and `Main.kt:1561` still calls `loadKind3Batched` when over the cap — CPU/memory blow-up the PR description promised was skipped | P1 (perf regression on mega-follow accounts) |
| 3 | `commons/wot/WoTService.kt` | No `close()/dispose()` → writer coroutine + `Channel` leak on account switch; leaks compound over long sessions | P1 (leak) |
| 4 | `commons/wot/WoTService.kt:37-49, 149-160` | Doc claims "per-key subscriber isolation via `Snapshot.withMutableSnapshot`" — that's a mis-attribution. Per-key isolation is a `SnapshotStateMap` property, not a `withMutableSnapshot` property; the `withMutableSnapshot` on *every* op just batches writes. Any consumer that reads the map iteratively (size, keys) *will* invalidate on every mutation, which the comment claims won't happen. Future Android integrator will trust the comment. | P2 (misleading docs → landmine) |
| 5 | `commons/relayClient/assemblers/FeedMetadataCoordinator.kt:320-368` | `queuedKind3Pubkeys` marks pubkeys sent, never reset on failure. If every index relay times out (mobile flake / cold-start), WoT stays empty for the entire session; `loadKind3Batched` will short-circuit thereafter. | P1 (silent WoT-empty session) |
| 6 | `commons/relayClient/assemblers/FeedMetadataCoordinator.kt:274, 338` | `eoseReceived: MutableSet` written from per-relay `Dispatchers.IO` `onEose` callbacks with no sync → race can drop an EOSE, blocking on the full 5 s timeout instead of firing early. Low ceiling but pre-existing pattern that this PR duplicates. | P2 (perf / responsiveness) |
Additional owner-flagged item:
- `relay.damus.io` shutting down end of month. Confirmed: no production
default on this branch references it. Only commonTest fixtures do — leave
those alone (they're wire-format fixtures, not runtime relay lists).
## Proposed Solution
### Outbox refactor: two-phase discovery
Replace the single "broadcast a kind-3 REQ to all index relays" flow with a
two-phase pipeline that reuses existing Quartz infrastructure. The pipeline
lives in `commons/commonMain` so **Desktop, Android (future), and `amy`** all
share it.
```
┌────────────────────────────────────────────────────┐
│ Phase 1 — kind-10002 discovery (index-relay REQ) │
│ inputs: pubkeys[], indexRelays[] │
│ emits: Map<HexKey, Set<NormalizedRelayUrl>> │
│ (author → declared write relays) │
│ │
│ • REQ kinds=[10002] authors=chunked-by-100 │
│ to every index relay. │
│ • Feed matching AdvertisedRelayListEvent into │
│ LocalCache (so future lookups skip the REQ). │
│ • Per-relay timeout (default 4s), NOT one global.│
└────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ Phase 2a — RelayListRecommendationProcessor │
│ inputs: authorMap from Phase 1 │
│ emits: Set<RelayRecommendation> │
│ (relay → author set, minimal cover) │
│ │
│ Reuses Quartz's existing algorithm which: │
│ • builds relay → author set (transpose) │
│ • greedily picks most-popular relay, removes │
│ covered authors, repeats │
│ • second pass to ensure ≥2-relay coverage per │
│ author │
│ • filters onion/localhost per config │
└────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ Phase 2b — per-relay kind 0 + kind 3 REQ │
│ For each RelayRecommendation: │
│ REQ kinds=[0,3] authors=[recommendation.users] │
│ with per-relay timeout, single subscription. │
│ Events flow into LocalCache via existing │
│ consume path. │
└────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ Phase 3 — Fallback for authors without 10002 │
│ Authors in the input set that never returned a │
│ 10002 fall back to the current index-relay flow │
│ (REQ kinds=[0,3] authors=[fallbackSet] on index │
│ relays). Bounded; only fires when non-empty. │
└────────────────────────────────────────────────────┘
│
▼
onEose() → WoTService.markReadyOnce()
```
Global 2 s startup fallback in `Main.kt` stays as the outermost safety net.
### Bug fixes (correctness first, always)
**Fix 1 — `DesktopLocalCache` accountPubkey race.** Make `accountPubkey`
either a constructor parameter or a required init that must resolve *before*
hydration starts. Reorder `Main.kt` so `localCache.accountPubkey =
account.pubKeyHex` runs before `localRelayStore.hydrate(localCache)`. Belt +
braces: inside `consumeContactList`, do not stamp `lastContactListByAuthor`
for events where `event.pubKey == accountPubkey` unless the self path
actually accepted the event. This eliminates the "poisoned stamp" for the
future relay retry even if a caller ever forgets to bind pubkey first.
**Fix 2 — MAX_FOLLOWS guard bypass.** Two-part fix:
- In `WoTService.handleFollowSet`, when the follow set exceeds `MAX_FOLLOWS`,
set `myFollows = emptySet()` *and* flip a `disabled: Boolean` flag. Both
`handleKind3` and every future op must early-return on `disabled`.
- In the outbox driver's entrypoint (formerly `Main.kt:1561`), consult
`WoTService.isDisabled` (new StateFlow) or `follows.size <=
WoTService.MAX_FOLLOWS` before dispatching Phase 1. When over the cap:
skip Phase 1 + 2 entirely and call `markReadyOnce()` immediately.
**Fix 3 — `WoTService.close()`.** Add:
```kotlin
private val supervisor = SupervisorJob(scope.coroutineContext[Job])
private val serviceScope = CoroutineScope(scope.coroutineContext + supervisor + writerDispatcher)
fun close() {
ops.close()
supervisor.cancel()
}
```
Call from account-switch (Main.kt clear path) and from `DesktopIAccount`
disposal. Add an internal `AutoCloseable` implement so callers can lean on
`use { }`.
**Fix 4 — Correct the misleading comments.** Rewrite `WoTService` KDoc to say:
> Scores are exposed via a Compose-observable `SnapshotStateMap`. Consumers
> that read a *specific key* (`scores[pubkey]`) recompose only when that key
> changes — this is `SnapshotStateMap`'s per-key observation. Consumers that
> iterate the map or read its size will recompose on any mutation.
>
> Ops are serialized through a single-writer `Channel`. Coalescing writes
> inside `Snapshot.withMutableSnapshot { }` batches state commits so a
> multi-key op emits a single Compose invalidation instead of one per key.
No behaviour change; the comment is the fix.
**Fix 5 — `queuedKind3Pubkeys` retryable.** Convert the current mark-on-send
set into mark-on-EOSE:
- Track `inFlight: MutableSet<HexKey>` for de-duplication during a single call.
- On successful EOSE (or per-relay EOSE), move pubkeys into `succeeded`
(unchanged behaviour: skip future REQs).
- On global timeout with zero events for a pubkey, **do not** promote to
`succeeded`; keep them retryable on the next `loadKind3Batched` /
`loadKind3ViaOutbox` call.
- Cheap: same `Set` mechanics, just gated by outcome instead of intent.
**Fix 6 — Synchronise `eoseReceived`.** Two options; pick (b):
- (a) Wrap in `Mutex` / `synchronized` — `synchronized` needs a JVM-only
path or an `expect/actual`.
- (b) **Use a single-writer coroutine**: replace the `MutableSet` +
`CompletableDeferred<Unit>` handshake with a `Channel<NormalizedRelayUrl>(
capacity = Channel.UNLIMITED)` + a launched consumer that increments a
local counter and completes the deferred when it hits `indexRelays.size`.
Same shape, zero shared mutable state across dispatchers. KMP-clean.
Apply the same fix to both `loadKind3Batched` and `loadMetadataBatched`
because the pattern is duplicated.
### Damus sweep
Grep of the current branch found `relay.damus.io` only in test fixtures
(`FeedDefinitionSerializerTest`, `TorRelayEvaluationTest`, `RichTextParserTest`,
`ZapSplitResolverTest`). None are production defaults. `DEFAULT_INDEX_RELAYS`
= `{nos.lol, nostr.wine, noswhere, primal.net}`;
`AmethystDefaults.DefaultIndexerRelayList` = `{purplepages, coracle, userkinds,
yabu, nostr1}`. Leave the test fixtures alone (they exercise URL parsing on
canonical example URLs — replacing them adds churn without protecting users).
Include a one-line status note in the PR description so Vitor sees "checked".
## Technical Approach
### Architecture — where each piece lives
Following the codebase-specific rule (`commons/ARCHITECTURE.md`): "protocol
in Quartz, business logic in commons, layouts in platform apps."
```
quartz/ (unchanged — reuse only)
nip65RelayList/AdvertisedRelayListEvent.kt — parser (existing)
nip65RelayList/RelayListRecommendationProcessor — transpose + cover (existing)
commons/commonMain/
wot/WoTService.kt — bug fixes 2/3/4
wot/OutboxDispatcher.kt — NEW (Phase 1-3 driver)
wot/OutboxRelayLoader.kt — MOVED from amethyst/,
Flow<Map<relay, authors>>
relayClient/assemblers/FeedMetadataCoordinator.kt — bug fixes 5/6 + calls
into OutboxDispatcher when
configured
desktopApp/jvmMain/
Main.kt — reorder localCache init,
call OutboxDispatcher
cache/DesktopLocalCache.kt — bug fix 1
— new consumeAdvertisedRelayList
path
cli/
commands/WotCommand.kt — amy wot sync via
OutboxDispatcher
```
### OutboxDispatcher API (draft)
```kotlin
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt
class OutboxDispatcher(
private val client: INostrClient,
private val scope: CoroutineScope,
private val indexRelays: () -> Set<NormalizedRelayUrl>, // lazy — respects settings updates
private val cache: OutboxCacheGateway, // interface, actual = DesktopLocalCache
private val perRelayTimeoutMs: Long = 4_000,
) {
data class Result(
val kind10002Received: Int,
val kind3Received: Int,
val kind0Received: Int,
val fallbackAuthors: Int,
)
/**
* Fetch kind-3 and kind-0 for [authors] via each author's declared write
* relays (NIP-65). Falls back to [indexRelays] for authors with no 10002.
*
* Suspending — returns after every phase EOSEs or times out. Callers
* that need "return immediately, mark ready later" should wrap in
* [scope.launch].
*/
suspend fun fetchKind0And3(authors: Set<HexKey>): Result
suspend fun fetchKind3Only(authors: Set<HexKey>): Result // WoT-specific
}
interface OutboxCacheGateway {
/** Returns the cached kind-10002 for [pubkey] if the local store already has one. */
fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent?
/** Called for every 10002 that comes back — cache should stash it. */
fun onOutboxDiscovered(event: AdvertisedRelayListEvent, relay: NormalizedRelayUrl)
/** Called for every kind-3 / kind-0 that comes back — cache should route through its consume path. */
fun onDiscoveredEvent(event: Event, relay: NormalizedRelayUrl)
}
```
`DesktopLocalCache` implements `OutboxCacheGateway`; `amy` gets a minimal
implementation that writes into its local store.
`OutboxRelayLoader` (moved from `amethyst/`) provides the *live* Flow-form for
reactive lookups; `OutboxDispatcher` uses it internally for the "check cache
first, only REQ what's missing" fast-path.
### Reactivity for "new follow arriving"
Current code (Main.kt:1559) collects `localCache.followedUsers` and calls
`loadKind3Batched(follows)` on every change. The dedup set means the diff
(only new pubkeys) actually flows through.
Under the outbox model, the analogous flow is:
```
localCache.followedUsers.collect { follows ->
wotService.onFollowSetChange(follows, account.pubKeyHex)
if (wotService.isDisabled) { wotService.markReadyOnce(); return@collect }
launch {
val result = outboxDispatcher.fetchKind3Only(follows) // dedup inside
wotService.markReadyOnce()
}
}
```
`fetchKind3Only` internally consults `inFlight` + `succeeded` and only REQs
the diff. Test scenario "user follows one new person mid-session" trivially
covered because Phase 1 for a single-element authors set is a single index-
relay REQ, and Phase 2 is one per-outbox REQ.
### `amy wot sync` under outbox
Replace the manual `Filter/chunked/ctx.drain(...)` block in
`WotCommand.sync` with:
```kotlin
val dispatcher = OutboxDispatcher(client, scope, ctx::indexRelays, AmyCacheGateway(store))
val result = dispatcher.fetchKind3Only(follows.toSet())
Output.emit("wot sync",
"10002=${result.kind10002Received} kind3=${result.kind3Received} " +
"fallback=${result.fallbackAuthors}")
```
The JSON schema for `--json` gains three new keys (`kind10002_received`,
`fallback_authors`, `kind3_received`) — additive, no rename.
### Concurrency & KMP concerns
- All new code targets `commonMain`. No `java.util.concurrent`, no
`synchronized {}` (needs jvmAndroid actual). Rely on `Channel`, `Mutex`,
`StateFlow`, and `Snapshot` — all KMP-safe.
- `Dispatchers.IO` isn't KMP either; use `Dispatchers.Default` in commonMain
and let platform code override if needed.
- Per-relay timeouts implemented via `withTimeoutOrNull(perRelayTimeoutMs)`
inside per-relay coroutines; overall EOSE gate uses a
`CompletableDeferred<Unit>` that trips when either (a) all per-relay jobs
complete or (b) the outer `withTimeoutOrNull(overallCap)` fires.
### Data flow: how discovered 10002s stop double-fetching
Every `AdvertisedRelayListEvent` received during Phase 1 goes through
`OutboxCacheGateway.onOutboxDiscovered(event, relay)` → the platform cache's
`consume` path. Next call for the same author checks `cachedOutbox(pubkey)`
before dispatching Phase 1, so we never REQ the same 10002 twice within a
session (or across sessions, if the local relay store persists the 10002 —
which it does, since kind-10002 events are indexed like any other event).
### Implementation Phases
#### Phase 1 — Bug fixes (correctness first, self-contained)
Ship-blockers, no outbox dependency, land these commits first so a revert
doesn't force rolling back the outbox refactor:
1. `fix(desktop-cache): eliminate accountPubkey race in
consumeContactList` — reorder Main.kt so pubkey binds before hydrate;
gate `lastContactListByAuthor` stamp inside self branch. Test:
`DesktopLocalCacheHydrationTest` — reproduce the wipe by running
hydration before pubkey bind, assert follow set survives relay retry.
2. `fix(wot): clear myFollows + set disabled flag when MAX_FOLLOWS exceeded`
— plus a Main.kt short-circuit before dispatching Phase 1. Test:
`WoTServiceTest.overCapDisablesEverything`.
3. `refactor(wot): close()/dispose() + AutoCloseable, call from
account-switch` — Test: `WoTServiceLifecycleTest.closeCancelsWriter`.
4. `docs(wot): correct SnapshotStateMap isolation comments` — comment-only.
5. `fix(coordinator): mark queuedKind3Pubkeys only on EOSE, allow retry on
timeout` — Test: `FeedMetadataCoordinatorTest.timeoutRetryIsAllowed`.
6. `fix(coordinator): single-writer EOSE aggregator (KMP-safe)` — Test:
`FeedMetadataCoordinatorTest.eoseReadyUnderConcurrentCallbacks`
using a fake client that fires EOSE from multiple dispatchers.
**Success criteria phase 1:** all six tests pass; `./gradlew :commons:jvmTest
:desktopApp:jvmTest :cli:test` green; `./gradlew spotlessApply` clean.
#### Phase 2 — Outbox scaffolding (commons)
7. `refactor(commons): move OutboxRelayLoader from amethyst/ to
commons/commonMain` — pure code motion; leave a re-export in the amethyst
package to avoid Android build breaks. Test: existing Android
`OutboxRelayLoaderTest` (if any) still passes.
8. `feat(commons): OutboxDispatcher two-phase kind-0/kind-3 fetcher` —
commonMain, plus jvmMain test that drives a fake `INostrClient` through
Phase 1/2/3 including the fallback path.
9. `feat(commons): OutboxCacheGateway interface + DesktopLocalCache impl` —
including a new `consumeAdvertisedRelayList(event, relay)` in
`DesktopLocalCache` that mirrors the existing `consumeContactList` pattern.
**Success criteria phase 2:** `./gradlew :commons:jvmTest` green;
`OutboxDispatcherTest` covers "author with 10002", "author without 10002 →
fallback", "index relay times out on Phase 1", and "per-relay timeout on
Phase 2 doesn't cancel other relays".
#### Phase 3 — Cutover (Main.kt + amy)
10. `feat(desktop): route WoT kind-3 fetch through OutboxDispatcher` —
Main.kt uses OutboxDispatcher; delete the direct `loadKind3Batched`
call. Preserve the 2 s startup fallback for `markReadyOnce`.
11. `feat(desktop): also route stranger-avatar kind-0 through
OutboxDispatcher` — MetadataPreloader gets a hook that prefers outbox
when a 10002 exists for the author.
12. `feat(cli): amy wot sync via OutboxDispatcher` — rewrite the manual
filter/drain in `WotCommand.sync`. Update its `--json` schema (additive).
**Success criteria phase 3:** manual testing sheet (Section: Test Plan)
passes end-to-end. `./gradlew test` green.
#### Phase 4 — Documentation & PR description
13. Update PR description's "Behaviour" section to reflect the outbox flow.
14. Add a top-level "Damus relay: production defaults verified clean" line
so Vitor doesn't have to look.
## Alternative Approaches Considered
**A. Do the outbox refactor in a follow-up PR.** Rejected by user: the same
files (WoTService, FeedMetadataCoordinator, Main.kt) also need the review
fixes, so a two-PR split would double the churn in the same seams.
**B. Skip Phase 1 (10002 discovery) and read the local cache only.** Would
break for cold-start accounts with no cached 10002s. Only works for
"warm-cache" sessions, defeating Vitor's ask on first login.
**C. Adopt `AmethystDefaults.DefaultIndexerRelayList` as the new index-relay
default.** The current branch keeps `{nos.lol, nostr.wine, noswhere,
primal.net}` for continuity. Adopting the Purple Pages / Coracle / etc. set
is a user-visible behaviour change deserving its own review. Deferred to a
follow-up ticket. Documented in `PreferencesIndexRelays.kt:85-92` already;
no action here.
**D. Use `graperank` as Vitor idly mused in the follow-up comment.** Not
actionable in this PR — it's a musing about extending `amy`, not a review
change. Called out here so the item doesn't get lost, but leave for a
future ticket.
## System-Wide Impact
### Interaction Graph
```
Login
└─ Main.kt:1541 LaunchedEffect binds localCache.accountPubkey
└─ (Fix 1: must run BEFORE the block below)
└─ Main.kt:893 launch(Dispatchers.IO) { localRelayStore.hydrate(localCache) }
└─ per-event: localCache.justConsumeMyOwnEvent → consumeContactList
└─ before fix: stamps lastContactListByAuthor with null accountPubkey
└─ after fix: stamp only inside self-branch, or after ordering guarantee
Login (parallel)
└─ Main.kt:1559 collect(followedUsers) →
└─ WoTService.onFollowSetChange
└─ ops.trySend(FollowSet) → writerLoop → handleFollowSet
└─ Fix 2: MAX_FOLLOWS → clear myFollows + disabled=true, return
└─ if !disabled: OutboxDispatcher.fetchKind3Only(follows)
└─ Phase 1: REQ 10002 on index relays
└─ OutboxCacheGateway.onOutboxDiscovered → DesktopLocalCache.consumeAdvertisedRelayList
└─ Phase 2a: RelayListRecommendationProcessor.reliableRelaySetFor
└─ Phase 2b: per-relay REQ kind=[0,3] authors=[relay's users]
└─ OutboxCacheGateway.onDiscoveredEvent → LocalCache.consume path
└─ consumeContactList (fixed) → _contactListEvents.tryEmit
└─ WoTService.applyKind3 → handleKind3 → updateScore
└─ Phase 3: fallback for missing-10002 authors → index-relay REQ
└─ WoTService.markReadyOnce → _isReady.value = true → badge composables recompose
Account switch
└─ Main.kt:874 localCache.clear() → resets lastContactListByAuthor
└─ Fix 3: WoTService.close() → cancels writer, drops ops channel
└─ OutboxDispatcher scope cancels → in-flight REQs unsubscribe
```
### Error & Failure Propagation
- `client.subscribe` failure inside `OutboxDispatcher` → swallowed at the
per-relay coroutine level, logged, moves on. The overall `withTimeoutOrNull`
ensures the caller never blocks past its budget.
- `AdvertisedRelayListEvent.writeRelaysNorm()` returning null (author has a
10002 but empty write list) → falls through to Phase 3 fallback.
- Cache consume path errors (e.g. corrupt event) → existing `LocalCache`
behavior; not new.
### State Lifecycle Risks
- Between `WoTService.close()` and `OutboxDispatcher` scope cancel there's a
small window where a pending REQ EOSE could arrive at a torn-down service.
Mitigation: `OutboxCacheGateway.onDiscoveredEvent` and
`WoTService.applyKind3` must be null-guarded against the "already-closed"
state — WoTService's writerLoop naturally handles this (channel closed →
loop exits).
- Fix 1 requires Main.kt reordering; if the reorder is done wrong and pubkey
bind is *later* than hydration, the bug recurs silently. Test:
`DesktopLocalCacheHydrationTest.regressionOrderingProtection`.
### API Surface Parity
`amy wot sync` and Desktop login both consume the same `OutboxDispatcher`,
so any protocol change propagates. Android is a future consumer — the
plan intentionally lives in commons/commonMain so wiring Android on top
is a Main.kt-equivalent + gateway impl.
### Integration Test Scenarios
1. Cold-start login, well-connected account (~350 follows, ~90% with 10002):
Phase 1 completes, Phase 2 fetches only from write relays, Phase 3 kicks
in for the ~10% no-10002 authors, WoT ready < 5 s, badges render.
2. Cold-start login, ~4000-follow account: MAX_FOLLOWS trips → dispatcher
skipped, `markReadyOnce()` immediately, no badges, no REQ traffic.
3. Cold-start login, all index relays unreachable: overall timeout fires,
`markReadyOnce()`; on next `followedUsers` emission, `inFlight` is empty
(thanks to fix 5) so a retry happens.
4. Mid-session follow: single-author `fetchKind3Only({newPubkey})` uses cache
hit if `cachedOutbox(newPubkey) != null`, else does one Phase-1 REQ.
5. Account switch: `WoTService.close()` runs; opening the same account again
creates a fresh instance without leaking the previous writer coroutine.
6. `amy wot sync` on a headless VM with only the OS event store: writes
10002 + kind 3 events to disk; second run of `amy wot get <hex>` returns
the correct hydrated score.
## Acceptance Criteria
### Functional
- [ ] `DesktopLocalCache.consumeContactList` no longer stamps
`lastContactListByAuthor` for the self path unless `accountPubkey` is
set and the event matches. Regression test exists.
- [ ] `WoTService` exposes `isDisabled: StateFlow<Boolean>`; caller
(Main.kt) skips OutboxDispatcher when disabled.
- [ ] `WoTService` implements `AutoCloseable`; account-switch path calls
`close()`.
- [ ] `FeedMetadataCoordinator.loadKind3Batched` and
`loadMetadataBatched` retry on timeout (pubkeys not promoted to
`succeeded`).
- [ ] Both `loadKind3Batched` and `loadMetadataBatched` use single-writer
EOSE aggregation (no `MutableSet` shared across dispatchers).
- [ ] `OutboxDispatcher.fetchKind3Only` and `fetchKind0And3` exist in
commons/commonMain with test coverage for the four scenarios in
"Integration Test Scenarios".
- [ ] `Main.kt` login path uses `OutboxDispatcher` for kind-3 seeding
(WoT + follow-set metadata).
- [ ] `amy wot sync` uses `OutboxDispatcher`; `--json` output additively
gains `kind10002_received`, `kind3_received`, `fallback_authors`.
### Non-functional
- [ ] `./gradlew test` green.
- [ ] `./gradlew spotlessApply` clean before commit.
- [ ] No production default relay list on this branch references
`relay.damus.io` (already verified; keep it verified after refactor).
- [ ] No use of `java.util.concurrent` / JVM-only `synchronized {}` in
`commons/commonMain/`.
- [ ] KDoc for `WoTService` accurately describes SnapshotStateMap
per-key isolation.
### Quality Gates
- [ ] Manual regression sheet covering integration scenarios 1-6 above.
- [ ] `amy wot sync --json` sample output attached to PR description.
- [ ] Follow-list-wipe regression covered by an automated test that
hydrates a cached kind-3 before binding pubkey and asserts nothing is
poisoned.
## Success Metrics
- WoT badge coverage on real accounts (Vitor's expected win): jump from
"index-relay-published authors only" to "any author with a 10002" —
measured by running the desktop app before/after and diffing the badge
count on a fixed follow-set.
- Zero follow-list-wipe reports in the two weeks after merge (davotoula
finding 1 was worst-case data loss).
- Zero index-relay REQ traffic for kind-0/kind-3 authors that publish a
10002. Measurable by wireshark on a test build.
## Dependencies & Prerequisites
- Quartz `AdvertisedRelayListEvent` + `RelayListRecommendationProcessor` —
already exist, reused verbatim.
- `INostrClient.subscribe(subId, filters, listener)` — already exists.
- `DesktopLocalCache` needs a new `consumeAdvertisedRelayList(event, relay)`
method — mirrors existing `consumeContactList` structure.
- `OutboxRelayLoader` — moved from `amethyst/` to `commons/commonMain`;
Android continues to compile because it only depends on things
already in commons/quartz.
No new third-party libraries introduced. No `libs.versions.toml` change.
## Risk Analysis & Mitigation
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Outbox refactor changes badge counts on live user accounts unexpectedly | Med | Med | Keep the Phase-3 fallback path so no author gets *worse* coverage than today. Manual A/B test on maintainer's account before merge. |
| `RelayListRecommendationProcessor.reliableRelaySetFor` picks pathologically many relays for a fragmented follow set | Low | Low | Algorithm already caps by second-pass "at least 2 relays per author" rule. Add a hard `MAX_RELAYS_PER_FETCH` (say 40) as a belt-and-braces guard. |
| Concurrent EOSE handshake rewrite introduces a new bug | Low | High | Test `FeedMetadataCoordinatorTest.eoseReadyUnderConcurrentCallbacks` with a fake client firing EOSE from three dispatchers 1000× to catch ordering assumptions. |
| Bug fix 1 (Main.kt reordering) breaks another consumer that read localCache before accountPubkey bind | Med | Med | Grep for all `localCache.accountPubkey` reads; verify none pre-date the bind. If any, thread the pubkey through as a parameter. |
| Adopting `AutoCloseable` on `WoTService` misleads callers into thinking it's `use`-scoped | Low | Low | Comment on `close()` says "call from account-switch/dispose only; instance lives for the account session". |
| `amy wot sync --json` schema change breaks downstream scripts | Med | Low | Additive fields only, no renames. Document in `cli/plans/*` if a plan exists there. |
## Resource Requirements
- One engineer, ~2-3 days including tests + manual regression.
- Test-relay access: can use `wss://nos.lol` and Purple Pages for real
Phase 1 verification.
- Access to a mega-follow test account (>2000 follows) to verify Fix 2.
- Access to an account with a well-populated 10002 network to verify
Phase 2 does what we think.
## Future Considerations
- Android wiring: `AndroidApp` currently doesn't wire WoTService. When it
does, it can lean on the same `OutboxDispatcher` — expected diff is
Main-equivalent + a `LocalCache` gateway.
- Graperank scoring (Vitor's follow-up musing): if `amy wot` grows a scoring
strategy plugin API, `OutboxDispatcher` remains unchanged; only the
post-fetch aggregation layer inside `WoTService` changes.
- Adopting `AmethystDefaults.DefaultIndexerRelayList`: separate ticket.
Deserves its own review because it's a user-visible behaviour change.
## Documentation Plan
- Update this plan's status to `completed` post-merge; write a short
"solutions" note if the accountPubkey race surprised us elsewhere.
- Update PR description "Behaviour" section to reflect outbox path.
- Update `commons/ARCHITECTURE.md` "where does my code go?" section with a
one-line entry for `OutboxDispatcher`.
## Sources & References
### Origin
- **PR review comments:**
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892248528 (davotoula, bugs 1+2)
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892272000 (davotoula, bugs 3-6 impact on Android)
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892302009 (vitorpamplona, outbox directive)
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892686911 (vitorpamplona, Graperank musing — out of scope)
### Internal References
- Kind-10002 parser: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt`
- Relay-cover algorithm: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt`
- Existing Android outbox loader: `amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt`
- WoT service (bugs 2-4): `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt`
- Feed metadata coordinator (bugs 5-6): `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt`
- Cache race (bug 1): `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:509-530`
- Main wiring: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:1541-1568, 764-768, 863-874`
- amy WoT: `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt`
- Index relay persistence: `commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt`
- Baseline plan the PR extended: `desktopApp/plans/2026-07-01-feat-desktop-wot-score-plan.md`
### External References
- NIP-65 (Relay List Metadata): https://github.com/nostr-protocol/nips/blob/master/65.md
- Original plan for the current PR: `docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md`
### Related Work
- PR #3483 (this PR): https://github.com/vitorpamplona/amethyst/pull/3483
- Prior WoT badge PR (base for this branch): `feat/desktop-wot-score`
## Unanswered questions
- Per-relay timeout budget — 4 s picked from thin air. Real number?
- Should the fallback in Phase 3 also hit the account's own home/search
relays, matching Android's `pickRelaysToLoadUsers` cascade? Or index-only?
- WoTService.close() called from account-switch — what's the canonical
disposal hook on Desktop? DesktopIAccount teardown?
- amy wot sync `--json` schema — is `fallback_authors` the right name, or
match Android naming?
- Should `OutboxDispatcher` be a per-account singleton (like WoTService) or
short-lived per fetch? Leaning singleton for the dedup set.
- Do we want to persist the "author has no 10002" fact so we skip Phase 1
for them on next login? Requires a small persistent map — worth it?
- Should Fix 1 (accountPubkey race) be split into its own hotfix commit
before the outbox refactor lands, so backporters have a clean cherry-pick?
@@ -33,13 +33,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.Volatile
/**
* Coordinates metadata and reactions loading for feed items.
@@ -73,6 +76,15 @@ class FeedMetadataCoordinator(
private val queuedPubkeys = mutableSetOf<HexKey>()
private val queuedNoteIds = mutableSetOf<HexKey>()
private val queuedBoostedIds = mutableSetOf<HexKey>()
private val queuedKind3Pubkeys = mutableSetOf<HexKey>()
// Batched paths only — pubkeys currently in-flight in a batched REQ.
// Prevents rapid re-fire of the same batch. Distinct from queuedPubkeys
// and queuedKind3Pubkeys (which record "asked and at least one relay
// returned EOSE") so a batch that times out with zero events can be
// retried on the next call — see PR #3483 review finding 5.
private val inFlightBatchedMetadata = mutableSetOf<HexKey>()
private val inFlightBatchedKind3 = mutableSetOf<HexKey>()
/**
* Start processing the subscription queue.
@@ -251,14 +263,24 @@ class FeedMetadataCoordinator(
/**
* Fast-path: batched metadata subscription for visible-viewport authors.
* Bypasses rate limiter. Single filter with all authors. Closes after EOSE.
*
* Pubkeys are moved into [queuedPubkeys] (dedup) only after at least one
* relay EOSE'd. On timeout with zero EOSE (index relays all unreachable)
* they roll out of [inFlightBatchedMetadata] so a subsequent call can
* retry — see PR #3483 review finding 5.
*/
fun loadMetadataBatched(
pubkeys: List<HexKey>,
timeoutMs: Long = 5_000L,
) {
val newPubkeys = pubkeys.filter { it !in queuedPubkeys }.distinct()
val newPubkeys =
pubkeys
.asSequence()
.filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata }
.distinct()
.toList()
if (newPubkeys.isEmpty()) return
queuedPubkeys.addAll(newPubkeys)
inFlightBatchedMetadata.addAll(newPubkeys)
scope.launch {
val filter =
@@ -269,8 +291,7 @@ class FeedMetadataCoordinator(
)
val filterMap = indexRelays.associateWith { listOf(filter) }
val subId = newSubId()
val eoseReceived = mutableSetOf<NormalizedRelayUrl>()
val allEose = CompletableDeferred<Unit>()
val gate = BatchEoseGate(scope, target = indexRelays.size)
val listener =
object : SubscriptionListener {
@@ -287,16 +308,96 @@ class FeedMetadataCoordinator(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eoseReceived.add(relay)
if (eoseReceived.size >= indexRelays.size) {
allEose.complete(Unit)
}
gate.notifyEose(relay)
}
}
client.subscribe(subId, filterMap, listener)
withTimeoutOrNull(timeoutMs) { allEose.await() }
val eosedRelays = gate.awaitAll(timeoutMs)
client.unsubscribe(subId)
if (eosedRelays > 0) {
queuedPubkeys.addAll(newPubkeys)
}
inFlightBatchedMetadata.removeAll(newPubkeys.toSet())
}
}
/**
* Batched kind-3 (follow list) subscription. Used by the WoT service
* to fetch the follow lists of every account the active user follows,
* so friends-of-friends counts can be computed.
*
* Chunks authors into ≤100 per Filter within a single subscription
* so relays with per-filter author caps (nostr-rs-relay defaults to
* ~100) don't silently truncate the batch. Aggregates EOSE across
* chunks and calls [onEose] once (or after [timeoutMs]).
*
* Pubkeys are moved into [queuedKind3Pubkeys] (dedup) only after at
* least one relay EOSE'd. On timeout with zero EOSE (index relays all
* unreachable — common on flaky mobile networks) they roll out of
* [inFlightBatchedKind3] so the next `loadKind3Batched` call retries
* — see PR #3483 review finding 5.
*/
fun loadKind3Batched(
pubkeys: Collection<HexKey>,
timeoutMs: Long = 5_000L,
onEose: () -> Unit = {},
) {
val newPubkeys =
pubkeys
.asSequence()
.filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 }
.distinct()
.toList()
if (newPubkeys.isEmpty()) {
onEose()
return
}
inFlightBatchedKind3.addAll(newPubkeys)
scope.launch {
val filters =
newPubkeys.chunked(100).map { chunk ->
Filter(
kinds = listOf(ContactListEvent.KIND),
authors = chunk,
limit = chunk.size,
)
}
val filterMap = indexRelays.associateWith { filters }
val subId = newSubId()
val gate = BatchEoseGate(scope, target = indexRelays.size)
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
this@FeedMetadataCoordinator.onEvent?.invoke(event, relay)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gate.notifyEose(relay)
}
}
client.subscribe(subId, filterMap, listener)
val eosedRelays = gate.awaitAll(timeoutMs)
client.unsubscribe(subId)
if (eosedRelays > 0) {
queuedKind3Pubkeys.addAll(newPubkeys)
}
inFlightBatchedKind3.removeAll(newPubkeys.toSet())
onEose()
}
}
@@ -307,5 +408,53 @@ class FeedMetadataCoordinator(
priorityQueue.clear()
queuedPubkeys.clear()
queuedNoteIds.clear()
queuedKind3Pubkeys.clear()
inFlightBatchedMetadata.clear()
inFlightBatchedKind3.clear()
}
/**
* Aggregates EOSE notifications from per-relay `onEose` callbacks
* (which the client may dispatch on `Dispatchers.IO`) via a
* [Channel]. The consumer coroutine is the sole reader/writer of the
* `seen` set, eliminating the race the previous `mutableSetOf` +
* shared-state check had — see PR #3483 review finding 6.
*
* [awaitAll] blocks up to [timeoutMs] and returns the number of
* relays that EOSE'd (may be less than [target] on timeout). The
* count feeds the retry decision in the batched loaders.
*/
private class BatchEoseGate(
private val scope: CoroutineScope,
private val target: Int,
) {
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
private val done = CompletableDeferred<Unit>()
@Volatile private var lastCount = 0
fun notifyEose(relay: NormalizedRelayUrl) {
incoming.trySend(relay)
}
suspend fun awaitAll(timeoutMs: Long): Int {
if (target <= 0) return 0
val consumer =
scope.launch {
val seen = mutableSetOf<NormalizedRelayUrl>()
for (relay in incoming) {
if (seen.add(relay)) {
lastCount = seen.size
if (seen.size >= target && !done.isCompleted) {
done.complete(Unit)
}
}
}
}
withTimeoutOrNull(timeoutMs) { done.await() }
incoming.close()
consumer.join()
return lastCount
}
}
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
@@ -62,6 +64,10 @@ data class ProfilePictureUrl(
* @param loadProfilePicture Whether to load the profile picture (false = show robohash only)
* @param loadRobohash Whether to generate robohash (false = show generic icon)
* @param useThumbnailCache Whether to use the thumbnail disk cache for faster repeated loads
* @param badge Optional overlay drawn on top of the avatar (bottom-right by
* convention). Used by Desktop for the WoT trust-score chip; Android call
* sites leave it null. When null the avatar renders as before (no extra
* `Box` wrapper).
*/
@Composable
fun UserAvatar(
@@ -73,7 +79,26 @@ fun UserAvatar(
loadProfilePicture: Boolean = true,
loadRobohash: Boolean = true,
useThumbnailCache: Boolean = false,
badge: @Composable (BoxScope.() -> Unit)? = null,
) {
if (badge != null) {
Box(modifier = modifier.size(size)) {
UserAvatar(
userHex = userHex,
pictureUrl = pictureUrl,
size = size,
modifier = Modifier,
contentDescription = contentDescription,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
useThumbnailCache = useThumbnailCache,
badge = null,
)
badge()
}
return
}
val avatarModifier =
remember(size, modifier) {
modifier
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -46,12 +47,17 @@ import org.jetbrains.compose.resources.stringResource
/**
* A card displaying user search result with avatar, name, and nip05/pubkey.
* Shared between Android and Desktop search screens.
*
* @param badge Optional overlay drawn on top of the avatar (bottom-right
* by convention). Used by Desktop for the WoT trust-score chip; Android
* call sites leave it null. Forwarded to [UserAvatar].
*/
@Composable
fun UserSearchCard(
user: User,
onClick: () -> Unit,
modifier: Modifier = Modifier,
badge: @Composable (BoxScope.() -> Unit)? = null,
) {
Card(
modifier =
@@ -73,6 +79,7 @@ fun UserSearchCard(
pictureUrl = user.profilePicture(),
size = 40.dp,
contentDescription = stringResource(Res.string.accessibility_user_avatar),
badge = badge,
)
Column(modifier = Modifier.weight(1f)) {
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.wot
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
/**
* Compose-observable score service. Provided by the Desktop app at App
* root when a user is logged in. Left null on Android and while logged
* out — leaf composables branch on `LocalWoTService.current == null` to
* skip the WoT rendering path.
*
* The badge-hide predicates (self, already-followed) are read from
* `commons.moderation.LocalSpamExemptKeys` — the same set already
* provided by the hashtag-spam filter.
*/
val LocalWoTService: ProvidableCompositionLocal<WoTService?> =
compositionLocalOf { null }
/**
* Whether the WoT service has finished its initial batch fetch (or the
* 2s startup timeout has elapsed). Read once at the App root via
* [WoTService.isReady] and provided down as a scalar so leaf composables
* don't each spawn a Flow collector.
*/
val LocalWoTReady: ProvidableCompositionLocal<Boolean> =
compositionLocalOf { false }
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.wot
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
/**
* Platform-agnostic interface between [OutboxDispatcher] and the platform's
* event cache. Desktop and `amy` each provide their own implementation —
* DesktopLocalCache on the app side, a minimal in-memory adapter over the
* amy local store on the CLI side.
*
* The dispatcher only needs three capabilities:
*
* 1. Peek at what kind-10002 events are already stored so it can skip
* Phase-1 discovery for authors whose write-relay list is already
* known (from hydration or a previous session's fetch).
* 2. Ingest a kind-10002 that just came back from an index relay so
* subsequent lookups don't re-fetch it.
* 3. Ingest a kind-0 or kind-3 that just came back from an outbox
* relay so the platform cache/UI can pick it up through the usual
* consume path.
*
* Every method must be idempotent — the dispatcher may re-fire the same
* event through the gateway if two relays happen to return the same
* addressable event.
*/
interface OutboxCacheGateway {
/**
* Returns the currently-cached kind-10002 event for [pubkey], or null
* if the platform cache doesn't have one yet.
*/
fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent?
/**
* Called for every kind-10002 the dispatcher receives during Phase 1.
* The gateway should route it through its normal consume path so the
* event is stored, deduped by createdAt, and picked up by any state
* holders observing the addressable-notes cache.
*/
fun onOutboxDiscovered(
event: AdvertisedRelayListEvent,
relay: NormalizedRelayUrl,
)
/**
* Called for every kind-0 (metadata) or kind-3 (contact list) the
* dispatcher receives during Phase 2 or Phase 3. The gateway should
* route it through its normal consume path — this is how new profile
* metadata and follow lists reach downstream consumers like the WoT
* service and the UI.
*/
fun onDiscoveredEvent(
event: Event,
relay: NormalizedRelayUrl,
)
}
@@ -0,0 +1,495 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.wot
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.RelayListRecommendationProcessor
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.Volatile
/**
* Fetches kind-0 (profile metadata) and kind-3 (contact list) events for a
* set of authors using the NIP-65 **outbox model**:
*
* 1. **Phase 1 — discover.** Ask the configured index relays (Purple Pages,
* Coracle, nos.lol, …) for the kind-10002 of each author. Merge with
* already-cached 10002s from [OutboxCacheGateway].
*
* 2. **Phase 2 — pick + fetch.** Feed the author → write-relays map into
* [RelayListRecommendationProcessor.reliableRelaySetFor] to get a
* minimal, popularity-based set of relays that covers every author.
* Open one subscription per recommended relay, filtered to that
* relay's authors, for kind-0 and/or kind-3.
*
* 3. **Phase 3 — fallback.** For any author whose kind-10002 the network
* never returned, fall back to the index-relay REQ (preserves the
* current behaviour so a coldish account doesn't lose signal).
*
* The dispatcher is single-scoped (one instance per account) so its dedup
* set survives across follow-set diffs. Call [clear] on account switch.
*
* @param client shared [INostrClient] used for every subscription
* @param scope account-lifetime scope; cancelling it cancels in-flight REQs
* @param indexRelays lazy accessor so a change through the settings UI
* takes effect on next fetch without recreating the
* dispatcher
* @param gateway platform-specific cache adapter (see [OutboxCacheGateway])
* @param perRelayTimeoutMs how long each REQ waits for its EOSE. Under
* the plan (2026-07-06): 4 s.
* @param overallTimeoutMs cap on the whole two-phase fetch. Belt against
* a phase getting stuck. Under the plan: 8 s.
* @param maxOutboxRelaysPerAuthor bound author → write-relays to the first N
* relays after the [RelayListRecommendationProcessor]
* chooses them, to keep fan-out predictable
*/
class OutboxDispatcher(
private val client: INostrClient,
private val scope: CoroutineScope,
private val indexRelays: () -> Set<NormalizedRelayUrl>,
private val gateway: OutboxCacheGateway,
private val perRelayTimeoutMs: Long = 4_000L,
private val overallTimeoutMs: Long = 20_000L,
@Suppress("UNUSED_PARAMETER") maxOutboxRelaysPerAuthor: Int = 5,
) {
/**
* Pubkeys we've already successfully fetched kind-3 for this session
* (Phase 1 or Phase 2 returned events for them). Skipping a second
* fetch is safe because a churn event from a subsequent kind-3
* republication still reaches [OutboxCacheGateway.onDiscoveredEvent]
* via other subscriptions (feed, notifications).
*/
private val kind3Succeeded = mutableSetOf<HexKey>()
/**
* Pubkeys we've already successfully fetched kind-0 for this session.
*/
private val kind0Succeeded = mutableSetOf<HexKey>()
/**
* Currently-in-flight authors — prevents rapid re-fire of the same
* fetch. Distinct from [kind3Succeeded]/[kind0Succeeded]: a zero-EOSE
* timeout rolls out of this set (allowing retry) instead of
* permanently marking the pubkey as done.
*/
private val kind3InFlight = mutableSetOf<HexKey>()
private val kind0InFlight = mutableSetOf<HexKey>()
/**
* Outcome counters. All values are aggregated across every phase of
* one [fetchKind3Only] / [fetchKind0And3] call. Callers log them for
* observability; `amy wot sync --json` also emits them so a caller
* can measure whether the outbox path is doing the work vs the
* fallback path.
*/
data class Result(
val authorsRequested: Int,
val kind10002Received: Int,
val kind3Received: Int,
val kind0Received: Int,
val outboxCoveredAuthors: Int,
val fallbackAuthors: Int,
)
/**
* Fetch kind-3 for every pubkey in [authors] via each author's outbox
* relay when known, falling back to index relays otherwise. Suspends
* until every phase EOSEs or times out.
*/
suspend fun fetchKind3Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = false, includeKind3 = true)
/**
* Fetch kind-3 AND kind-0 for every pubkey in [authors]. Same phase
* pipeline; a single per-outbox-relay subscription pulls both kinds
* so we don't double the connection count.
*/
suspend fun fetchKind0And3(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = true)
/**
* Fetch kind-0 only. Used by the metadata preloader when it decides
* to bypass the index-relay batch for a specific author (e.g. a
* profile screen visit where the author's outbox is already cached).
*/
suspend fun fetchKind0Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = false)
/**
* Drop every dedup marker. Call on account switch so a fresh account
* doesn't inherit the previous account's "already fetched" state.
*/
fun clear() {
kind3Succeeded.clear()
kind0Succeeded.clear()
kind3InFlight.clear()
kind0InFlight.clear()
}
private suspend fun run(
authors: Set<HexKey>,
includeKind0: Boolean,
includeKind3: Boolean,
): Result {
if (authors.isEmpty()) return zeroResult(0)
val newForKind3 =
if (includeKind3) authors.filter { it !in kind3Succeeded && it !in kind3InFlight }.toSet() else emptySet()
val newForKind0 =
if (includeKind0) authors.filter { it !in kind0Succeeded && it !in kind0InFlight }.toSet() else emptySet()
if (newForKind3.isEmpty() && newForKind0.isEmpty()) {
Log.d("OutboxDispatcher") { "skip: all authors deduped (succeeded or in-flight)" }
return zeroResult(authors.size)
}
kind3InFlight.addAll(newForKind3)
kind0InFlight.addAll(newForKind0)
return try {
val result =
withTimeoutOrNull(overallTimeoutMs) {
doRun(authors, newForKind3, newForKind0, includeKind0, includeKind3)
}
if (result == null) {
Log.w("OutboxDispatcher") { "overall timeout ${overallTimeoutMs}ms exceeded — returning zero result" }
zeroResult(authors.size)
} else {
result
}
} finally {
kind3InFlight.removeAll(newForKind3)
kind0InFlight.removeAll(newForKind0)
}
}
private suspend fun doRun(
allAuthors: Set<HexKey>,
newForKind3: Set<HexKey>,
newForKind0: Set<HexKey>,
includeKind0: Boolean,
includeKind3: Boolean,
): Result {
val relayCounts = FetchCounters()
val relaysConfigured = indexRelays()
val newTargets = (newForKind3 + newForKind0)
// Split into "have cached 10002" vs "need Phase 1".
val cachedOutbox = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
val toDiscover = mutableSetOf<HexKey>()
for (author in newTargets) {
val write =
gateway
.cachedOutbox(author)
?.writeRelaysNorm()
.orEmpty()
.toSet()
if (write.isNotEmpty()) cachedOutbox[author] = write else toDiscover.add(author)
}
Log.d("OutboxDispatcher") {
"start authors=${allAuthors.size} newKind3=${newForKind3.size} newKind0=${newForKind0.size} " +
"cachedOutbox=${cachedOutbox.size} toDiscover=${toDiscover.size} " +
"indexRelays=${relaysConfigured.size}"
}
// Phase 1 — discover kind-10002 on the index relays. runPhase1
// returns pubkey → list of (event, relay) so we can pick the
// newest event (some relays return outdated 10002s).
val discovered = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
if (toDiscover.isNotEmpty() && relaysConfigured.isNotEmpty()) {
val (phase1Events, phase1EosedCount) = runPhase1(toDiscover, relaysConfigured)
phase1Events.forEach { (pubkey, results) ->
val newest = results.maxByOrNull { it.first.createdAt } ?: return@forEach
gateway.onOutboxDiscovered(newest.first, newest.second)
val write =
newest.first
.writeRelaysNorm()
.orEmpty()
.toSet()
if (write.isNotEmpty()) discovered[pubkey] = write
}
relayCounts.kind10002 += phase1Events.values.sumOf { it.size }
Log.d("OutboxDispatcher") {
"phase1 done eosed=$phase1EosedCount/${relaysConfigured.size} " +
"10002-events=${relayCounts.kind10002} discovered=${discovered.size}"
}
}
val outboxMap = cachedOutbox + discovered
val authorsWithOutbox = outboxMap.keys
val fallbackAuthors = newTargets - authorsWithOutbox
// Phase 2 — per-outbox-relay REQ, kind-3 and/or kind-0. All
// recommended relays are subscribed in a single call so the pool
// fans out in parallel; a per-relay 4 s timeout bounds the wait
// regardless of how many relays the recommendation set contains.
val kind3BeforePhase2 = relayCounts.kind3
val kind0BeforePhase2 = relayCounts.kind0
if (outboxMap.isNotEmpty() && (includeKind0 || includeKind3)) {
val recommendations = RelayListRecommendationProcessor.reliableRelaySetFor(outboxMap)
val phase2FilterMap =
recommendations
.mapNotNull { rec ->
val authorsForThisRelay =
rec.users.intersect(
if (includeKind0 && includeKind3) {
newTargets
} else if (includeKind3) {
newForKind3
} else {
newForKind0
},
)
if (authorsForThisRelay.isEmpty()) return@mapNotNull null
val kinds =
buildList {
if (includeKind0 && authorsForThisRelay.any { it in newForKind0 }) add(MetadataEvent.KIND)
if (includeKind3 && authorsForThisRelay.any { it in newForKind3 }) add(ContactListEvent.KIND)
}
if (kinds.isEmpty()) return@mapNotNull null
rec.relay to
authorsForThisRelay.chunked(100).map { chunk ->
Filter(
kinds = kinds,
authors = chunk,
limit = chunk.size * kinds.size,
)
}
}.toMap()
Log.d("OutboxDispatcher") { "phase2 recommendations=${recommendations.size} relays-with-work=${phase2FilterMap.size}" }
if (phase2FilterMap.isNotEmpty()) {
runPhase2Or3(phase2FilterMap, counters = relayCounts)
}
}
Log.d("OutboxDispatcher") {
"phase2 done kind3=${relayCounts.kind3 - kind3BeforePhase2} kind0=${relayCounts.kind0 - kind0BeforePhase2}"
}
// Phase 3 — index-relay fallback for authors with no 10002.
val kind3BeforePhase3 = relayCounts.kind3
val kind0BeforePhase3 = relayCounts.kind0
if (fallbackAuthors.isNotEmpty() && relaysConfigured.isNotEmpty()) {
val kinds =
buildList {
if (includeKind0 && fallbackAuthors.any { it in newForKind0 }) add(MetadataEvent.KIND)
if (includeKind3 && fallbackAuthors.any { it in newForKind3 }) add(ContactListEvent.KIND)
}
if (kinds.isNotEmpty()) {
Log.d("OutboxDispatcher") { "phase3 fallback authors=${fallbackAuthors.size} kinds=$kinds relays=${relaysConfigured.size}" }
val filters =
fallbackAuthors.chunked(100).map { chunk ->
Filter(
kinds = kinds,
authors = chunk,
limit = chunk.size * kinds.size,
)
}
val phase3FilterMap = relaysConfigured.associateWith { filters }
runPhase2Or3(phase3FilterMap, counters = relayCounts)
Log.d("OutboxDispatcher") {
"phase3 done kind3=${relayCounts.kind3 - kind3BeforePhase3} kind0=${relayCounts.kind0 - kind0BeforePhase3}"
}
}
}
// Promote to succeeded — a completed run means we've asked; even if
// an author had no publishable data we don't need to keep pounding
// relays every follow-set change.
kind3Succeeded.addAll(newForKind3)
kind0Succeeded.addAll(newForKind0)
return Result(
authorsRequested = allAuthors.size,
kind10002Received = relayCounts.kind10002,
kind3Received = relayCounts.kind3,
kind0Received = relayCounts.kind0,
outboxCoveredAuthors = authorsWithOutbox.size,
fallbackAuthors = fallbackAuthors.size,
)
}
// ------------------------------------------------------------------
private class FetchCounters {
var kind10002 = 0
var kind3 = 0
var kind0 = 0
}
/**
* Phase 1 helper. Returns a map of pubkey → list of (event, relay) so
* caller can pick the newest, plus a boolean-per-relay EOSE indicator
* (currently ignored but recorded for future retry telemetry).
*/
private suspend fun runPhase1(
pubkeys: Set<HexKey>,
relays: Set<NormalizedRelayUrl>,
): Pair<Map<HexKey, List<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>, Int> {
val filters =
pubkeys.chunked(100).map { chunk ->
Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND),
authors = chunk,
limit = chunk.size,
)
}
val filterMap = relays.associateWith { filters }
val received = mutableMapOf<HexKey, MutableList<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>()
val gate = BatchEoseGate(scope, target = relays.size)
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event is AdvertisedRelayListEvent && event.pubKey in pubkeys) {
received
.getOrPut(event.pubKey) { mutableListOf() }
.add(event to relay)
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gate.notifyEose(relay)
}
}
val subId = newSubId()
client.subscribe(subId, filterMap, listener)
val eosedCount = gate.awaitAll(perRelayTimeoutMs)
client.unsubscribe(subId)
return received to eosedCount
}
/**
* Phase 2 or Phase 3 helper. Opens a single subscription that
* fans out to every relay in [filterMap] (Phase 2 uses per-outbox-
* relay filters; Phase 3 uses the index-relay set with a shared
* fallback filter). All relays are subscribed in parallel — the
* per-relay timeout bounds the total wait regardless of relay count.
*/
private suspend fun runPhase2Or3(
filterMap: Map<NormalizedRelayUrl, List<Filter>>,
counters: FetchCounters,
) {
val gate = BatchEoseGate(scope, target = filterMap.size)
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
when (event.kind) {
MetadataEvent.KIND -> counters.kind0++
ContactListEvent.KIND -> counters.kind3++
}
gateway.onDiscoveredEvent(event, relay)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gate.notifyEose(relay)
}
}
val subId = newSubId()
client.subscribe(subId, filterMap, listener)
gate.awaitAll(perRelayTimeoutMs)
client.unsubscribe(subId)
}
private fun zeroResult(requested: Int) =
Result(
authorsRequested = requested,
kind10002Received = 0,
kind3Received = 0,
kind0Received = 0,
outboxCoveredAuthors = 0,
fallbackAuthors = 0,
)
/**
* KMP-safe EOSE aggregator (same as FeedMetadataCoordinator's local
* one — duplicated locally instead of exported to keep the fix scope
* minimal). Per-relay `onEose` callbacks may run on any dispatcher
* (typically `Dispatchers.IO`) so we funnel them through a Channel
* and let a single consumer coroutine own the `seen` set.
*/
private class BatchEoseGate(
private val scope: CoroutineScope,
private val target: Int,
) {
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
private val done = CompletableDeferred<Unit>()
@Volatile private var lastCount = 0
fun notifyEose(relay: NormalizedRelayUrl) {
incoming.trySend(relay)
}
suspend fun awaitAll(timeoutMs: Long): Int {
if (target <= 0) return 0
val consumer =
scope.launch {
val seen = mutableSetOf<NormalizedRelayUrl>()
for (relay in incoming) {
if (seen.add(relay)) {
lastCount = seen.size
if (seen.size >= target && !done.isCompleted) {
done.complete(Unit)
}
}
}
}
withTimeoutOrNull(timeoutMs) { done.await() }
incoming.close()
consumer.join()
return lastCount
}
}
}
@@ -0,0 +1,306 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.wot
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.runtime.snapshots.SnapshotStateMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Friends-of-friends trust score computed from the active user's follow
* graph. For every pubkey X the score is the count of accounts in the
* active user's follow set who also follow X.
*
* ## Reactivity model
*
* Scores are exposed via a Compose-observable [SnapshotStateMap]. Consumers
* that read a **single key** (`scores[pubkey]`) recompose only when that
* key changes — this is `SnapshotStateMap`'s built-in per-key observation
* and applies whether or not the writer wraps in a snapshot block.
* Consumers that iterate the map or read `size` recompose on **any**
* mutation.
*
* The writer wraps each op in [Snapshot.withMutableSnapshot] to *coalesce*
* an op's writes into a single Compose commit — so a Kind3 op that
* touches N reverse-index targets emits one invalidation, not N. It does
* not confer additional per-key isolation on top of `SnapshotStateMap`'s
* own semantics.
*
* ## Concurrency
*
* All internal state is mutated from a single writer coroutine
* ([writerLoop]) on [writerDispatcher] (default [Dispatchers.Default]), so
* concurrent [applyKind3] / [onFollowSetChange] / [markReadyOnce] calls
* from different threads are serialized without extra locking.
*
* ## Lifecycle
*
* Call [close] on account switch / logout so the writer coroutine exits
* and the ops channel is released. Post-close ops are silently dropped.
*/
@Stable
class WoTService(
private val scope: CoroutineScope,
/** Dispatcher for the internal writer coroutine. Tests override with `Dispatchers.Unconfined` for synchronous behavior. */
private val writerDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : AutoCloseable {
/**
* Sparse per-pubkey score map. Entries with count 0 are removed
* (not stored as 0) to keep the Compose subscriber tracking tight.
* Callers should read as `scores[pubkey] ?: 0`.
*/
private val _scores: SnapshotStateMap<HexKey, Int> = mutableStateMapOf()
val scores: SnapshotStateMap<HexKey, Int> get() = _scores
// Reverse index: target pubkey → set of my-follows who follow them.
private val reverseIndex = HashMap<HexKey, MutableSet<HexKey>>()
// Per-follower cached follow set (excluding self / follower itself).
// Enables diff-based updates when a follower republishes their kind-3.
private val perFollowerSnapshot = HashMap<HexKey, Set<HexKey>>()
private var myFollows: Set<HexKey> = emptySet()
private var selfPubkey: HexKey? = null
private var readyMarked = false
private var disabled = false
private val _isReady = MutableStateFlow(false)
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
private val _isDisabled = MutableStateFlow(false)
/**
* True when the active user's follow set exceeds [MAX_FOLLOWS] and WoT
* scoring has been shut off. Callers that dispatch the batch kind-3
* REQ must gate on this — a disabled service silently accepts and
* ignores all subsequent [applyKind3] calls, so a caller that keeps
* flooding kind-3s wastes bandwidth for nothing.
*/
val isDisabled: StateFlow<Boolean> = _isDisabled.asStateFlow()
private val ops = Channel<Op>(capacity = Channel.UNLIMITED)
init {
scope.launch(writerDispatcher) { writerLoop() }
}
/** Update the active user's follow set (and self pubkey). */
fun onFollowSetChange(
newFollows: Set<HexKey>,
newSelf: HexKey?,
) {
ops.trySend(Op.FollowSet(newFollows, newSelf))
}
/**
* Ingest a kind-3 event for a followed pubkey. Ignored when the
* event's author isn't in the current follow set. Follow lists are
* capped at [MAX_FOLLOWS_PER_EVENT] to bound CPU cost against a
* hostile publisher.
*/
fun applyKind3(
follower: HexKey,
follows: Set<HexKey>,
) {
val bounded =
if (follows.size > MAX_FOLLOWS_PER_EVENT) {
follows.take(MAX_FOLLOWS_PER_EVENT).toSet()
} else {
follows
}
ops.trySend(Op.Kind3(follower, bounded))
}
/**
* Mark the service as ready to render badges. Idempotent — subsequent
* calls are no-ops. Trigger from the first EOSE on the batch kind-3
* REQ, or from a startup-timeout fallback, whichever fires first.
*/
fun markReadyOnce() {
ops.trySend(Op.MarkReady)
}
/** Clear all state. Used on logout / account switch. */
fun clear() {
ops.trySend(Op.Clear)
}
/**
* Returns a plain [Map] snapshot of current scores for headless
* callers (e.g. the amy CLI) that don't run inside a Compose
* composition. O(N) copy from the underlying [SnapshotStateMap].
*/
fun scoresSnapshot(): Map<HexKey, Int> = HashMap(_scores)
private sealed interface Op {
data class FollowSet(
val newFollows: Set<HexKey>,
val newSelf: HexKey?,
) : Op
data class Kind3(
val follower: HexKey,
val follows: Set<HexKey>,
) : Op
data object MarkReady : Op
data object Clear : Op
}
private suspend fun writerLoop() {
for (op in ops) {
Snapshot.withMutableSnapshot {
when (op) {
is Op.FollowSet -> handleFollowSet(op.newFollows, op.newSelf)
is Op.Kind3 -> handleKind3(op.follower, op.follows)
Op.MarkReady -> handleMarkReady()
Op.Clear -> handleClear()
}
}
}
}
private fun handleFollowSet(
newFollows: Set<HexKey>,
newSelf: HexKey?,
) {
// Guardrail — massive follow lists don't produce a useful WoT signal.
// Do this BEFORE assigning myFollows so applyKind3's `follower in
// myFollows` gate doesn't accidentally credit anyone once the
// caller keeps pumping kind-3s in (a caller that fails to gate on
// isDisabled would otherwise fully repopulate reverseIndex/_scores
// and defeat the guardrail — see PR #3483 review finding 2).
if (newFollows.size > MAX_FOLLOWS) {
reverseIndex.clear()
perFollowerSnapshot.clear()
_scores.clear()
myFollows = emptySet()
selfPubkey = newSelf
disabled = true
_isDisabled.value = true
handleMarkReady()
return
}
val removed = myFollows - newFollows
myFollows = newFollows
selfPubkey = newSelf
// Follow set is back within limits (or was already) — re-enable if
// we had previously flipped disabled=true.
if (disabled) {
disabled = false
_isDisabled.value = false
}
// Uncredit any follower we're no longer following.
removed.forEach { follower ->
val prevFollows = perFollowerSnapshot.remove(follower) ?: return@forEach
prevFollows.forEach { target ->
val set = reverseIndex[target] ?: return@forEach
set.remove(follower)
if (set.isEmpty()) reverseIndex.remove(target)
updateScore(target)
}
}
// Added followers will be credited when their kind-3 arrives via applyKind3.
}
private fun handleKind3(
follower: HexKey,
follows: Set<HexKey>,
) {
if (disabled) return
if (follower !in myFollows) return
val old = perFollowerSnapshot[follower] ?: emptySet()
val excluded = setOfNotNull(follower, selfPubkey)
val effective = follows - excluded
val added = effective - old
val removed = old - effective
perFollowerSnapshot[follower] = effective
added.forEach { target ->
reverseIndex.getOrPut(target) { hashSetOf() }.add(follower)
updateScore(target)
}
removed.forEach { target ->
val set = reverseIndex[target] ?: return@forEach
set.remove(follower)
if (set.isEmpty()) reverseIndex.remove(target)
updateScore(target)
}
}
private fun handleMarkReady() {
if (!readyMarked) {
readyMarked = true
_isReady.value = true
}
}
private fun handleClear() {
reverseIndex.clear()
perFollowerSnapshot.clear()
_scores.clear()
myFollows = emptySet()
selfPubkey = null
readyMarked = false
_isReady.value = false
disabled = false
_isDisabled.value = false
}
/**
* Cancel the writer coroutine and release the ops channel. Call from
* account-switch / logout paths. Post-close [applyKind3] / [onFollowSetChange]
* / [markReadyOnce] / [clear] calls are silently dropped (the `trySend`
* on a closed [Channel] fails without throwing).
*
* Idempotent; safe to call multiple times.
*/
override fun close() {
ops.close()
}
private fun updateScore(target: HexKey) {
val n = reverseIndex[target]?.size ?: 0
if (n > 0) _scores[target] = n else _scores.remove(target)
}
companion object {
/** Skip WoT entirely for accounts following more than this many pubkeys. */
const val MAX_FOLLOWS = 2000
/** Cap follows per kind-3 event to bound CPU cost against a hostile publisher. */
const val MAX_FOLLOWS_PER_EVENT = 5000
}
}
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relays.index
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.prefs.Preferences
/**
* User-configurable set of relays used to fetch profile metadata
* (kind 0) and follow lists (kind 3) — the "index relays" set passed
* to `FeedMetadataCoordinator` in the Desktop app and to `wot sync`
* in `amy`.
*
* Backed by [java.util.prefs.Preferences] at a fixed node
* `com/vitorpamplona/amethyst/relays/index` (JVM-user-scoped). The
* shared node means Desktop and `amy` running as the same OS user
* observe the same setting without extra plumbing — the same trick
* `PreferencesHashtagSpamSettings` uses for the hashtag-spam filter.
*
* Not per-account: users typically have a single preferred set of
* index relays regardless of which account is currently logged in.
* If per-account overrides become necessary later, layer a per-user
* key on top; this class stays the base.
*
* CSV serialisation for the persisted value matches what
* `DesktopAccountRelays` uses for its categories — no JSON dep, no
* `Serializable` contract. URLs are normalised via
* [RelayUrlNormalizer.normalizeOrNull] at both write and read time so
* malformed entries never enter the effective set.
*/
class PreferencesIndexRelays(
private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME),
) {
private val mutableRelays: MutableStateFlow<Set<NormalizedRelayUrl>> =
MutableStateFlow(parse(prefs.get(KEY_URLS, "")))
/**
* Current user override. Empty when the user has not configured
* anything — callers should route through [effective] to get the
* defaults-fallback resolved set.
*/
val relays: StateFlow<Set<NormalizedRelayUrl>> = mutableRelays.asStateFlow()
fun setRelays(new: Set<NormalizedRelayUrl>) {
mutableRelays.value = new
prefs.put(KEY_URLS, new.joinToString(",") { it.url })
}
/**
* Resolves the set the relay client should actually use — the user
* override when non-empty, otherwise [DEFAULT_INDEX_RELAYS]. Never
* returns empty (unless the caller has explicitly reset both the
* override and the defaults to empty, which would require a code
* change here).
*/
fun effective(): Set<NormalizedRelayUrl> = mutableRelays.value.ifEmpty { DEFAULT_INDEX_RELAYS }
companion object {
const val NODE_NAME = "com/vitorpamplona/amethyst/relays/index"
const val KEY_URLS = "urls"
/**
* Byte-for-byte identical to `DefaultRelays.RELAYS` at
* `desktopApp/.../network/RelayStatus.kt`. Preserves current
* behaviour for users who never open the settings UI.
*
* Note: `commons/AmethystDefaults.kt` also has
* `DefaultIndexerRelayList` (Purple Pages, Coracle …) which is
* more purpose-built for indexing. Adopting it is a separate
* ticket — see the plan's "Out of Scope" section.
*/
val DEFAULT_INDEX_RELAYS: Set<NormalizedRelayUrl> =
listOf(
"wss://nos.lol",
"wss://nostr.wine",
"wss://relay.noswhere.com",
"wss://relay.primal.net",
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
internal fun parse(csv: String): Set<NormalizedRelayUrl> =
csv
.split(",")
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
}
}
@@ -0,0 +1,297 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayClient.assemblers
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Regression tests for PR #3483 review findings on FeedMetadataCoordinator:
*
* - Finding 5: `queuedKind3Pubkeys` was marked-on-send, so if every index
* relay timed out the pubkeys were permanently marked and subsequent
* calls short-circuited — WoT stayed empty for the whole session.
* Fix: pubkeys land in `queuedKind3Pubkeys` only after ≥1 EOSE; on
* zero-EOSE timeout they roll out of `inFlightBatchedKind3` for retry.
*
* - Finding 6: `eoseReceived: MutableSet` was mutated from per-relay
* `onEose` callbacks running on `Dispatchers.IO` with no sync. Fix:
* `BatchEoseGate` funnels EOSE notifications through a `Channel` so a
* single consumer coroutine is the sole reader/writer of the `seen`
* set.
*/
class FeedMetadataCoordinatorTest {
private lateinit var scope: CoroutineScope
private val relay1 = NormalizedRelayUrl("wss://relay1.test/")
private val relay2 = NormalizedRelayUrl("wss://relay2.test/")
private val relay3 = NormalizedRelayUrl("wss://relay3.test/")
private val indexRelays = setOf(relay1, relay2, relay3)
@Before
fun setup() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
@After
fun teardown() {
scope.cancel()
}
private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0')
/**
* Fake client that captures subscribe/unsubscribe and lets the test
* drive EOSE notifications on any dispatcher we choose.
*/
private class ControllableClient(
private val delegate: INostrClient = EmptyNostrClient(),
) : INostrClient by delegate {
val subscriptions = mutableMapOf<String, SubscriptionListener?>()
val subscribeCalls = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
var unsubscribeCallCount = 0
private set
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
subscriptions[subId] = listener
subscribeCalls.add(filters)
}
override fun unsubscribe(subId: String) {
subscriptions.remove(subId)
unsubscribeCallCount++
}
fun fireEose(relay: NormalizedRelayUrl) {
subscriptions.values.filterNotNull().forEach { it.onEose(relay, forFilters = null) }
}
}
@Test
fun `loadKind3Batched retries after zero-EOSE timeout`() =
runBlocking {
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = indexRelays,
)
val pubkeys = listOf(pubkey(1), pubkey(2), pubkey(3))
// Call 1 — no relay EOSEs; must time out.
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(350) // exceed the timeout
// Call 2 — the same pubkeys must be re-subscribed since call 1
// never got a successful EOSE. The old code would silently
// short-circuit here.
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(50) // let the launcher run
assertEquals(
"Zero-EOSE timeout must not permanently dedup pubkeys",
2,
client.subscribeCalls.size,
)
assertEquals(
"Second call must re-request the same author set",
pubkeys.size,
client.subscribeCalls[1]
.values
.first()
.first()
.authors!!
.size,
)
}
@Test
fun `loadKind3Batched short-circuits after successful EOSE`() =
runBlocking {
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = indexRelays,
)
val pubkeys = listOf(pubkey(1), pubkey(2))
coordinator.loadKind3Batched(pubkeys, timeoutMs = 1_000)
// Give the launcher time to register the listener before we fire.
delay(50)
indexRelays.forEach(client::fireEose)
delay(200) // let the coordinator finish + promote to queued
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(50)
assertEquals(
"Successful call must dedup subsequent identical calls",
1,
client.subscribeCalls.size,
)
}
@Test
fun `loadKind3Batched promotes even when only some relays EOSE`() =
runBlocking {
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = indexRelays,
)
val pubkeys = listOf(pubkey(1))
coordinator.loadKind3Batched(pubkeys, timeoutMs = 300)
delay(30)
// Only 1 of 3 EOSEs — timeout still fires but we made progress.
client.fireEose(relay1)
delay(400)
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(50)
assertEquals(
"≥1 EOSE = progress = promote to queued (avoid re-asking)",
1,
client.subscribeCalls.size,
)
}
/**
* Regression for finding 6 — pumps EOSE from many dispatchers in
* parallel. The old MutableSet-based code could drop entries or throw
* ConcurrentModificationException on the internal HashSet iterator.
* BatchEoseGate must aggregate every distinct relay exactly once.
*/
@Test
fun `EOSE aggregator is safe under concurrent per-relay callbacks`() =
runBlocking {
val bigIndexSet =
(0..19).map { NormalizedRelayUrl("wss://relay$it.test/") }.toSet()
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = bigIndexSet,
)
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 2_000)
delay(50) // wait for subscription
// Fire EOSEs concurrently from many dispatchers.
val jobs =
bigIndexSet.map { relay ->
scope.launch(Dispatchers.IO) {
client.fireEose(relay)
}
}
jobs.forEach { it.join() }
// The 2nd call must short-circuit — every relay EOSE'd, so
// pubkey(1) is now in queuedKind3Pubkeys.
delay(100)
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
delay(50)
assertEquals(
"Under concurrent EOSE from all relays, aggregator must reach target",
1,
client.subscribeCalls.size,
)
}
@Test
fun `loadMetadataBatched follows the same retry semantics`() =
runBlocking {
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = indexRelays,
)
val pubkeys = listOf(pubkey(1), pubkey(2))
// Call 1 — zero EOSE, timeout.
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
delay(350)
// Call 2 — must re-subscribe.
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
delay(50)
assertTrue(
"Metadata batch also retries on zero-EOSE timeout",
client.subscribeCalls.size >= 2,
)
}
@Test
fun `clear releases in-flight dedup so a fresh call always fires`() =
runBlocking {
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = indexRelays,
)
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
delay(50)
// clear() must drop the in-flight tracker even mid-request.
coordinator.clear()
delay(300) // let call 1 finish + roll back
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
delay(50)
assertTrue(client.subscribeCalls.size >= 2)
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relays.index
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.prefs.Preferences
class PreferencesIndexRelaysTest {
private val testNode = "com/vitorpamplona/amethyst/test/relays/index_${System.currentTimeMillis()}"
private fun prefs(): Preferences = Preferences.userRoot().node(testNode)
@Before
fun setup() {
prefs().clear()
}
@After
fun teardown() {
prefs().removeNode()
}
@Test
fun defaultsWhenPreferencesUnset() {
val store = PreferencesIndexRelays(prefs())
assertTrue(store.relays.value.isEmpty())
assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective())
}
@Test
fun setRelaysPersistsAcrossInstances() {
val store = PreferencesIndexRelays(prefs())
val urls =
listOf("wss://relay.example", "wss://index.example")
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
store.setRelays(urls)
assertEquals(urls, store.relays.value)
val reloaded = PreferencesIndexRelays(prefs())
assertEquals(urls, reloaded.relays.value)
assertEquals(urls, reloaded.effective())
}
@Test
fun effectiveFallsBackWhenOverrideCleared() {
val store = PreferencesIndexRelays(prefs())
val urls =
listOf("wss://relay.example")
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
store.setRelays(urls)
store.setRelays(emptySet())
assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective())
}
@Test
fun emptyEntriesInCsvAreSkipped() {
// Plant a URL list with empty tokens (extra commas). The
// parser should skip blanks silently.
prefs().put(PreferencesIndexRelays.KEY_URLS, "wss://good.example,,wss://also-good.example,")
val store = PreferencesIndexRelays(prefs())
// Both good URLs should be present; no blank / empty entry.
assertEquals(2, store.relays.value.size)
assertTrue(store.relays.value.none { it.url.isBlank() })
}
@Test
fun defaultSetIsNotEmpty() {
// Guardrail against a future refactor accidentally clearing the constant.
assertTrue(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS.isNotEmpty())
}
}
@@ -0,0 +1,383 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.wot
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Coverage for the outbox pipeline defined in
* `commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md`.
* Scenarios:
*
* 1. Author has a cached kind-10002 → Phase 1 skipped, Phase 2 REQs
* the author's write relay directly.
* 2. Author has no cached 10002 → Phase 1 discovers, Phase 2 uses the
* discovered write relays.
* 3. Author with no 10002 anywhere → Phase 3 fallback to index relays.
* 4. Per-relay timeout on Phase 1 doesn't cancel Phase 2 for authors
* that already had a cached outbox.
* 5. clear() releases dedup so a fresh call always re-runs.
*/
class OutboxDispatcherTest {
private lateinit var scope: CoroutineScope
private val indexRelay1 = NormalizedRelayUrl("wss://index1.test/")
private val indexRelay2 = NormalizedRelayUrl("wss://index2.test/")
private val indexRelays = setOf(indexRelay1, indexRelay2)
private val outboxAlice = NormalizedRelayUrl("wss://alice-outbox.test/")
private val outboxBob = NormalizedRelayUrl("wss://bob-outbox.test/")
private val alice = pubkey(1)
private val bob = pubkey(2)
private val charlie = pubkey(3)
@Before
fun setup() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
@After
fun teardown() {
scope.cancel()
}
private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0')
private fun dummySig() = "0".repeat(128)
private fun outboxEventFor(
author: HexKey,
writeRelays: List<NormalizedRelayUrl>,
createdAt: Long = 1_700_000_000,
): AdvertisedRelayListEvent {
val tags = writeRelays.map { arrayOf("r", it.url, "write") }.toTypedArray()
return AdvertisedRelayListEvent(
id = "out-$author".take(64).padEnd(64, '0'),
pubKey = author,
createdAt = createdAt,
tags = tags,
content = "",
sig = dummySig(),
)
}
private fun kind3For(
author: HexKey,
follows: List<HexKey>,
) = ContactListEvent(
id = "k3-$author".take(64).padEnd(64, '0'),
pubKey = author,
createdAt = 1_700_000_100,
tags = follows.map { arrayOf("p", it) }.toTypedArray(),
content = "",
sig = dummySig(),
)
private class RecordingGateway : OutboxCacheGateway {
val cache = mutableMapOf<HexKey, AdvertisedRelayListEvent>()
val discoveredOutbox = mutableListOf<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>()
val discoveredEvents = mutableListOf<Pair<Event, NormalizedRelayUrl>>()
override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? = cache[pubkey]
override fun onOutboxDiscovered(
event: AdvertisedRelayListEvent,
relay: NormalizedRelayUrl,
) {
cache[event.pubKey] = event
discoveredOutbox.add(event to relay)
}
override fun onDiscoveredEvent(
event: Event,
relay: NormalizedRelayUrl,
) {
discoveredEvents.add(event to relay)
}
}
/**
* Fake INostrClient that replays a scripted set of events + auto-EOSEs
* per relay when [subscribe] is called. The script is keyed by the
* REQ's `(kinds, relay)` pair so tests can seed different responses
* for Phase-1 and Phase-2 subs.
*/
private class ScriptedClient(
private val delegate: INostrClient = EmptyNostrClient(),
) : INostrClient by delegate {
// (kind, relay) → list of events to return
private val script = mutableMapOf<Pair<Int, NormalizedRelayUrl>, List<Event>>()
private val eoseNever = mutableSetOf<NormalizedRelayUrl>()
val allSubscribeCalls = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
fun scriptEvent(
kind: Int,
relay: NormalizedRelayUrl,
events: List<Event>,
) {
script[kind to relay] = events
}
fun neverEose(relay: NormalizedRelayUrl) {
eoseNever.add(relay)
}
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
allSubscribeCalls.add(filters)
filters.forEach { (relay, filterList) ->
filterList.forEach { filter ->
filter.kinds?.forEach { kind ->
script[kind to relay]?.forEach { event ->
listener?.onEvent(event, isLive = false, relay = relay, forFilters = null)
}
}
}
if (relay !in eoseNever) {
listener?.onEose(relay, forFilters = null)
}
}
}
override fun unsubscribe(subId: String) { /* no-op */ }
}
@Test
fun `cached outbox skips Phase 1 and fetches directly from write relay`() =
runBlocking {
val client = ScriptedClient()
val gateway = RecordingGateway()
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { indexRelays },
gateway = gateway,
perRelayTimeoutMs = 400,
overallTimeoutMs = 2_000,
)
val result = dispatcher.fetchKind3Only(setOf(alice))
assertEquals(1, result.kind3Received)
assertEquals(1, result.outboxCoveredAuthors)
assertEquals(0, result.fallbackAuthors)
assertTrue(
"Phase 2 must REQ from Alice's own outbox relay",
client.allSubscribeCalls.any { call -> outboxAlice in call.keys },
)
assertTrue(
"No Phase 1 REQ should be sent to index relays when 10002 is cached",
client.allSubscribeCalls.none { call -> indexRelays.any { it in call.keys } },
)
}
@Test
fun `Phase 1 discovers 10002 then Phase 2 fetches from the discovered write relay`() =
runBlocking {
val client = ScriptedClient()
val gateway = RecordingGateway()
val bobOutbox = outboxEventFor(bob, listOf(outboxBob))
indexRelays.forEach { rel ->
client.scriptEvent(AdvertisedRelayListEvent.KIND, rel, listOf(bobOutbox))
}
client.scriptEvent(ContactListEvent.KIND, outboxBob, listOf(kind3For(bob, listOf(alice))))
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { indexRelays },
gateway = gateway,
perRelayTimeoutMs = 400,
overallTimeoutMs = 2_000,
)
val result = dispatcher.fetchKind3Only(setOf(bob))
assertTrue("Discovered 10002 count > 0", result.kind10002Received > 0)
assertEquals(1, result.kind3Received)
assertEquals(1, result.outboxCoveredAuthors)
assertEquals(0, result.fallbackAuthors)
assertTrue(
"Gateway was told about the discovered 10002",
gateway.discoveredOutbox.any { it.first.pubKey == bob },
)
}
@Test
fun `author with no 10002 falls back to index-relay REQ`() =
runBlocking {
val client = ScriptedClient()
val gateway = RecordingGateway()
// No 10002 anywhere. Charlie's kind-3 sits only on the index relays.
indexRelays.forEach { rel ->
client.scriptEvent(ContactListEvent.KIND, rel, listOf(kind3For(charlie, listOf(alice))))
}
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { indexRelays },
gateway = gateway,
perRelayTimeoutMs = 400,
overallTimeoutMs = 2_000,
)
val result = dispatcher.fetchKind3Only(setOf(charlie))
assertEquals(1, result.fallbackAuthors)
assertEquals(0, result.outboxCoveredAuthors)
assertTrue(
"Fallback path receives the kind-3",
result.kind3Received >= 1,
)
}
@Test
fun `cached-outbox author still fetched when Phase 1 for other authors times out`() =
runBlocking {
val client = ScriptedClient()
val gateway = RecordingGateway()
// Alice has cached outbox — Phase 2 must fetch from her write relay.
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
// Bob has no cached outbox and index relays never EOSE for Phase 1.
indexRelays.forEach(client::neverEose)
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { indexRelays },
gateway = gateway,
perRelayTimeoutMs = 200,
overallTimeoutMs = 2_000,
)
val result = dispatcher.fetchKind3Only(setOf(alice, bob))
// Alice was covered by cached outbox; Bob wasn't but Phase 1 timed
// out, so he became a fallback candidate.
assertEquals(
"Alice always covered by cached outbox",
1,
result.outboxCoveredAuthors,
)
assertTrue(result.kind3Received >= 1)
}
@Test
fun `clear releases dedup so a subsequent identical call refetches`() =
runBlocking {
val client = ScriptedClient()
val gateway = RecordingGateway()
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { indexRelays },
gateway = gateway,
perRelayTimeoutMs = 400,
overallTimeoutMs = 2_000,
)
dispatcher.fetchKind3Only(setOf(alice))
val subCountAfterFirst = client.allSubscribeCalls.size
// Second call without clear() — should short-circuit.
dispatcher.fetchKind3Only(setOf(alice))
assertEquals(subCountAfterFirst, client.allSubscribeCalls.size)
// After clear(), the same call re-runs Phase 2.
dispatcher.clear()
dispatcher.fetchKind3Only(setOf(alice))
assertTrue(client.allSubscribeCalls.size > subCountAfterFirst)
}
/**
* BatchEoseGate stress — inside OutboxDispatcher this is a private
* class but the observable effect (Phase 1 completes when all index
* relays EOSE, and stays within the timeout budget) is what matters.
*/
@Test
fun `EOSE aggregation is safe with many concurrent index-relay callbacks`() =
runBlocking {
val bigIndexSet = (0..15).map { NormalizedRelayUrl("wss://index$it.test/") }.toSet()
val client = ScriptedClient()
val gateway = RecordingGateway()
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { bigIndexSet },
gateway = gateway,
perRelayTimeoutMs = 1_000,
overallTimeoutMs = 3_000,
)
// Kick off a fetch and race the subscribe call. ScriptedClient
// fires EOSE inline; we simulate concurrent per-relay EOSE by
// launching multiple dispatchers as a smoke test.
val fetchJob = scope.launch { dispatcher.fetchKind3Only(setOf(alice, bob, charlie)) }
// Give the launcher a moment to enter Phase 1's subscribe.
delay(50)
fetchJob.join()
// No CME thrown, no hang past the timeout budget.
}
}
@@ -0,0 +1,286 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.wot
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class WoTServiceTest {
private lateinit var scope: CoroutineScope
private lateinit var svc: WoTService
// Fixed test pubkeys for readability.
private val me = "self".padEnd(64, '0')
private val a = "aaaa".padEnd(64, '0')
private val b = "bbbb".padEnd(64, '0')
private val c = "cccc".padEnd(64, '0')
private val d = "dddd".padEnd(64, '0')
private val e = "eeee".padEnd(64, '0')
@Before
fun setup() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
svc = WoTService(scope, writerDispatcher = Dispatchers.Unconfined)
}
@After
fun teardown() {
scope.cancel()
}
/**
* With `Dispatchers.Unconfined` + `Channel.UNLIMITED`, `trySend` from the
* test thread synchronously resumes the writer coroutine — so no explicit
* wait is needed. This helper is a no-op we keep for future scheduler
* changes.
*/
private fun drain() = Unit
@Test
fun emptyGraphYieldsEmptyScores() {
svc.onFollowSetChange(emptySet(), me)
drain()
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
}
@Test
fun singleFollowerCreditsTargets() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c, d))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(1, svc.scoresSnapshot()[d])
}
@Test
fun overlappingFollowersSumScores() {
svc.onFollowSetChange(setOf(a, b), me)
svc.applyKind3(a, setOf(c, d))
svc.applyKind3(b, setOf(c, e))
drain()
assertEquals(2, svc.scoresSnapshot()[c])
assertEquals(1, svc.scoresSnapshot()[d])
assertEquals(1, svc.scoresSnapshot()[e])
}
@Test
fun removingFollowerDecrementsAllContributions() {
svc.onFollowSetChange(setOf(a, b), me)
svc.applyKind3(a, setOf(c, d))
svc.applyKind3(b, setOf(c, e))
drain()
// A drops out.
svc.onFollowSetChange(setOf(b), me)
drain()
assertEquals(1, svc.scoresSnapshot()[c])
// d had only A crediting it — should be gone.
assertFalse(c in svc.scoresSnapshot() && d in svc.scoresSnapshot() && svc.scoresSnapshot()[d] == null)
assertEquals(null, svc.scoresSnapshot()[d])
assertEquals(1, svc.scoresSnapshot()[e])
}
@Test
fun kind3ChurnAppliesDiff() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c, d))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(1, svc.scoresSnapshot()[d])
// A republishes with a different set — d removed, e added.
svc.applyKind3(a, setOf(c, e))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(null, svc.scoresSnapshot()[d])
assertEquals(1, svc.scoresSnapshot()[e])
}
@Test
fun selfInclusionInKind3IsExcluded() {
svc.onFollowSetChange(setOf(a), me)
// A's kind-3 includes self (me) — must not inflate self's score.
svc.applyKind3(a, setOf(c, me))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(null, svc.scoresSnapshot()[me])
}
@Test
fun followerSelfInclusionIsExcluded() {
svc.onFollowSetChange(setOf(a), me)
// A's kind-3 includes A itself — must not inflate A's own score.
svc.applyKind3(a, setOf(c, a))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(null, svc.scoresSnapshot()[a])
}
@Test
fun kind3FromNonFollowerIsIgnored() {
svc.onFollowSetChange(setOf(a), me)
// e is NOT in my follow set — their kind-3 shouldn't credit anyone.
svc.applyKind3(e, setOf(c, d))
drain()
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
}
@Test
fun sparseMapDropsZeroCounts() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c))
drain()
assertTrue(c in svc.scoresSnapshot())
// A republishes with an empty follow set.
svc.applyKind3(a, emptySet())
drain()
// c dropped to 0 → removed from map, not stored as 0.
assertFalse(c in svc.scoresSnapshot())
}
private fun fakePubkey(seed: Int): String = seed.toString(16).padStart(64, '0')
@Test
fun guardrailSkipsHugeFollowSets() {
val hugeFollows = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
svc.onFollowSetChange(hugeFollows, me)
drain()
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
assertTrue(runBlocking { svc.isReady.first() })
assertTrue(runBlocking { svc.isDisabled.first() })
}
/**
* Regression for PR #3483 review finding 2: even after the guardrail
* trips, applyKind3 for a follower in the huge follow set used to
* repopulate reverseIndex/_scores because myFollows had already been
* assigned. Fix clears myFollows AND sets a disabled flag; both gate
* handleKind3 so the guardrail actually holds under sustained pump.
*/
@Test
fun guardrailIgnoresApplyKind3AfterTrip() {
val huge = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
svc.onFollowSetChange(huge, me)
drain()
val anyFollower = huge.first()
svc.applyKind3(anyFollower, setOf(c, d, e))
drain()
assertEquals(
"Guardrail must block score repopulation via applyKind3",
emptyMap<String, Int>(),
svc.scoresSnapshot(),
)
}
@Test
fun guardrailReleasesWhenFollowSetShrinksBack() {
val huge = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
svc.onFollowSetChange(huge, me)
drain()
assertTrue(runBlocking { svc.isDisabled.first() })
// User trims their follow list — dispatcher should re-engage.
svc.onFollowSetChange(setOf(a, b), me)
drain()
assertFalse(runBlocking { svc.isDisabled.first() })
// And WoT scoring resumes normally.
svc.applyKind3(a, setOf(c, d))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
}
@Test
fun closeStopsAcceptingOps() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
svc.close()
drain()
// Post-close writes are dropped silently.
svc.applyKind3(a, setOf(d))
drain()
assertEquals(null, svc.scoresSnapshot()[d])
// State observed before close remains readable.
assertEquals(1, svc.scoresSnapshot()[c])
}
@Test
fun closeIsIdempotent() {
svc.close()
svc.close() // should not throw
}
@Test
fun maxFollowsPerEventCap() {
svc.onFollowSetChange(setOf(a), me)
val huge = (0..WoTService.MAX_FOLLOWS_PER_EVENT + 100).map { fakePubkey(it) }.toSet()
svc.applyKind3(a, huge)
drain()
// Cap kicks in after MAX_FOLLOWS_PER_EVENT — no crash, score map bounded.
assertTrue(svc.scoresSnapshot().size <= WoTService.MAX_FOLLOWS_PER_EVENT)
}
@Test
fun markReadyOnceFiresReady() {
assertFalse(runBlocking { svc.isReady.first() })
svc.markReadyOnce()
drain()
assertTrue(runBlocking { svc.isReady.first() })
}
@Test
fun clearResetsEverything() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c, d))
svc.markReadyOnce()
drain()
svc.clear()
drain()
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
assertFalse(runBlocking { svc.isReady.first() })
}
@Test
fun scoresSnapshotIsHashMapCopy() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c))
drain()
val snap = svc.scoresSnapshot()
assertEquals(1, snap[c])
// Modifying the snapshot must not affect the service.
(snap as MutableMap<String, Int>).clear()
assertEquals(1, svc.scoresSnapshot()[c])
}
}
@@ -0,0 +1,73 @@
# Manual testing sheet — Desktop Web-of-Trust Score Badges
Plan: `docs/plans/2026-07-01-feat-desktop-wot-score-plan.md`
Run with `./gradlew :desktopApp:run`. Sign in with an account that has a
follow list (WoT is meaningless without one).
## Pre-flight
- **Followed authors' kind-3 events** need to be reachable via the
configured index relays. On first launch, badges may take a couple of
seconds to appear while the batch REQ completes.
- Hashtag-spam PR (#3431) providers must be live — WoTBadgedAvatar reads
`LocalSpamExemptKeys` for the self+follows hide predicate.
## Scenarios
| # | Test | Expected |
|---|------|----------|
| **T1** | **Cold start with ≥50 follows.** Log in, watch avatars for 2 s. | Batch REQ fires once; badges appear on some strangers within ~2 s. |
| **T2** | **No badge on self.** Open own profile in a Profile column. | Own header avatar has no chip regardless of any follower kind-3s. |
| **T3** | **No badge on followed authors.** Any note by a person you follow. | Card renders with clean avatar, no chip. |
| **T4** | **Badge on a stranger who's followed by 2 of your follows.** Find such a note (or contrive one). | Small chip showing "2" bottom-right of avatar. |
| **T5** | **Tooltip on hover.** Hover the badge for ~1 s. | Plain tooltip: "N of the people you follow follow this person". |
| **T6** | **99+ overflow.** Simulate a pubkey scored 200+ (e.g. a well-connected celebrity in your graph). | Badge shows `99+`. |
| **T7** | **No layout shift.** Scroll a busy column while badges arrive mid-scroll. | Avatar sizes don't jump — badge overlays the existing avatar bounds. |
| **T8** | **Kind-3 churn.** Watch a followed author's kind-3 update mid-session (or manually publish one from another client). | Their contribution to affected pubkeys' scores diffs correctly; no double-counting. |
| **T9** | **Follow someone new.** Follow a fresh pubkey via the UI. | Their kind-3 is fetched via a subsequent `loadKind3Batched`; anyone they follow gets their score incremented once their kind-3 arrives. |
| **T10** | **Unfollow someone.** Unfollow an existing follow via the UI. | Every pubkey they were crediting has their score decremented; some may drop to 0 and lose their badge. |
| **T11** | **Guardrail (mega-follow account).** Log in with an account following ≥ 2 000 pubkeys. | `LocalWoTReady` becomes true immediately; no badges anywhere; no batch REQ fires. |
| **T12** | **Empty graph.** Log in with an account following 0 people. | `LocalWoTReady` becomes true after the 2 s fallback timeout; no badges. |
| **T13** | **`consumeContactList` prerequisite fix.** From another client, watch a kind-3 event from a follower arrive while you're logged in. | Your own `_followedUsers` (used by feed filters, sidebar) stays unchanged. Verify by opening a filtered feed and confirming it hasn't broken. |
| **T14** | **Account switch.** Switch to a different logged-in account. | Old scores gone; new account's follow set drives new WoT map. No leaked badges. |
| **T15** | **amy wot get.** `./gradlew :cli:installDist && cli/build/install/cli/bin/amy wot get <npub>` (against an account with kind-3 events in `~/.amy/shared/events-store/`). | Output: `pubkey=<hex> score=<n>` or JSON with `--json`. |
| **T16** | **amy wot list.** `amy wot list --threshold 3 --limit 20 --json`. | JSON of top-20 pubkeys with score ≥ 3, sorted desc. |
| **T17** | **amy wot sync.** `amy wot sync` (with follows in local store). | Runs a chunked kind-3 REQ against outbox relays, stores fresh events. Subsequent `amy wot get` reflects the update. |
## Known v1 limitations
- **Notification-tab avatars are not badged.** Notifications use a custom
56 dp compact card that doesn't route through `NoteCard` / `UserAvatar`.
Deferred to v2.
- **Search-result "person" cards** (via `UserSearchCard`) are not badged
in v1 — they use a different composable.
- **Cross-column reveal state doesn't matter** (WoT is stateless per
render; no reveal to persist).
- **No filter/threshold gating of feeds or notifications v1** — badges
are display-only. v2 will add the threshold Setting.
- **`amy wot sync` uses outbox/inbox relays**, not index relays. The
Desktop app uses `indexRelays`. If the two disagree, results may
differ slightly. Follow-up ticket: unified `indexRelays` for both.
## Sign-off
- [ ] T1 Cold start
- [ ] T2 No self badge
- [ ] T3 No badge on follows
- [ ] T4 Stranger scored 2
- [ ] T5 Tooltip
- [ ] T6 99+ overflow
- [ ] T7 No layout shift
- [ ] T8 Kind-3 churn
- [ ] T9 New follow
- [ ] T10 Unfollow
- [ ] T11 Guardrail
- [ ] T12 Empty graph
- [ ] T13 Cache prerequisite fix
- [ ] T14 Account switch
- [ ] T15 amy wot get
- [ ] T16 amy wot list
- [ ] T17 amy wot sync
Tester: ________________ Date: ________________
@@ -78,13 +78,14 @@ import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady
import com.vitorpamplona.amethyst.commons.wot.LocalWoTService
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome
@@ -126,7 +127,6 @@ import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -844,6 +844,17 @@ private fun AppInner(
// node so the `amy` CLI binary observes the same toggle.
val hashtagSpamSettings = remember { PreferencesHashtagSpamSettings() }
// Index-relay preference — user-configurable set used to fetch profile
// metadata (kind 0) and follow lists (kind 3). Persisted in a shared
// java.util.prefs node so `amy wot sync` reads from the same source of
// truth. Falls back to PreferencesIndexRelays.DEFAULT_INDEX_RELAYS when
// the user hasn't configured anything.
val indexRelaysStore =
remember {
com.vitorpamplona.amethyst.commons.relays.index
.PreferencesIndexRelays()
}
// Local relay store — persists events to SQLite per account
val localRelayStore =
remember {
@@ -928,18 +939,16 @@ private fun AppInner(
}
}
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
// Subscriptions coordinator — uses the user's configured index relays
// (or PreferencesIndexRelays.DEFAULT_INDEX_RELAYS as fallback) for
// metadata + follow-list REQs. Changes made via the settings UI take
// effect on next relaunch — the coordinator snapshots the set here.
val subscriptionsCoordinator =
remember(relayManager, localCache) {
remember(relayManager, localCache, indexRelaysStore) {
DesktopRelaySubscriptionsCoordinator(
client = relayManager.client,
scope = scope,
indexRelays =
DefaultRelays.RELAYS
.mapNotNull {
RelayUrlNormalizer.normalizeOrNull(it)
}.toSet(),
indexRelays = indexRelaysStore.effective(),
localCache = localCache,
).also { it.startCleanupLoop() }
}
@@ -950,6 +959,7 @@ private fun AppInner(
when (val state = accountState) {
is AccountState.LoggedOut -> {
subscriptionsCoordinator.clear()
localCache.accountPubkey = null
localCache.clear()
localRelayMaintenance.stop()
localRelayStore.close()
@@ -961,11 +971,23 @@ private fun AppInner(
if (previousAccountPubKey != null && previousAccountPubKey != currentPubKey) {
// Account switched — clear old data so new feed loads fresh
subscriptionsCoordinator.clear()
localCache.accountPubkey = null
localCache.clear()
localRelayMaintenance.stop()
localRelayStore.close()
subscriptionsCoordinator.start()
}
// Bind the active-user pubkey BEFORE hydration launches. The
// hydration coroutine below reads the local relay store on
// Dispatchers.IO and calls consumeContactList; without this
// ordering, a cached self kind-3 would be stamped without
// updating _followedUsers, and a later relay retry of the
// same event would be rejected by the createdAt gate,
// leaving the follow list empty and FollowAction.follow
// publishing a fresh kind-3 that wipes the real one.
// See commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
// (Fix 1).
localCache.accountPubkey = currentPubKey
// Open local relay store for the current account and hydrate cache
localRelayStore.openForAccount(currentPubKey)
localRelayMaintenance.start()
@@ -1261,6 +1283,7 @@ private fun AppInner(
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
indexRelaysStore = indexRelaysStore,
nip11Fetcher = nip11Fetcher,
appScope = scope,
torStatus = currentTorStatus,
@@ -1383,6 +1406,7 @@ fun MainContent(
account: AccountState.LoggedIn,
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
indexRelaysStore: com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays,
nip11Fetcher: Nip11Fetcher,
appScope: CoroutineScope,
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
@@ -1414,6 +1438,14 @@ fun MainContent(
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays)
}
// When iAccount is replaced (account switch), the previous WoTService's
// internal writer coroutine + ops Channel would otherwise leak — the
// outer `scope` lives for the whole session. Close the previous
// instance on dispose so account-switch is a clean teardown.
DisposableEffect(iAccount) {
onDispose { iAccount.wotService.close() }
}
// Follow Packs state — single per-account holder for Discover + sidebar + naddr cards
val followPacksState =
remember(iAccount, localCache, relayManager, scope) {
@@ -1426,13 +1458,14 @@ fun MainContent(
)
}
// Aggregated relay categories (feed, notifications, search, DM)
// Aggregated relay categories (feed, notifications, search, DM, index)
val relayCategories =
remember(iAccount.nip65RelayList, accountRelays, relayManager) {
remember(iAccount.nip65RelayList, accountRelays, relayManager, indexRelaysStore) {
DesktopRelayCategories(
nip65State = iAccount.nip65RelayList,
accountRelays = accountRelays,
connectedRelays = relayManager.connectedRelays,
indexRelaysStore = indexRelaysStore,
scope = scope,
)
}
@@ -1677,6 +1710,71 @@ fun MainContent(
relayHealthStore.scanNow()
}
// Web-of-Trust: pubkey is already bound by the outer LaunchedEffect
// that also gates hydration ordering (see the LoggedIn branch above).
// This effect re-asserts the binding to cover the (rare) case where
// MainContent's `account` diverges from the outer accountState mid-
// recomposition; it's idempotent when they already match.
LaunchedEffect(localCache, account.pubKeyHex) {
localCache.accountPubkey = account.pubKeyHex
}
val wotReady by iAccount.wotService.isReady.collectAsState()
LaunchedEffect(
iAccount.wotService,
localCache,
subscriptionsCoordinator,
account.pubKeyHex,
) {
// Fan-in of every accepted kind-3 event from the local cache.
launch {
localCache.contactListEvents.collect { evt ->
iAccount.wotService.applyKind3(evt.pubKey, evt.verifiedFollowKeySet())
}
}
// React to changes in the active user's follow set. Under the
// outbox model (PR #3483 review directive from Vitor) kind-3
// fetch goes to each author's declared write relays instead of a
// static index-relay broadcast — the OutboxDispatcher does the
// NIP-65 discovery, transposes with RelayListRecommendationProcessor
// and issues per-outbox-relay REQs. Falls back to index relays
// for authors that never returned a 10002.
launch {
localCache.followedUsers.collect { follows ->
iAccount.wotService.onFollowSetChange(follows, account.pubKeyHex)
when {
iAccount.wotService.isDisabled.value -> {
// Guardrail — mega-follow accounts skip WoT
// entirely so we don't dispatch a batch that
// would be discarded anyway.
iAccount.wotService.markReadyOnce()
}
follows.isEmpty() -> {
iAccount.wotService.markReadyOnce()
}
else -> {
launch {
val result = subscriptionsCoordinator.loadKind3ViaOutbox(follows)
Log.d("WotOutbox") {
"fetchKind3Only authors=${result.authorsRequested} " +
"covered=${result.outboxCoveredAuthors} " +
"fallback=${result.fallbackAuthors} " +
"kind10002=${result.kind10002Received} " +
"kind3=${result.kind3Received}"
}
iAccount.wotService.markReadyOnce()
}
}
}
}
}
// Safety net: mark ready after 2s regardless of REQ progress so
// avatars stop suppressing badges even if index relays never EOSE.
launch {
kotlinx.coroutines.delay(2_000)
iAccount.wotService.markReadyOnce()
}
}
CompositionLocalProvider(
LocalRelayCategories provides relayCategories,
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
@@ -1685,6 +1783,8 @@ fun MainContent(
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayHealthStore provides relayHealthStore,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayListMutator provides relayListMutator,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState provides followPacksState,
LocalWoTService provides iAccount.wotService,
LocalWoTReady provides wotReady,
) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
@@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
@@ -66,6 +67,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
@@ -91,6 +93,27 @@ class DesktopLocalCache : ICacheProvider {
private val _followedUsers = MutableStateFlow<Set<HexKey>>(emptySet())
val followedUsers: StateFlow<Set<HexKey>> = _followedUsers.asStateFlow()
/**
* Active user's pubkey (hex). Set from Main.kt on login. When set, only
* kind-3 events from this pubkey update [_followedUsers] and
* [lastContactListEvent]. Other users' kind-3 events still flow through
* [contactListEvents] for consumers like the WoT service.
*/
@Volatile
var accountPubkey: HexKey? = null
/**
* Fires for every accepted kind-3 event (both the active user's and
* other users' — filtered downstream). Buffered so slow consumers don't
* block the consume path.
*/
private val _contactListEvents =
MutableSharedFlow<ContactListEvent>(
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val contactListEvents: SharedFlow<ContactListEvent> = _contactListEvents.asSharedFlow()
/** Increments on each metadata update — observe to recompose when user names change. */
private val _metadataVersion = MutableStateFlow(0L)
val metadataVersion: StateFlow<Long> = _metadataVersion.asStateFlow()
@@ -281,11 +304,43 @@ class DesktopLocalCache : ICacheProvider {
consumeComment(event, relay)
}
is AdvertisedRelayListEvent -> {
consumeAdvertisedRelayList(event, relay)
}
else -> {
false
}
}
/**
* Consumes a kind 10002 (NIP-65) advertised relay list event. Stores
* the newest per-author copy in [addressableNotes] so the outbox
* dispatcher can look up each follow's declared write relays without
* a fresh REQ. Emits nothing to the event stream — the UI doesn't
* render kind 10002s directly.
*/
private fun consumeAdvertisedRelayList(
event: AdvertisedRelayListEvent,
relay: NormalizedRelayUrl?,
): Boolean {
val addressableNote = getOrCreateAddressableNote(event.address())
val existing = addressableNote.event
if (existing != null && existing.createdAt >= event.createdAt) return false
val author = getOrCreateUser(event.pubKey)
addressableNote.loadEvent(event, author, emptyList())
relay?.let { addressableNote.addRelay(it) }
return false
}
/**
* Returns the cached kind-10002 event for [pubkey], if any. Used by the
* outbox dispatcher to skip a Phase-1 REQ for authors whose write-relay
* list is already in the store (from a previous session's local relay
* hydration or an in-session discovery).
*/
fun cachedAdvertisedRelayList(pubkey: HexKey): AdvertisedRelayListEvent? = addressableNotes.get(AdvertisedRelayListEvent.createAddress(pubkey).toValue())?.event as? AdvertisedRelayListEvent
/**
* Consumes a kind 1 text note event.
* Creates/updates Note in cache and links reply relationships.
@@ -472,19 +527,47 @@ class DesktopLocalCache : ICacheProvider {
/**
* Consumes a kind 3 contact list event (replaceable).
* Updates the cached followedUsers set.
*
* Tracks the newest kind-3 per author (not a single global scalar) so
* ingesting other users' follow lists (e.g. for WoT scoring) doesn't
* corrupt the active user's state. Only the active user's kind-3 updates
* [_followedUsers] / [lastContactListEvent]. Every accepted event fans
* out on [_contactListEvents] for downstream consumers.
*/
private var lastContactListCreatedAt = 0L
private val lastContactListByAuthor = ConcurrentHashMap<HexKey, Long>()
var lastContactListEvent: ContactListEvent? = null
private set
private fun consumeContactList(event: ContactListEvent): Boolean {
// Replaceable event — only accept newer contact lists
if (event.createdAt <= lastContactListCreatedAt) return false
lastContactListCreatedAt = event.createdAt
lastContactListEvent = event
_followedUsers.value = event.verifiedFollowKeySet()
// Replaceable event — only accept newer contact lists per author.
val prev = lastContactListByAuthor[event.pubKey] ?: 0L
if (event.createdAt <= prev) return false
// Stamp lastContactListByAuthor *only* on branches where we know
// whether this event is the active user's own kind-3. If accountPubkey
// hasn't been bound yet (login/hydration ordering window), skip the
// stamp entirely so a later relay retry — after Main.kt binds
// accountPubkey — is not rejected by the createdAt gate. The
// _followedUsers state remains untouched in that case; downstream
// consumers still get the fan-out via _contactListEvents (WoT etc).
val currentAccountPubkey = accountPubkey
when {
event.pubKey == currentAccountPubkey -> {
lastContactListEvent = event
_followedUsers.value = event.verifiedFollowKeySet()
lastContactListByAuthor[event.pubKey] = event.createdAt
}
currentAccountPubkey != null -> {
// Known-not-self: safe to stamp.
lastContactListByAuthor[event.pubKey] = event.createdAt
}
else -> {
// accountPubkey not bound yet — cannot tell if this is self.
// Defer stamping so the relay retry that arrives after bind
// will still populate _followedUsers.
}
}
// Store in addressableNotes too — Kind3FollowListState.getFollowListEvent
// reads from getOrCreateAddressableNote(...) and would otherwise see a
@@ -493,6 +576,8 @@ class DesktopLocalCache : ICacheProvider {
val addressableNote = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
addressableNote.loadEvent(event, author, emptyList())
_contactListEvents.tryEmit(event)
return true
}
@@ -786,7 +871,9 @@ class DesktopLocalCache : ICacheProvider {
followerCounts.clear()
followingCounts.clear()
notesByAuthor.clear()
lastContactListCreatedAt = 0L
lastContactListByAuthor.clear()
lastContactListEvent = null
accountPubkey = null
}
}
@@ -93,6 +93,14 @@ class DesktopIAccount(
},
)
/**
* Friends-of-friends trust score. Populated by Main.kt's login flow
* from batch kind-3 fetches on the active user's follow set.
*/
val wotService =
com.vitorpamplona.amethyst.commons.wot
.WoTService(scope)
val nip65RelayList =
Nip65RelayListState(
signer,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.model
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
@@ -47,6 +49,13 @@ class DesktopRelayCategories(
accountRelays: DesktopAccountRelays,
/** Reactive connected relay set — used as fallback when NIP-65 is empty */
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
/**
* Shared index-relay preference — app-global, backed by
* [PreferencesIndexRelays] and visible to `amy` via the same
* Preferences node. Used by [indexRelays] and by
* `Main.kt` when constructing the subscriptions coordinator.
*/
private val indexRelaysStore: PreferencesIndexRelays,
scope: CoroutineScope,
) {
/** Default relays — ALWAYS populated, used as stateIn initial value */
@@ -99,6 +108,26 @@ class DesktopRelayCategories(
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
/**
* Index relays: user override → [PreferencesIndexRelays.DEFAULT_INDEX_RELAYS].
*
* Unlike [feedRelays] / [notificationRelays] / [dmRelays] this
* category does *not* combine with connected/NIP-65 sets — it's a
* curated user choice about where to look up metadata and follow
* lists, not a "what's actually reachable right now" derived set.
* No debounce needed: writes are gated by settings-screen UI, not
* fanned in from a subscription pipeline.
*/
val indexRelays: StateFlow<Set<NormalizedRelayUrl>> =
indexRelaysStore.relays
.map { it.ifEmpty { PreferencesIndexRelays.DEFAULT_INDEX_RELAYS } }
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, indexRelaysStore.effective())
fun setIndexRelays(new: Set<NormalizedRelayUrl>) {
indexRelaysStore.setRelays(new)
}
companion object {
val DEFAULT_SEARCH_RELAYS = DefaultSearchRelayList
}
@@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoo
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter
import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert
import com.vitorpamplona.amethyst.commons.wot.OutboxCacheGateway
import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -33,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -98,6 +101,51 @@ class DesktopRelaySubscriptionsCoordinator(
},
)
/**
* Bridges [OutboxDispatcher] to [DesktopLocalCache]. Every event the
* dispatcher receives goes through [DesktopLocalCache.consume] so it
* lands in the same code path as events arriving from feed
* subscriptions — kind-10002 caches into `addressableNotes`; kind-0
* updates the user metadata; kind-3 fans out through
* `_contactListEvents` for the WoT service.
*/
private val outboxGateway =
object : OutboxCacheGateway {
override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? = localCache.cachedAdvertisedRelayList(pubkey)
override fun onOutboxDiscovered(
event: AdvertisedRelayListEvent,
relay: NormalizedRelayUrl,
) {
localCache.consume(event, relay)
}
override fun onDiscoveredEvent(
event: Event,
relay: NormalizedRelayUrl,
) {
localCache.consume(event, relay)
}
}
/**
* NIP-65 outbox model for kind-0 and kind-3 fetching. Per PR #3483
* review directive from Vitor: index relays discover each author's
* write-relay list, then kind-0/kind-3 REQs go to that author's
* declared write relays. See [OutboxDispatcher] for the pipeline.
*
* Kept as a val (not lazy) because [clear] must reset its dedup
* markers on account switch. The dispatcher itself is stateless
* across accounts as long as `clear()` is called.
*/
val outboxDispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { indexRelays },
gateway = outboxGateway,
)
// Event bundler: batches consumed notes before emitting to SharedFlow
// 250ms for desktop (Android uses 1000ms to save battery)
private val eventBundler =
@@ -264,6 +312,19 @@ class DesktopRelaySubscriptionsCoordinator(
feedMetadata.loadMetadataBatched(pubkeys)
}
/**
* Batched kind-3 (follow list) fetch. Used by the WoT service to
* build friends-of-friends counts. Chunks authors into ≤100 per
* Filter within one subscription. [onEose] fires once all chunks
* finish (or after the 5s internal timeout).
*/
fun loadKind3Batched(
pubkeys: Collection<HexKey>,
onEose: () -> Unit = {},
) {
feedMetadata.loadKind3Batched(pubkeys, onEose = onEose)
}
// -- DM Subscription Support --
/** Active DM subscription IDs for cleanup */
@@ -374,10 +435,20 @@ class DesktopRelaySubscriptionsCoordinator(
unsubscribeFromDms()
feedMetadata.clear()
outboxDispatcher.clear()
rateLimiter.reset()
cleanupJob?.cancel()
}
/**
* Fetch kind-3 (follow lists) for [pubkeys] via each author's outbox
* relay per NIP-65 (see [OutboxDispatcher]). Suspends until every
* phase EOSEs or times out. Callers typically launch this on a
* scope-owned coroutine and mark the WoT service ready in the
* continuation. Returns per-phase counters for observability.
*/
suspend fun loadKind3ViaOutbox(pubkeys: Set<HexKey>): OutboxDispatcher.Result = outboxDispatcher.fetchKind3Only(pubkeys)
// ----- Memory Cleanup -----
private val memoryBean = ManagementFactory.getMemoryMXBean()
@@ -99,7 +99,6 @@ import com.vitorpamplona.amethyst.commons.search.QuerySerializer
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.elements.BoostedMark
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.feeds.NewPostsChip
@@ -136,6 +135,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
@@ -290,14 +290,14 @@ private fun FeedNoteCardBody(
) {
GenericRepostLayout(
baseAuthorPicture = {
UserAvatar(
WoTBadgedAvatar(
userHex = event.pubKey,
pictureUrl = reposterUser?.profilePicture(),
size = 35.dp,
)
},
repostAuthorPicture = {
UserAvatar(
WoTBadgedAvatar(
userHex = originalEvent.pubKey,
pictureUrl = originalUser?.profilePicture(),
size = 35.dp,
@@ -77,7 +77,6 @@ import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus
import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner
import com.vitorpamplona.amethyst.commons.state.FollowState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
@@ -90,6 +89,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscri
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
@@ -682,7 +682,7 @@ fun UserProfileScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
UserAvatar(
WoTBadgedAvatar(
userHex = pubKeyHex,
pictureUrl = picture,
size = 56.dp,
@@ -1155,7 +1155,7 @@ fun UserProfileScreen(
}
Spacer(Modifier.width(4.dp))
}
UserAvatar(
WoTBadgedAvatar(
userHex = pubKeyHex,
pictureUrl = picture,
size = 28.dp,
@@ -58,7 +58,6 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext
import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
@@ -69,6 +68,7 @@ import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
@@ -259,7 +259,7 @@ fun NoteCard(
},
),
) {
UserAvatar(
WoTBadgedAvatar(
userHex = note.pubKeyHex,
pictureUrl = note.profilePictureUrl,
size = 32.dp,
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
/**
* Compact trust-score chip drawn on top of a [UserAvatar]. Shows the raw
* count of accounts in the active user's follow set who also follow this
* pubkey. Clamps display to `"99+"` for counts over 99 to keep the chip
* width predictable.
*
* Wrapped in a Material3 [TooltipBox] so hovering the badge shows a
* plain tooltip explaining what the number means. `isPersistent = true`
* fixes the Compose Multiplatform default of tooltips dismissing too
* quickly for mouse users (JB compose-multiplatform#3539).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WoTBadge(
count: Int,
modifier: Modifier = Modifier,
) {
if (count <= 0) return
val display = if (count > 99) "99+" else count.toString()
val state = rememberTooltipState(isPersistent = true)
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above, 4.dp),
tooltip = { PlainTooltip { Text("$count of the people you follow follow this person") } },
state = state,
) {
Box(
modifier
.size(18.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.semantics { contentDescription = "Followed by $count of your contacts" },
contentAlignment = Alignment.Center,
) {
Text(
text = display,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady
import com.vitorpamplona.amethyst.commons.wot.LocalWoTService
/**
* Drop-in replacement for [UserAvatar] that overlays a Web-of-Trust
* score chip when four gates all pass:
*
* 1. `LocalWoTService.current` is non-null (Desktop provides it; Android
* leaves it null and this composable falls back to a plain avatar).
* 2. `LocalWoTReady.current == true` (initial batch fetch complete OR
* startup timeout elapsed).
* 3. `userHex !in LocalSpamExemptKeys.current` — same set the hashtag-spam
* filter uses; contains the active user's pubkey plus everyone they
* follow. Skips self-badge and already-trusted accounts in one check.
*
* The score is read as a plain snapshot access from
* `WoTService.scores` — Compose tracks the read per key, so avatars only
* recompose when their own score changes.
*/
@Composable
fun WoTBadgedAvatar(
userHex: String,
pictureUrl: String?,
size: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
loadProfilePicture: Boolean = true,
loadRobohash: Boolean = true,
useThumbnailCache: Boolean = false,
) {
val service = LocalWoTService.current
val ready = LocalWoTReady.current
val exemptKeys = LocalSpamExemptKeys.current
val score =
if (service != null && ready && userHex !in exemptKeys) {
service.scores[userHex] ?: 0
} else {
0
}
UserAvatar(
userHex = userHex,
pictureUrl = pictureUrl,
size = size,
modifier = modifier,
contentDescription = contentDescription,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
useThumbnailCache = useThumbnailCache,
badge =
if (score > 0) {
{
WoTBadge(
count = score,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
} else {
null
},
)
}
@@ -0,0 +1,222 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.relay
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Editor for the shared "index relays" — the set used by
* `FeedMetadataCoordinator` (Desktop) and `amy wot sync` (CLI) to fetch
* profile metadata (kind 0) and follow lists (kind 3).
*
* Matches the buffered-Save UX of the sibling relay editors
* (Search / DM / Blocked). Add/Remove mutate a local buffer; Save
* commits the buffer to `PreferencesIndexRelays`. Reset restores the
* built-in defaults into the buffer (still requires Save to persist).
*
* The running Desktop coordinator continues using its constructor-time
* snapshot until the app is relaunched, so persisted changes take effect
* on next launch.
*/
@Composable
fun IndexRelaysEditor(
categories: DesktopRelayCategories,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val persisted by categories.indexRelays.collectAsState()
val localRelays = remember { mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>() }
var newRelayUrl by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var savedMessage by remember { mutableStateOf<String?>(null) }
LaunchedEffect(persisted) {
localRelays.clear()
localRelays.addAll(persisted.sortedBy { it.url })
}
Column(modifier = modifier.fillMaxWidth()) {
Text(
"Relays queried for profile metadata and follow lists (Web-of-Trust). Changes take effect on next relaunch. Shared with the `amy` CLI.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = newRelayUrl,
onValueChange = {
newRelayUrl = it
error = null
},
label = { Text("wss://relay.example.com") },
singleLine = true,
isError = error != null,
supportingText = error?.let { { Text(it) } },
modifier =
Modifier
.weight(1f)
.onPreviewKeyEvent { event ->
if (event.key == Key.Enter && event.type == KeyEventType.KeyDown) {
error = tryAddSimpleRelay(newRelayUrl, localRelays)
if (error == null) newRelayUrl = ""
true
} else {
false
}
},
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = {
error = tryAddSimpleRelay(newRelayUrl, localRelays)
if (error == null) newRelayUrl = ""
},
) {
Icon(MaterialSymbols.Add, contentDescription = "Add relay")
}
}
if (localRelays.isNotEmpty()) {
Text(
"${localRelays.size} relay(s) configured",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
}
localRelays.toList().forEach { url ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
url.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
)
IconButton(onClick = { localRelays.remove(url) }, modifier = Modifier.size(28.dp)) {
Icon(
MaterialSymbols.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp),
)
}
}
}
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
if (newRelayUrl.isNotBlank()) {
val addError = tryAddSimpleRelay(newRelayUrl, localRelays)
if (addError != null) {
error = addError
return@Button
}
newRelayUrl = ""
}
if (localRelays.isEmpty()) {
error = "Add at least one relay before saving (or Reset to defaults)"
return@Button
}
categories.setIndexRelays(localRelays.toSet())
scope.launch {
savedMessage = "Saved ${localRelays.size} relay(s) — restart to apply"
delay(3000)
savedMessage = null
}
},
) {
Text("Save")
}
OutlinedButton(
onClick = {
localRelays.clear()
localRelays.addAll(
PreferencesIndexRelays.DEFAULT_INDEX_RELAYS.sortedBy { it.url },
)
error = null
},
) {
Text("Reset to defaults")
}
savedMessage?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -162,6 +162,18 @@ fun RelayConfigTab(
},
)
}
Spacer(Modifier.height(16.dp))
// 6. Index Relays — app-global (not per-account); shared with `amy wot sync`.
CollapsibleSection(
title = "Index Relays",
description = "Where the Web-of-Trust and profile lookups fetch kind 0/3 events",
) {
IndexRelaysEditor(
categories = LocalRelayCategories.current,
)
}
}
}
@@ -55,12 +55,16 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady
import com.vitorpamplona.amethyst.commons.wot.LocalWoTService
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadge
import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -116,6 +120,7 @@ fun SearchResultsList(
UserSearchCard(
user = user,
onClick = { onNavigateToProfile(user.pubkeyHex) },
badge = wotBadgeFor(user.pubkeyHex),
)
}
if (people.size > 5) {
@@ -126,6 +131,7 @@ fun SearchResultsList(
UserSearchCard(
user = user,
onClick = { onNavigateToProfile(user.pubkeyHex) },
badge = wotBadgeFor(user.pubkeyHex),
)
}
}
@@ -286,6 +292,34 @@ fun SearchResultsList(
}
}
/**
* Returns a WoT-badge lambda for the given pubkey, or null when the
* badge should be hidden. Same gates as [WoTBadgedAvatar]:
* - WoT service is provided
* - initial batch fetch has finished (or 2 s startup timeout fired)
* - the pubkey is not exempt (self or already followed)
* - the score is > 0
* Inlined here (rather than wrapped in a new composable) because it's
* only used at the two SearchResultsList person-result call sites.
*/
@Composable
private fun wotBadgeFor(userHex: String): (@Composable androidx.compose.foundation.layout.BoxScope.() -> Unit)? {
val service = LocalWoTService.current
val ready = LocalWoTReady.current
val exempt = LocalSpamExemptKeys.current
val score =
if (service != null && ready && userHex !in exempt) {
service.scores[userHex] ?: 0
} else {
0
}
return if (score > 0) {
{ WoTBadge(count = score, modifier = Modifier.align(Alignment.BottomEnd)) }
} else {
null
}
}
@Composable
private fun SortableHeader(
title: String,
@@ -152,7 +152,7 @@ class CoordinatorPipelineTest {
fun `consumeEvent routes text note into cache and triggers ViewModel update`() =
runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val (coordinator, _) = createCoordinator(cache, scope)
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
@@ -191,7 +191,7 @@ class CoordinatorPipelineTest {
fun `consumeEvent updates lastEventAt timestamp`() =
runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val (coordinator, _) = createCoordinator(cache, scope)
assertTrue(coordinator.lastEventAt.value == null, "lastEventAt should be null initially")
@@ -217,7 +217,7 @@ class CoordinatorPipelineTest {
fun `contact list consumed via coordinator updates followedUsers`() =
runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val (coordinator, _) = createCoordinator(cache, scope)
val contactEvent =
@@ -244,7 +244,7 @@ class CoordinatorPipelineTest {
fun `following feed shows notes after contact list and text notes arrive via coordinator`() =
runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val (coordinator, _) = createCoordinator(cache, scope)
// Step 1: Contact list arrives
@@ -294,7 +294,7 @@ class CoordinatorPipelineTest {
fun `following feed remains empty when no contact list has been consumed`() =
runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val (coordinator, _) = createCoordinator(cache, scope)
// No contact list consumed — followedUsers is empty
@@ -334,7 +334,7 @@ class CoordinatorPipelineTest {
fun `duplicate events are not double-counted in feed`() =
runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val (coordinator, _) = createCoordinator(cache, scope)
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
@@ -372,7 +372,7 @@ class CoordinatorPipelineTest {
fun `requestInteractions opens subscription on client`() =
runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val (coordinator, client) = createCoordinator(cache, scope)
val noteIds = listOf("n1".padEnd(64, '0'))
@@ -393,7 +393,7 @@ class CoordinatorPipelineTest {
fun `requestInteractions with empty noteIds returns without opening subscription`() =
runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val (coordinator, client) = createCoordinator(cache, scope)
coordinator.requestInteractions(emptyList(), setOf(relayUrl))
@@ -124,7 +124,7 @@ class DesktopCachePipelineTest {
@Test
fun `consume text note creates Note in cache`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val event = textNote("note1".padEnd(64, '0'), userPubKey)
val consumed = cache.consume(event, relayUrl, wasVerified = true)
@@ -137,7 +137,7 @@ class DesktopCachePipelineTest {
@Test
fun `consume same note twice returns false`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val event = textNote("note1".padEnd(64, '0'), userPubKey)
cache.consume(event, relayUrl, wasVerified = true)
@@ -148,7 +148,7 @@ class DesktopCachePipelineTest {
@Test
fun `consume contact list updates followedUsers`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey))
cache.consume(event, relayUrl, wasVerified = true)
@@ -158,7 +158,7 @@ class DesktopCachePipelineTest {
@Test
fun `newer contact list replaces older`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100)
val newer =
contactList(
@@ -176,7 +176,7 @@ class DesktopCachePipelineTest {
@Test
fun `older contact list is rejected`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val newer = contactList("cl2".padEnd(64, '0'), userPubKey, listOf(followedPubKey, unfollowedPubKey), createdAt = 200)
val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100)
@@ -192,7 +192,7 @@ class DesktopCachePipelineTest {
@Test
fun `consume reaction links to target note`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val noteId = "note1".padEnd(64, '0')
val note = textNote(noteId, userPubKey)
val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId)
@@ -204,6 +204,69 @@ class DesktopCachePipelineTest {
assertTrue(cachedNote.countReactions() > 0, "Note should have reactions after consuming reaction event")
}
// -----------------------------------------------------------------------
// 1b. accountPubkey race regression (PR #3483 review finding 1)
//
// Reproduces the "hydration before pubkey bind" data-loss race: if a
// self kind-3 arrives while accountPubkey is null (e.g. from disk during
// login), the cache used to stamp lastContactListByAuthor without
// populating _followedUsers. Then the same event arriving from a relay
// AFTER pubkey binding was rejected by the createdAt gate, leaving the
// follow set empty. FollowAction.follow then called createFromScratch
// and wiped the real follow list. Fix: skip the stamp when
// accountPubkey is null so the later relay retry can populate cleanly.
// -----------------------------------------------------------------------
@Test
fun `self kind-3 hydrated before pubkey bind does not poison later relay retry`() {
val cache = DesktopLocalCache() // accountPubkey deliberately unset
val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100)
// Phase A — hydration path: consume with accountPubkey unbound.
cache.consume(event, relayUrl, wasVerified = true)
assertEquals(
emptySet(),
cache.followedUsers.value,
"Follow set stays empty until accountPubkey is bound",
)
// Phase B — Main.kt binds accountPubkey.
cache.accountPubkey = userPubKey
// Phase C — relay replay of the SAME event. Must NOT be rejected by
// the createdAt gate; must populate _followedUsers.
cache.consume(event, relayUrl, wasVerified = true)
assertEquals(
setOf(followedPubKey),
cache.followedUsers.value,
"Later relay retry of same self kind-3 must populate follow set",
)
}
@Test
fun `non-self kind-3 hydrated before pubkey bind still stamps and does not touch followedUsers`() {
val cache = DesktopLocalCache()
val other = contactList("cl2".padEnd(64, '0'), followedPubKey, listOf(unfollowedPubKey), createdAt = 100)
cache.consume(other, relayUrl, wasVerified = true)
cache.accountPubkey = userPubKey
// followedUsers is for the active user only; a non-self kind-3
// should never touch it. followedUsers must stay empty.
assertEquals(emptySet(), cache.followedUsers.value)
// And the newer version of the same non-self kind-3 must still be
// accepted (stamping happened in the known-not-self branch would
// reject; here we skipped stamping when pubkey was null so a
// newer replay lands cleanly).
val newer = contactList("cl2b".padEnd(64, '0'), followedPubKey, listOf(unfollowedPubKey, userPubKey), createdAt = 200)
cache.consume(newer, relayUrl, wasVerified = true)
// No direct assertion on internal state; the fact that this
// returns without throwing + does not affect followedUsers is
// the invariant. The next line documents intent.
assertEquals(emptySet(), cache.followedUsers.value)
}
// -----------------------------------------------------------------------
// 2. Event stream emission
// -----------------------------------------------------------------------
@@ -211,7 +274,7 @@ class DesktopCachePipelineTest {
@Test
fun `consume emits to eventStream`() =
runBlocking {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val collected = mutableListOf<Set<Note>>()
val job =
@@ -240,7 +303,7 @@ class DesktopCachePipelineTest {
@Test
fun `GlobalFeedFilter includes all text notes`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val filter = DesktopGlobalFeedFilter(cache)
// Add notes from different authors
@@ -254,7 +317,7 @@ class DesktopCachePipelineTest {
@Test
fun `FollowingFeedFilter only includes notes from followed users`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true)
@@ -269,7 +332,7 @@ class DesktopCachePipelineTest {
@Test
fun `FollowingFeedFilter returns empty when no follows`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl, wasVerified = true)
val filter = DesktopFollowingFeedFilter(cache) { emptySet() }
@@ -280,7 +343,7 @@ class DesktopCachePipelineTest {
@Test
fun `ProfileFeedFilter only shows notes from target pubkey`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true)
cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl, wasVerified = true)
@@ -293,7 +356,7 @@ class DesktopCachePipelineTest {
@Test
fun `ThreadFilter returns root and replies`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val rootId = "root".padEnd(64, '0')
val replyId = "reply".padEnd(64, '0')
@@ -308,7 +371,7 @@ class DesktopCachePipelineTest {
@Test
fun `NotificationFeedFilter shows events tagging user`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val noteId = "note1".padEnd(64, '0')
cache.consume(textNote(noteId, userPubKey, createdAt = 100), relayUrl, wasVerified = true)
@@ -333,7 +396,7 @@ class DesktopCachePipelineTest {
@Test
fun `ViewModel starts in Loading then transitions to Loaded after refresh`() =
runBlocking {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true)
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
@@ -352,7 +415,7 @@ class DesktopCachePipelineTest {
@Test
fun `ViewModel shows Empty when cache has no matching notes`() =
runBlocking {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
waitForBundler()
@@ -365,7 +428,7 @@ class DesktopCachePipelineTest {
@Test
fun `ViewModel updates when new notes arrive via eventStream`() =
runBlocking {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
waitForBundler()
@@ -388,7 +451,7 @@ class DesktopCachePipelineTest {
@Test
fun `Following ViewModel only shows followed users notes via eventStream`() =
runBlocking {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
@@ -418,7 +481,7 @@ class DesktopCachePipelineTest {
@Test
fun `Following ViewModel feed is empty when followedUsers is empty`() =
runBlocking {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
// No contact list consumed — followedUsers remains empty
val e1 = textNote("n1".padEnd(64, '0'), followedPubKey)
@@ -441,7 +504,7 @@ class DesktopCachePipelineTest {
@Test
fun `clear resets all cache state`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true)
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
@@ -458,7 +521,7 @@ class DesktopCachePipelineTest {
@Test
fun `global feed is sorted newest first`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
cache.consume(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl, wasVerified = true)
cache.consume(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl, wasVerified = true)
cache.consume(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl, wasVerified = true)
@@ -476,7 +539,7 @@ class DesktopCachePipelineTest {
@Test
fun `consumeMetadata updates user info`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val metadata =
com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent(
id = "meta1".padEnd(64, '0'),
@@ -501,7 +564,7 @@ class DesktopCachePipelineTest {
@Test
fun `GlobalFeedFilter applyFilter only accepts TextNoteEvents`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val filter = DesktopGlobalFeedFilter(cache)
// Create a text note
@@ -522,7 +585,7 @@ class DesktopCachePipelineTest {
@Test
fun `FollowingFeedFilter applyFilter respects follow set`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
@@ -547,7 +610,7 @@ class DesktopCachePipelineTest {
@Test
fun `profile follower count is cached and survives clear of note cache`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
assertEquals(0, cache.getCachedFollowerCount(userPubKey))
@@ -561,7 +624,7 @@ class DesktopCachePipelineTest {
@Test
fun `profile following count is cached`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
cache.cacheFollowingCount(userPubKey, 150)
assertEquals(150, cache.getCachedFollowingCount(userPubKey))
@@ -569,7 +632,7 @@ class DesktopCachePipelineTest {
@Test
fun `clear resets profile count caches`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
cache.cacheFollowerCount(userPubKey, 42)
cache.cacheFollowingCount(userPubKey, 150)
@@ -581,7 +644,7 @@ class DesktopCachePipelineTest {
@Test
fun `metadata is available from cache after consumption`() {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
val metadata =
com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent(
id = "meta1".padEnd(64, '0'),
@@ -140,7 +140,7 @@ class LocalRelayStoreHydrationTest {
@Test
fun hydratingAnEmptyDatabaseSucceedsAndLeavesCacheEmpty() =
runTest {
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
val store = newStore()
try {
store.hydrate(cache)
@@ -165,7 +165,7 @@ class LocalRelayStoreHydrationTest {
// empty when phase 2 ran and the metadata would never load.
seedDatabase(listOf(followeeMetadata, contactList))
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
val store = newStore()
// Pin a strong reference to the followee's User for the duration of the
@@ -206,7 +206,7 @@ class LocalRelayStoreHydrationTest {
val recentNote = makeTextNote(author, "recent", createdAt = nowSeconds() - 3600)
seedDatabase(listOf(recentNote))
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
val store = newStore()
try {
store.hydrate(cache)
@@ -226,7 +226,7 @@ class LocalRelayStoreHydrationTest {
val oldNote = makeTextNote(author, "stale", createdAt = eightDaysAgo)
seedDatabase(listOf(oldNote))
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
val store = newStore()
try {
store.hydrate(cache)
@@ -256,7 +256,7 @@ class LocalRelayStoreHydrationTest {
val note = makeTextNote(author, "round-trip")
seedDatabase(listOf(note))
val cache = DesktopLocalCache()
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
val store = newStore()
try {
store.hydrate(cache)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,701 @@
---
title: WoT follow-ups — search-result badges + shared index relays
type: feat
status: active
date: 2026-07-01
origin: docs/plans/2026-07-01-feat-desktop-wot-score-plan.md
deepened: 2026-07-01
---
# WoT follow-ups — search-result badges + shared index relays
## Enhancement Summary
**Deepened on:** 2026-07-01 (same day as plan write).
**Agents used:** code-simplicity-reviewer, targeted repo verification sweep.
### Key corrections vs first draft
1. **Split into two PRs.** Item 1 (search badges) is mechanical and has
zero coupling to Items 2+3. Ship it alone. Items 2 + 3 stay bundled
because the UI (Item 3) is the write path for the persistence
(Item 2) — reviewing them separately means reviewing dead code or a
headless feature.
2. **App-global (not per-account) index-relay override.** First draft
made this per-account to match `searchRelays` / `dmRelays`. But
`searchRelays` / `dmRelays` are per-account because they're NIP-51 /
NIP-17 identity-scoped semantics; index relays are a user preference
about where profile-metadata lookups go, and users have a single
preferred set regardless of which account they're logged into.
App-global halves the API surface and matches user mental model.
3. **`PreferencesIndexRelays` in `commons/jvmMain/`, not extending
`DesktopAccountRelays`.** Verification found `DesktopAccountRelays`
uses `Preferences.userNodeForPackage(DesktopAccountRelays::class.java)`,
which is a per-class node — **not visible to amy** running from a
different classpath. To achieve the "one truth for Desktop and amy"
goal, the shared node must be an explicit
`Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`,
which is exactly the pattern `PreferencesHashtagSpamSettings` uses.
New small class mirrors that shape.
4. **Drop `WoTBadgedSearchCard`.** Only two call sites; the 6-line
score computation inlines cleanly. New wrapper composable earns its
keep at 3+ call sites, not 2.
5. **Drop `DefaultIndexRelays.kt` in commons.** Speculative — no
Android caller. amy can duplicate the 4 URLs (they change ~never)
or read a single constant from a shared location. Extracting to
commons is architectural neatness without a consumer.
6. **`DesktopRelayCategories.indexRelays` uses `override ?: default`
only.** Not the full combine used by `searchRelays` (which
intersects with NIP-65 discovery). Index relays are a curated user
choice, not a "what's actually reachable right now" derived set. No
`debounce` / `stateIn` combine needed — a straight-through StateFlow
from the Preferences read is enough.
7. **Drop "Reset to defaults" button in Item 3.** Removing all relays
from the UI already falls back to `DefaultRelays.RELAYS`. Delete-all
IS the reset.
8. **Drop integration scenarios 2 and 6.** #2 (badge respects
exemptions) is covered by existing `WoTBadgedAvatar` tests — same
code path. #6 (empty override falls back) is a single unit test on
`PreferencesIndexRelays`, not a manual scenario.
9. **`RelaySettingsScreen` current content** was mischaracterised — it
already has 6 sections (Wallet Connect, Media Server, Image
Compression, Tor, Namecoin, Local Relay, Content Filters). Index
Relays fits between Local Relay and Content Filters (both have
dividers).
10. **Adopt-not-in-this-PR discovery: `commons/AmethystDefaults.kt`
already has `DefaultIndexerRelayList`** (Purple Pages, Coracle,
etc). Desktop today uses the wrong list (`DefaultRelays.RELAYS` =
general-purpose relays) for its index REQs. That's a real
behavioural bug worth a separate ticket — not this one — because
changing default index relays is a user-visible behaviour shift and
deserves its own review.
---
## Overview
Three small follow-ups to the just-shipped Web-of-Trust score feature
(branch `feat/desktop-wot-score`, closed for manual testing):
1. **Badges on search-result person cards.** The main NoteCard header
already renders `WoTBadgedAvatar`, but the Search screen's person
picker uses a different composable (`UserSearchCard`) that doesn't
currently accept a badge.
2. **Unify amy `wot sync` with Desktop on the same relay set.** Desktop
currently uses a hard-coded `DefaultRelays.RELAYS` list as its
`indexRelays`; amy uses whatever the user's NIP-65 outbox/inbox lists
contain. When the two disagree, `amy wot get` after `amy wot sync`
returns a different score than the Desktop UI would compute.
3. **Add an Index Relays section to the Relays settings screen** so
users can customise which relays back both surfaces from one place.
**Shipping plan:** two PRs.
- **PR A — Search badges (Item 1).** ~40 LOC, one commons param
addition, two Desktop call-site inline changes. Independent of the
other work. Ships first.
- **PR B — Shared index relays (Items 2 + 3).** Introduces a small
Preferences-backed class in `commons/jvmMain/`, wires the coordinator
to read from it, adds a settings-screen section, and updates
`amy wot sync` to read the same node. ~300 LOC. Ships second.
## Problem Statement
Three concrete regressions/gaps from the manual-testing pass of the WoT
PR:
- **Item 1.** When searching for a person in the Desktop search screen,
their result card is a stranger 90% of the time (that's the point of
searching), but there's no trust cue on the card. Users who find WoT
badges useful on feed avatars want the same signal here.
- **Item 2.** amy's `wot sync` uses `ctx.outboxRelays()` (NIP-65 write
list) with a fall-back to inbox. Those are legitimate relays for
publishing / receiving events, but they are *not* what Desktop uses
to fetch profile metadata and follow lists — Desktop hits a
hard-coded `indexRelays` set (nos.lol, nostr.wine,
relay.noswhere.com, relay.primal.net today). Result: `amy wot get`
after a fresh `amy wot sync` can produce a score that lags or
diverges from the Desktop UI for the same account.
- **Item 3.** The Relays settings screen already contains six
sections; there's no UI to inspect or change which relays are
considered "index relays" — the values live only in the hard-coded
default list in `RelayStatus.kt`.
## Proposed Solution
### PR A — Item 1: Badge slot on `UserSearchCard`
`UserSearchCard` in
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt`
gets an optional `badge` slot that forwards to its embedded
`UserAvatar` (which already has the slot from the WoT PR):
```kotlin
@Composable
fun UserSearchCard(
user: User,
onClick: () -> Unit,
modifier: Modifier = Modifier,
badge: @Composable (BoxScope.() -> Unit)? = null,
) {
// Existing layout, unchanged, except:
UserAvatar(
userHex = user.pubkeyHex,
pictureUrl = user.profilePicture(),
size = 40.dp,
contentDescription = stringResource(Res.string.accessibility_user_avatar),
badge = badge,
)
// …rest of the Row unchanged
}
```
Backward compatible — default `null` means no visual change for callers
that don't opt in. The layout impact is zero: `UserAvatar` handles the
badge's `Box` overlay itself; the badge lives on the avatar's bottom-
right corner, and the `ArrowForward` icon at the row's trailing edge
doesn't collide with it.
Two Desktop call sites in
`desktopApp/.../ui/search/SearchResultsList.kt:116,126` inline the score
computation directly at the call:
```kotlin
val service = LocalWoTService.current
val ready = LocalWoTReady.current
val exempt = LocalSpamExemptKeys.current
val score = if (service != null && ready && user.pubkeyHex !in exempt) {
service.scores[user.pubkeyHex] ?: 0
} else 0
UserSearchCard(
user = user,
onClick = { … },
badge = if (score > 0) {
{ WoTBadge(count = score, modifier = Modifier.align(Alignment.BottomEnd)) }
} else null,
)
```
The two sites are 4 lines apart; a small local `remember` block above
them can factor the read if we want (optional micro-cleanup — not
required).
**Not migrated in PR A:**
- `desktopApp/.../ui/chats/NewDmDialog.kt` (three sites) — the DM
recipient picker. Same rationale as before: when picking a DM
recipient you're already committing to messaging that person; a
trust badge is more noise than signal. Add later if testing calls
for it.
### PR B — Item 2: Shared `indexRelays` between Desktop and amy
#### Persist via `java.util.prefs`, node shared with amy
`Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`.
Same JVM-user-wide `java.util.prefs` trick the hashtag-spam PR (#3431)
uses — Desktop and amy running as the same OS user see the same node.
**Not stored per-account.** Users have a single preferred set of index
relays regardless of which account is logged in. Halves the API
surface and matches user intuition. If a user with two accounts
genuinely needs separate index relays per account, we add per-account
overlay later on demand — YAGNI now.
**Not persisted via `DesktopAccountRelays`.** That class uses
`Preferences.userNodeForPackage(DesktopAccountRelays::class.java)`,
which resolves to a per-class node that `cli/` running from a different
classpath **would not see**. Extending it would give us Desktop-local
config with no amy visibility — the opposite of what we want.
#### New shared class
`commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt`
(new file, mirrors `PreferencesHashtagSpamSettings` shape):
```kotlin
class PreferencesIndexRelays(
private val prefs: Preferences =
Preferences.userRoot().node(NODE_NAME),
) {
private val _relays =
MutableStateFlow(parse(prefs.get(KEY_URLS, "")))
val relays: StateFlow<Set<NormalizedRelayUrl>> = _relays.asStateFlow()
fun setRelays(new: Set<NormalizedRelayUrl>) {
_relays.value = new
prefs.put(KEY_URLS, new.joinToString(",") { it.url })
}
/** Resolves the effective set — user override if non-empty, else defaults. */
fun effective(): Set<NormalizedRelayUrl> =
_relays.value.ifEmpty { DEFAULT_INDEX_RELAYS }
companion object {
const val NODE_NAME = "com/vitorpamplona/amethyst/relays/index"
const val KEY_URLS = "urls"
/**
* Byte-for-byte identical to `DefaultRelays.RELAYS` at
* `desktopApp/.../network/RelayStatus.kt`. Preserves current
* behaviour for users who never open the settings UI.
*
* Note: `commons/AmethystDefaults.kt` also has
* `DefaultIndexerRelayList` (Purple Pages, Coracle, …) which
* is more purpose-built. Adopting it is a separate ticket —
* see Out of Scope.
*/
val DEFAULT_INDEX_RELAYS: Set<NormalizedRelayUrl> = setOf(
"wss://nos.lol",
"wss://nostr.wine",
"wss://relay.noswhere.com",
"wss://relay.primal.net",
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
private fun parse(csv: String): Set<NormalizedRelayUrl> =
csv.split(",")
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
}
}
```
CSV serialisation matches what `DesktopAccountRelays` uses for its
categories (`prefs.put(key, relays.joinToString(",") { it.url })`) — no
JSON, no `Serializable`, no dependencies beyond `RelayUrlNormalizer`.
#### Desktop wiring
Add `indexRelays: StateFlow<Set<NormalizedRelayUrl>>` to
`DesktopRelayCategories`, backed by the new class. Simple straight-
through, no combine:
```kotlin
class DesktopRelayCategories(
// existing params
private val indexRelaysStore: PreferencesIndexRelays,
) {
// existing categories…
val indexRelays: StateFlow<Set<NormalizedRelayUrl>> =
indexRelaysStore.relays
.map { it.ifEmpty { PreferencesIndexRelays.DEFAULT_INDEX_RELAYS } }
.stateIn(scope, SharingStarted.Eagerly, indexRelaysStore.effective())
fun setIndexRelays(new: Set<NormalizedRelayUrl>) = indexRelaysStore.setRelays(new)
}
```
`Main.kt` — the constructor at
`desktopApp/.../Main.kt:847-859` swaps the hard-coded literal for the
current effective set:
```kotlin
// before:
indexRelays = DefaultRelays.RELAYS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet(),
// after:
indexRelays = indexRelaysStore.effective(),
```
`indexRelaysStore` is instantiated once at App() root (before the
coordinator) and provided into `DesktopRelayCategories`. UI reads from
`LocalRelayCategories.current.indexRelays`.
**Changes take effect on next relaunch.** Documented in the settings
section's help text. The existing coordinator has no re-target API for
`indexRelays`; teaching it one is out of scope. Rationale: index-relay
churn is expected to be rare, and users who edit the list generally
expect to restart anyway.
#### amy wiring
New helper on `cli/.../Context.kt`:
```kotlin
fun indexRelays(): Set<NormalizedRelayUrl> {
val prefs = Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")
val csv = prefs.get("urls", "")
val user = csv.split(",")
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
return user.ifEmpty {
// Same defaults as PreferencesIndexRelays.DEFAULT_INDEX_RELAYS
// Duplicated here (4 URLs) — they change ~never.
setOf(
"wss://nos.lol", "wss://nostr.wine",
"wss://relay.noswhere.com", "wss://relay.primal.net",
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
}
}
```
`WotCommand.sync` swaps:
```kotlin
// before:
val relays = ctx.outboxRelays().ifEmpty { ctx.inboxRelays() }
// after:
val relays = ctx.indexRelays()
```
The 4-URL duplication is fine per the simplicity review — the list
changes ~never; a single shared commons constant would be architectural
neatness with no material win. Adding a whole
`commons/defaults/DefaultIndexRelays.kt` for a 4-line constant fails
YAGNI on a plan we're specifically told to keep small.
#### Optional: `amy relay index …` verbs — deferred
v1 configuration lives in the Desktop settings section. If someone
running amy headless wants to seed the Preferences node, they can do
so with a five-line JVM one-liner:
```
java -cp … -e 'Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index").put("urls","wss://foo,wss://bar")'
```
CLI verbs are a follow-up ticket if demand appears.
### PR B — Item 3: Index Relays section in `RelaySettingsScreen`
Insert a new section in `RelaySettingsScreen`
(`desktopApp/.../Main.kt` line 1797 onward). Current sections in order:
1. Wallet Connect (NWC)
2. Media Server Settings
3. Image Compression Settings
4. Tor Settings
5. Namecoin Settings
6. Local Relay (conditional)
7. Content Filters (hashtag-spam)
Insert **between Local Relay and Content Filters** — both already have
a `HorizontalDivider` around them.
Section renders:
- Title: "Index Relays"
- One-line explainer: "Used to fetch profile metadata and follow lists
(Web-of-Trust). Changes take effect on next relaunch."
- `LazyColumn` of `Text(relay.url) + IconButton(Icons.Default.Close, onClick = onRemove)` — 30 LOC ballpark.
- Add-row: `OutlinedTextField + Button("Add")`. Normalises input via
`RelayUrlNormalizer.normalizeOrNull`; ignores nulls silently (or
surfaces "invalid relay URL" if trivial).
- No "Reset to defaults" button — removing all entries falls back to
defaults automatically (delete-all is the reset).
Reads:
```kotlin
val indexRelays by LocalRelayCategories.current.indexRelays.collectAsState()
val categories = LocalRelayCategories.current
// then in add/remove handlers:
categories.setIndexRelays(indexRelays + newUrl)
categories.setIndexRelays(indexRelays - existingUrl)
```
## Technical Considerations
### Recomposition + reactivity (Item 1)
Inlining the score computation at each `UserSearchCard` call still gets
per-key snapshot tracking — `service.scores[pubkey]` is a
`SnapshotStateMap` read that Compose tracks per-key. Only the row for
the changed pubkey recomposes when its score updates. Identical
behaviour to what we shipped in `WoTBadgedAvatar`; the wrapper
composable would have added a subscriber node with no gain.
### Live re-targeting of the coordinator (Item 2)
Existing `DesktopRelaySubscriptionsCoordinator` reads `indexRelays`
once at construction and holds it. Teaching it to swap
`indexRelays` mid-flight is a real refactor (in-flight subscription
state, cross-EOSE semantics). Ship "changes take effect on next
relaunch" for v1; add live re-targeting in a follow-up if users notice.
### Preferences node identity across Desktop and amy (Item 2)
Both processes use
`Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`.
Because `java.util.prefs.Preferences` is JVM-user-scoped
(per OS user, per prefs backend — plist on macOS, dconf on Linux,
registry on Windows), both processes end up looking at the same
physical store. `PreferencesHashtagSpamSettings` already relies on this
guarantee in shipped code.
### CSV vs JSON serialisation (Item 2)
CSV (`joinToString(",") { it.url }`) matches what `DesktopAccountRelays`
does for its categories. No dependency on Jackson or Serialisation at
the storage boundary. Trade-off: URLs cannot contain commas (they
can't per RFC anyway — commas are reserved). We normalise through
`RelayUrlNormalizer.normalizeOrNull` at both write time (in
`setRelays`) and read time (in `parse` / `Context.indexRelays()`), so
persisted CSV never contains an invalid URL.
### Default list ergonomics (deferred)
Verification surfaced a real bug: `commons/AmethystDefaults.kt`
already contains `DefaultIndexerRelayList` (Purple Pages, Coracle,
etc.) — a purpose-built index-relay set — but Desktop currently uses
`DefaultRelays.RELAYS` (nos.lol, nostr.wine, relay.noswhere.com,
relay.primal.net), which are general-purpose. That default mismatch is
a real behaviour improvement to be made, but it's a user-visible
behavioural change that deserves its own PR + review. **This plan
preserves byte-parity with today's default** and flags the improvement
in Out of Scope.
## System-Wide Impact
### Interaction graph
```
PR A (Item 1):
User opens Search column → types query
→ SearchResultsList renders LazyColumn of user results
→ Each result inlines: read LocalWoTService.scores, gate on ready/exempt
→ pass a WoTBadge lambda to UserSearchCard(badge=...)
→ UserSearchCard forwards to UserAvatar(badge=...)
→ UserAvatar renders Box overlay with WoTBadge chip
PR B (Items 2+3):
User opens Relays settings → Index Relays section
→ List rendered from LocalRelayCategories.indexRelays
→ User adds / removes a relay
→ categories.setIndexRelays(newSet)
→ indexRelaysStore.setRelays(newSet)
→ prefs.put("urls", csv) at
com/vitorpamplona/amethyst/relays/index
→ indexRelays StateFlow emits new value
amy wot sync (later):
→ ctx.indexRelays() reads the same prefs node
→ identical relay set — Desktop and amy compute the same score
Desktop app next launch:
→ indexRelaysStore.effective() returns user set (or defaults)
→ coordinator constructed with that set
```
### Error & failure propagation
- **Empty override set** (user removed all entries): fall back to
defaults at both `indexRelaysStore.effective()` and
`ctx.indexRelays()`. Never allow an empty batch REQ — WoT would
silently break.
- **Malformed URL entry** (e.g. old persisted CSV with a URL that no
longer normalises): filter through
`RelayUrlNormalizer.normalizeOrNull` at read time, drop nulls.
- **Preferences read failure** (`BackingStoreException`): treat as
"unset → use defaults". Log at debug, do not surface to the user.
### State lifecycle risks
- **Cross-account leak:** App-global preference, no per-account
identity in the key — by design.
- **Coordinator using stale set after user changes indexRelays:** Yes,
in v1 the coordinator keeps its constructor-time set until relaunch.
Documented in the UI. Not a data-integrity risk — just a UX quirk.
### API surface parity
- **PR A:** `UserSearchCard` badge slot — commonMain, backward-
compatible. Android call sites (if any exist post-merge) unchanged;
badge slot stays null.
- **PR B, new:** `PreferencesIndexRelays` class in `commons/jvmMain/`.
- **PR B, modified:** `DesktopRelayCategories` gains an `indexRelays`
StateFlow + `setIndexRelays(...)`. `Main.kt` coordinator
construction. `Context.kt` gains `indexRelays()`.
`WotCommand.sync` swaps its relay source. `RelaySettingsScreen`
gains an "Index Relays" section.
- **Nothing** in `amethyst/` (Android) is touched — this is Desktop +
amy only.
### Integration test scenarios
1. **Search badge shows.** Load a search result for a stranger scored
≥ 1 in the WoT map — the badge renders bottom-right of the avatar.
2. **Index Relays default state.** Fresh install → open Relays
settings → Index Relays section lists the four
`DEFAULT_INDEX_RELAYS` entries as read-only (or marked "(default)").
3. **Index Relays override persists.** Add a new relay → close the
app → relaunch → new relay still present. Remove one → close →
relaunch → still gone.
4. **amy sees the same override.** After the Desktop override above,
`amy wot sync` uses the new relay set. Verify by observing which
relays receive the kind-3 REQ (packet capture or a debug print
inside `WotCommand.sync`).
5. **Bad URL doesn't crash.** Manually plant an invalid entry in the
Preferences node → app restart → invalid entries filtered out, UI
shows only valid entries.
## Acceptance Criteria
### PR A — Functional (Item 1)
- [ ] `UserSearchCard` in commons accepts optional
`badge: @Composable (BoxScope.() -> Unit)? = null` and forwards
it to its `UserAvatar` call.
- [ ] Both call sites in
`desktopApp/.../ui/search/SearchResultsList.kt` (currently at
lines ~116 and ~126) inline the WoT-score computation and pass a
`WoTBadge` lambda when score > 0 and pubkey not in
`LocalSpamExemptKeys`.
- [ ] `NewDmDialog` call sites remain unchanged.
### PR B — Functional (Items 2+3)
- [ ] `PreferencesIndexRelays` created at
`commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt`.
Persists to `Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`
key `urls` as CSV. Exposes
`relays: StateFlow<Set<NormalizedRelayUrl>>`,
`setRelays(new)`, `effective(): Set<NormalizedRelayUrl>`, and
`DEFAULT_INDEX_RELAYS` constant that matches `DefaultRelays.RELAYS`
byte-for-byte.
- [ ] `DesktopRelayCategories.indexRelays: StateFlow<Set<NormalizedRelayUrl>>`
exposed — straight-through from `PreferencesIndexRelays.relays`,
empty falls back to defaults. `setIndexRelays(new)` delegates.
- [ ] `Main.kt:847-859` constructs
`DesktopRelaySubscriptionsCoordinator` with
`indexRelays = indexRelaysStore.effective()` instead of the
hard-coded `DefaultRelays.RELAYS.mapNotNull { … }.toSet()`.
Behaviour on fresh install identical to today.
- [ ] `cli/.../Context.kt` gains `indexRelays(): Set<NormalizedRelayUrl>`
reading the same Preferences node, falling back to the same 4
defaults inline.
- [ ] `WotCommand.sync` uses `ctx.indexRelays()` instead of
`outboxRelays()/inboxRelays()`.
- [ ] `RelaySettingsScreen` has an "Index Relays" section between
Local Relay and Content Filters, with:
- Title + one-line explainer including "Changes take effect on
next relaunch."
- List of current relays with per-row remove button.
- Add-row: URL input + Add button, normalises via
`RelayUrlNormalizer.normalizeOrNull`, silently drops nulls.
- No "Reset to defaults" button (remove-all is the reset).
### Non-functional (both PRs)
- [ ] `./gradlew spotlessApply` — no diff.
- [ ] `./gradlew :commons:compileKotlinJvm :desktopApp:compileKotlin
:cli:compileKotlin` — clean.
- [ ] `./gradlew test` — full suite passes.
- [ ] No new `Preferences` writes on any render path — only on
settings-screen mutations.
### Quality gates
- [ ] **PR A:** manual smoke — open Search, type a query, verify
badges appear on results scored ≥ 1 in the WoT map; none on
follows/self.
- [ ] **PR B:** unit test for `PreferencesIndexRelays` round-trip
(write set → new instance → same set out).
- [ ] **PR B:** unit test for `PreferencesIndexRelays.effective()`
fallback when Preferences is unset.
- [ ] **PR B:** unit test for `ctx.indexRelays()` fallback behaviour
when Preferences is unset.
- [ ] **PR B:** three new manual scenarios added to the WoT testing
sheet — search-badge visibility, Preferences override
persistence across restart, amy sync uses override (packet
capture or debug log).
## Success Metrics
- Search results have the same at-a-glance trust cue as feed cards.
- `amy wot get <target>` after `amy wot sync` returns a score identical
to the Desktop UI within ~2 s of the same relay set having been
configured.
- Users who add / remove index relays in the settings UI see their
change reflected on next relaunch, verified via a debug log line.
## Dependencies & Risks
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| Coordinator snapshot of `indexRelays` leaks stale set until relaunch | high (accepted v1) | Document as "changes on relaunch" in the UI; follow-up ticket for live re-targeting. |
| Empty override silently kills WoT | medium | Fallback-to-defaults guard at *both* `indexRelaysStore.effective()` and `ctx.indexRelays()`. Unit-tested. |
| CSV serialisation confuses a user who hand-edits the prefs file | low | Documented as internal; users are expected to use the UI. Hand-edit path stays functional as long as URLs don't contain commas (they can't per RFC). |
| Two Desktop and cli defaults drift out of sync (4 URLs duplicated in two places) | low | Comment in both files pointing to each other. If the list ever needs to change, both places must update. Realistically the list changes ~never. |
| `commons/AmethystDefaults.DefaultIndexerRelayList` continues to be the "correct" index-relay set while we're shipping the "general-purpose" defaults | ok (deferred) | Out-of-scope. Separate ticket to adopt as default. |
## Out of Scope (deferred)
- **`amy relay index add / remove / list` verbs.** Defer until there's
demand from headless workflows.
- **Live re-targeting of index-relay subscriptions** without app
relaunch. Separate coordinator refactor.
- **NIP-51 kind 30002 based index-relay list** for cross-Nostr-client
portability.
- **DM-recipient-picker badges** in `NewDmDialog`. Ship only if manual
testing complains.
- **Adopting `commons/AmethystDefaults.DefaultIndexerRelayList` as the
Desktop / amy default.** Real improvement, but a user-visible
behavioural change. Standalone ticket + review.
- **Per-account index-relay overrides.** YAGNI now — single-user
preference dominates. Add later if demand shows up.
## Sources & References
### Origin
- **WoT PR plan:** `docs/plans/2026-07-01-feat-desktop-wot-score-plan.md`
- **Manual testing sheet:**
`desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md`
### Internal references
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt:51-108`
— target for badge slot (Item 1).
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt:82`
— badge slot already exists here (from the WoT PR).
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt:116,126`
— the two person-result call sites to migrate.
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:847-859`
— where the hard-coded `indexRelays` is passed to the coordinator.
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt:40-47`
— `DefaultRelays.RELAYS` (byte-parity target for
`DEFAULT_INDEX_RELAYS`).
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt:80-89`
— `searchRelays` pattern (reference; index relays uses a *simpler*
shape).
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt`
— per-class Preferences node pattern **we're deliberately not
reusing** (would not be visible to amy).
- `commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettings.kt`
— pattern for shared Preferences node used across Desktop + amy;
`PreferencesIndexRelays` mirrors this shape.
- `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt:329-362`
— where `outboxRelays()` / `inboxRelays()` live.
- `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt`
— swap `sync` to `ctx.indexRelays()`.
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/AmethystDefaults.kt:62-63`
— `DefaultIndexerRelayList` (Purple Pages, Coracle …) — flagged as
future default adoption, **not touched** in this plan.
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt`
— `indexRelays` constructor param (already exists, no change needed).
### Skill references
- `relay-client` — DesktopRelayCategories composition + StateFlow
patterns.
- `compose-expert` — CompositionLocal readers + badge slot forwarding.
- `amy-expert` — Context helper pattern (`outboxRelays()` etc.), CLI
verb shape, shared JVM `Preferences` node semantics.
- `kotlin-flow-state-event-modeling` — StateFlow<Set<…>> straight-
through vs combine semantics.
### Related work
- WoT PR branch: `feat/desktop-wot-score` (closed for manual testing).
- Hashtag-spam PR: https://github.com/vitorpamplona/amethyst/pull/3431
(merged) — the `java.util.prefs` shared-node pattern
`PreferencesIndexRelays` mirrors.
- Feature backlog:
`desktopApp/plans/_desktop-feature-backlog.md` item #2 (parent WoT
feature).