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.
49 KiB
title, type, status, date, origin, deepened
| title | type | status | date | origin | deepened |
|---|---|---|---|---|---|
| Desktop Web-of-Trust Score Badges | feat | active | 2026-07-01 | docs/brainstorms/2026-07-01-feat-wot-score-brainstorm.md | 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
- Prerequisite: fix
DesktopLocalCache.consumeContactList. The current implementation writes_followedUsers = event.verifiedFollowKeySet()for any incoming kind-3, gated only on a single globallastContactListCreatedAtscalar. Once WoT starts fetching followed authors' kind-3 events, this actively corrupts the active user's follow-set state. Refactor to per-authorMap<HexKey, Long>and guard the_followedUsers/lastContactListEventwrite onevent.pubKey == account.pubKeyHex. This is a prerequisite commit in this PR — WoT cannot ship without it. - Badge is a slot, not an embed.
UserAvatarincommonMaingets an optionalbadge: @Composable (BoxScope.() -> Unit)? = nullparameter. Desktop call sites pass aWoTBadge()-bearing lambda; Android passes null. No CompositionLocal reads inside the shared composable, noexpect/actualdance,TooltipBoximport stays in the Desktop-only source set of the caller. - Service on
DesktopIAccount, not inremember. Match the established pattern ofKind3FollowListState,BookmarkListState,Nip65RelayListState— the service is a field onDesktopIAccountconstructed withaccount.scope, exposed as a property, provided viaLocalWoTServicefromMain.kt. Lifecycle matches the login session, not the composition. - Kind-3 event flow via
SharedFlow<ContactListEvent>. Not a mutable listener list.DesktopLocalCacheexposes aSharedFlow<ContactListEvent>(buffer 64,DROP_OLDESToverflow), emitted from insideconsumeContactList. WoTService collects it from anaccount.scope-scoped coroutine. - Batch kind-3 loader — chunk 100 per Filter, explicit
onEosehook. ExistingFeedMetadataCoordinator.loadMetadataBatchedat line 267 silently doesauthors.take(100)— blind copy would drop 400 of 500 follows. NewloadKind3Batchedchunks into ≤100-author Filters, aggregates EOSE across chunks, and exposesonEose: () -> Unitso the caller canmarkReady()on real EOSE (not just the 2 s timeout). - Use Material3
TooltipBox, notTooltipArea. Multiplatform, built-in a11y (screen readers, keyboard focus), and the JetBrains deprecation direction (JB #4275). Setstate = rememberTooltipState(isPersistent = true)to fix the known "vanishes too fast" desktop bug. WoTScoresealed hierarchy → plainInt+ sparse map. DropUnknown— represent "not queried" as absence from the map._scores.remove(target)when count hits 0. Keeps the map sparse and Compose subscriber tracking cheap.- Drop
derivedStateOfat the leaf. Plainservice.scores[userHex] ?: 0is already snapshot-tracked per key.derivedStateOfadds a subscriber node and a comparator per avatar for no gain when there's only one read. - Readiness as a plain
BooleanCompositionLocal, not per-avatarcollectAsState. CollectisReadyonce at App root, provide viaLocalWoTReady. 150 collectors per screen becomes 1. - Batch writes with
Snapshot.withMutableSnapshot { }.applyKind3mutates_scoresfor many keys — wrap the loop in a single mutable snapshot so all readers see one atomic frame. - Amy verbs ship in v1 (not v2).
amy wot get <pubkey>,amy wot list --threshold N,amy wot sync. Service exposesscoresSnapshot(): Map<HexKey, Int>for headless readers.FsEventStoreat~/.amy/shared/events-store/already caches kind-3 events → warm-cache queries need no relay traffic. - Bound follows per event. Cap
verifiedFollowKeySet()result at 5000 entries inapplyKind3— prevents CPU DoS from a hostile follower publishing a 100k-tag kind-3. WoTServicewrites onDispatchers.Default, single-writer coroutine. SerializeapplyKind3,onFollowSetChange, etc. inside anactor-style coroutine so composite state stays consistent across concurrent kind-3 arrivals.
New considerations discovered
- Simplicity review argued for dropping
WoTScore,isReady, andperFollowerSnapshot. Kept the last two (correctness vs churn) but agreed on droppingWoTScore. - Pattern review pushed for
commons/moderation/wot/(sibling to hashtag-spam). Keptcommons/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
authorslist toindexRelays). WoT introduces no novel privacy regression. - All ingested events are
event.verify()-checked beforeLocalCache.consumeruns (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 UserAvatars 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
Intin a sparseSnapshotStateMap. Absence ≡ "not queried yet or definitively zero." Both hide the badge. - Prerequisite: fix
consumeContactListscope corruption. - Batch REQ: chunked into ≤100-author Filters, aggregated EOSE.
- Startup gate:
isReady = trueafter first-chunk EOSE OR 2 s timeout, whichever first. SingleBooleanprovided 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:
- Observes the active user's follow set from
DesktopLocalCache.followedUsers. - Issues chunked batch REQs (≤100 authors each, aggregated EOSE) for
kinds=[3]on the sameindexRelaysused byFeedMetadataCoordinator.loadMetadataBatched. - Collects a new
DesktopLocalCache.contactListEvents: SharedFlow<ContactListEvent>and callsapplyKind3(...)for each event whose author is in the follow set. - Maintains a reverse index (
Map<HexKey, MutableSet<HexKey>>from target-pubkey → set of my-follows who follow them) and a per-follower snapshot (Map<HexKey, Set<HexKey>>) for diff updates. - Publishes score changes atomically to
_scores: SnapshotStateMap<HexKey, Int>inside aSnapshot.withMutableSnapshot { }block. - Marks
isReady = trueon 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 == trueservice.scores[pubkey] > 0pubkey != selfPubkeypubkey !in followedKeys
WoTBadge uses Material3 TooltipBox with
state = rememberTooltipState(isPersistent = true).
Why this shape
- Per-account service on
DesktopIAccountmatches the codebase convention (kind3FollowList,bookmarkList).rememberinside 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
UserAvatarfor Desktop-owned rendering. Even the sibling composable needs somewhere to draw the badge. Adding a scalarbadgeslot toUserAvataravoids duplicating the entire avatar layout and keeps Android intact (nulls the slot). - Sparse
SnapshotStateMap. StoringUnknownfor 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<Boolean> │ │
│ │ ui/components/UserAvatar.kt + badge: @Composable slot │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
▲
│
┌─────────────────────────────┴────────────────────────────────────┐
│ desktopApp/ │
│ cache/DesktopLocalCache.kt (PREREQUISITE FIX) │
│ - lastContactListByAuthor: MutableMap<HexKey, Long> │
│ - contactListEvents: SharedFlow<ContactListEvent> │
│ - _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 <pubkey|npub> [--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:
// 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<HexKey, Long>()
private val _contactListEvents = MutableSharedFlow<ContactListEvent>(
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val contactListEvents: SharedFlow<ContactListEvent> = _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:
@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<HexKey, Int> = mutableStateMapOf()
val scores: SnapshotStateMap<HexKey, Int> get() = _scores
// Internal state — always mutated from the [writer] actor coroutine.
private val reverseIndex = HashMap<HexKey, MutableSet<HexKey>>()
private val perFollowerSnapshot = HashMap<HexKey, Set<HexKey>>()
private var myFollows: Set<HexKey> = emptySet()
private var selfPubkey: HexKey? = null
// Readiness
private val readyOnce = AtomicBoolean(false)
private val _isReady = MutableStateFlow(false)
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
// Serialize state mutations
private val ops = Channel<Op>(capacity = Channel.BUFFERED)
init {
scope.launch(Dispatchers.Default) {
for (op in ops) processOne(op)
}
}
private sealed interface Op {
data class FollowSet(val new: Set<HexKey>, val self: HexKey?) : Op
data class Kind3(val follower: HexKey, val follows: Set<HexKey>) : Op
object MarkReady : Op
}
fun onFollowSetChange(newFollows: Set<HexKey>, newSelf: HexKey?) {
ops.trySend(Op.FollowSet(newFollows, newSelf))
}
fun applyKind3(follower: HexKey, follows: Set<HexKey>) {
val bounded = if (follows.size > MAX_FOLLOWS_PER_EVENT) {
follows.take(MAX_FOLLOWS_PER_EVENT).toSet()
} else follows
ops.trySend(Op.Kind3(follower, bounded))
}
fun markReadyOnce() {
if (readyOnce.compareAndSet(false, true)) ops.trySend(Op.MarkReady)
}
/** For amy — returns a plain snapshot, no Compose runtime required. */
fun scoresSnapshot(): Map<HexKey, Int> = HashMap(_scores)
/** For amy — hydrate from a local event store before querying. */
suspend fun hydrateFromStore(store: IEventStore, myFollows: Set<HexKey>) {
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<HexKey>, 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<HexKey>) {
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.applyKind3boundsfollows.sizeto 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/:
private val queuedKind3Pubkeys = mutableSetOf<HexKey>()
/**
* 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<HexKey>,
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<NormalizedRelayUrl>()
val allEose = CompletableDeferred<Unit>()
val listener = object : SubscriptionListener {
override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
this@FeedMetadataCoordinator.onEvent?.invoke(event, relay)
}
override fun onEose(relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
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)
@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:
@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:
@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:
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:
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 <pubkey|npub> [--json]
Prints: pubkey=<hex> score=<n> contributed_by=<hex,hex,…>
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:
- Reads active-user pubkey from
Context.currentAccount. - Hydrates
WoTServicefromFsEventStore(~/.amy/shared/events-store/). - Optionally runs
wot sync(idempotent) for freshness. - 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
applyKind3op mutates the map for many keys inside a singleSnapshot.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 anHashMap(_scores)— O(N) copy; amy calls it once per verb invocation, fine.
Compose recomposition
- Plain
service.scores[userHex] ?: 0read is snapshot-tracked per key. When only one key updates, only avatars for that pubkey recompose. LocalWoTReady: CompositionLocal<Boolean>is collected once at App root; no per-avatar Flow collector.- Badge visibility is decided in
WoTBadgedAvatar(Desktop) — theUserAvatarslot receivesbadge=nullwhen hidden, so no overlay Box + no recomposition of hidden branches.
Stability annotations
WoTService:@Stable. Public API isscores: SnapshotStateMap(Compose-tracked) +isReady: StateFlow(viacollectAsState). Private mutable state doesn't leak.Intis stable by definition — sparse map values are trivially stable.WoTBadge,WoTBadgedAvatar,UserAvatarall have primitive/stable params after the slot addition.
Threading
- All mutations to
reverseIndex/perFollowerSnapshothappen inside the single writer coroutine onDispatchers.Default. _scoresSnapshotStateMap is thread-safe for individual puts; composite writes wrapped inSnapshot.withMutableSnapshot { }are applied atomically.- Composable reads happen on the Main dispatcher via snapshot; safe.
Follow-set reactivity
DesktopLocalCache._followedUsers: StateFlow<Set<HexKey>>(already reactive) is the source of truth for the active user's follow set — once the prerequisite cache fix lands.- The
LaunchedEffectinMain.ktcollects it and forwards toWoTService.onFollowSetChangeand re-triggers the batch REQ for newly-followed pubkeys (existing ones deduped byqueuedKind3Pubkeys).
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
indexRelaysvia 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)inapplyKind3bounds CPU cost against a hostile 100 k-tag kind-3.- No persistence —
Preferencesuntouched. Reverse index lives in memory only. - App-global operation (not per-account): follow-set state lives on
DesktopIAccount, discarded on logout. Account switch = newIAccount= newWoTService.
System-Wide Impact
Interaction graph
DesktopLocalCache.consumeContactList(event) ← (prerequisite fix)
│
├─ if event.pubKey == self → update _followedUsers, lastContactListEvent
│
└─ tryEmit → contactListEvents: SharedFlow<ContactListEvent>
│
▼ 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_OLDESToncontactListEvents— under extreme flood, oldest events dropped. Acceptable v1 (relay flood is itself abnormal). - Account switch:
LaunchedEffectcancelled → collect on oldcontactListEventsstops. OldWoTServicereferenced only via the cancelled effect and oldIAccount— 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 inDesktopLocalCacheTest. - 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 byLaunchedEffect(wotService, localCache)keying.
API surface parity
- commons:
WoTService,LocalWoTService,LocalWoTReady,LocalWoTSelfKey,LocalWoTFollowedKeys(CompositionLocals).UserAvatargains optionalbadgeslot (backward compatible — default null). - desktopApp:
WoTBadge,WoTBadgedAvatarcomposables; call-site migration at 6 v1 surfaces (feed, quote-embed, thread reply, notification, search result, profile header). Coordinator getsloadKind3Batched.DesktopLocalCachegetscontactListEvents: SharedFlow+ per-authorlastContactListByAuthormap.DesktopIAccountgetswotServiceproperty. - amethyst (Android): no changes v1.
UserAvatarbadge slot is optional; Android call sites pass no lambda. - cli (amy): three verbs (
wot get,wot list,wot sync), ~150 LOC incommands/WotCommand.kt, wired intoMain.ktdispatch.
Integration test scenarios
- Cold start with 250 follows. Login → batch REQ fires with 3 chunks of 100 authors → badges appear within ≤ 2 s.
- Follow a new person mid-session. New pubkey added to
_followedUsers→onFollowSetChangecredits nothing yet; newloadKind3Batched(followSet)picks up their kind-3 (deduped byqueuedKind3Pubkeys); score for their followers ticks up as their kind-3 lands. - Unfollow a follower.
onFollowSetChange(removed)uncredits; affected pubkeys' scores decrement; some may drop to 0 and lose badges (map entry removed). - Kind-3 churn. Same follower republishes with +5 / -2 diff →
applyKind3computes diff correctly, no double-counting. - Account switch. New
IAccount→ newWoTService→ old service GC'd. Old service's map does not leak into new UI. - Guardrail trip. 3000-follow account → guardrail clears state, marks ready, no batch REQ.
- Empty graph. 0 follows → onFollowSetChange with empty set,
nothing to fetch,
markReadyOnce()fires from fallback timeout. - 99+ overflow. Simulate score 200 → badge shows "99+".
- Self exemption. Own avatar in profile header never shows a badge even if some follower's kind-3 lists self.
- Follow-list exemption. Followed author's avatar never shows a badge even when others in follow-list follow them.
- Kind-3 malformed. 100 k-tag kind-3 →
applyKind3truncates to 5 000, service stays responsive. - Prerequisite fix verification. Send a kind-3 event whose author
is not the active user — verify
_followedUsersunchanged, butcontactListEventsemits. - Amy
wot get.amy wot get <npub>afteramy wot syncreturns correct score. - Amy warm cache. Second
amy wot get <npub>invocation withoutsyncusesFsEventStorehydration, no relay traffic.
Acceptance Criteria
Functional
- Prerequisite:
DesktopLocalCache.consumeContactListrefactored with per-authorlastContactListByAuthor: Map<HexKey, Long>and guards_followedUsers/lastContactListEventwrites onevent.pubKey == accountPubkey. Also emits every kind-3 to a newcontactListEvents: SharedFlow<ContactListEvent>. WoTServiceincommons/commonMain/.../wot/WoTService.kt— sparseSnapshotStateMap<HexKey, Int>, single-writer coroutine actor,Snapshot.withMutableSnapshot { }for atomic frames,onFollowSetChange,applyKind3(withMAX_FOLLOWS_PER_EVENTbound),markReadyOnce,clear,scoresSnapshot,hydrateFromStore,isReady: StateFlow<Boolean>.LocalWoTService,LocalWoTReady,LocalWoTSelfKey,LocalWoTFollowedKeysCompositionLocals incommons/commonMain/.../wot/.UserAvatarincommons/commonMain/.../ui/components/UserAvatar.ktgains optionalbadge: @Composable (BoxScope.() -> Unit)? = nullparameter. Backward compatible — default null.WoTBadgeindesktopApp/src/jvmMain/.../ui/note/WoTBadge.kt— Material3TooltipBoxwithisPersistent = true,PlainTooltip,Boxoverlay chip,contentDescriptionfor a11y, 99+ clamp.WoTBadgedAvatarindesktopApp/src/jvmMain/.../ui/note/WoTBadgedAvatar.kt— composesUserAvatarwith aWoTBadgeslot lambda; reads gates from CompositionLocals.FeedMetadataCoordinator.loadKind3Batched(pubkeys, timeoutMs = 5s, onEose)— chunks into ≤100-author Filters, aggregates EOSE across chunks, dedupes againstqueuedKind3Pubkeys.DesktopRelaySubscriptionsCoordinator.loadKind3Batched(pubkeys, onEose)delegate.DesktopIAccount.wotService: WoTService— constructed withaccount.scopeat IAccount init.Main.ktinsideLoggedInbranch: - CollectswotService.isReadyonce →isReadyCollected: Boolean. - CollectslocalCache.contactListEvents→wotService.applyKind3. - CollectslocalCache.followedUsers→wotService.onFollowSetChange+loadKind3Batched. - Fallbackdelay(2_000)→markReadyOnce(). - Provides all fourLocalWoT*CompositionLocals to descendants.- Call-site migration to
WoTBadgedAvatarat 6 v1 surfaces:FeedNoteCardheader,QuotedNoteEmbedauthor,ThreadScreenreply avatars,NotificationsScreenitems,SearchResultsListavatars,UserProfileScreenheader. - Amy verbs:
-
amy wot get <pubkey|npub> [--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 spotlessApplyproduces no diff. - Compiles cleanly:
./gradlew :commons:compileKotlinJvm :desktopApp:compileKotlin :cli:build. - No
Preferenceswrites — feature is stateless across restarts. - Badge overlay uses absolute positioning inside a
Boxsized 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. handleFollowSetfrom empty → 3 follows, thenhandleKind3for 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().
- Empty graph → onFollowSetChange with empty set → nothing to
compute, still fires
- Unit test for
DesktopLocalCache.consumeContactListprerequisite fix — non-self kind-3 doesn't mutate_followedUsers; both events emit tocontactListEvents. - Unit test for
loadKind3Batchedfilter 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 syncproduce correct output and JSON schemas. - Manual testing sheet at
desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.mdcovering 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 getreturns within 100 ms on a warmFsEventStore.
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.
UserAvatarbadge 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 —consumeContactListscope 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—loadMetadataBatchedblueprint; 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 forwotService.commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt— add optionalbadgeslot.desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt:75-95— prior tooltip pattern (upgrading to Material3TooltipBoxinWoTBadge).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—FsEventStoreat~/.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
— per-key read tracking guarantees for
SnapshotStateMap. - Android Developers — SnapshotStateMap reference
—
toMap()is O(1),.size/ iteration are structural reads. - Kotlin docs — Compose Multiplatform Material3 TooltipBox
— cross-platform tooltip primitive; deprecation path for
TooltipArea. - JetBrains issue #4275 — Deprecate TooltipArea — direction of travel.
Skill references
account-state—IAccountfollow-set access,Kind3FollowListStatescoping.relay-client—FeedMetadataCoordinatorbatch REQ pattern.compose-recomposition-performance—SnapshotStateMapper-key subscriber isolation,Snapshot.withMutableSnapshotfor atomic frames.compose-stability-diagnostics—@Stablecontract honesty onWoTService.compose-slot-api-pattern— badge slot onUserAvataras visual extension point.nostr-expert—ContactListEvent.verifiedFollowKeySet().kotlin-flow-state-event-modeling—SharedFlow<ContactListEvent>with buffer +DROP_OLDESToverflow;StateFlow<Boolean>for readiness.amy-expert— CLI verb structure,FsEventStorehydration, 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.mditem #2 (this plan).