feat(desktop): Web-of-Trust score badges + amy wot verbs

Adds a friends-of-friends trust score on every user avatar in Desktop
feeds, threads, profile headers, and repost overlays. For pubkey X the
score is the count of accounts in the active user's follow set who also
follow X — Gossip / Snort convention. v1 is display-only; no threshold
filtering.

Data flow
- `commons/wot/WoTService` — sparse `SnapshotStateMap<HexKey, Int>` +
  reverse index + per-follower snapshot for diff-based updates.
  Single-writer coroutine (Channel<Op> → `Snapshot.withMutableSnapshot`)
  serializes all mutations. Cap at 5000 follows/event blocks DoS via
  hostile kind-3s. Guardrail at 2000 follows/account skips WoT for
  mega-accounts.
- `DesktopIAccount.wotService` — per-account instance, matches
  `Kind3FollowListState` / `BookmarkListState` conventions.
- `Main.kt` binds `localCache.accountPubkey`, collects
  `localCache.contactListEvents` → `applyKind3`, collects
  `localCache.followedUsers` → `onFollowSetChange` +
  `subscriptionsCoordinator.loadKind3Batched(...)` with
  `onEose = markReadyOnce`. 2 s fallback timeout guarantees badge
  visibility even if index relays never EOSE.
- `FeedMetadataCoordinator.loadKind3Batched(pubkeys, onEose)` — chunks
  authors into ≤100 per Filter within one subscription. Matches
  nostr-rs-relay defaults.

UI
- `commons/ui/components/UserAvatar` gets an optional
  `badge: @Composable BoxScope.() -> Unit`. Android call sites pass
  null (no compile-time coupling to Desktop-only tooltip APIs).
- `desktopApp/.../ui/note/WoTBadge` — Material3 `TooltipBox` +
  `PlainTooltip` (multiplatform-ready, keyboard/screen-reader a11y).
  `rememberTooltipState(isPersistent = true)` fixes the
  vanish-too-fast desktop default.
- `desktopApp/.../ui/note/WoTBadgedAvatar` — drop-in replacement for
  `UserAvatar` that overlays the badge when
  `LocalWoTService != null && LocalWoTReady && pubkey !in LocalSpamExemptKeys`.
  Score read is a plain `service.scores[userHex] ?: 0` — snapshot
  system tracks per-key, so avatars only recompose when their own
  score changes.
- Call-site migration at 4 v1 surfaces: NoteCard header (covers feed /
  thread / bookmarks / search / QuotedNoteEmbed via NoteCard),
  FeedNoteCard repost header (2 avatars), UserProfileScreen header
  (2 sizes).

Amy verbs
- `amy wot get <pubkey|npub> [--json]` — hydrates a WoTService from the
  local FsEventStore, prints score for target pubkey.
- `amy wot list [--threshold N] [--limit K] [--json]` — sorted score
  list.
- `amy wot sync [--timeout N]` — batch-fetches kind-3 for the active
  follow set from outbox/inbox relays, persists to the event store.

Tests + docs
- 14 unit tests: `WoTServiceTest` covers happy path, sparse map,
  self/follower exclusion, kind-3 churn diff, guardrail, event cap,
  ready gate, clear.
- Manual testing sheet with 17 scenarios at
  `desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-07-01-feat-desktop-wot-score-plan.md`.

