From 9284e12e86298aef3033eca6dffa8b89a1449fe8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 2 Jul 2026 18:03:47 +0300 Subject: [PATCH] feat(desktop): Web-of-Trust score badges + amy wot verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` + reverse index + per-follower snapshot for diff-based updates. Single-writer coroutine (Channel → `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 [--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. --- .../com/vitorpamplona/amethyst/cli/Main.kt | 2 + .../amethyst/cli/commands/WotCommand.kt | 169 +++ .../assemblers/FeedMetadataCoordinator.kt | 68 + .../commons/ui/components/UserAvatar.kt | 25 + .../amethyst/commons/wot/LocalWoTService.kt | 46 + .../amethyst/commons/wot/WoTService.kt | 246 ++++ .../amethyst/commons/wot/WoTServiceTest.kt | 219 ++++ ...26-07-01-wot-score-manual-testing-sheet.md | 73 ++ .../vitorpamplona/amethyst/desktop/Main.kt | 45 + .../amethyst/desktop/model/DesktopIAccount.kt | 8 + .../DesktopRelaySubscriptionsCoordinator.kt | 13 + .../amethyst/desktop/ui/FeedScreen.kt | 6 +- .../amethyst/desktop/ui/UserProfileScreen.kt | 6 +- .../amethyst/desktop/ui/note/NoteCard.kt | 4 +- .../amethyst/desktop/ui/note/WoTBadge.kt | 83 ++ .../desktop/ui/note/WoTBadgedAvatar.kt | 91 ++ .../2026-07-01-feat-desktop-wot-score-plan.md | 1090 +++++++++++++++++ 17 files changed, 2186 insertions(+), 8 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/LocalWoTService.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTServiceTest.kt create mode 100644 desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadge.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt create mode 100644 docs/plans/2026-07-01-feat-desktop-wot-score-plan.md 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 4211bd2509..2051251cde 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): 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..c162d6ad27 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt @@ -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 ` — 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) + 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 { + 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/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..ca5f1bb770 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,6 +33,7 @@ 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 @@ -73,6 +74,7 @@ class FeedMetadataCoordinator( private val queuedPubkeys = mutableSetOf() private val queuedNoteIds = mutableSetOf() private val queuedBoostedIds = mutableSetOf() + private val queuedKind3Pubkeys = mutableSetOf() /** * Start processing the subscription queue. @@ -300,6 +302,71 @@ class FeedMetadataCoordinator( } } + /** + * 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]). + */ + 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 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 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() + } + } + /** * Clear queued items. Call when switching feeds. */ @@ -307,5 +374,6 @@ class FeedMetadataCoordinator( priorityQueue.clear() queuedPubkeys.clear() queuedNoteIds.clear() + queuedKind3Pubkeys.clear() } } 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/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/WoTService.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt new file mode 100644 index 0000000000..758b0355c6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt @@ -0,0 +1,246 @@ +/* + * 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. + * + * Scores are exposed via a Compose-observable [SnapshotStateMap] with + * per-key subscriber isolation — only avatars whose specific pubkey + * scored differently recompose when the map mutates. + * + * All internal state is mutated from a single writer coroutine + * ([writerLoop]) on [Dispatchers.Default], so concurrent + * [applyKind3] / [onFollowSetChange] / [markReadyOnce] calls from + * different threads are serialized without extra locking. + */ +@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, +) { + /** + * 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 val _isReady = MutableStateFlow(false) + val isReady: StateFlow = _isReady.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?, + ) { + val removed = myFollows - newFollows + myFollows = newFollows + selfPubkey = newSelf + + // Guardrail — massive follow lists don't produce a useful WoT signal. + if (myFollows.size > MAX_FOLLOWS) { + reverseIndex.clear() + perFollowerSnapshot.clear() + _scores.clear() + handleMarkReady() + return + } + + // 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 (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 + } + + 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/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..48eb6e0259 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTServiceTest.kt @@ -0,0 +1,219 @@ +/* + * 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() }) + } + + @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 94e84c0b3a..db7958e5e6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -78,6 +78,8 @@ 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 @@ -1522,6 +1524,47 @@ fun MainContent( relayHealthStore.scanNow() } + // Web-of-Trust: bind the active user's pubkey so the cache guards + // active-user state against other users' kind-3 events, then feed all + // incoming kind-3 events to the WoT service and fetch the follow lists + // of every account the user follows. + 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. + launch { + localCache.followedUsers.collect { follows -> + iAccount.wotService.onFollowSetChange(follows, account.pubKeyHex) + if (follows.isNotEmpty()) { + subscriptionsCoordinator.loadKind3Batched(follows) { + iAccount.wotService.markReadyOnce() + } + } else { + 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, @@ -1530,6 +1573,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/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/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index afa616fb00..a159730f5e 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 @@ -264,6 +264,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 */ 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/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).