mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user