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

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

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

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

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

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

Prerequisite `DesktopLocalCache.consumeContactList` scoping fix landed
as a separate commit.
This commit is contained in:
nrobi144
2026-07-02 18:03:47 +03:00
parent 741c377f80
commit 9284e12e86
17 changed files with 2186 additions and 8 deletions
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand
import com.vitorpamplona.amethyst.cli.commands.SyncCommand
import com.vitorpamplona.amethyst.cli.commands.UseCommand
import com.vitorpamplona.amethyst.cli.commands.VerifyCommand
import com.vitorpamplona.amethyst.cli.commands.WotCommand
import com.vitorpamplona.amethyst.cli.commands.ZapCommand
import com.vitorpamplona.amethyst.cli.commands.cashu.CashuCommands
import com.vitorpamplona.amethyst.cli.commands.cashu.CashuMintCommands
@@ -236,6 +237,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"podcast" -> PodcastCommands.dispatch(dataDir, tail)
"podcast20" -> Podcast20Commands.dispatch(dataDir, tail)
"bunker" -> BunkerCommand.run(dataDir, tail)
"wot" -> WotCommand.dispatch(dataDir, tail)
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.wot.WoTService
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
/**
* `amy wot <get|list|sync>` — Web-of-Trust score queries.
*
* The score for a pubkey X is the count of accounts in the active user's
* kind-3 follow set who also follow X. `get` and `list` are read-only —
* they hydrate the score map from whatever kind-3 events already live in
* the local event store. `sync` pulls fresh kind-3 events from the
* configured relay pool so the next `get` / `list` is up to date.
*/
object WotCommand {
suspend fun dispatch(
dataDir: DataDir,
rest: Array<String>,
): Int {
val head = rest.firstOrNull() ?: return usage()
val tail = rest.drop(1).toTypedArray()
return when (head) {
"get" -> get(dataDir, tail)
"list" -> list(dataDir, tail)
"sync" -> sync(dataDir, tail)
else -> usage()
}
}
private fun usage(): Int = Output.error("bad_args", "wot <get|list|sync>")
private suspend fun get(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "wot get <pubkey|npub>")
val userArg = rest[0]
Context.open(dataDir).use { ctx ->
ctx.prepare()
val target = ctx.requireUserHex(userArg)
val (svc, scope) = buildHydratedService(ctx)
try {
val score = svc.scoresSnapshot()[target] ?: 0
Output.emit(mapOf("pubkey" to target, "score" to score))
return 0
} finally {
scope.cancel()
}
}
}
private suspend fun list(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val threshold = args.flag("threshold")?.toIntOrNull() ?: 1
val limit = args.flag("limit")?.toIntOrNull() ?: 50
Context.open(dataDir).use { ctx ->
ctx.prepare()
val (svc, scope) = buildHydratedService(ctx)
try {
val entries =
svc
.scoresSnapshot()
.entries
.asSequence()
.filter { it.value >= threshold }
.sortedByDescending { it.value }
.take(limit)
.map { mapOf("pubkey" to it.key, "score" to it.value) }
.toList()
Output.emit(mapOf("count" to entries.size, "entries" to entries))
return 0
} finally {
scope.cancel()
}
}
}
private suspend fun sync(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val timeoutMs = args.flag("timeout")?.toLongOrNull()?.times(1000) ?: 5_000L
Context.open(dataDir).use { ctx ->
ctx.prepare()
val self = ctx.identity.pubKeyHex
val myKind3 = ctx.contactsOf(self)
val follows =
myKind3?.verifiedFollowKeySet()?.toList()
?: return Output.error("no_follows", "no kind-3 in local store; run `amy follow` first")
if (follows.isEmpty()) {
Output.emit(mapOf("synced" to 0, "detail" to "empty follow set"))
return 0
}
val relays = ctx.outboxRelays().ifEmpty { ctx.inboxRelays() }
if (relays.isEmpty()) return Output.error("no_relays", "no relays configured")
// Chunk authors into ≤100 per Filter for relays with per-filter caps.
val filters =
follows.chunked(100).map { chunk ->
Filter(
kinds = listOf(ContactListEvent.KIND),
authors = chunk,
limit = chunk.size,
)
}
val received = ctx.drain(relays.associateWith { filters }, timeoutMs)
val kind3Events = received.mapNotNull { it.second as? ContactListEvent }
// Persist to store so future `get` / `list` see them.
kind3Events.forEach { runCatching { ctx.store.insert(it) } }
Output.emit(mapOf("received" to kind3Events.size, "followers" to follows.size))
return 0
}
}
/**
* Build a [WoTService], populate it from the local event store, then
* return the (service, backing scope). Caller must cancel the scope
* when done.
*/
private suspend fun buildHydratedService(ctx: Context): Pair<WoTService, CoroutineScope> {
val self = ctx.identity.pubKeyHex
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val svc = WoTService(scope, writerDispatcher = Dispatchers.Unconfined)
val myKind3 = ctx.contactsOf(self)
val follows: Set<HexKey> = myKind3?.verifiedFollowKeySet() ?: emptySet()
svc.onFollowSetChange(follows, self)
// Pull each follower's kind-3 from the store and feed into the service.
follows.forEach { follower ->
val followerKind3 = ctx.contactsOf(follower) ?: return@forEach
svc.applyKind3(follower, followerKind3.verifiedFollowKeySet())
}
svc.markReadyOnce()
return svc to scope
}
}
@@ -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<HexKey>()
private val queuedNoteIds = mutableSetOf<HexKey>()
private val queuedBoostedIds = mutableSetOf<HexKey>()
private val queuedKind3Pubkeys = mutableSetOf<HexKey>()
/**
* 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<HexKey>,
timeoutMs: Long = 5_000L,
onEose: () -> Unit = {},
) {
val newPubkeys = pubkeys.filter { it !in queuedKind3Pubkeys }.distinct()
if (newPubkeys.isEmpty()) {
onEose()
return
}
queuedKind3Pubkeys.addAll(newPubkeys)
scope.launch {
val 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<NormalizedRelayUrl>()
val allEose = CompletableDeferred<Unit>()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
this@FeedMetadataCoordinator.onEvent?.invoke(event, relay)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eoseReceived.add(relay)
if (eoseReceived.size >= indexRelays.size) {
allEose.complete(Unit)
}
}
}
client.subscribe(subId, filterMap, listener)
withTimeoutOrNull(timeoutMs) { allEose.await() }
client.unsubscribe(subId)
onEose()
}
}
/**
* Clear queued items. Call when switching feeds.
*/
@@ -307,5 +374,6 @@ class FeedMetadataCoordinator(
priorityQueue.clear()
queuedPubkeys.clear()
queuedNoteIds.clear()
queuedKind3Pubkeys.clear()
}
}
@@ -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
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.wot
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
/**
* Compose-observable score service. Provided by the Desktop app at App
* root when a user is logged in. Left null on Android and while logged
* out — leaf composables branch on `LocalWoTService.current == null` to
* skip the WoT rendering path.
*
* The badge-hide predicates (self, already-followed) are read from
* `commons.moderation.LocalSpamExemptKeys` — the same set already
* provided by the hashtag-spam filter.
*/
val LocalWoTService: ProvidableCompositionLocal<WoTService?> =
compositionLocalOf { null }
/**
* Whether the WoT service has finished its initial batch fetch (or the
* 2s startup timeout has elapsed). Read once at the App root via
* [WoTService.isReady] and provided down as a scalar so leaf composables
* don't each spawn a Flow collector.
*/
val LocalWoTReady: ProvidableCompositionLocal<Boolean> =
compositionLocalOf { false }
@@ -0,0 +1,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<HexKey, Int> = mutableStateMapOf()
val scores: SnapshotStateMap<HexKey, Int> get() = _scores
// Reverse index: target pubkey → set of my-follows who follow them.
private val reverseIndex = HashMap<HexKey, MutableSet<HexKey>>()
// Per-follower cached follow set (excluding self / follower itself).
// Enables diff-based updates when a follower republishes their kind-3.
private val perFollowerSnapshot = HashMap<HexKey, Set<HexKey>>()
private var myFollows: Set<HexKey> = emptySet()
private var selfPubkey: HexKey? = null
private var readyMarked = false
private val _isReady = MutableStateFlow(false)
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
private val ops = Channel<Op>(capacity = Channel.UNLIMITED)
init {
scope.launch(writerDispatcher) { writerLoop() }
}
/** Update the active user's follow set (and self pubkey). */
fun onFollowSetChange(
newFollows: Set<HexKey>,
newSelf: HexKey?,
) {
ops.trySend(Op.FollowSet(newFollows, newSelf))
}
/**
* Ingest a kind-3 event for a followed pubkey. Ignored when the
* event's author isn't in the current follow set. Follow lists are
* capped at [MAX_FOLLOWS_PER_EVENT] to bound CPU cost against a
* hostile publisher.
*/
fun applyKind3(
follower: HexKey,
follows: Set<HexKey>,
) {
val bounded =
if (follows.size > MAX_FOLLOWS_PER_EVENT) {
follows.take(MAX_FOLLOWS_PER_EVENT).toSet()
} else {
follows
}
ops.trySend(Op.Kind3(follower, bounded))
}
/**
* Mark the service as ready to render badges. Idempotent — subsequent
* calls are no-ops. Trigger from the first EOSE on the batch kind-3
* REQ, or from a startup-timeout fallback, whichever fires first.
*/
fun markReadyOnce() {
ops.trySend(Op.MarkReady)
}
/** Clear all state. Used on logout / account switch. */
fun clear() {
ops.trySend(Op.Clear)
}
/**
* Returns a plain [Map] snapshot of current scores for headless
* callers (e.g. the amy CLI) that don't run inside a Compose
* composition. O(N) copy from the underlying [SnapshotStateMap].
*/
fun scoresSnapshot(): Map<HexKey, Int> = HashMap(_scores)
private sealed interface Op {
data class FollowSet(
val newFollows: Set<HexKey>,
val newSelf: HexKey?,
) : Op
data class Kind3(
val follower: HexKey,
val follows: Set<HexKey>,
) : Op
data object MarkReady : Op
data object Clear : Op
}
private suspend fun writerLoop() {
for (op in ops) {
Snapshot.withMutableSnapshot {
when (op) {
is Op.FollowSet -> handleFollowSet(op.newFollows, op.newSelf)
is Op.Kind3 -> handleKind3(op.follower, op.follows)
Op.MarkReady -> handleMarkReady()
Op.Clear -> handleClear()
}
}
}
}
private fun handleFollowSet(
newFollows: Set<HexKey>,
newSelf: HexKey?,
) {
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<HexKey>,
) {
if (follower !in myFollows) return
val old = perFollowerSnapshot[follower] ?: emptySet()
val excluded = setOfNotNull(follower, selfPubkey)
val effective = follows - excluded
val added = effective - old
val removed = old - effective
perFollowerSnapshot[follower] = effective
added.forEach { target ->
reverseIndex.getOrPut(target) { hashSetOf() }.add(follower)
updateScore(target)
}
removed.forEach { target ->
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
}
}
@@ -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<String, Int>(), svc.scoresSnapshot())
}
@Test
fun singleFollowerCreditsTargets() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c, d))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(1, svc.scoresSnapshot()[d])
}
@Test
fun overlappingFollowersSumScores() {
svc.onFollowSetChange(setOf(a, b), me)
svc.applyKind3(a, setOf(c, d))
svc.applyKind3(b, setOf(c, e))
drain()
assertEquals(2, svc.scoresSnapshot()[c])
assertEquals(1, svc.scoresSnapshot()[d])
assertEquals(1, svc.scoresSnapshot()[e])
}
@Test
fun removingFollowerDecrementsAllContributions() {
svc.onFollowSetChange(setOf(a, b), me)
svc.applyKind3(a, setOf(c, d))
svc.applyKind3(b, setOf(c, e))
drain()
// A drops out.
svc.onFollowSetChange(setOf(b), me)
drain()
assertEquals(1, svc.scoresSnapshot()[c])
// d had only A crediting it — should be gone.
assertFalse(c in svc.scoresSnapshot() && d in svc.scoresSnapshot() && svc.scoresSnapshot()[d] == null)
assertEquals(null, svc.scoresSnapshot()[d])
assertEquals(1, svc.scoresSnapshot()[e])
}
@Test
fun kind3ChurnAppliesDiff() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c, d))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(1, svc.scoresSnapshot()[d])
// A republishes with a different set — d removed, e added.
svc.applyKind3(a, setOf(c, e))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(null, svc.scoresSnapshot()[d])
assertEquals(1, svc.scoresSnapshot()[e])
}
@Test
fun selfInclusionInKind3IsExcluded() {
svc.onFollowSetChange(setOf(a), me)
// A's kind-3 includes self (me) — must not inflate self's score.
svc.applyKind3(a, setOf(c, me))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(null, svc.scoresSnapshot()[me])
}
@Test
fun followerSelfInclusionIsExcluded() {
svc.onFollowSetChange(setOf(a), me)
// A's kind-3 includes A itself — must not inflate A's own score.
svc.applyKind3(a, setOf(c, a))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(null, svc.scoresSnapshot()[a])
}
@Test
fun kind3FromNonFollowerIsIgnored() {
svc.onFollowSetChange(setOf(a), me)
// e is NOT in my follow set — their kind-3 shouldn't credit anyone.
svc.applyKind3(e, setOf(c, d))
drain()
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
}
@Test
fun sparseMapDropsZeroCounts() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c))
drain()
assertTrue(c in svc.scoresSnapshot())
// A republishes with an empty follow set.
svc.applyKind3(a, emptySet())
drain()
// c dropped to 0 → removed from map, not stored as 0.
assertFalse(c in svc.scoresSnapshot())
}
private fun fakePubkey(seed: Int): String = seed.toString(16).padStart(64, '0')
@Test
fun guardrailSkipsHugeFollowSets() {
val hugeFollows = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
svc.onFollowSetChange(hugeFollows, me)
drain()
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
assertTrue(runBlocking { svc.isReady.first() })
}
@Test
fun maxFollowsPerEventCap() {
svc.onFollowSetChange(setOf(a), me)
val huge = (0..WoTService.MAX_FOLLOWS_PER_EVENT + 100).map { fakePubkey(it) }.toSet()
svc.applyKind3(a, huge)
drain()
// Cap kicks in after MAX_FOLLOWS_PER_EVENT — no crash, score map bounded.
assertTrue(svc.scoresSnapshot().size <= WoTService.MAX_FOLLOWS_PER_EVENT)
}
@Test
fun markReadyOnceFiresReady() {
assertFalse(runBlocking { svc.isReady.first() })
svc.markReadyOnce()
drain()
assertTrue(runBlocking { svc.isReady.first() })
}
@Test
fun clearResetsEverything() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c, d))
svc.markReadyOnce()
drain()
svc.clear()
drain()
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
assertFalse(runBlocking { svc.isReady.first() })
}
@Test
fun scoresSnapshotIsHashMapCopy() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c))
drain()
val snap = svc.scoresSnapshot()
assertEquals(1, snap[c])
// Modifying the snapshot must not affect the service.
(snap as MutableMap<String, Int>).clear()
assertEquals(1, svc.scoresSnapshot()[c])
}
}
@@ -0,0 +1,73 @@
# Manual testing sheet — Desktop Web-of-Trust Score Badges
Plan: `docs/plans/2026-07-01-feat-desktop-wot-score-plan.md`
Run with `./gradlew :desktopApp:run`. Sign in with an account that has a
follow list (WoT is meaningless without one).
## Pre-flight
- **Followed authors' kind-3 events** need to be reachable via the
configured index relays. On first launch, badges may take a couple of
seconds to appear while the batch REQ completes.
- Hashtag-spam PR (#3431) providers must be live — WoTBadgedAvatar reads
`LocalSpamExemptKeys` for the self+follows hide predicate.
## Scenarios
| # | Test | Expected |
|---|------|----------|
| **T1** | **Cold start with ≥50 follows.** Log in, watch avatars for 2 s. | Batch REQ fires once; badges appear on some strangers within ~2 s. |
| **T2** | **No badge on self.** Open own profile in a Profile column. | Own header avatar has no chip regardless of any follower kind-3s. |
| **T3** | **No badge on followed authors.** Any note by a person you follow. | Card renders with clean avatar, no chip. |
| **T4** | **Badge on a stranger who's followed by 2 of your follows.** Find such a note (or contrive one). | Small chip showing "2" bottom-right of avatar. |
| **T5** | **Tooltip on hover.** Hover the badge for ~1 s. | Plain tooltip: "N of the people you follow follow this person". |
| **T6** | **99+ overflow.** Simulate a pubkey scored 200+ (e.g. a well-connected celebrity in your graph). | Badge shows `99+`. |
| **T7** | **No layout shift.** Scroll a busy column while badges arrive mid-scroll. | Avatar sizes don't jump — badge overlays the existing avatar bounds. |
| **T8** | **Kind-3 churn.** Watch a followed author's kind-3 update mid-session (or manually publish one from another client). | Their contribution to affected pubkeys' scores diffs correctly; no double-counting. |
| **T9** | **Follow someone new.** Follow a fresh pubkey via the UI. | Their kind-3 is fetched via a subsequent `loadKind3Batched`; anyone they follow gets their score incremented once their kind-3 arrives. |
| **T10** | **Unfollow someone.** Unfollow an existing follow via the UI. | Every pubkey they were crediting has their score decremented; some may drop to 0 and lose their badge. |
| **T11** | **Guardrail (mega-follow account).** Log in with an account following ≥ 2 000 pubkeys. | `LocalWoTReady` becomes true immediately; no badges anywhere; no batch REQ fires. |
| **T12** | **Empty graph.** Log in with an account following 0 people. | `LocalWoTReady` becomes true after the 2 s fallback timeout; no badges. |
| **T13** | **`consumeContactList` prerequisite fix.** From another client, watch a kind-3 event from a follower arrive while you're logged in. | Your own `_followedUsers` (used by feed filters, sidebar) stays unchanged. Verify by opening a filtered feed and confirming it hasn't broken. |
| **T14** | **Account switch.** Switch to a different logged-in account. | Old scores gone; new account's follow set drives new WoT map. No leaked badges. |
| **T15** | **amy wot get.** `./gradlew :cli:installDist && cli/build/install/cli/bin/amy wot get <npub>` (against an account with kind-3 events in `~/.amy/shared/events-store/`). | Output: `pubkey=<hex> score=<n>` or JSON with `--json`. |
| **T16** | **amy wot list.** `amy wot list --threshold 3 --limit 20 --json`. | JSON of top-20 pubkeys with score ≥ 3, sorted desc. |
| **T17** | **amy wot sync.** `amy wot sync` (with follows in local store). | Runs a chunked kind-3 REQ against outbox relays, stores fresh events. Subsequent `amy wot get` reflects the update. |
## Known v1 limitations
- **Notification-tab avatars are not badged.** Notifications use a custom
56 dp compact card that doesn't route through `NoteCard` / `UserAvatar`.
Deferred to v2.
- **Search-result "person" cards** (via `UserSearchCard`) are not badged
in v1 — they use a different composable.
- **Cross-column reveal state doesn't matter** (WoT is stateless per
render; no reveal to persist).
- **No filter/threshold gating of feeds or notifications v1** — badges
are display-only. v2 will add the threshold Setting.
- **`amy wot sync` uses outbox/inbox relays**, not index relays. The
Desktop app uses `indexRelays`. If the two disagree, results may
differ slightly. Follow-up ticket: unified `indexRelays` for both.
## Sign-off
- [ ] T1 Cold start
- [ ] T2 No self badge
- [ ] T3 No badge on follows
- [ ] T4 Stranger scored 2
- [ ] T5 Tooltip
- [ ] T6 99+ overflow
- [ ] T7 No layout shift
- [ ] T8 Kind-3 churn
- [ ] T9 New follow
- [ ] T10 Unfollow
- [ ] T11 Guardrail
- [ ] T12 Empty graph
- [ ] T13 Cache prerequisite fix
- [ ] T14 Account switch
- [ ] T15 amy wot get
- [ ] T16 amy wot list
- [ ] T17 amy wot sync
Tester: ________________ Date: ________________
@@ -78,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()) {
@@ -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,
@@ -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<HexKey>,
onEose: () -> Unit = {},
) {
feedMetadata.loadKind3Batched(pubkeys, onEose = onEose)
}
// -- DM Subscription Support --
/** Active DM subscription IDs for cleanup */
@@ -99,7 +99,6 @@ import com.vitorpamplona.amethyst.commons.search.QuerySerializer
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.elements.BoostedMark
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.feeds.NewPostsChip
@@ -136,6 +135,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
@@ -290,14 +290,14 @@ private fun FeedNoteCardBody(
) {
GenericRepostLayout(
baseAuthorPicture = {
UserAvatar(
WoTBadgedAvatar(
userHex = event.pubKey,
pictureUrl = reposterUser?.profilePicture(),
size = 35.dp,
)
},
repostAuthorPicture = {
UserAvatar(
WoTBadgedAvatar(
userHex = originalEvent.pubKey,
pictureUrl = originalUser?.profilePicture(),
size = 35.dp,
@@ -77,7 +77,6 @@ import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus
import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner
import com.vitorpamplona.amethyst.commons.state.FollowState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
@@ -90,6 +89,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscri
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
@@ -682,7 +682,7 @@ fun UserProfileScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
UserAvatar(
WoTBadgedAvatar(
userHex = pubKeyHex,
pictureUrl = picture,
size = 56.dp,
@@ -1155,7 +1155,7 @@ fun UserProfileScreen(
}
Spacer(Modifier.width(4.dp))
}
UserAvatar(
WoTBadgedAvatar(
userHex = pubKeyHex,
pictureUrl = picture,
size = 28.dp,
@@ -58,7 +58,6 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext
import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
@@ -69,6 +68,7 @@ import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
@@ -259,7 +259,7 @@ fun NoteCard(
},
),
) {
UserAvatar(
WoTBadgedAvatar(
userHex = note.pubKeyHex,
pictureUrl = note.profilePictureUrl,
size = 32.dp,
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
/**
* Compact trust-score chip drawn on top of a [UserAvatar]. Shows the raw
* count of accounts in the active user's follow set who also follow this
* pubkey. Clamps display to `"99+"` for counts over 99 to keep the chip
* width predictable.
*
* Wrapped in a Material3 [TooltipBox] so hovering the badge shows a
* plain tooltip explaining what the number means. `isPersistent = true`
* fixes the Compose Multiplatform default of tooltips dismissing too
* quickly for mouse users (JB compose-multiplatform#3539).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WoTBadge(
count: Int,
modifier: Modifier = Modifier,
) {
if (count <= 0) return
val display = if (count > 99) "99+" else count.toString()
val state = rememberTooltipState(isPersistent = true)
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above, 4.dp),
tooltip = { PlainTooltip { Text("$count of the people you follow follow this person") } },
state = state,
) {
Box(
modifier
.size(18.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.semantics { contentDescription = "Followed by $count of your contacts" },
contentAlignment = Alignment.Center,
) {
Text(
text = display,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady
import com.vitorpamplona.amethyst.commons.wot.LocalWoTService
/**
* Drop-in replacement for [UserAvatar] that overlays a Web-of-Trust
* score chip when four gates all pass:
*
* 1. `LocalWoTService.current` is non-null (Desktop provides it; Android
* leaves it null and this composable falls back to a plain avatar).
* 2. `LocalWoTReady.current == true` (initial batch fetch complete OR
* startup timeout elapsed).
* 3. `userHex !in LocalSpamExemptKeys.current` — same set the hashtag-spam
* filter uses; contains the active user's pubkey plus everyone they
* follow. Skips self-badge and already-trusted accounts in one check.
*
* The score is read as a plain snapshot access from
* `WoTService.scores` — Compose tracks the read per key, so avatars only
* recompose when their own score changes.
*/
@Composable
fun WoTBadgedAvatar(
userHex: String,
pictureUrl: String?,
size: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
loadProfilePicture: Boolean = true,
loadRobohash: Boolean = true,
useThumbnailCache: Boolean = false,
) {
val service = LocalWoTService.current
val ready = LocalWoTReady.current
val exemptKeys = LocalSpamExemptKeys.current
val score =
if (service != null && ready && userHex !in exemptKeys) {
service.scores[userHex] ?: 0
} else {
0
}
UserAvatar(
userHex = userHex,
pictureUrl = pictureUrl,
size = size,
modifier = modifier,
contentDescription = contentDescription,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
useThumbnailCache = useThumbnailCache,
badge =
if (score > 0) {
{
WoTBadge(
count = score,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
} else {
null
},
)
}
File diff suppressed because it is too large Load Diff