diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index c77c881dac..4643c406ea 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -448,6 +448,23 @@ class Context( /** Union of all three buckets. */ suspend fun anyRelays(): Set = 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 = + 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 366d638577..1851af8e97 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): 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() diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt new file mode 100644 index 0000000000..c81ce32c4b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt @@ -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 ` — 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, + ): 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 ") + + private suspend fun get( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "wot get ") + 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, + ): 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, + ): 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()) + 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` 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 { + 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 = 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 + } +} diff --git a/commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md b/commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md new file mode 100644 index 0000000000..43a3ff19db --- /dev/null +++ b/commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md @@ -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> │ + │ (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 │ + │ (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` 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` handshake with a `Channel( + 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> + 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, // 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): Result + suspend fun fetchKind3Only(authors: Set): 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` 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 ` 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`; 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? diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt index d0ef61e5b1..659ca97dcd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt @@ -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() private val queuedNoteIds = mutableSetOf() private val queuedBoostedIds = mutableSetOf() + private val queuedKind3Pubkeys = mutableSetOf() + + // 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() + private val inFlightBatchedKind3 = mutableSetOf() /** * 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, 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() - val allEose = CompletableDeferred() + val gate = BatchEoseGate(scope, target = indexRelays.size) val listener = object : SubscriptionListener { @@ -287,16 +308,96 @@ class FeedMetadataCoordinator( relay: NormalizedRelayUrl, forFilters: List?, ) { - 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, + 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?, + ) { + this@FeedMetadataCoordinator.onEvent?.invoke(event, relay) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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(Channel.UNLIMITED) + private val done = CompletableDeferred() + + @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() + 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 + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt index 1006585991..9a559ebde8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt index 60fe3b5531..bd7e82f54f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt @@ -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)) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/LocalWoTService.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/LocalWoTService.kt new file mode 100644 index 0000000000..767687a343 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/LocalWoTService.kt @@ -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 = + 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 = + compositionLocalOf { false } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxCacheGateway.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxCacheGateway.kt new file mode 100644 index 0000000000..a9c451f05f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxCacheGateway.kt @@ -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, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt new file mode 100644 index 0000000000..24b6401cb3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt @@ -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, + 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() + + /** + * Pubkeys we've already successfully fetched kind-0 for this session. + */ + private val kind0Succeeded = mutableSetOf() + + /** + * 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() + private val kind0InFlight = mutableSetOf() + + /** + * 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): 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): 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): 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, + 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, + newForKind3: Set, + newForKind0: Set, + 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>() + val toDiscover = mutableSetOf() + 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>() + 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, + relays: Set, + ): Pair>>, 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>>() + val gate = BatchEoseGate(scope, target = relays.size) + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is AdvertisedRelayListEvent && event.pubKey in pubkeys) { + received + .getOrPut(event.pubKey) { mutableListOf() } + .add(event to relay) + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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>, + counters: FetchCounters, + ) { + val gate = BatchEoseGate(scope, target = filterMap.size) + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + when (event.kind) { + MetadataEvent.KIND -> counters.kind0++ + ContactListEvent.KIND -> counters.kind3++ + } + gateway.onDiscoveredEvent(event, relay) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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(Channel.UNLIMITED) + private val done = CompletableDeferred() + + @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() + 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 + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt new file mode 100644 index 0000000000..4f5a01075b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt @@ -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 = mutableStateMapOf() + val scores: SnapshotStateMap get() = _scores + + // Reverse index: target pubkey → set of my-follows who follow them. + private val reverseIndex = HashMap>() + + // Per-follower cached follow set (excluding self / follower itself). + // Enables diff-based updates when a follower republishes their kind-3. + private val perFollowerSnapshot = HashMap>() + + private var myFollows: Set = emptySet() + private var selfPubkey: HexKey? = null + private var readyMarked = false + private var disabled = false + + private val _isReady = MutableStateFlow(false) + val isReady: StateFlow = _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 = _isDisabled.asStateFlow() + + private val ops = Channel(capacity = Channel.UNLIMITED) + + init { + scope.launch(writerDispatcher) { writerLoop() } + } + + /** Update the active user's follow set (and self pubkey). */ + fun onFollowSetChange( + newFollows: Set, + 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, + ) { + 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 = HashMap(_scores) + + private sealed interface Op { + data class FollowSet( + val newFollows: Set, + val newSelf: HexKey?, + ) : Op + + data class Kind3( + val follower: HexKey, + val follows: Set, + ) : 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, + 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, + ) { + 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 + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt new file mode 100644 index 0000000000..ba6d768f6a --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt @@ -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> = + 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> = mutableRelays.asStateFlow() + + fun setRelays(new: Set) { + 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 = 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 = + 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 = + csv + .split(",") + .mapNotNull { it.trim().takeIf(String::isNotEmpty) } + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinatorTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinatorTest.kt new file mode 100644 index 0000000000..c0f8a609d4 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinatorTest.kt @@ -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() + val subscribeCalls = mutableListOf>>() + var unsubscribeCallCount = 0 + private set + + override fun subscribe( + subId: String, + filters: Map>, + 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) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelaysTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelaysTest.kt new file mode 100644 index 0000000000..0024dfc31f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelaysTest.kt @@ -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()) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt new file mode 100644 index 0000000000..9238b5ce1d --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt @@ -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, + 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, + ) = 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() + val discoveredOutbox = mutableListOf>() + val discoveredEvents = mutableListOf>() + + 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, List>() + private val eoseNever = mutableSetOf() + val allSubscribeCalls = mutableListOf>>() + + fun scriptEvent( + kind: Int, + relay: NormalizedRelayUrl, + events: List, + ) { + script[kind to relay] = events + } + + fun neverEose(relay: NormalizedRelayUrl) { + eoseNever.add(relay) + } + + override fun subscribe( + subId: String, + filters: Map>, + 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. + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTServiceTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTServiceTest.kt new file mode 100644 index 0000000000..1ac29587fb --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTServiceTest.kt @@ -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(), 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(), 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(), 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(), + 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(), 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).clear() + assertEquals(1, svc.scoresSnapshot()[c]) + } +} diff --git a/desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md b/desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md new file mode 100644 index 0000000000..4f8286c61b --- /dev/null +++ b/desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md @@ -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 ` (against an account with kind-3 events in `~/.amy/shared/events-store/`). | Output: `pubkey= score=` 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: ________________ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 33a6b9d6d2..645defc77b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index d32ba9093f..2212833b67 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -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>(emptySet()) val followedUsers: StateFlow> = _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( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val contactListEvents: SharedFlow = _contactListEvents.asSharedFlow() + /** Increments on each metadata update — observe to recompose when user names change. */ private val _metadataVersion = MutableStateFlow(0L) val metadataVersion: StateFlow = _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() 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 } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index a76b64b18b..20fccefa62 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt index b7fc38fa52..586c7b1922 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt @@ -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>, + /** + * 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> = + indexRelaysStore.relays + .map { it.ifEmpty { PreferencesIndexRelays.DEFAULT_INDEX_RELAYS } } + .distinctUntilChanged() + .stateIn(scope, SharingStarted.Eagerly, indexRelaysStore.effective()) + + fun setIndexRelays(new: Set) { + indexRelaysStore.setRelays(new) + } + companion object { val DEFAULT_SEARCH_RELAYS = DefaultSearchRelayList } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index afa616fb00..33afa1921d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -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, + 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): OutboxDispatcher.Result = outboxDispatcher.fetchKind3Only(pubkeys) + // ----- Memory Cleanup ----- private val memoryBean = ManagementFactory.getMemoryMXBean() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index c240c645d2..ec07c0e06b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index a5288551ea..8540b85045 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 565a9b2156..b23adb506c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadge.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadge.kt new file mode 100644 index 0000000000..7db95ed012 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadge.kt @@ -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, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt new file mode 100644 index 0000000000..9e91f361b1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt @@ -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 + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/IndexRelaysEditor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/IndexRelaysEditor.kt new file mode 100644 index 0000000000..2767d08630 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/IndexRelaysEditor.kt @@ -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() } + var newRelayUrl by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var savedMessage by remember { mutableStateOf(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, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt index 82448c2c05..7865f991be 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt @@ -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, + ) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt index 4b35e4d95c..68bd2e8fbc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -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, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt index aefb0bb1ee..a43b3758e6 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt @@ -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)) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt index 457a9a1fc1..5760661c5b 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt @@ -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>() 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'), diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt index 3bd92f908a..f039e7deb7 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt @@ -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) diff --git a/docs/plans/2026-07-01-feat-desktop-wot-score-plan.md b/docs/plans/2026-07-01-feat-desktop-wot-score-plan.md new file mode 100644 index 0000000000..dab7ff006b --- /dev/null +++ b/docs/plans/2026-07-01-feat-desktop-wot-score-plan.md @@ -0,0 +1,1090 @@ +--- +title: Desktop Web-of-Trust Score Badges +type: feat +status: active +date: 2026-07-01 +origin: docs/brainstorms/2026-07-01-feat-wot-score-brainstorm.md +deepened: 2026-07-01 +--- + +# Desktop Web-of-Trust Score Badges + +## Enhancement Summary + +**Deepened on:** 2026-07-01 (same day as plan write). + +**Review agents used:** architecture-strategist, code-simplicity-reviewer, +pattern-recognition-specialist, performance-oracle, security-sentinel, +agent-native-reviewer · plus best-practices research (SnapshotStateMap + +Compose tooltip patterns) and a code-verification sweep. + +### Key corrections vs initial draft + +1. **Prerequisite: fix `DesktopLocalCache.consumeContactList`.** The + current implementation writes `_followedUsers = event.verifiedFollowKeySet()` + for **any** incoming kind-3, gated only on a single global + `lastContactListCreatedAt` scalar. Once WoT starts fetching followed + authors' kind-3 events, this **actively corrupts** the active user's + follow-set state. Refactor to per-author `Map` and + guard the `_followedUsers` / `lastContactListEvent` write on + `event.pubKey == account.pubKeyHex`. This is a **prerequisite commit** + in this PR — WoT cannot ship without it. +2. **Badge is a slot, not an embed.** `UserAvatar` in `commonMain` gets + an optional `badge: @Composable (BoxScope.() -> Unit)? = null` + parameter. Desktop call sites pass a `WoTBadge()`-bearing lambda; + Android passes null. No CompositionLocal reads inside the shared + composable, no `expect/actual` dance, `TooltipBox` import stays in + the Desktop-only source set of the caller. +3. **Service on `DesktopIAccount`, not in `remember`.** Match the + established pattern of `Kind3FollowListState`, `BookmarkListState`, + `Nip65RelayListState` — the service is a field on `DesktopIAccount` + constructed with `account.scope`, exposed as a property, provided via + `LocalWoTService` from `Main.kt`. Lifecycle matches the login session, + not the composition. +4. **Kind-3 event flow via `SharedFlow`.** Not a + mutable listener list. `DesktopLocalCache` exposes a + `SharedFlow` (buffer 64, `DROP_OLDEST` overflow), + emitted from inside `consumeContactList`. WoTService collects it from + an `account.scope`-scoped coroutine. +5. **Batch kind-3 loader — chunk 100 per Filter, explicit `onEose` hook.** + Existing `FeedMetadataCoordinator.loadMetadataBatched` at line 267 + silently does `authors.take(100)` — blind copy would drop 400 of 500 + follows. New `loadKind3Batched` chunks into ≤100-author Filters, + aggregates EOSE across chunks, and exposes `onEose: () -> Unit` so + the caller can `markReady()` on real EOSE (not just the 2 s timeout). +6. **Use Material3 `TooltipBox`, not `TooltipArea`.** Multiplatform, + built-in a11y (screen readers, keyboard focus), and the JetBrains + deprecation direction ([JB #4275](https://github.com/JetBrains/compose-multiplatform/issues/4275)). + Set `state = rememberTooltipState(isPersistent = true)` to fix the + known "vanishes too fast" desktop bug. +7. **`WoTScore` sealed hierarchy → plain `Int` + sparse map.** Drop + `Unknown` — represent "not queried" as *absence* from the map. + `_scores.remove(target)` when count hits 0. Keeps the map sparse and + Compose subscriber tracking cheap. +8. **Drop `derivedStateOf` at the leaf.** Plain + `service.scores[userHex] ?: 0` is already snapshot-tracked per key. + `derivedStateOf` adds a subscriber node and a comparator per avatar + for no gain when there's only one read. +9. **Readiness as a plain `Boolean` CompositionLocal, not per-avatar + `collectAsState`.** Collect `isReady` once at App root, provide via + `LocalWoTReady`. 150 collectors per screen becomes 1. +10. **Batch writes with `Snapshot.withMutableSnapshot { }`.** + `applyKind3` mutates `_scores` for many keys — wrap the loop in a + single mutable snapshot so all readers see one atomic frame. +11. **Amy verbs ship in v1 (not v2).** `amy wot get `, + `amy wot list --threshold N`, `amy wot sync`. Service exposes + `scoresSnapshot(): Map` for headless readers. + `FsEventStore` at `~/.amy/shared/events-store/` already caches + kind-3 events → warm-cache queries need no relay traffic. +12. **Bound follows per event.** Cap `verifiedFollowKeySet()` result at + 5000 entries in `applyKind3` — prevents CPU DoS from a hostile + follower publishing a 100k-tag kind-3. +13. **`WoTService` writes on `Dispatchers.Default`, single-writer + coroutine.** Serialize `applyKind3`, `onFollowSetChange`, etc. + inside an `actor`-style coroutine so composite state stays + consistent across concurrent kind-3 arrivals. + +### New considerations discovered + +- Simplicity review argued for dropping `WoTScore`, `isReady`, and + `perFollowerSnapshot`. Kept the last two (correctness vs churn) but + agreed on dropping `WoTScore`. +- Pattern review pushed for `commons/moderation/wot/` (sibling to + hashtag-spam). Kept **`commons/wot/`** — v1 is display-only, not + moderation. If v2 adds filtering we relocate then. +- Security review confirmed: follow-list leak via batch REQ is + **pre-existing** (metadata coordinator already sends the same + `authors` list to `indexRelays`). WoT introduces no novel privacy + regression. +- All ingested events are `event.verify()`-checked before + `LocalCache.consume` runs (`DesktopLocalCache.kt:191`) — forged + kind-3s cannot pass the sig check. Score inflation via fake events is + not a viable attack. + +--- + +## Overview + +Compute a friends-of-friends trust score for every pubkey based on the +active user's follow graph, and render it as a small number chip +overlaid on `UserAvatar`s at Desktop call sites. **v1 is +display-only** — no filtering. Kind-3 follow lists for the active user's +follows are fetched via a proactive chunked batch REQ at login and kept +current through incremental diff updates. + +**Carried forward from brainstorm** +(`docs/brainstorms/2026-07-01-feat-wot-score-brainstorm.md`): +raw-count semantics · display-only · auto-hide badge when score = 0 · +hide badge on already-followed authors and self · number chip in avatar +corner · tooltip explaining the count · proactive batch kind-3 REQ at +login + refresh on follow-list change · no cross-session persistence · +no settings UI. + +**Resolved during planning + deepening:** +- **Platform target:** Desktop only in v1. Badge is a slot on shared + `UserAvatar`; Desktop call sites pass the lambda, Android passes null. +- **Score model:** plain `Int` in a sparse `SnapshotStateMap`. Absence + ≡ "not queried yet or definitively zero." Both hide the badge. +- **Prerequisite:** fix `consumeContactList` scope corruption. +- **Batch REQ:** chunked into ≤100-author Filters, aggregated EOSE. +- **Startup gate:** `isReady = true` after first-chunk EOSE OR 2 s + timeout, whichever first. Single `Boolean` provided via CompositionLocal. +- **Guardrail:** skip WoT graph if `myFollows.size > 2000`. +- **Overflow:** display `"99+"` when count > 99. +- **Amy CLI:** three verbs ship in v1 (`get`, `list`, `sync`). + +## Problem Statement + +Nostr's public graph makes it easy for a stranger to appear in your +notifications, mentions, search results, or as a repost's original +author. Without a trust cue you have to click into each profile to +assess. Gossip and Snort solved this with a friends-of-friends count: +"N of the people you follow also follow this person." On Desktop, where +3–6 columns and dozens of avatars are on screen at once, that cue +scales better than mobile. Amethyst Desktop today shows every pubkey +identically. + +## Proposed Solution + +### High-level approach + +Introduce a per-account **`WoTService`** attached to +`DesktopIAccount` (mirroring `kind3FollowList`, `bookmarkList`, +`nip65RelayList`). At login the service: + +1. Observes the active user's follow set from + `DesktopLocalCache.followedUsers`. +2. Issues chunked batch REQs (≤100 authors each, aggregated EOSE) for + `kinds=[3]` on the same `indexRelays` used by + `FeedMetadataCoordinator.loadMetadataBatched`. +3. Collects a new `DesktopLocalCache.contactListEvents: + SharedFlow` and calls `applyKind3(...)` for each + event whose author is in the follow set. +4. Maintains a **reverse index** (`Map>` + from target-pubkey → set of my-follows who follow them) and a + **per-follower snapshot** (`Map>`) for diff + updates. +5. Publishes score changes atomically to `_scores: SnapshotStateMap` + inside a `Snapshot.withMutableSnapshot { }` block. +6. Marks `isReady = true` on aggregated EOSE or 2 s timeout — whichever + fires first. + +Desktop UI uses a sibling composable **`WoTBadgedAvatar`** (in +`desktopApp/.../ui/note/`) that composes the shared `UserAvatar` with a +badge slot. The slot renders a `WoTBadge` when four gates pass: + +- `LocalWoTReady.current == true` +- `service.scores[pubkey] > 0` +- `pubkey != selfPubkey` +- `pubkey !in followedKeys` + +`WoTBadge` uses Material3 `TooltipBox` with +`state = rememberTooltipState(isPersistent = true)`. + +### Why this shape + +- **Per-account service on `DesktopIAccount`** matches the codebase + convention (`kind3FollowList`, `bookmarkList`). `remember` inside + composition ties lifecycle to the composable's tree, which is wrong + for a data service. +- **Sibling composable, not embedded slot in `UserAvatar`.** Prior art: + `BunkerHeartbeatIndicator` — decoration lives next to the primitive, + not inside it. Explicit call-site migration is a feature: it lets us + ship v1 on high-value surfaces (feeds, thread, notifications, profile) + and defer minor spots. +- **Slot on `UserAvatar` for Desktop-owned rendering.** Even the + sibling composable needs somewhere to draw the badge. Adding a + scalar `badge` slot to `UserAvatar` avoids duplicating the entire + avatar layout and keeps Android intact (nulls the slot). +- **Sparse `SnapshotStateMap`.** Storing `Unknown` for every unqueried + pubkey would bloat the map to 250 k entries; keeping it sparse (only + positive scores) reduces subscriber overhead 10×. +- **Chunked batch REQ.** Relays vary wildly in filter-size caps + (nostr-rs-relay defaults ~100, strfry accepts hundreds). Chunking + 100 authors per Filter within one subscription is the correct + pragmatic default. +- **Diff-based `applyKind3`.** Kind-3 events churn (many clients + republish frequently). Full recompute on every event would drop the + reverse index and lose recomposition isolation. Diff keeps + per-target changes minimal. +- **Amy parity in v1.** Service is a plain class in `commons/`; the + three verbs are ~150 LOC total. Skipping them trains the wrong habit. + +### Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ commons/ (platform-agnostic; callable by Desktop, amy, Android) │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ commonMain/ │ │ +│ │ wot/WoTService.kt Snapshot map + reverse index │ │ +│ │ + applyKind3, onFollowSetChange │ │ +│ │ + awaitReady(onEose, timeout) │ │ +│ │ + scoresSnapshot() (headless) │ │ +│ │ wot/LocalWoTService.kt CompositionLocal (nullable) │ │ +│ │ wot/LocalWoTReady.kt CompositionLocal │ │ +│ │ ui/components/UserAvatar.kt + badge: @Composable slot │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + ▲ + │ +┌─────────────────────────────┴────────────────────────────────────┐ +│ desktopApp/ │ +│ cache/DesktopLocalCache.kt (PREREQUISITE FIX) │ +│ - lastContactListByAuthor: MutableMap │ +│ - contactListEvents: SharedFlow │ +│ - _followedUsers writes gated on event.pubKey==self │ +│ model/DesktopIAccount.kt │ +│ val wotService = WoTService(scope) │ +│ subscriptions/DesktopRelaySubscriptionsCoordinator.kt │ +│ fun loadKind3Batched(pubkeys, onEose: () -> Unit) │ +│ ui/note/WoTBadgedAvatar.kt │ +│ Composes UserAvatar with a WoTBadge slot lambda │ +│ ui/note/WoTBadge.kt │ +│ Material3 TooltipBox + Box overlay chip │ +│ Main.kt (LoggedIn branch) │ +│ CompositionLocalProvider( │ +│ LocalWoTService provides account.wotService, │ +│ LocalWoTReady provides isReadyCollected, │ +│ ) { … } │ +│ │ +│ Call sites migrated (v1 subset): │ +│ - FeedNoteCard header │ +│ - QuotedNoteEmbed author │ +│ - Thread reply avatars │ +│ - Notifications item │ +│ - Search result item │ +│ - Profile header │ +│ (Other avatars deferred; migration is opportunistic.) │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ cli/ (amy — v1 verbs) │ +│ commands/WotCommand.kt │ +│ amy wot get [--json] │ +│ amy wot list [--threshold N] [--limit K] [--json] │ +│ amy wot sync (one-shot batch REQ, exits on EOSE / 5s) │ +│ Context.kt exposes an FsEventStore hydration primitive │ +│ WoTService.hydrateFromStore(store, myFollows) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Prerequisite: `consumeContactList` cache fix + +This must land before or in the same PR as the WoT service. Otherwise +the batch REQ we introduce will trigger the corruption bug. + +**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:447-453` + +Change: + +```kotlin +// BEFORE (buggy — any kind-3 with newer createdAt wins globally) +private fun consumeContactList(event: ContactListEvent): Boolean { + if (event.createdAt <= lastContactListCreatedAt) return false + lastContactListCreatedAt = event.createdAt + lastContactListEvent = event + _followedUsers.value = event.verifiedFollowKeySet() + return true +} + +// AFTER (per-author tracking + self-guard + SharedFlow emit) +private val lastContactListByAuthor = mutableMapOf() +private val _contactListEvents = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, +) +val contactListEvents: SharedFlow = _contactListEvents.asSharedFlow() + +private fun consumeContactList(event: ContactListEvent): Boolean { + val prev = lastContactListByAuthor[event.pubKey] ?: 0L + if (event.createdAt <= prev) return false + lastContactListByAuthor[event.pubKey] = event.createdAt + + // Active-user's kind-3 updates local follow-set state. + if (event.pubKey == accountPubkey) { + lastContactListEvent = event + _followedUsers.value = event.verifiedFollowKeySet() + } + + // All kind-3 events fan out on the SharedFlow for consumers (WoTService). + _contactListEvents.tryEmit(event) + return true +} +``` + +### Core algorithm (updated) + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt`: + +```kotlin +@Stable +class WoTService( + private val scope: CoroutineScope, +) { + // Truth-map: pubkey → count of my-follows who follow them. + // Sparse — entries with count = 0 are removed. + private val _scores: SnapshotStateMap = mutableStateMapOf() + val scores: SnapshotStateMap get() = _scores + + // Internal state — always mutated from the [writer] actor coroutine. + private val reverseIndex = HashMap>() + private val perFollowerSnapshot = HashMap>() + private var myFollows: Set = emptySet() + private var selfPubkey: HexKey? = null + + // Readiness + private val readyOnce = AtomicBoolean(false) + private val _isReady = MutableStateFlow(false) + val isReady: StateFlow = _isReady.asStateFlow() + + // Serialize state mutations + private val ops = Channel(capacity = Channel.BUFFERED) + + init { + scope.launch(Dispatchers.Default) { + for (op in ops) processOne(op) + } + } + + private sealed interface Op { + data class FollowSet(val new: Set, val self: HexKey?) : Op + data class Kind3(val follower: HexKey, val follows: Set) : Op + object MarkReady : Op + } + + fun onFollowSetChange(newFollows: Set, newSelf: HexKey?) { + ops.trySend(Op.FollowSet(newFollows, newSelf)) + } + + fun applyKind3(follower: HexKey, follows: Set) { + val bounded = if (follows.size > MAX_FOLLOWS_PER_EVENT) { + follows.take(MAX_FOLLOWS_PER_EVENT).toSet() + } else follows + ops.trySend(Op.Kind3(follower, bounded)) + } + + fun markReadyOnce() { + if (readyOnce.compareAndSet(false, true)) ops.trySend(Op.MarkReady) + } + + /** For amy — returns a plain snapshot, no Compose runtime required. */ + fun scoresSnapshot(): Map = HashMap(_scores) + + /** For amy — hydrate from a local event store before querying. */ + suspend fun hydrateFromStore(store: IEventStore, myFollows: Set) { + onFollowSetChange(myFollows, selfPubkey) + store.iterateBy(kinds = setOf(ContactListEvent.KIND), authors = myFollows) { event -> + applyKind3(event.pubKey, (event as ContactListEvent).verifiedFollowKeySet()) + } + } + + fun clear() { ops.trySend(Op.FollowSet(emptySet(), null)) } + + private fun processOne(op: Op) { + Snapshot.withMutableSnapshot { + when (op) { + is Op.FollowSet -> handleFollowSet(op.new, op.self) + is Op.Kind3 -> handleKind3(op.follower, op.follows) + Op.MarkReady -> _isReady.value = true + } + } + } + + private fun handleFollowSet(newFollows: Set, newSelf: HexKey?) { + val added = newFollows - myFollows + val removed = myFollows - newFollows + myFollows = newFollows + selfPubkey = newSelf + + // Guardrail + if (myFollows.size > MAX_FOLLOWS) { + _scores.clear(); reverseIndex.clear(); perFollowerSnapshot.clear() + markReadyOnce() + return + } + + // Uncredit removed followers + removed.forEach { follower -> + perFollowerSnapshot.remove(follower)?.forEach { target -> + reverseIndex[target]?.let { set -> + set.remove(follower) + updateScore(target) + } + } + } + // Added followers are credited when their kind-3 arrives. + } + + private fun handleKind3(follower: HexKey, follows: Set) { + 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 -> + reverseIndex[target]?.let { set -> + set.remove(follower) + if (set.isEmpty()) reverseIndex.remove(target) + updateScore(target) + } + } + } + + private fun updateScore(target: HexKey) { + val n = reverseIndex[target]?.size ?: 0 + if (n > 0) _scores[target] = n else _scores.remove(target) + } + + companion object { + const val MAX_FOLLOWS = 2000 + const val MAX_FOLLOWS_PER_EVENT = 5000 + } +} +``` + +Notes: +- All mutations run inside a single writer coroutine on + `Dispatchers.Default` — no concurrent map access races. +- `Snapshot.withMutableSnapshot { }` wraps each op so Compose readers + see one atomic frame per event. +- `applyKind3` bounds `follows.size` to 5 000 at ingest — DoS protection + against a hostile 100 k-tag kind-3. +- `_scores.remove(target)` on 0-count keeps the map sparse — Compose + subscriber tracking scales with map size. + +### Batch kind-3 loader (with explicit EOSE hook + chunking) + +Add to `FeedMetadataCoordinator` in `commons/.../relayClient/assemblers/`: + +```kotlin +private val queuedKind3Pubkeys = mutableSetOf() + +/** + * Fetches kind-3 for a batch of authors, chunking into ≤100-author + * Filters within a single subscription. Calls [onEose] once all + * chunks EOSE (or after [timeoutMs]). + */ +fun loadKind3Batched( + pubkeys: Collection, + timeoutMs: Long = 5_000L, + onEose: () -> Unit = {}, +) { + val newPubkeys = pubkeys.filter { it !in queuedKind3Pubkeys }.distinct() + if (newPubkeys.isEmpty()) { onEose(); return } + queuedKind3Pubkeys.addAll(newPubkeys) + + scope.launch { + val chunks = newPubkeys.chunked(100) + val filters = chunks.map { chunk -> + Filter( + kinds = listOf(ContactListEvent.KIND), + authors = chunk, + limit = chunk.size, + ) + } + val filterMap = indexRelays.associateWith { filters } + val subId = newSubId() + val eoseReceived = mutableSetOf() + val allEose = CompletableDeferred() + + val listener = object : SubscriptionListener { + override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?) { + this@FeedMetadataCoordinator.onEvent?.invoke(event, relay) + } + override fun onEose(relay: NormalizedRelayUrl, forFilters: List?) { + eoseReceived.add(relay) + if (eoseReceived.size >= indexRelays.size) allEose.complete(Unit) + } + } + + client.subscribe(subId, filterMap, listener) + withTimeoutOrNull(timeoutMs) { allEose.await() } + client.unsubscribe(subId) + onEose() + } +} +``` + +`DesktopRelaySubscriptionsCoordinator.loadKind3Batched(pubkeys, onEose)` +delegates. + +### `UserAvatar` badge slot (commonMain change) + +```kotlin +@Composable +fun UserAvatar( + userHex: String, + pictureUrl: String?, + size: Dp, + modifier: Modifier = Modifier, + /* existing params… */ + badge: @Composable (BoxScope.() -> Unit)? = null, +) { + if (badge == null) { + // Existing single-image render path — unchanged + AvatarImage(userHex, pictureUrl, size, modifier, /* … */) + } else { + Box(modifier.size(size)) { + AvatarImage(userHex, pictureUrl, size, Modifier, /* … */) + badge() + } + } +} +``` + +### `WoTBadgedAvatar` (Desktop-only, drop-in replacement at v1 call sites) + +`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt`: + +```kotlin +@Composable +fun WoTBadgedAvatar( + userHex: String, + pictureUrl: String?, + size: Dp, + modifier: Modifier = Modifier, + /* … pass-through params matching UserAvatar … */ +) { + val service = LocalWoTService.current + val ready = LocalWoTReady.current + val selfKey = LocalWoTSelfKey.current + val followedKeys = LocalWoTFollowedKeys.current + + val score = if (service != null && ready && userHex != selfKey && userHex !in followedKeys) { + service.scores[userHex] ?: 0 // plain snapshot read, per-key tracked + } else 0 + + UserAvatar( + userHex = userHex, + pictureUrl = pictureUrl, + size = size, + modifier = modifier, + badge = if (score > 0) { { WoTBadge(count = score, modifier = Modifier.align(Alignment.BottomEnd)) } } else null, + ) +} +``` + +### `WoTBadge` (Material3 `TooltipBox`, Desktop-only) + +`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadge.kt`: + +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WoTBadge(count: Int, modifier: Modifier = Modifier) { + val display = if (count > 99) "99+" else count.toString() + val tooltipState = rememberTooltipState(isPersistent = true) // desktop hover fix + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text("$count of the people you follow follow this person") } }, + state = tooltipState, + ) { + 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, + ) + } + } +} +``` + +### Wiring in `Main.kt` + +Inside the `AccountState.LoggedIn` branch, alongside the existing +`LocalHashtagSpamSettings` / `LocalSpamExemptKeys` providers: + +```kotlin +val wotService = account.iAccount.wotService +val isReady by wotService.isReady.collectAsState() + +LaunchedEffect(wotService, localCache) { + // Feed kind-3 events into the service. + launch { localCache.contactListEvents.collect { evt -> + wotService.applyKind3(evt.pubKey, evt.verifiedFollowKeySet()) + } } + // React to follow-set changes. + launch { localCache.followedUsers.collect { follows -> + wotService.onFollowSetChange(follows, account.pubKeyHex) + subscriptionsCoordinator.loadKind3Batched(follows, onEose = { wotService.markReadyOnce() }) + } } + // Fallback: mark ready after 2 s regardless. + delay(2_000) + wotService.markReadyOnce() +} + +CompositionLocalProvider( + LocalWoTService provides wotService, + LocalWoTReady provides isReady, + LocalWoTSelfKey provides account.pubKeyHex, + LocalWoTFollowedKeys provides followedUsers, // already collected above for spam exempt + // existing hashtag-spam CompositionLocals… +) { /* … */ } +``` + +Attach `wotService` to `DesktopIAccount`: + +```kotlin +class DesktopIAccount( + private val signer: NostrSigner, + val scope: CoroutineScope, + /* … */ +) : IAccount { + val kind3FollowList = Kind3FollowListState(signer, scope, /* … */) + val wotService = WoTService(scope) + /* … */ +} +``` + +### Amy verbs (v1) + +`cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt`: + +``` +amy wot get [--json] + Prints: pubkey= score= contributed_by= + JSON: { "pubkey": "...", "score": n, "contributed_by": ["...", ...] } + +amy wot list [--threshold N] [--limit K] [--json] + Prints scored pubkeys sorted desc; --threshold filters. + +amy wot sync + Loads active-user follow set + runs loadKind3Batched once. + Exits after aggregated EOSE (max 5 s). +``` + +Each verb: +1. Reads active-user pubkey from `Context.currentAccount`. +2. Hydrates `WoTService` from `FsEventStore` (~/.amy/shared/events-store/). +3. Optionally runs `wot sync` (idempotent) for freshness. +4. Queries and prints. + +Total ~150 LOC. Reuses existing `Context` / `FsEventStore` / +`indexRelays` wiring. + +## Technical Considerations + +### Performance + +- **Reverse-index memory:** at MAX_FOLLOWS = 2000 × avg follows 500 ≈ + 1 M entries worst case; at 500 × 500 ≈ 250 k. Compose bookkeeping + overhead on the SnapshotStateMap is ~80–120 bytes/entry; sparse map + (Unknown = absence) keeps this bounded by *positive-score pubkeys* + only — typically 5–50 k, not 250 k. +- **Write batching:** each `applyKind3` op mutates the map for many + keys inside a single `Snapshot.withMutableSnapshot { }` — one wake-up + per reader, one atomic frame. +- **Chunked REQ:** 500-follow account issues 5 chunks × 100 authors + each within a single subscription. Total REQ payload ~30 KB. +- **`scoresSnapshot()`** does an `HashMap(_scores)` — O(N) copy; amy + calls it once per verb invocation, fine. + +### Compose recomposition + +- Plain `service.scores[userHex] ?: 0` read is snapshot-tracked per + key. When only one key updates, only avatars for that pubkey + recompose. +- `LocalWoTReady: CompositionLocal` is collected **once** at + App root; no per-avatar Flow collector. +- Badge visibility is decided in `WoTBadgedAvatar` (Desktop) — the + `UserAvatar` slot receives `badge=null` when hidden, so no overlay + Box + no recomposition of hidden branches. + +### Stability annotations + +- `WoTService`: `@Stable`. Public API is `scores: SnapshotStateMap` + (Compose-tracked) + `isReady: StateFlow` (via `collectAsState`). + Private mutable state doesn't leak. +- `Int` is stable by definition — sparse map values are trivially + stable. +- `WoTBadge`, `WoTBadgedAvatar`, `UserAvatar` all have primitive/stable + params after the slot addition. + +### Threading + +- All mutations to `reverseIndex` / `perFollowerSnapshot` happen inside + the single writer coroutine on `Dispatchers.Default`. +- `_scores` SnapshotStateMap is thread-safe for individual puts; + composite writes wrapped in `Snapshot.withMutableSnapshot { }` are + applied atomically. +- Composable reads happen on the Main dispatcher via snapshot; safe. + +### Follow-set reactivity + +- `DesktopLocalCache._followedUsers: StateFlow>` (already + reactive) is the source of truth for the active user's follow set — + once the prerequisite cache fix lands. +- The `LaunchedEffect` in `Main.kt` collects it and forwards to + `WoTService.onFollowSetChange` and re-triggers the batch REQ for + newly-followed pubkeys (existing ones deduped by + `queuedKind3Pubkeys`). + +### Startup timeline + +| t | Event | +|---|-------| +| 0 ms | User logs in | +| ~50 ms | `LocalCache.followedUsers` emits current follow set | +| ~100 ms | `loadKind3Batched(follows, onEose = …)` fires 5 chunks | +| 200 ms – 2 s | Kind-3 events land, WoTService diffs, `_scores` populated | +| ≤ 2 s | `markReadyOnce()` — via first-chunk EOSE OR 2 s fallback | +| ≥ ready | Badges appear | + +### Security / privacy + +- Follow-list leak via batch REQ is **pre-existing** — same authors + set already sent to `indexRelays` via metadata batch. WoT introduces + no novel disclosure. +- `event.verify()` runs on every ingested event + (`DesktopLocalCache.kt:191`) — forged kind-3 events cannot pass. +- `follows.take(5000)` in `applyKind3` bounds CPU cost against a + hostile 100 k-tag kind-3. +- No persistence — `Preferences` untouched. Reverse index lives in + memory only. +- App-global operation (not per-account): follow-set state lives on + `DesktopIAccount`, discarded on logout. Account switch = new + `IAccount` = new `WoTService`. + +## System-Wide Impact + +### Interaction graph + +``` +DesktopLocalCache.consumeContactList(event) ← (prerequisite fix) + │ + ├─ if event.pubKey == self → update _followedUsers, lastContactListEvent + │ + └─ tryEmit → contactListEvents: SharedFlow + │ + ▼ collected in Main.kt LaunchedEffect + WoTService.applyKind3(event.pubKey, event.verifiedFollowKeySet()) + │ + ▼ dispatched to writer coroutine + processOne(Op.Kind3) inside Snapshot.withMutableSnapshot { } + │ + ▼ diff vs perFollowerSnapshot + reverseIndex[target].add/remove(follower) + │ + ▼ updateScore(target) + _scores[target] = n (or .remove if n == 0) + │ + ▼ Compose snapshot commit + Avatars reading scores[target] recompose + +── parallel path ── +DesktopLocalCache._followedUsers emits new set + │ + ▼ collected in Main.kt LaunchedEffect +WoTService.onFollowSetChange(newFollows, selfPubkey) + │ + ├─ diff added / removed followers + ├─ uncredit removed followers' contributions + ▼ +subscriptionsCoordinator.loadKind3Batched(followSet, onEose = wotService::markReadyOnce) + │ + ▼ chunked REQ on indexRelays +Kind-3 events return → route back through consume() path above +``` + +### Error & failure propagation + +- Batch REQ timeout: 2 s fallback fires `markReadyOnce()` regardless. + Partial data is acceptable — badges appear for pubkeys we did get. +- `verifiedFollowKeySet()` on malformed event: Quartz handles internally, + returns possibly-empty set. Zero contribution. +- Writer coroutine crash: never — all operations are exception-safe (map + ops, set diffs). No I/O in the writer path. +- SharedFlow overflow: `DROP_OLDEST` on `contactListEvents` — under + extreme flood, oldest events dropped. Acceptable v1 (relay flood is + itself abnormal). +- Account switch: `LaunchedEffect` cancelled → collect on old + `contactListEvents` stops. Old `WoTService` referenced only via the + cancelled effect and old `IAccount` — GC'd cleanly. + +### State lifecycle risks + +- **Prerequisite cache fix** is the highest lifecycle risk. Without it, + the WoT batch REQ actively corrupts `_followedUsers` — cascading + bugs in every FeedFilter, mute list, and account-relay logic. + Prevention: land the fix as a separate commit in this PR, gate by + unit test in `DesktopLocalCacheTest`. +- Reverse-index size grows with `myFollows.size × avg-follows-of-follows`. + Guardrail at 2000 clears the map; entering guardrail mid-session is + a supported transition. +- SharedFlow subscribers must be scoped to `account.scope` — a leak + from a stale coroutine would collect old events into a stale + service. Enforced by `LaunchedEffect(wotService, localCache)` + keying. + +### API surface parity + +- **commons:** `WoTService`, `LocalWoTService`, `LocalWoTReady`, + `LocalWoTSelfKey`, `LocalWoTFollowedKeys` (CompositionLocals). + `UserAvatar` gains optional `badge` slot (backward compatible — + default null). +- **desktopApp:** `WoTBadge`, `WoTBadgedAvatar` composables; call-site + migration at 6 v1 surfaces (feed, quote-embed, thread reply, + notification, search result, profile header). Coordinator gets + `loadKind3Batched`. `DesktopLocalCache` gets `contactListEvents: + SharedFlow` + per-author `lastContactListByAuthor` map. + `DesktopIAccount` gets `wotService` property. +- **amethyst (Android):** no changes v1. `UserAvatar` badge slot is + optional; Android call sites pass no lambda. +- **cli (amy):** three verbs (`wot get`, `wot list`, `wot sync`), + ~150 LOC in `commands/WotCommand.kt`, wired into `Main.kt` dispatch. + +### Integration test scenarios + +1. **Cold start with 250 follows.** Login → batch REQ fires with 3 + chunks of 100 authors → badges appear within ≤ 2 s. +2. **Follow a new person mid-session.** New pubkey added to + `_followedUsers` → `onFollowSetChange` credits nothing yet; new + `loadKind3Batched(followSet)` picks up their kind-3 (deduped by + `queuedKind3Pubkeys`); score for their followers ticks up as their + kind-3 lands. +3. **Unfollow a follower.** `onFollowSetChange(removed)` uncredits; + affected pubkeys' scores decrement; some may drop to 0 and lose + badges (map entry removed). +4. **Kind-3 churn.** Same follower republishes with +5 / -2 diff → + `applyKind3` computes diff correctly, no double-counting. +5. **Account switch.** New `IAccount` → new `WoTService` → old service + GC'd. Old service's map does not leak into new UI. +6. **Guardrail trip.** 3000-follow account → guardrail clears state, + marks ready, no batch REQ. +7. **Empty graph.** 0 follows → onFollowSetChange with empty set, + nothing to fetch, `markReadyOnce()` fires from fallback timeout. +8. **99+ overflow.** Simulate score 200 → badge shows "99+". +9. **Self exemption.** Own avatar in profile header never shows a + badge even if some follower's kind-3 lists self. +10. **Follow-list exemption.** Followed author's avatar never shows a + badge even when others in follow-list follow them. +11. **Kind-3 malformed.** 100 k-tag kind-3 → `applyKind3` truncates + to 5 000, service stays responsive. +12. **Prerequisite fix verification.** Send a kind-3 event whose author + is not the active user — verify `_followedUsers` unchanged, but + `contactListEvents` emits. +13. **Amy `wot get`.** `amy wot get ` after + `amy wot sync` returns correct score. +14. **Amy warm cache.** Second `amy wot get ` invocation + without `sync` uses `FsEventStore` hydration, no relay traffic. + +## Acceptance Criteria + +### Functional + +- [ ] **Prerequisite:** `DesktopLocalCache.consumeContactList` refactored + with per-author `lastContactListByAuthor: Map` and + guards `_followedUsers` / `lastContactListEvent` writes on + `event.pubKey == accountPubkey`. Also emits every kind-3 to a new + `contactListEvents: SharedFlow`. +- [ ] `WoTService` in + `commons/commonMain/.../wot/WoTService.kt` — sparse + `SnapshotStateMap`, single-writer coroutine actor, + `Snapshot.withMutableSnapshot { }` for atomic frames, + `onFollowSetChange`, `applyKind3` (with `MAX_FOLLOWS_PER_EVENT` + bound), `markReadyOnce`, `clear`, `scoresSnapshot`, + `hydrateFromStore`, `isReady: StateFlow`. +- [ ] `LocalWoTService`, `LocalWoTReady`, `LocalWoTSelfKey`, + `LocalWoTFollowedKeys` CompositionLocals in + `commons/commonMain/.../wot/`. +- [ ] `UserAvatar` in `commons/commonMain/.../ui/components/UserAvatar.kt` + gains optional `badge: @Composable (BoxScope.() -> Unit)? = null` + parameter. Backward compatible — default null. +- [ ] `WoTBadge` in `desktopApp/src/jvmMain/.../ui/note/WoTBadge.kt` — + Material3 `TooltipBox` with `isPersistent = true`, `PlainTooltip`, + `Box` overlay chip, `contentDescription` for a11y, 99+ clamp. +- [ ] `WoTBadgedAvatar` in `desktopApp/src/jvmMain/.../ui/note/WoTBadgedAvatar.kt` — + composes `UserAvatar` with a `WoTBadge` slot lambda; reads gates + from CompositionLocals. +- [ ] `FeedMetadataCoordinator.loadKind3Batched(pubkeys, timeoutMs = 5s, onEose)` — + chunks into ≤100-author Filters, aggregates EOSE across chunks, + dedupes against `queuedKind3Pubkeys`. +- [ ] `DesktopRelaySubscriptionsCoordinator.loadKind3Batched(pubkeys, onEose)` + delegate. +- [ ] `DesktopIAccount.wotService: WoTService` — constructed with + `account.scope` at IAccount init. +- [ ] `Main.kt` inside `LoggedIn` branch: + - Collects `wotService.isReady` once → `isReadyCollected: Boolean`. + - Collects `localCache.contactListEvents` → `wotService.applyKind3`. + - Collects `localCache.followedUsers` → + `wotService.onFollowSetChange` + `loadKind3Batched`. + - Fallback `delay(2_000)` → `markReadyOnce()`. + - Provides all four `LocalWoT*` CompositionLocals to descendants. +- [ ] Call-site migration to `WoTBadgedAvatar` at 6 v1 surfaces: + `FeedNoteCard` header, `QuotedNoteEmbed` author, + `ThreadScreen` reply avatars, `NotificationsScreen` items, + `SearchResultsList` avatars, `UserProfileScreen` header. +- [ ] Amy verbs: + - `amy wot get [--json]` + - `amy wot list [--threshold N] [--limit K] [--json]` + - `amy wot sync` + +### Non-functional + +- [ ] No measurable frame-time regression during scroll in a 250-note + deck column with badges rendering (manual profiler smoke test). +- [ ] Spotless clean: `./gradlew spotlessApply` produces no diff. +- [ ] Compiles cleanly: `./gradlew :commons:compileKotlinJvm + :desktopApp:compileKotlin :cli:build`. +- [ ] No `Preferences` writes — feature is stateless across restarts. +- [ ] Badge overlay uses absolute positioning inside a `Box` sized to + the avatar; no layout shift when a score arrives mid-scroll. +- [ ] Batch REQ payload chunked ≤ 100 authors per Filter. + +### Quality gates + +- [ ] Unit tests for `WoTService`: + - Empty graph → onFollowSetChange with empty set → nothing to + compute, still fires `markReadyOnce()` via caller. + - `handleFollowSet` from empty → 3 follows, then `handleKind3` for + each with overlapping follow sets → verify scores. + - Removing a follower decrements every pubkey they contributed to; + sparse-map guarantee (removed at 0-count). + - Kind-3 churn: same follower publishes new set → diff applied + correctly, no double-counting, no leaked entries in + `perFollowerSnapshot`. + - Self-exclusion: kind-3 including active-user pubkey doesn't + inflate self-score. + - Follower-self-exclusion: kind-3 including follower's own pubkey + doesn't inflate their own score. + - Guardrail: 3000-follow input yields empty map + `_isReady = true`. + - `MAX_FOLLOWS_PER_EVENT`: 6000-follow kind-3 truncated to 5000. + - `scoresSnapshot()` returns a plain HashMap equal to + `_scores.toMap()`. +- [ ] Unit test for `DesktopLocalCache.consumeContactList` prerequisite + fix — non-self kind-3 doesn't mutate `_followedUsers`; both + events emit to `contactListEvents`. +- [ ] Unit test for `loadKind3Batched` filter shape — 300 authors + chunked into 3 Filters, one subscription, aggregated EOSE, + onEose callback fired. +- [ ] Amy verb integration tests (in `cli/tests/wot/`): + `wot get`, `wot list`, `wot sync` produce correct output and + JSON schemas. +- [ ] Manual testing sheet at + `desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md` + covering the 14 integration scenarios above. + +## Success Metrics + +- Users report badges give useful trust cues on strangers without + cluttering follows / self. +- Batch REQ completes within 5 s for accounts with ≤ 500 follows on + typical index relays. +- No frame drops > 16 ms during scroll or startup. +- `amy wot get` returns within 100 ms on a warm `FsEventStore`. + +## Dependencies & Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Prerequisite cache fix breaks existing feed filters that depend on `_followedUsers` mutation semantics | medium | Prerequisite fix has its own unit test in this PR; audit all readers of `_followedUsers` (Kind3FollowListState, feed filters) — behavior unchanged for the *active-user* code path, only the *other-user* path stops overwriting | +| `verifiedFollowKeySet()` allocation cost during batch flood | low | Not cached in Quartz; measured ~1 ms per event; 500 events × 1 ms = 500 ms on a background thread — acceptable | +| `TooltipBox` visual defaults differ from `TooltipArea` | low | Test both hover and long-press behaviour manually; adjust padding/positioning if needed | +| Amy verb tests miss a `SnapshotStateMap` initialization edge | low | Amy verb test explicitly constructs `WoTService`, populates via `hydrateFromStore`, reads via `scoresSnapshot()` | +| Merge conflict with in-flight hashtag-spam PR (#3431) | medium | Both features touch `Main.kt` CompositionLocalProvider block. If hashtag-spam merges first, rebase; the two providers stack (independent locals) | +| Compose runtime version pinning for the 2026 deadlock fix | low | Verify `libs.versions.toml` compose runtime version ≥ current stable; upgrade if needed | +| Follow-set leak via batch REQ to index relays | pre-existing (metadata already leaks same info) | No new leak. Follow-up ticket: NIP-65 outbox routing for both kind-0 and kind-3 batches; do not gate WoT on it | + +## Out of Scope (deferred) + +- **Threshold-based filtering** (notifications, DMs, feeds, search) — v2. +- **Persistence of scores across sessions** — cached kind-3 events give + fast cold start via `hydrateFromStore`; no separate cache. +- **Settings UI** (toggle to hide badges entirely, threshold picker) — + v2. +- **Mutual-follow drilldown** ("click chip to see who") — v2. +- **Colored-ring alternative rendering** — v2 experiment. +- **Android UI** — v2. `UserAvatar` badge slot is Android-safe today + (default null). +- **Weighting** (zap-weighted, mutual-weighted, decay over time) — + YAGNI. +- **NIP-65 outbox routing** for batch REQ (correctness > simplicity + trade-off) — cross-cutting follow-up ticket, applies to metadata + coordinator too. +- **Cross-account aggregation** — YAGNI. + +## Sources & References + +### Origin + +- **Brainstorm:** `docs/brainstorms/2026-07-01-feat-wot-score-brainstorm.md` + +### Internal references + +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:447-453` + — **prerequisite fix target** — `consumeContactList` scope corruption. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:191` + — `event.verify()` gate confirms signature integrity. +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt:255-301` + — `loadMetadataBatched` blueprint; note the `.take(100)` on line 267 + (we chunk explicitly instead). +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt` + — service-on-account pattern; `signer`-scoped, `Kind3Follows.authors`. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt` + — attach point for `wotService`. +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt` + — add optional `badge` slot. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt:75-95` + — prior tooltip pattern (upgrading to Material3 `TooltipBox` in + `WoTBadge`). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:979-982,1424-1427` + — CompositionLocalProvider wiring sites. +- `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt` + — `FsEventStore` at `~/.amy/shared/events-store/` for amy hydration. +- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt:56` + — `verifiedFollowKeySet()` — not cached, bounded at parse time. + +### External references + +- [Zach Klippenstein — Compose Snapshot system](https://blog.zachklipp.com/introduction-to-the-compose-snapshot-system/) + — per-key read tracking guarantees for `SnapshotStateMap`. +- [Android Developers — SnapshotStateMap reference](https://developer.android.com/reference/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMap) + — `toMap()` is O(1), `.size` / iteration are structural reads. +- [Kotlin docs — Compose Multiplatform Material3 TooltipBox](https://kotlinlang.org/api/compose-multiplatform/material3/androidx.compose.material3/-tooltip-box.html) + — cross-platform tooltip primitive; deprecation path for + `TooltipArea`. +- [JetBrains issue #4275 — Deprecate TooltipArea](https://github.com/JetBrains/compose-multiplatform/issues/4275) + — direction of travel. + +### Skill references + +- `account-state` — `IAccount` follow-set access, `Kind3FollowListState` + scoping. +- `relay-client` — `FeedMetadataCoordinator` batch REQ pattern. +- `compose-recomposition-performance` — `SnapshotStateMap` per-key + subscriber isolation, `Snapshot.withMutableSnapshot` for atomic + frames. +- `compose-stability-diagnostics` — `@Stable` contract honesty on + `WoTService`. +- `compose-slot-api-pattern` — badge slot on `UserAvatar` as visual + extension point. +- `nostr-expert` — `ContactListEvent.verifiedFollowKeySet()`. +- `kotlin-flow-state-event-modeling` — `SharedFlow` + with buffer + `DROP_OLDEST` overflow; `StateFlow` for + readiness. +- `amy-expert` — CLI verb structure, `FsEventStore` hydration, JSON + output contract. + +### Related work + +- Hashtag-spam PR: https://github.com/vitorpamplona/amethyst/pull/3431 + — same CompositionLocal pattern; same "Content Filters" area for v2 + threshold UI when filtering ships. +- Feature backlog: `desktopApp/plans/_desktop-feature-backlog.md` item + #2 (this plan). diff --git a/docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md b/docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md new file mode 100644 index 0000000000..21c1eb853d --- /dev/null +++ b/docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md @@ -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> = _relays.asStateFlow() + + fun setRelays(new: Set) { + _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 = + _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 = 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 = + 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>` 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> = + indexRelaysStore.relays + .map { it.ifEmpty { PreferencesIndexRelays.DEFAULT_INDEX_RELAYS } } + .stateIn(scope, SharingStarted.Eagerly, indexRelaysStore.effective()) + + fun setIndexRelays(new: Set) = 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 { + 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>`, + `setRelays(new)`, `effective(): Set`, and + `DEFAULT_INDEX_RELAYS` constant that matches `DefaultRelays.RELAYS` + byte-for-byte. +- [ ] `DesktopRelayCategories.indexRelays: StateFlow>` + 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` + 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 ` 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> 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).