mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54: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
|
||||
}
|
||||
}
|
||||
+68
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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
|
||||
|
||||
+46
@@ -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()) {
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+13
@@ -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,
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -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
Reference in New Issue
Block a user