Prerequisite `DesktopLocalCache.consumeContactList` scoping fix landed
as a separate commit.
This commit is contained in:
nrobi144
2026-07-02 18:03:47 +03:00
parent 741c377f80
commit 9284e12e86
17 changed files with 2186 additions and 8 deletions
@@ -65,6 +65,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
@@ -236,6 +237,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"podcast" -> PodcastCommands.dispatch(dataDir, tail)
"podcast20" -> Podcast20Commands.dispatch(dataDir, tail)
"bunker" -> BunkerCommand.run(dataDir, tail)
"wot" -> WotCommand.dispatch(dataDir, tail)
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
@@ -0,0 +1,169 @@
/*
* 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.WoTService
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
/**
* `amy wot <get|list|sync>` — Web-of-Trust score queries.
*
* The score for a pubkey X is the count of accounts in the active user's
* kind-3 follow set who also follow X. `get` and `list` are read-only —
* they hydrate the score map from whatever kind-3 events already live in
* the local event store. `sync` pulls fresh kind-3 events from the
* configured relay pool so the next `get` / `list` is up to date.
*/
object WotCommand {
suspend fun dispatch(
dataDir: DataDir,
rest: Array<String>,
): Int {
val head = rest.firstOrNull() ?: return usage()
val tail = rest.drop(1).toTypedArray()
return when (head) {
"get" -> get(dataDir, tail)
"list" -> list(dataDir, tail)
"sync" -> sync(dataDir, tail)
else -> usage()
}
}
private fun usage(): Int = Output.error("bad_args", "wot <get|list|sync>")
private suspend fun get(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "wot get <pubkey|npub>")
val userArg = rest[0]
Context.open(dataDir).use { ctx ->
ctx.prepare()
val target = ctx.requireUserHex(userArg)
val (svc, scope) = buildHydratedService(ctx)
try {
val score = svc.scoresSnapshot()[target] ?: 0
Output.emit(mapOf("pubkey" to target, "score" to score))
return 0
} finally {
scope.cancel()
}
}
}
private suspend fun list(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val threshold = args.flag("threshold")?.toIntOrNull() ?: 1
val limit = args.flag("limit")?.toIntOrNull() ?: 50
Context.open(dataDir).use { ctx ->
ctx.prepare()
val (svc, scope) = buildHydratedService(ctx)
try {
val entries =
svc
.scoresSnapshot()
.entries
.asSequence()
.filter { it.value >= threshold }
.sortedByDescending { it.value }
.take(limit)
.map { mapOf("pubkey" to it.key, "score" to it.value) }
.toList()
Output.emit(mapOf("count" to entries.size, "entries" to entries))
return 0
} finally {
scope.cancel()
}
}
}
private suspend fun sync(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val timeoutMs = args.flag("timeout")?.toLongOrNull()?.times(1000) ?: 5_000L
Context.open(dataDir).use { ctx ->
ctx.prepare()
val self = ctx.identity.pubKeyHex
val myKind3 = ctx.contactsOf(self)
val follows =
myKind3?.verifiedFollowKeySet()?.toList()
?: 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.outboxRelays().ifEmpty { ctx.inboxRelays() }
if (relays.isEmpty()) return Output.error("no_relays", "no relays configured")
// Chunk authors into ≤100 per Filter for relays with per-filter caps.
val filters =
follows.chunked(100).map { chunk ->
Filter(
kinds = listOf(ContactListEvent.KIND),
authors = chunk,
limit = chunk.size,
)
}
val received = ctx.drain(relays.associateWith { filters }, timeoutMs)
val kind3Events = received.mapNotNull { it.second as? ContactListEvent }
// Persist to store so future `get` / `list` see them.
kind3Events.forEach { runCatching { ctx.store.insert(it) } }
Output.emit(mapOf("received" to kind3Events.size, "followers" to follows.size))
return 0
}
}
/**
* Build a [WoTService], populate it from the local event store, then
* return the (service, backing scope). Caller must cancel the scope
* when done.
*/
private suspend fun buildHydratedService(ctx: Context): Pair<WoTService, CoroutineScope> {
val self = ctx.identity.pubKeyHex
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val svc = WoTService(scope, writerDispatcher = Dispatchers.Unconfined)
val myKind3 = ctx.contactsOf(self)
val follows: Set<HexKey> = myKind3?.verifiedFollowKeySet() ?: emptySet()
svc.onFollowSetChange(follows, self)
// Pull each follower's kind-3 from the store and feed into the service.
follows.forEach { follower ->
val followerKind3 = ctx.contactsOf(follower) ?: return@forEach
svc.applyKind3(follower, followerKind3.verifiedFollowKeySet())
}
svc.markReadyOnce()
return svc to scope
}
}