mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge pull request #3483 from nrobi144/feat/wot-shared-index-relays
feat(desktop): Web-of-Trust score badges + shared index relays + amy wot verbs
This commit is contained in:
@@ -448,6 +448,23 @@ class Context(
|
||||
/** Union of all three buckets. */
|
||||
suspend fun anyRelays(): Set<NormalizedRelayUrl> = outboxRelays() + inboxRelays() + keyPackageRelays()
|
||||
|
||||
/**
|
||||
* Index relays — the shared, app-global set used to fetch profile
|
||||
* metadata (kind 0) and follow lists (kind 3). Mirrors the Desktop
|
||||
* app's `LocalRelayCategories.indexRelays` by reading from the same
|
||||
* `java.util.prefs` node
|
||||
* (`com/vitorpamplona/amethyst/relays/index`). Falls back to the
|
||||
* shipping defaults when the user hasn't configured anything.
|
||||
*
|
||||
* This is what `amy wot sync` uses; `outboxRelays()` /
|
||||
* `inboxRelays()` remain for callers that want relay lists derived
|
||||
* from NIP-65 identity semantics.
|
||||
*/
|
||||
fun indexRelays(): Set<NormalizedRelayUrl> =
|
||||
com.vitorpamplona.amethyst.commons.relays.index
|
||||
.PreferencesIndexRelays()
|
||||
.effective()
|
||||
|
||||
/**
|
||||
* Seed relays for "look up someone we know nothing about" queries —
|
||||
* fetching another user's kind:10002 / 10050 / 10051 / 30443 before we
|
||||
|
||||
@@ -68,6 +68,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
|
||||
@@ -287,6 +288,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,225 @@
|
||||
/*
|
||||
* 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.OutboxCacheGateway
|
||||
import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher
|
||||
import com.vitorpamplona.amethyst.commons.wot.WoTService
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* `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)
|
||||
// Overall timeout; per-relay budget is set by OutboxDispatcher's
|
||||
// default (4s). `--timeout N` overrides the overall cap.
|
||||
val overallTimeoutMs = args.flag("timeout")?.toLongOrNull()?.times(1000) ?: 8_000L
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val self = ctx.identity.pubKeyHex
|
||||
val myKind3 = ctx.contactsOf(self)
|
||||
val follows =
|
||||
myKind3?.verifiedFollowKeySet()?.toSet()
|
||||
?: 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.indexRelays()
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no index relays configured")
|
||||
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
try {
|
||||
// Buffer discovered events; persist synchronously after
|
||||
// the fetch. `store.insert` is suspending so we can't call
|
||||
// it from the non-suspending gateway callbacks. This also
|
||||
// keeps `insert` errors surfaceable in a single log line
|
||||
// rather than swallowed into a race.
|
||||
val buffered = Collections.synchronizedList(mutableListOf<Event>())
|
||||
val gateway =
|
||||
object : OutboxCacheGateway {
|
||||
override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? =
|
||||
// Amy's store lookup is suspending; can't do
|
||||
// it here. The dispatcher then falls through
|
||||
// to Phase 1 discovery for every author, which
|
||||
// matches the old `amy wot sync` behaviour of
|
||||
// always re-asking. A future optimisation
|
||||
// could pre-populate a `Map<HexKey,
|
||||
// AdvertisedRelayListEvent>` before dispatch.
|
||||
null
|
||||
|
||||
override fun onOutboxDiscovered(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
buffered.add(event)
|
||||
}
|
||||
|
||||
override fun onDiscoveredEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
buffered.add(event)
|
||||
}
|
||||
}
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = ctx.client,
|
||||
scope = scope,
|
||||
indexRelays = { relays },
|
||||
gateway = gateway,
|
||||
overallTimeoutMs = overallTimeoutMs,
|
||||
)
|
||||
|
||||
val result = dispatcher.fetchKind3Only(follows)
|
||||
|
||||
// Persist to store so future `get` / `list` see them.
|
||||
val eventsToPersist = synchronized(buffered) { buffered.toList() }
|
||||
eventsToPersist.forEach { runCatching { ctx.store.insert(it) } }
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"followers" to follows.size,
|
||||
"authors_requested" to result.authorsRequested,
|
||||
"kind10002_received" to result.kind10002Received,
|
||||
"kind3_received" to result.kind3Received,
|
||||
"outbox_covered_authors" to result.outboxCoveredAuthors,
|
||||
"fallback_authors" to result.fallbackAuthors,
|
||||
"persisted" to eventsToPersist.size,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
---
|
||||
title: WoT fetch via outbox model + PR #3483 review fixes
|
||||
type: fix
|
||||
status: completed
|
||||
date: 2026-07-06
|
||||
origin: PR https://github.com/vitorpamplona/amethyst/pull/3483 review comments (Vitor Pamplona, davotoula)
|
||||
---
|
||||
|
||||
# WoT fetch via outbox model + PR #3483 review fixes
|
||||
|
||||
## Overview
|
||||
|
||||
PR #3483 (branch `feat/wot-shared-index-relays`) adds Web-of-Trust badges + shared
|
||||
Index Relays + `amy wot` verbs. Two reviewers flagged issues:
|
||||
|
||||
- **Vitor** (owner): stop broadcasting kind-0/kind-3 REQs to a static index-relay
|
||||
list. Use the outbox model: index relays discover each author's kind-10002,
|
||||
then kind-0/kind-3 REQs go to each author's declared write relays.
|
||||
- **davotoula**: six correctness / perf / lifecycle bugs across
|
||||
`DesktopLocalCache`, `WoTService`, and `FeedMetadataCoordinator` — some
|
||||
Desktop-scoped, most in `commons/commonMain` so Android inherits them the
|
||||
moment WoT gets wired there.
|
||||
|
||||
This plan lands **both** in a single PR revision:
|
||||
|
||||
- The outbox-model refactor for kind-0 / kind-3 fetching (Vitor's ask).
|
||||
- All six correctness/perf/lifecycle fixes (davotoula's ask).
|
||||
- A sweep confirming no production default references the dying
|
||||
`relay.damus.io`.
|
||||
|
||||
The scope is intentionally larger than a normal review-fix cycle because the
|
||||
outbox refactor changes the same seams the bug-fixes touch — separating them
|
||||
would produce a churny diff.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### 1. Index-relay broadcast is architecturally wrong for kind-0 / kind-3
|
||||
|
||||
Current flow (this branch):
|
||||
|
||||
```
|
||||
Login (~350 follows) ─▶ Main.kt:1315
|
||||
└── FeedMetadataCoordinator.loadKind3Batched(follows)
|
||||
└── REQ kinds=[3] authors=[follows chunked/100]
|
||||
to *every* index relay in
|
||||
PreferencesIndexRelays.effective()
|
||||
```
|
||||
|
||||
Semantics:
|
||||
|
||||
- Every kind-3 event is fetched from index relays whether or not the author
|
||||
publishes there.
|
||||
- Users who *only* publish to their own outbox (increasingly common on modern
|
||||
Nostr) return no kind-3 — WoT signal is wrong (undercount).
|
||||
- Index-relay operators absorb the entire follow set's worth of REQ authors,
|
||||
even when other relays hold the data.
|
||||
- The same anti-pattern exists for kind-0 profile metadata (via
|
||||
`loadMetadataBatched`) and inside `amy wot sync` (which reimplements the same
|
||||
broadcast in `WotCommand.sync`).
|
||||
|
||||
Vitor's directive (quoting review):
|
||||
|
||||
> Kind 0 and 3 must be downloaded from the outbox relay (10002, write) of each
|
||||
> user. Basically, find all 10002 events via index relays (purple pages, etc),
|
||||
> then parse them all to find a list of relays per author, invert the map to
|
||||
> get a list of authors per relay, then use that list to download posts, kind
|
||||
> 0 and contact lists from each author.
|
||||
|
||||
### 2. Six correctness / perf / lifecycle bugs
|
||||
|
||||
| # | File:line | Symptom | Severity |
|
||||
|---|-----------|---------|----------|
|
||||
| 1 | `DesktopLocalCache.kt:509-530` | `accountPubkey` race → self kind-3 stamped `lastContactListByAuthor` before self-check; later relay retry rejected by `createdAt <= prev`; empty follow view; `FollowAction.follow` calls `createFromScratch(...)` and **wipes real follow list** | **P0 (data loss)** |
|
||||
| 2 | `commons/wot/WoTService.kt:162-190` | `handleFollowSet` sets `myFollows` before the `MAX_FOLLOWS` guard, guard doesn't return `myFollows` to empty, and `Main.kt:1561` still calls `loadKind3Batched` when over the cap — CPU/memory blow-up the PR description promised was skipped | P1 (perf regression on mega-follow accounts) |
|
||||
| 3 | `commons/wot/WoTService.kt` | No `close()/dispose()` → writer coroutine + `Channel` leak on account switch; leaks compound over long sessions | P1 (leak) |
|
||||
| 4 | `commons/wot/WoTService.kt:37-49, 149-160` | Doc claims "per-key subscriber isolation via `Snapshot.withMutableSnapshot`" — that's a mis-attribution. Per-key isolation is a `SnapshotStateMap` property, not a `withMutableSnapshot` property; the `withMutableSnapshot` on *every* op just batches writes. Any consumer that reads the map iteratively (size, keys) *will* invalidate on every mutation, which the comment claims won't happen. Future Android integrator will trust the comment. | P2 (misleading docs → landmine) |
|
||||
| 5 | `commons/relayClient/assemblers/FeedMetadataCoordinator.kt:320-368` | `queuedKind3Pubkeys` marks pubkeys sent, never reset on failure. If every index relay times out (mobile flake / cold-start), WoT stays empty for the entire session; `loadKind3Batched` will short-circuit thereafter. | P1 (silent WoT-empty session) |
|
||||
| 6 | `commons/relayClient/assemblers/FeedMetadataCoordinator.kt:274, 338` | `eoseReceived: MutableSet` written from per-relay `Dispatchers.IO` `onEose` callbacks with no sync → race can drop an EOSE, blocking on the full 5 s timeout instead of firing early. Low ceiling but pre-existing pattern that this PR duplicates. | P2 (perf / responsiveness) |
|
||||
|
||||
Additional owner-flagged item:
|
||||
- `relay.damus.io` shutting down end of month. Confirmed: no production
|
||||
default on this branch references it. Only commonTest fixtures do — leave
|
||||
those alone (they're wire-format fixtures, not runtime relay lists).
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Outbox refactor: two-phase discovery
|
||||
|
||||
Replace the single "broadcast a kind-3 REQ to all index relays" flow with a
|
||||
two-phase pipeline that reuses existing Quartz infrastructure. The pipeline
|
||||
lives in `commons/commonMain` so **Desktop, Android (future), and `amy`** all
|
||||
share it.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Phase 1 — kind-10002 discovery (index-relay REQ) │
|
||||
│ inputs: pubkeys[], indexRelays[] │
|
||||
│ emits: Map<HexKey, Set<NormalizedRelayUrl>> │
|
||||
│ (author → declared write relays) │
|
||||
│ │
|
||||
│ • REQ kinds=[10002] authors=chunked-by-100 │
|
||||
│ to every index relay. │
|
||||
│ • Feed matching AdvertisedRelayListEvent into │
|
||||
│ LocalCache (so future lookups skip the REQ). │
|
||||
│ • Per-relay timeout (default 4s), NOT one global.│
|
||||
└────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Phase 2a — RelayListRecommendationProcessor │
|
||||
│ inputs: authorMap from Phase 1 │
|
||||
│ emits: Set<RelayRecommendation> │
|
||||
│ (relay → author set, minimal cover) │
|
||||
│ │
|
||||
│ Reuses Quartz's existing algorithm which: │
|
||||
│ • builds relay → author set (transpose) │
|
||||
│ • greedily picks most-popular relay, removes │
|
||||
│ covered authors, repeats │
|
||||
│ • second pass to ensure ≥2-relay coverage per │
|
||||
│ author │
|
||||
│ • filters onion/localhost per config │
|
||||
└────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Phase 2b — per-relay kind 0 + kind 3 REQ │
|
||||
│ For each RelayRecommendation: │
|
||||
│ REQ kinds=[0,3] authors=[recommendation.users] │
|
||||
│ with per-relay timeout, single subscription. │
|
||||
│ Events flow into LocalCache via existing │
|
||||
│ consume path. │
|
||||
└────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Phase 3 — Fallback for authors without 10002 │
|
||||
│ Authors in the input set that never returned a │
|
||||
│ 10002 fall back to the current index-relay flow │
|
||||
│ (REQ kinds=[0,3] authors=[fallbackSet] on index │
|
||||
│ relays). Bounded; only fires when non-empty. │
|
||||
└────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
onEose() → WoTService.markReadyOnce()
|
||||
```
|
||||
|
||||
Global 2 s startup fallback in `Main.kt` stays as the outermost safety net.
|
||||
|
||||
### Bug fixes (correctness first, always)
|
||||
|
||||
**Fix 1 — `DesktopLocalCache` accountPubkey race.** Make `accountPubkey`
|
||||
either a constructor parameter or a required init that must resolve *before*
|
||||
hydration starts. Reorder `Main.kt` so `localCache.accountPubkey =
|
||||
account.pubKeyHex` runs before `localRelayStore.hydrate(localCache)`. Belt +
|
||||
braces: inside `consumeContactList`, do not stamp `lastContactListByAuthor`
|
||||
for events where `event.pubKey == accountPubkey` unless the self path
|
||||
actually accepted the event. This eliminates the "poisoned stamp" for the
|
||||
future relay retry even if a caller ever forgets to bind pubkey first.
|
||||
|
||||
**Fix 2 — MAX_FOLLOWS guard bypass.** Two-part fix:
|
||||
- In `WoTService.handleFollowSet`, when the follow set exceeds `MAX_FOLLOWS`,
|
||||
set `myFollows = emptySet()` *and* flip a `disabled: Boolean` flag. Both
|
||||
`handleKind3` and every future op must early-return on `disabled`.
|
||||
- In the outbox driver's entrypoint (formerly `Main.kt:1561`), consult
|
||||
`WoTService.isDisabled` (new StateFlow) or `follows.size <=
|
||||
WoTService.MAX_FOLLOWS` before dispatching Phase 1. When over the cap:
|
||||
skip Phase 1 + 2 entirely and call `markReadyOnce()` immediately.
|
||||
|
||||
**Fix 3 — `WoTService.close()`.** Add:
|
||||
|
||||
```kotlin
|
||||
private val supervisor = SupervisorJob(scope.coroutineContext[Job])
|
||||
private val serviceScope = CoroutineScope(scope.coroutineContext + supervisor + writerDispatcher)
|
||||
|
||||
fun close() {
|
||||
ops.close()
|
||||
supervisor.cancel()
|
||||
}
|
||||
```
|
||||
|
||||
Call from account-switch (Main.kt clear path) and from `DesktopIAccount`
|
||||
disposal. Add an internal `AutoCloseable` implement so callers can lean on
|
||||
`use { }`.
|
||||
|
||||
**Fix 4 — Correct the misleading comments.** Rewrite `WoTService` KDoc to say:
|
||||
|
||||
> Scores are exposed via a Compose-observable `SnapshotStateMap`. Consumers
|
||||
> that read a *specific key* (`scores[pubkey]`) recompose only when that key
|
||||
> changes — this is `SnapshotStateMap`'s per-key observation. Consumers that
|
||||
> iterate the map or read its size will recompose on any mutation.
|
||||
>
|
||||
> Ops are serialized through a single-writer `Channel`. Coalescing writes
|
||||
> inside `Snapshot.withMutableSnapshot { }` batches state commits so a
|
||||
> multi-key op emits a single Compose invalidation instead of one per key.
|
||||
|
||||
No behaviour change; the comment is the fix.
|
||||
|
||||
**Fix 5 — `queuedKind3Pubkeys` retryable.** Convert the current mark-on-send
|
||||
set into mark-on-EOSE:
|
||||
- Track `inFlight: MutableSet<HexKey>` for de-duplication during a single call.
|
||||
- On successful EOSE (or per-relay EOSE), move pubkeys into `succeeded`
|
||||
(unchanged behaviour: skip future REQs).
|
||||
- On global timeout with zero events for a pubkey, **do not** promote to
|
||||
`succeeded`; keep them retryable on the next `loadKind3Batched` /
|
||||
`loadKind3ViaOutbox` call.
|
||||
- Cheap: same `Set` mechanics, just gated by outcome instead of intent.
|
||||
|
||||
**Fix 6 — Synchronise `eoseReceived`.** Two options; pick (b):
|
||||
- (a) Wrap in `Mutex` / `synchronized` — `synchronized` needs a JVM-only
|
||||
path or an `expect/actual`.
|
||||
- (b) **Use a single-writer coroutine**: replace the `MutableSet` +
|
||||
`CompletableDeferred<Unit>` handshake with a `Channel<NormalizedRelayUrl>(
|
||||
capacity = Channel.UNLIMITED)` + a launched consumer that increments a
|
||||
local counter and completes the deferred when it hits `indexRelays.size`.
|
||||
Same shape, zero shared mutable state across dispatchers. KMP-clean.
|
||||
|
||||
Apply the same fix to both `loadKind3Batched` and `loadMetadataBatched`
|
||||
because the pattern is duplicated.
|
||||
|
||||
### Damus sweep
|
||||
|
||||
Grep of the current branch found `relay.damus.io` only in test fixtures
|
||||
(`FeedDefinitionSerializerTest`, `TorRelayEvaluationTest`, `RichTextParserTest`,
|
||||
`ZapSplitResolverTest`). None are production defaults. `DEFAULT_INDEX_RELAYS`
|
||||
= `{nos.lol, nostr.wine, noswhere, primal.net}`;
|
||||
`AmethystDefaults.DefaultIndexerRelayList` = `{purplepages, coracle, userkinds,
|
||||
yabu, nostr1}`. Leave the test fixtures alone (they exercise URL parsing on
|
||||
canonical example URLs — replacing them adds churn without protecting users).
|
||||
|
||||
Include a one-line status note in the PR description so Vitor sees "checked".
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture — where each piece lives
|
||||
|
||||
Following the codebase-specific rule (`commons/ARCHITECTURE.md`): "protocol
|
||||
in Quartz, business logic in commons, layouts in platform apps."
|
||||
|
||||
```
|
||||
quartz/ (unchanged — reuse only)
|
||||
nip65RelayList/AdvertisedRelayListEvent.kt — parser (existing)
|
||||
nip65RelayList/RelayListRecommendationProcessor — transpose + cover (existing)
|
||||
|
||||
commons/commonMain/
|
||||
wot/WoTService.kt — bug fixes 2/3/4
|
||||
wot/OutboxDispatcher.kt — NEW (Phase 1-3 driver)
|
||||
wot/OutboxRelayLoader.kt — MOVED from amethyst/,
|
||||
Flow<Map<relay, authors>>
|
||||
relayClient/assemblers/FeedMetadataCoordinator.kt — bug fixes 5/6 + calls
|
||||
into OutboxDispatcher when
|
||||
configured
|
||||
|
||||
desktopApp/jvmMain/
|
||||
Main.kt — reorder localCache init,
|
||||
call OutboxDispatcher
|
||||
cache/DesktopLocalCache.kt — bug fix 1
|
||||
— new consumeAdvertisedRelayList
|
||||
path
|
||||
|
||||
cli/
|
||||
commands/WotCommand.kt — amy wot sync via
|
||||
OutboxDispatcher
|
||||
```
|
||||
|
||||
### OutboxDispatcher API (draft)
|
||||
|
||||
```kotlin
|
||||
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt
|
||||
class OutboxDispatcher(
|
||||
private val client: INostrClient,
|
||||
private val scope: CoroutineScope,
|
||||
private val indexRelays: () -> Set<NormalizedRelayUrl>, // lazy — respects settings updates
|
||||
private val cache: OutboxCacheGateway, // interface, actual = DesktopLocalCache
|
||||
private val perRelayTimeoutMs: Long = 4_000,
|
||||
) {
|
||||
data class Result(
|
||||
val kind10002Received: Int,
|
||||
val kind3Received: Int,
|
||||
val kind0Received: Int,
|
||||
val fallbackAuthors: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetch kind-3 and kind-0 for [authors] via each author's declared write
|
||||
* relays (NIP-65). Falls back to [indexRelays] for authors with no 10002.
|
||||
*
|
||||
* Suspending — returns after every phase EOSEs or times out. Callers
|
||||
* that need "return immediately, mark ready later" should wrap in
|
||||
* [scope.launch].
|
||||
*/
|
||||
suspend fun fetchKind0And3(authors: Set<HexKey>): Result
|
||||
suspend fun fetchKind3Only(authors: Set<HexKey>): Result // WoT-specific
|
||||
}
|
||||
|
||||
interface OutboxCacheGateway {
|
||||
/** Returns the cached kind-10002 for [pubkey] if the local store already has one. */
|
||||
fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent?
|
||||
/** Called for every 10002 that comes back — cache should stash it. */
|
||||
fun onOutboxDiscovered(event: AdvertisedRelayListEvent, relay: NormalizedRelayUrl)
|
||||
/** Called for every kind-3 / kind-0 that comes back — cache should route through its consume path. */
|
||||
fun onDiscoveredEvent(event: Event, relay: NormalizedRelayUrl)
|
||||
}
|
||||
```
|
||||
|
||||
`DesktopLocalCache` implements `OutboxCacheGateway`; `amy` gets a minimal
|
||||
implementation that writes into its local store.
|
||||
|
||||
`OutboxRelayLoader` (moved from `amethyst/`) provides the *live* Flow-form for
|
||||
reactive lookups; `OutboxDispatcher` uses it internally for the "check cache
|
||||
first, only REQ what's missing" fast-path.
|
||||
|
||||
### Reactivity for "new follow arriving"
|
||||
|
||||
Current code (Main.kt:1559) collects `localCache.followedUsers` and calls
|
||||
`loadKind3Batched(follows)` on every change. The dedup set means the diff
|
||||
(only new pubkeys) actually flows through.
|
||||
|
||||
Under the outbox model, the analogous flow is:
|
||||
|
||||
```
|
||||
localCache.followedUsers.collect { follows ->
|
||||
wotService.onFollowSetChange(follows, account.pubKeyHex)
|
||||
if (wotService.isDisabled) { wotService.markReadyOnce(); return@collect }
|
||||
launch {
|
||||
val result = outboxDispatcher.fetchKind3Only(follows) // dedup inside
|
||||
wotService.markReadyOnce()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`fetchKind3Only` internally consults `inFlight` + `succeeded` and only REQs
|
||||
the diff. Test scenario "user follows one new person mid-session" trivially
|
||||
covered because Phase 1 for a single-element authors set is a single index-
|
||||
relay REQ, and Phase 2 is one per-outbox REQ.
|
||||
|
||||
### `amy wot sync` under outbox
|
||||
|
||||
Replace the manual `Filter/chunked/ctx.drain(...)` block in
|
||||
`WotCommand.sync` with:
|
||||
|
||||
```kotlin
|
||||
val dispatcher = OutboxDispatcher(client, scope, ctx::indexRelays, AmyCacheGateway(store))
|
||||
val result = dispatcher.fetchKind3Only(follows.toSet())
|
||||
Output.emit("wot sync",
|
||||
"10002=${result.kind10002Received} kind3=${result.kind3Received} " +
|
||||
"fallback=${result.fallbackAuthors}")
|
||||
```
|
||||
|
||||
The JSON schema for `--json` gains three new keys (`kind10002_received`,
|
||||
`fallback_authors`, `kind3_received`) — additive, no rename.
|
||||
|
||||
### Concurrency & KMP concerns
|
||||
|
||||
- All new code targets `commonMain`. No `java.util.concurrent`, no
|
||||
`synchronized {}` (needs jvmAndroid actual). Rely on `Channel`, `Mutex`,
|
||||
`StateFlow`, and `Snapshot` — all KMP-safe.
|
||||
- `Dispatchers.IO` isn't KMP either; use `Dispatchers.Default` in commonMain
|
||||
and let platform code override if needed.
|
||||
- Per-relay timeouts implemented via `withTimeoutOrNull(perRelayTimeoutMs)`
|
||||
inside per-relay coroutines; overall EOSE gate uses a
|
||||
`CompletableDeferred<Unit>` that trips when either (a) all per-relay jobs
|
||||
complete or (b) the outer `withTimeoutOrNull(overallCap)` fires.
|
||||
|
||||
### Data flow: how discovered 10002s stop double-fetching
|
||||
|
||||
Every `AdvertisedRelayListEvent` received during Phase 1 goes through
|
||||
`OutboxCacheGateway.onOutboxDiscovered(event, relay)` → the platform cache's
|
||||
`consume` path. Next call for the same author checks `cachedOutbox(pubkey)`
|
||||
before dispatching Phase 1, so we never REQ the same 10002 twice within a
|
||||
session (or across sessions, if the local relay store persists the 10002 —
|
||||
which it does, since kind-10002 events are indexed like any other event).
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1 — Bug fixes (correctness first, self-contained)
|
||||
|
||||
Ship-blockers, no outbox dependency, land these commits first so a revert
|
||||
doesn't force rolling back the outbox refactor:
|
||||
|
||||
1. `fix(desktop-cache): eliminate accountPubkey race in
|
||||
consumeContactList` — reorder Main.kt so pubkey binds before hydrate;
|
||||
gate `lastContactListByAuthor` stamp inside self branch. Test:
|
||||
`DesktopLocalCacheHydrationTest` — reproduce the wipe by running
|
||||
hydration before pubkey bind, assert follow set survives relay retry.
|
||||
2. `fix(wot): clear myFollows + set disabled flag when MAX_FOLLOWS exceeded`
|
||||
— plus a Main.kt short-circuit before dispatching Phase 1. Test:
|
||||
`WoTServiceTest.overCapDisablesEverything`.
|
||||
3. `refactor(wot): close()/dispose() + AutoCloseable, call from
|
||||
account-switch` — Test: `WoTServiceLifecycleTest.closeCancelsWriter`.
|
||||
4. `docs(wot): correct SnapshotStateMap isolation comments` — comment-only.
|
||||
5. `fix(coordinator): mark queuedKind3Pubkeys only on EOSE, allow retry on
|
||||
timeout` — Test: `FeedMetadataCoordinatorTest.timeoutRetryIsAllowed`.
|
||||
6. `fix(coordinator): single-writer EOSE aggregator (KMP-safe)` — Test:
|
||||
`FeedMetadataCoordinatorTest.eoseReadyUnderConcurrentCallbacks`
|
||||
using a fake client that fires EOSE from multiple dispatchers.
|
||||
|
||||
**Success criteria phase 1:** all six tests pass; `./gradlew :commons:jvmTest
|
||||
:desktopApp:jvmTest :cli:test` green; `./gradlew spotlessApply` clean.
|
||||
|
||||
#### Phase 2 — Outbox scaffolding (commons)
|
||||
|
||||
7. `refactor(commons): move OutboxRelayLoader from amethyst/ to
|
||||
commons/commonMain` — pure code motion; leave a re-export in the amethyst
|
||||
package to avoid Android build breaks. Test: existing Android
|
||||
`OutboxRelayLoaderTest` (if any) still passes.
|
||||
8. `feat(commons): OutboxDispatcher two-phase kind-0/kind-3 fetcher` —
|
||||
commonMain, plus jvmMain test that drives a fake `INostrClient` through
|
||||
Phase 1/2/3 including the fallback path.
|
||||
9. `feat(commons): OutboxCacheGateway interface + DesktopLocalCache impl` —
|
||||
including a new `consumeAdvertisedRelayList(event, relay)` in
|
||||
`DesktopLocalCache` that mirrors the existing `consumeContactList` pattern.
|
||||
|
||||
**Success criteria phase 2:** `./gradlew :commons:jvmTest` green;
|
||||
`OutboxDispatcherTest` covers "author with 10002", "author without 10002 →
|
||||
fallback", "index relay times out on Phase 1", and "per-relay timeout on
|
||||
Phase 2 doesn't cancel other relays".
|
||||
|
||||
#### Phase 3 — Cutover (Main.kt + amy)
|
||||
|
||||
10. `feat(desktop): route WoT kind-3 fetch through OutboxDispatcher` —
|
||||
Main.kt uses OutboxDispatcher; delete the direct `loadKind3Batched`
|
||||
call. Preserve the 2 s startup fallback for `markReadyOnce`.
|
||||
11. `feat(desktop): also route stranger-avatar kind-0 through
|
||||
OutboxDispatcher` — MetadataPreloader gets a hook that prefers outbox
|
||||
when a 10002 exists for the author.
|
||||
12. `feat(cli): amy wot sync via OutboxDispatcher` — rewrite the manual
|
||||
filter/drain in `WotCommand.sync`. Update its `--json` schema (additive).
|
||||
|
||||
**Success criteria phase 3:** manual testing sheet (Section: Test Plan)
|
||||
passes end-to-end. `./gradlew test` green.
|
||||
|
||||
#### Phase 4 — Documentation & PR description
|
||||
|
||||
13. Update PR description's "Behaviour" section to reflect the outbox flow.
|
||||
14. Add a top-level "Damus relay: production defaults verified clean" line
|
||||
so Vitor doesn't have to look.
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
**A. Do the outbox refactor in a follow-up PR.** Rejected by user: the same
|
||||
files (WoTService, FeedMetadataCoordinator, Main.kt) also need the review
|
||||
fixes, so a two-PR split would double the churn in the same seams.
|
||||
|
||||
**B. Skip Phase 1 (10002 discovery) and read the local cache only.** Would
|
||||
break for cold-start accounts with no cached 10002s. Only works for
|
||||
"warm-cache" sessions, defeating Vitor's ask on first login.
|
||||
|
||||
**C. Adopt `AmethystDefaults.DefaultIndexerRelayList` as the new index-relay
|
||||
default.** The current branch keeps `{nos.lol, nostr.wine, noswhere,
|
||||
primal.net}` for continuity. Adopting the Purple Pages / Coracle / etc. set
|
||||
is a user-visible behaviour change deserving its own review. Deferred to a
|
||||
follow-up ticket. Documented in `PreferencesIndexRelays.kt:85-92` already;
|
||||
no action here.
|
||||
|
||||
**D. Use `graperank` as Vitor idly mused in the follow-up comment.** Not
|
||||
actionable in this PR — it's a musing about extending `amy`, not a review
|
||||
change. Called out here so the item doesn't get lost, but leave for a
|
||||
future ticket.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction Graph
|
||||
|
||||
```
|
||||
Login
|
||||
└─ Main.kt:1541 LaunchedEffect binds localCache.accountPubkey
|
||||
└─ (Fix 1: must run BEFORE the block below)
|
||||
└─ Main.kt:893 launch(Dispatchers.IO) { localRelayStore.hydrate(localCache) }
|
||||
└─ per-event: localCache.justConsumeMyOwnEvent → consumeContactList
|
||||
└─ before fix: stamps lastContactListByAuthor with null accountPubkey
|
||||
└─ after fix: stamp only inside self-branch, or after ordering guarantee
|
||||
|
||||
Login (parallel)
|
||||
└─ Main.kt:1559 collect(followedUsers) →
|
||||
└─ WoTService.onFollowSetChange
|
||||
└─ ops.trySend(FollowSet) → writerLoop → handleFollowSet
|
||||
└─ Fix 2: MAX_FOLLOWS → clear myFollows + disabled=true, return
|
||||
└─ if !disabled: OutboxDispatcher.fetchKind3Only(follows)
|
||||
└─ Phase 1: REQ 10002 on index relays
|
||||
└─ OutboxCacheGateway.onOutboxDiscovered → DesktopLocalCache.consumeAdvertisedRelayList
|
||||
└─ Phase 2a: RelayListRecommendationProcessor.reliableRelaySetFor
|
||||
└─ Phase 2b: per-relay REQ kind=[0,3] authors=[relay's users]
|
||||
└─ OutboxCacheGateway.onDiscoveredEvent → LocalCache.consume path
|
||||
└─ consumeContactList (fixed) → _contactListEvents.tryEmit
|
||||
└─ WoTService.applyKind3 → handleKind3 → updateScore
|
||||
└─ Phase 3: fallback for missing-10002 authors → index-relay REQ
|
||||
└─ WoTService.markReadyOnce → _isReady.value = true → badge composables recompose
|
||||
|
||||
Account switch
|
||||
└─ Main.kt:874 localCache.clear() → resets lastContactListByAuthor
|
||||
└─ Fix 3: WoTService.close() → cancels writer, drops ops channel
|
||||
└─ OutboxDispatcher scope cancels → in-flight REQs unsubscribe
|
||||
```
|
||||
|
||||
### Error & Failure Propagation
|
||||
|
||||
- `client.subscribe` failure inside `OutboxDispatcher` → swallowed at the
|
||||
per-relay coroutine level, logged, moves on. The overall `withTimeoutOrNull`
|
||||
ensures the caller never blocks past its budget.
|
||||
- `AdvertisedRelayListEvent.writeRelaysNorm()` returning null (author has a
|
||||
10002 but empty write list) → falls through to Phase 3 fallback.
|
||||
- Cache consume path errors (e.g. corrupt event) → existing `LocalCache`
|
||||
behavior; not new.
|
||||
|
||||
### State Lifecycle Risks
|
||||
|
||||
- Between `WoTService.close()` and `OutboxDispatcher` scope cancel there's a
|
||||
small window where a pending REQ EOSE could arrive at a torn-down service.
|
||||
Mitigation: `OutboxCacheGateway.onDiscoveredEvent` and
|
||||
`WoTService.applyKind3` must be null-guarded against the "already-closed"
|
||||
state — WoTService's writerLoop naturally handles this (channel closed →
|
||||
loop exits).
|
||||
- Fix 1 requires Main.kt reordering; if the reorder is done wrong and pubkey
|
||||
bind is *later* than hydration, the bug recurs silently. Test:
|
||||
`DesktopLocalCacheHydrationTest.regressionOrderingProtection`.
|
||||
|
||||
### API Surface Parity
|
||||
|
||||
`amy wot sync` and Desktop login both consume the same `OutboxDispatcher`,
|
||||
so any protocol change propagates. Android is a future consumer — the
|
||||
plan intentionally lives in commons/commonMain so wiring Android on top
|
||||
is a Main.kt-equivalent + gateway impl.
|
||||
|
||||
### Integration Test Scenarios
|
||||
|
||||
1. Cold-start login, well-connected account (~350 follows, ~90% with 10002):
|
||||
Phase 1 completes, Phase 2 fetches only from write relays, Phase 3 kicks
|
||||
in for the ~10% no-10002 authors, WoT ready < 5 s, badges render.
|
||||
2. Cold-start login, ~4000-follow account: MAX_FOLLOWS trips → dispatcher
|
||||
skipped, `markReadyOnce()` immediately, no badges, no REQ traffic.
|
||||
3. Cold-start login, all index relays unreachable: overall timeout fires,
|
||||
`markReadyOnce()`; on next `followedUsers` emission, `inFlight` is empty
|
||||
(thanks to fix 5) so a retry happens.
|
||||
4. Mid-session follow: single-author `fetchKind3Only({newPubkey})` uses cache
|
||||
hit if `cachedOutbox(newPubkey) != null`, else does one Phase-1 REQ.
|
||||
5. Account switch: `WoTService.close()` runs; opening the same account again
|
||||
creates a fresh instance without leaking the previous writer coroutine.
|
||||
6. `amy wot sync` on a headless VM with only the OS event store: writes
|
||||
10002 + kind 3 events to disk; second run of `amy wot get <hex>` returns
|
||||
the correct hydrated score.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
|
||||
- [ ] `DesktopLocalCache.consumeContactList` no longer stamps
|
||||
`lastContactListByAuthor` for the self path unless `accountPubkey` is
|
||||
set and the event matches. Regression test exists.
|
||||
- [ ] `WoTService` exposes `isDisabled: StateFlow<Boolean>`; caller
|
||||
(Main.kt) skips OutboxDispatcher when disabled.
|
||||
- [ ] `WoTService` implements `AutoCloseable`; account-switch path calls
|
||||
`close()`.
|
||||
- [ ] `FeedMetadataCoordinator.loadKind3Batched` and
|
||||
`loadMetadataBatched` retry on timeout (pubkeys not promoted to
|
||||
`succeeded`).
|
||||
- [ ] Both `loadKind3Batched` and `loadMetadataBatched` use single-writer
|
||||
EOSE aggregation (no `MutableSet` shared across dispatchers).
|
||||
- [ ] `OutboxDispatcher.fetchKind3Only` and `fetchKind0And3` exist in
|
||||
commons/commonMain with test coverage for the four scenarios in
|
||||
"Integration Test Scenarios".
|
||||
- [ ] `Main.kt` login path uses `OutboxDispatcher` for kind-3 seeding
|
||||
(WoT + follow-set metadata).
|
||||
- [ ] `amy wot sync` uses `OutboxDispatcher`; `--json` output additively
|
||||
gains `kind10002_received`, `kind3_received`, `fallback_authors`.
|
||||
|
||||
### Non-functional
|
||||
|
||||
- [ ] `./gradlew test` green.
|
||||
- [ ] `./gradlew spotlessApply` clean before commit.
|
||||
- [ ] No production default relay list on this branch references
|
||||
`relay.damus.io` (already verified; keep it verified after refactor).
|
||||
- [ ] No use of `java.util.concurrent` / JVM-only `synchronized {}` in
|
||||
`commons/commonMain/`.
|
||||
- [ ] KDoc for `WoTService` accurately describes SnapshotStateMap
|
||||
per-key isolation.
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] Manual regression sheet covering integration scenarios 1-6 above.
|
||||
- [ ] `amy wot sync --json` sample output attached to PR description.
|
||||
- [ ] Follow-list-wipe regression covered by an automated test that
|
||||
hydrates a cached kind-3 before binding pubkey and asserts nothing is
|
||||
poisoned.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- WoT badge coverage on real accounts (Vitor's expected win): jump from
|
||||
"index-relay-published authors only" to "any author with a 10002" —
|
||||
measured by running the desktop app before/after and diffing the badge
|
||||
count on a fixed follow-set.
|
||||
- Zero follow-list-wipe reports in the two weeks after merge (davotoula
|
||||
finding 1 was worst-case data loss).
|
||||
- Zero index-relay REQ traffic for kind-0/kind-3 authors that publish a
|
||||
10002. Measurable by wireshark on a test build.
|
||||
|
||||
## Dependencies & Prerequisites
|
||||
|
||||
- Quartz `AdvertisedRelayListEvent` + `RelayListRecommendationProcessor` —
|
||||
already exist, reused verbatim.
|
||||
- `INostrClient.subscribe(subId, filters, listener)` — already exists.
|
||||
- `DesktopLocalCache` needs a new `consumeAdvertisedRelayList(event, relay)`
|
||||
method — mirrors existing `consumeContactList` structure.
|
||||
- `OutboxRelayLoader` — moved from `amethyst/` to `commons/commonMain`;
|
||||
Android continues to compile because it only depends on things
|
||||
already in commons/quartz.
|
||||
|
||||
No new third-party libraries introduced. No `libs.versions.toml` change.
|
||||
|
||||
## Risk Analysis & Mitigation
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Outbox refactor changes badge counts on live user accounts unexpectedly | Med | Med | Keep the Phase-3 fallback path so no author gets *worse* coverage than today. Manual A/B test on maintainer's account before merge. |
|
||||
| `RelayListRecommendationProcessor.reliableRelaySetFor` picks pathologically many relays for a fragmented follow set | Low | Low | Algorithm already caps by second-pass "at least 2 relays per author" rule. Add a hard `MAX_RELAYS_PER_FETCH` (say 40) as a belt-and-braces guard. |
|
||||
| Concurrent EOSE handshake rewrite introduces a new bug | Low | High | Test `FeedMetadataCoordinatorTest.eoseReadyUnderConcurrentCallbacks` with a fake client firing EOSE from three dispatchers 1000× to catch ordering assumptions. |
|
||||
| Bug fix 1 (Main.kt reordering) breaks another consumer that read localCache before accountPubkey bind | Med | Med | Grep for all `localCache.accountPubkey` reads; verify none pre-date the bind. If any, thread the pubkey through as a parameter. |
|
||||
| Adopting `AutoCloseable` on `WoTService` misleads callers into thinking it's `use`-scoped | Low | Low | Comment on `close()` says "call from account-switch/dispose only; instance lives for the account session". |
|
||||
| `amy wot sync --json` schema change breaks downstream scripts | Med | Low | Additive fields only, no renames. Document in `cli/plans/*` if a plan exists there. |
|
||||
|
||||
## Resource Requirements
|
||||
|
||||
- One engineer, ~2-3 days including tests + manual regression.
|
||||
- Test-relay access: can use `wss://nos.lol` and Purple Pages for real
|
||||
Phase 1 verification.
|
||||
- Access to a mega-follow test account (>2000 follows) to verify Fix 2.
|
||||
- Access to an account with a well-populated 10002 network to verify
|
||||
Phase 2 does what we think.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Android wiring: `AndroidApp` currently doesn't wire WoTService. When it
|
||||
does, it can lean on the same `OutboxDispatcher` — expected diff is
|
||||
Main-equivalent + a `LocalCache` gateway.
|
||||
- Graperank scoring (Vitor's follow-up musing): if `amy wot` grows a scoring
|
||||
strategy plugin API, `OutboxDispatcher` remains unchanged; only the
|
||||
post-fetch aggregation layer inside `WoTService` changes.
|
||||
- Adopting `AmethystDefaults.DefaultIndexerRelayList`: separate ticket.
|
||||
Deserves its own review because it's a user-visible behaviour change.
|
||||
|
||||
## Documentation Plan
|
||||
|
||||
- Update this plan's status to `completed` post-merge; write a short
|
||||
"solutions" note if the accountPubkey race surprised us elsewhere.
|
||||
- Update PR description "Behaviour" section to reflect outbox path.
|
||||
- Update `commons/ARCHITECTURE.md` "where does my code go?" section with a
|
||||
one-line entry for `OutboxDispatcher`.
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **PR review comments:**
|
||||
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892248528 (davotoula, bugs 1+2)
|
||||
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892272000 (davotoula, bugs 3-6 impact on Android)
|
||||
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892302009 (vitorpamplona, outbox directive)
|
||||
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892686911 (vitorpamplona, Graperank musing — out of scope)
|
||||
|
||||
### Internal References
|
||||
|
||||
- Kind-10002 parser: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt`
|
||||
- Relay-cover algorithm: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt`
|
||||
- Existing Android outbox loader: `amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt`
|
||||
- WoT service (bugs 2-4): `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt`
|
||||
- Feed metadata coordinator (bugs 5-6): `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt`
|
||||
- Cache race (bug 1): `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:509-530`
|
||||
- Main wiring: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:1541-1568, 764-768, 863-874`
|
||||
- amy WoT: `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt`
|
||||
- Index relay persistence: `commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt`
|
||||
- Baseline plan the PR extended: `desktopApp/plans/2026-07-01-feat-desktop-wot-score-plan.md`
|
||||
|
||||
### External References
|
||||
|
||||
- NIP-65 (Relay List Metadata): https://github.com/nostr-protocol/nips/blob/master/65.md
|
||||
- Original plan for the current PR: `docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md`
|
||||
|
||||
### Related Work
|
||||
|
||||
- PR #3483 (this PR): https://github.com/vitorpamplona/amethyst/pull/3483
|
||||
- Prior WoT badge PR (base for this branch): `feat/desktop-wot-score`
|
||||
|
||||
## Unanswered questions
|
||||
|
||||
- Per-relay timeout budget — 4 s picked from thin air. Real number?
|
||||
- Should the fallback in Phase 3 also hit the account's own home/search
|
||||
relays, matching Android's `pickRelaysToLoadUsers` cascade? Or index-only?
|
||||
- WoTService.close() called from account-switch — what's the canonical
|
||||
disposal hook on Desktop? DesktopIAccount teardown?
|
||||
- amy wot sync `--json` schema — is `fallback_authors` the right name, or
|
||||
match Android naming?
|
||||
- Should `OutboxDispatcher` be a per-account singleton (like WoTService) or
|
||||
short-lived per fetch? Leaning singleton for the dedup set.
|
||||
- Do we want to persist the "author has no 10002" fact so we skip Phase 1
|
||||
for them on next login? Requires a small persistent map — worth it?
|
||||
- Should Fix 1 (accountPubkey race) be split into its own hotfix commit
|
||||
before the outbox refactor lands, so backporters have a clean cherry-pick?
|
||||
+158
-9
@@ -33,13 +33,16 @@ 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
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Coordinates metadata and reactions loading for feed items.
|
||||
@@ -73,6 +76,15 @@ class FeedMetadataCoordinator(
|
||||
private val queuedPubkeys = mutableSetOf<HexKey>()
|
||||
private val queuedNoteIds = mutableSetOf<HexKey>()
|
||||
private val queuedBoostedIds = mutableSetOf<HexKey>()
|
||||
private val queuedKind3Pubkeys = mutableSetOf<HexKey>()
|
||||
|
||||
// Batched paths only — pubkeys currently in-flight in a batched REQ.
|
||||
// Prevents rapid re-fire of the same batch. Distinct from queuedPubkeys
|
||||
// and queuedKind3Pubkeys (which record "asked and at least one relay
|
||||
// returned EOSE") so a batch that times out with zero events can be
|
||||
// retried on the next call — see PR #3483 review finding 5.
|
||||
private val inFlightBatchedMetadata = mutableSetOf<HexKey>()
|
||||
private val inFlightBatchedKind3 = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Start processing the subscription queue.
|
||||
@@ -251,14 +263,24 @@ class FeedMetadataCoordinator(
|
||||
/**
|
||||
* Fast-path: batched metadata subscription for visible-viewport authors.
|
||||
* Bypasses rate limiter. Single filter with all authors. Closes after EOSE.
|
||||
*
|
||||
* Pubkeys are moved into [queuedPubkeys] (dedup) only after at least one
|
||||
* relay EOSE'd. On timeout with zero EOSE (index relays all unreachable)
|
||||
* they roll out of [inFlightBatchedMetadata] so a subsequent call can
|
||||
* retry — see PR #3483 review finding 5.
|
||||
*/
|
||||
fun loadMetadataBatched(
|
||||
pubkeys: List<HexKey>,
|
||||
timeoutMs: Long = 5_000L,
|
||||
) {
|
||||
val newPubkeys = pubkeys.filter { it !in queuedPubkeys }.distinct()
|
||||
val newPubkeys =
|
||||
pubkeys
|
||||
.asSequence()
|
||||
.filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata }
|
||||
.distinct()
|
||||
.toList()
|
||||
if (newPubkeys.isEmpty()) return
|
||||
queuedPubkeys.addAll(newPubkeys)
|
||||
inFlightBatchedMetadata.addAll(newPubkeys)
|
||||
|
||||
scope.launch {
|
||||
val filter =
|
||||
@@ -269,8 +291,7 @@ class FeedMetadataCoordinator(
|
||||
)
|
||||
val filterMap = indexRelays.associateWith { listOf(filter) }
|
||||
val subId = newSubId()
|
||||
val eoseReceived = mutableSetOf<NormalizedRelayUrl>()
|
||||
val allEose = CompletableDeferred<Unit>()
|
||||
val gate = BatchEoseGate(scope, target = indexRelays.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
@@ -287,16 +308,96 @@ class FeedMetadataCoordinator(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eoseReceived.add(relay)
|
||||
if (eoseReceived.size >= indexRelays.size) {
|
||||
allEose.complete(Unit)
|
||||
}
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
withTimeoutOrNull(timeoutMs) { allEose.await() }
|
||||
val eosedRelays = gate.awaitAll(timeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
if (eosedRelays > 0) {
|
||||
queuedPubkeys.addAll(newPubkeys)
|
||||
}
|
||||
inFlightBatchedMetadata.removeAll(newPubkeys.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]).
|
||||
*
|
||||
* Pubkeys are moved into [queuedKind3Pubkeys] (dedup) only after at
|
||||
* least one relay EOSE'd. On timeout with zero EOSE (index relays all
|
||||
* unreachable — common on flaky mobile networks) they roll out of
|
||||
* [inFlightBatchedKind3] so the next `loadKind3Batched` call retries
|
||||
* — see PR #3483 review finding 5.
|
||||
*/
|
||||
fun loadKind3Batched(
|
||||
pubkeys: Collection<HexKey>,
|
||||
timeoutMs: Long = 5_000L,
|
||||
onEose: () -> Unit = {},
|
||||
) {
|
||||
val newPubkeys =
|
||||
pubkeys
|
||||
.asSequence()
|
||||
.filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 }
|
||||
.distinct()
|
||||
.toList()
|
||||
if (newPubkeys.isEmpty()) {
|
||||
onEose()
|
||||
return
|
||||
}
|
||||
inFlightBatchedKind3.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 gate = BatchEoseGate(scope, target = indexRelays.size)
|
||||
|
||||
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>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
val eosedRelays = gate.awaitAll(timeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
if (eosedRelays > 0) {
|
||||
queuedKind3Pubkeys.addAll(newPubkeys)
|
||||
}
|
||||
inFlightBatchedKind3.removeAll(newPubkeys.toSet())
|
||||
|
||||
onEose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,5 +408,53 @@ class FeedMetadataCoordinator(
|
||||
priorityQueue.clear()
|
||||
queuedPubkeys.clear()
|
||||
queuedNoteIds.clear()
|
||||
queuedKind3Pubkeys.clear()
|
||||
inFlightBatchedMetadata.clear()
|
||||
inFlightBatchedKind3.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates EOSE notifications from per-relay `onEose` callbacks
|
||||
* (which the client may dispatch on `Dispatchers.IO`) via a
|
||||
* [Channel]. The consumer coroutine is the sole reader/writer of the
|
||||
* `seen` set, eliminating the race the previous `mutableSetOf` +
|
||||
* shared-state check had — see PR #3483 review finding 6.
|
||||
*
|
||||
* [awaitAll] blocks up to [timeoutMs] and returns the number of
|
||||
* relays that EOSE'd (may be less than [target] on timeout). The
|
||||
* count feeds the retry decision in the batched loaders.
|
||||
*/
|
||||
private class BatchEoseGate(
|
||||
private val scope: CoroutineScope,
|
||||
private val target: Int,
|
||||
) {
|
||||
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
|
||||
private val done = CompletableDeferred<Unit>()
|
||||
|
||||
@Volatile private var lastCount = 0
|
||||
|
||||
fun notifyEose(relay: NormalizedRelayUrl) {
|
||||
incoming.trySend(relay)
|
||||
}
|
||||
|
||||
suspend fun awaitAll(timeoutMs: Long): Int {
|
||||
if (target <= 0) return 0
|
||||
val consumer =
|
||||
scope.launch {
|
||||
val seen = mutableSetOf<NormalizedRelayUrl>()
|
||||
for (relay in incoming) {
|
||||
if (seen.add(relay)) {
|
||||
lastCount = seen.size
|
||||
if (seen.size >= target && !done.isCompleted) {
|
||||
done.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
withTimeoutOrNull(timeoutMs) { done.await() }
|
||||
incoming.close()
|
||||
consumer.join()
|
||||
return lastCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+7
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -46,12 +47,17 @@ import org.jetbrains.compose.resources.stringResource
|
||||
/**
|
||||
* A card displaying user search result with avatar, name, and nip05/pubkey.
|
||||
* Shared between Android and Desktop search screens.
|
||||
*
|
||||
* @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. Forwarded to [UserAvatar].
|
||||
*/
|
||||
@Composable
|
||||
fun UserSearchCard(
|
||||
user: User,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
badge: @Composable (BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
@@ -73,6 +79,7 @@ fun UserSearchCard(
|
||||
pictureUrl = user.profilePicture(),
|
||||
size = 40.dp,
|
||||
contentDescription = stringResource(Res.string.accessibility_user_avatar),
|
||||
badge = badge,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
|
||||
+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 }
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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 com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
|
||||
/**
|
||||
* Platform-agnostic interface between [OutboxDispatcher] and the platform's
|
||||
* event cache. Desktop and `amy` each provide their own implementation —
|
||||
* DesktopLocalCache on the app side, a minimal in-memory adapter over the
|
||||
* amy local store on the CLI side.
|
||||
*
|
||||
* The dispatcher only needs three capabilities:
|
||||
*
|
||||
* 1. Peek at what kind-10002 events are already stored so it can skip
|
||||
* Phase-1 discovery for authors whose write-relay list is already
|
||||
* known (from hydration or a previous session's fetch).
|
||||
* 2. Ingest a kind-10002 that just came back from an index relay so
|
||||
* subsequent lookups don't re-fetch it.
|
||||
* 3. Ingest a kind-0 or kind-3 that just came back from an outbox
|
||||
* relay so the platform cache/UI can pick it up through the usual
|
||||
* consume path.
|
||||
*
|
||||
* Every method must be idempotent — the dispatcher may re-fire the same
|
||||
* event through the gateway if two relays happen to return the same
|
||||
* addressable event.
|
||||
*/
|
||||
interface OutboxCacheGateway {
|
||||
/**
|
||||
* Returns the currently-cached kind-10002 event for [pubkey], or null
|
||||
* if the platform cache doesn't have one yet.
|
||||
*/
|
||||
fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent?
|
||||
|
||||
/**
|
||||
* Called for every kind-10002 the dispatcher receives during Phase 1.
|
||||
* The gateway should route it through its normal consume path so the
|
||||
* event is stored, deduped by createdAt, and picked up by any state
|
||||
* holders observing the addressable-notes cache.
|
||||
*/
|
||||
fun onOutboxDiscovered(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
)
|
||||
|
||||
/**
|
||||
* Called for every kind-0 (metadata) or kind-3 (contact list) the
|
||||
* dispatcher receives during Phase 2 or Phase 3. The gateway should
|
||||
* route it through its normal consume path — this is how new profile
|
||||
* metadata and follow lists reach downstream consumers like the WoT
|
||||
* service and the UI.
|
||||
*/
|
||||
fun onDiscoveredEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
)
|
||||
}
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
* 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 com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
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.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.RelayListRecommendationProcessor
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Fetches kind-0 (profile metadata) and kind-3 (contact list) events for a
|
||||
* set of authors using the NIP-65 **outbox model**:
|
||||
*
|
||||
* 1. **Phase 1 — discover.** Ask the configured index relays (Purple Pages,
|
||||
* Coracle, nos.lol, …) for the kind-10002 of each author. Merge with
|
||||
* already-cached 10002s from [OutboxCacheGateway].
|
||||
*
|
||||
* 2. **Phase 2 — pick + fetch.** Feed the author → write-relays map into
|
||||
* [RelayListRecommendationProcessor.reliableRelaySetFor] to get a
|
||||
* minimal, popularity-based set of relays that covers every author.
|
||||
* Open one subscription per recommended relay, filtered to that
|
||||
* relay's authors, for kind-0 and/or kind-3.
|
||||
*
|
||||
* 3. **Phase 3 — fallback.** For any author whose kind-10002 the network
|
||||
* never returned, fall back to the index-relay REQ (preserves the
|
||||
* current behaviour so a coldish account doesn't lose signal).
|
||||
*
|
||||
* The dispatcher is single-scoped (one instance per account) so its dedup
|
||||
* set survives across follow-set diffs. Call [clear] on account switch.
|
||||
*
|
||||
* @param client shared [INostrClient] used for every subscription
|
||||
* @param scope account-lifetime scope; cancelling it cancels in-flight REQs
|
||||
* @param indexRelays lazy accessor so a change through the settings UI
|
||||
* takes effect on next fetch without recreating the
|
||||
* dispatcher
|
||||
* @param gateway platform-specific cache adapter (see [OutboxCacheGateway])
|
||||
* @param perRelayTimeoutMs how long each REQ waits for its EOSE. Under
|
||||
* the plan (2026-07-06): 4 s.
|
||||
* @param overallTimeoutMs cap on the whole two-phase fetch. Belt against
|
||||
* a phase getting stuck. Under the plan: 8 s.
|
||||
* @param maxOutboxRelaysPerAuthor bound author → write-relays to the first N
|
||||
* relays after the [RelayListRecommendationProcessor]
|
||||
* chooses them, to keep fan-out predictable
|
||||
*/
|
||||
class OutboxDispatcher(
|
||||
private val client: INostrClient,
|
||||
private val scope: CoroutineScope,
|
||||
private val indexRelays: () -> Set<NormalizedRelayUrl>,
|
||||
private val gateway: OutboxCacheGateway,
|
||||
private val perRelayTimeoutMs: Long = 4_000L,
|
||||
private val overallTimeoutMs: Long = 20_000L,
|
||||
@Suppress("UNUSED_PARAMETER") maxOutboxRelaysPerAuthor: Int = 5,
|
||||
) {
|
||||
/**
|
||||
* Pubkeys we've already successfully fetched kind-3 for this session
|
||||
* (Phase 1 or Phase 2 returned events for them). Skipping a second
|
||||
* fetch is safe because a churn event from a subsequent kind-3
|
||||
* republication still reaches [OutboxCacheGateway.onDiscoveredEvent]
|
||||
* via other subscriptions (feed, notifications).
|
||||
*/
|
||||
private val kind3Succeeded = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Pubkeys we've already successfully fetched kind-0 for this session.
|
||||
*/
|
||||
private val kind0Succeeded = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Currently-in-flight authors — prevents rapid re-fire of the same
|
||||
* fetch. Distinct from [kind3Succeeded]/[kind0Succeeded]: a zero-EOSE
|
||||
* timeout rolls out of this set (allowing retry) instead of
|
||||
* permanently marking the pubkey as done.
|
||||
*/
|
||||
private val kind3InFlight = mutableSetOf<HexKey>()
|
||||
private val kind0InFlight = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Outcome counters. All values are aggregated across every phase of
|
||||
* one [fetchKind3Only] / [fetchKind0And3] call. Callers log them for
|
||||
* observability; `amy wot sync --json` also emits them so a caller
|
||||
* can measure whether the outbox path is doing the work vs the
|
||||
* fallback path.
|
||||
*/
|
||||
data class Result(
|
||||
val authorsRequested: Int,
|
||||
val kind10002Received: Int,
|
||||
val kind3Received: Int,
|
||||
val kind0Received: Int,
|
||||
val outboxCoveredAuthors: Int,
|
||||
val fallbackAuthors: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetch kind-3 for every pubkey in [authors] via each author's outbox
|
||||
* relay when known, falling back to index relays otherwise. Suspends
|
||||
* until every phase EOSEs or times out.
|
||||
*/
|
||||
suspend fun fetchKind3Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = false, includeKind3 = true)
|
||||
|
||||
/**
|
||||
* Fetch kind-3 AND kind-0 for every pubkey in [authors]. Same phase
|
||||
* pipeline; a single per-outbox-relay subscription pulls both kinds
|
||||
* so we don't double the connection count.
|
||||
*/
|
||||
suspend fun fetchKind0And3(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = true)
|
||||
|
||||
/**
|
||||
* Fetch kind-0 only. Used by the metadata preloader when it decides
|
||||
* to bypass the index-relay batch for a specific author (e.g. a
|
||||
* profile screen visit where the author's outbox is already cached).
|
||||
*/
|
||||
suspend fun fetchKind0Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = false)
|
||||
|
||||
/**
|
||||
* Drop every dedup marker. Call on account switch so a fresh account
|
||||
* doesn't inherit the previous account's "already fetched" state.
|
||||
*/
|
||||
fun clear() {
|
||||
kind3Succeeded.clear()
|
||||
kind0Succeeded.clear()
|
||||
kind3InFlight.clear()
|
||||
kind0InFlight.clear()
|
||||
}
|
||||
|
||||
private suspend fun run(
|
||||
authors: Set<HexKey>,
|
||||
includeKind0: Boolean,
|
||||
includeKind3: Boolean,
|
||||
): Result {
|
||||
if (authors.isEmpty()) return zeroResult(0)
|
||||
|
||||
val newForKind3 =
|
||||
if (includeKind3) authors.filter { it !in kind3Succeeded && it !in kind3InFlight }.toSet() else emptySet()
|
||||
val newForKind0 =
|
||||
if (includeKind0) authors.filter { it !in kind0Succeeded && it !in kind0InFlight }.toSet() else emptySet()
|
||||
|
||||
if (newForKind3.isEmpty() && newForKind0.isEmpty()) {
|
||||
Log.d("OutboxDispatcher") { "skip: all authors deduped (succeeded or in-flight)" }
|
||||
return zeroResult(authors.size)
|
||||
}
|
||||
|
||||
kind3InFlight.addAll(newForKind3)
|
||||
kind0InFlight.addAll(newForKind0)
|
||||
|
||||
return try {
|
||||
val result =
|
||||
withTimeoutOrNull(overallTimeoutMs) {
|
||||
doRun(authors, newForKind3, newForKind0, includeKind0, includeKind3)
|
||||
}
|
||||
if (result == null) {
|
||||
Log.w("OutboxDispatcher") { "overall timeout ${overallTimeoutMs}ms exceeded — returning zero result" }
|
||||
zeroResult(authors.size)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
} finally {
|
||||
kind3InFlight.removeAll(newForKind3)
|
||||
kind0InFlight.removeAll(newForKind0)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doRun(
|
||||
allAuthors: Set<HexKey>,
|
||||
newForKind3: Set<HexKey>,
|
||||
newForKind0: Set<HexKey>,
|
||||
includeKind0: Boolean,
|
||||
includeKind3: Boolean,
|
||||
): Result {
|
||||
val relayCounts = FetchCounters()
|
||||
val relaysConfigured = indexRelays()
|
||||
val newTargets = (newForKind3 + newForKind0)
|
||||
|
||||
// Split into "have cached 10002" vs "need Phase 1".
|
||||
val cachedOutbox = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
|
||||
val toDiscover = mutableSetOf<HexKey>()
|
||||
for (author in newTargets) {
|
||||
val write =
|
||||
gateway
|
||||
.cachedOutbox(author)
|
||||
?.writeRelaysNorm()
|
||||
.orEmpty()
|
||||
.toSet()
|
||||
if (write.isNotEmpty()) cachedOutbox[author] = write else toDiscover.add(author)
|
||||
}
|
||||
|
||||
Log.d("OutboxDispatcher") {
|
||||
"start authors=${allAuthors.size} newKind3=${newForKind3.size} newKind0=${newForKind0.size} " +
|
||||
"cachedOutbox=${cachedOutbox.size} toDiscover=${toDiscover.size} " +
|
||||
"indexRelays=${relaysConfigured.size}"
|
||||
}
|
||||
|
||||
// Phase 1 — discover kind-10002 on the index relays. runPhase1
|
||||
// returns pubkey → list of (event, relay) so we can pick the
|
||||
// newest event (some relays return outdated 10002s).
|
||||
val discovered = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
|
||||
if (toDiscover.isNotEmpty() && relaysConfigured.isNotEmpty()) {
|
||||
val (phase1Events, phase1EosedCount) = runPhase1(toDiscover, relaysConfigured)
|
||||
phase1Events.forEach { (pubkey, results) ->
|
||||
val newest = results.maxByOrNull { it.first.createdAt } ?: return@forEach
|
||||
gateway.onOutboxDiscovered(newest.first, newest.second)
|
||||
val write =
|
||||
newest.first
|
||||
.writeRelaysNorm()
|
||||
.orEmpty()
|
||||
.toSet()
|
||||
if (write.isNotEmpty()) discovered[pubkey] = write
|
||||
}
|
||||
relayCounts.kind10002 += phase1Events.values.sumOf { it.size }
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase1 done eosed=$phase1EosedCount/${relaysConfigured.size} " +
|
||||
"10002-events=${relayCounts.kind10002} discovered=${discovered.size}"
|
||||
}
|
||||
}
|
||||
|
||||
val outboxMap = cachedOutbox + discovered
|
||||
val authorsWithOutbox = outboxMap.keys
|
||||
val fallbackAuthors = newTargets - authorsWithOutbox
|
||||
|
||||
// Phase 2 — per-outbox-relay REQ, kind-3 and/or kind-0. All
|
||||
// recommended relays are subscribed in a single call so the pool
|
||||
// fans out in parallel; a per-relay 4 s timeout bounds the wait
|
||||
// regardless of how many relays the recommendation set contains.
|
||||
val kind3BeforePhase2 = relayCounts.kind3
|
||||
val kind0BeforePhase2 = relayCounts.kind0
|
||||
if (outboxMap.isNotEmpty() && (includeKind0 || includeKind3)) {
|
||||
val recommendations = RelayListRecommendationProcessor.reliableRelaySetFor(outboxMap)
|
||||
val phase2FilterMap =
|
||||
recommendations
|
||||
.mapNotNull { rec ->
|
||||
val authorsForThisRelay =
|
||||
rec.users.intersect(
|
||||
if (includeKind0 && includeKind3) {
|
||||
newTargets
|
||||
} else if (includeKind3) {
|
||||
newForKind3
|
||||
} else {
|
||||
newForKind0
|
||||
},
|
||||
)
|
||||
if (authorsForThisRelay.isEmpty()) return@mapNotNull null
|
||||
val kinds =
|
||||
buildList {
|
||||
if (includeKind0 && authorsForThisRelay.any { it in newForKind0 }) add(MetadataEvent.KIND)
|
||||
if (includeKind3 && authorsForThisRelay.any { it in newForKind3 }) add(ContactListEvent.KIND)
|
||||
}
|
||||
if (kinds.isEmpty()) return@mapNotNull null
|
||||
rec.relay to
|
||||
authorsForThisRelay.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
authors = chunk,
|
||||
limit = chunk.size * kinds.size,
|
||||
)
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
Log.d("OutboxDispatcher") { "phase2 recommendations=${recommendations.size} relays-with-work=${phase2FilterMap.size}" }
|
||||
if (phase2FilterMap.isNotEmpty()) {
|
||||
runPhase2Or3(phase2FilterMap, counters = relayCounts)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase2 done kind3=${relayCounts.kind3 - kind3BeforePhase2} kind0=${relayCounts.kind0 - kind0BeforePhase2}"
|
||||
}
|
||||
|
||||
// Phase 3 — index-relay fallback for authors with no 10002.
|
||||
val kind3BeforePhase3 = relayCounts.kind3
|
||||
val kind0BeforePhase3 = relayCounts.kind0
|
||||
if (fallbackAuthors.isNotEmpty() && relaysConfigured.isNotEmpty()) {
|
||||
val kinds =
|
||||
buildList {
|
||||
if (includeKind0 && fallbackAuthors.any { it in newForKind0 }) add(MetadataEvent.KIND)
|
||||
if (includeKind3 && fallbackAuthors.any { it in newForKind3 }) add(ContactListEvent.KIND)
|
||||
}
|
||||
if (kinds.isNotEmpty()) {
|
||||
Log.d("OutboxDispatcher") { "phase3 fallback authors=${fallbackAuthors.size} kinds=$kinds relays=${relaysConfigured.size}" }
|
||||
val filters =
|
||||
fallbackAuthors.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
authors = chunk,
|
||||
limit = chunk.size * kinds.size,
|
||||
)
|
||||
}
|
||||
val phase3FilterMap = relaysConfigured.associateWith { filters }
|
||||
runPhase2Or3(phase3FilterMap, counters = relayCounts)
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase3 done kind3=${relayCounts.kind3 - kind3BeforePhase3} kind0=${relayCounts.kind0 - kind0BeforePhase3}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Promote to succeeded — a completed run means we've asked; even if
|
||||
// an author had no publishable data we don't need to keep pounding
|
||||
// relays every follow-set change.
|
||||
kind3Succeeded.addAll(newForKind3)
|
||||
kind0Succeeded.addAll(newForKind0)
|
||||
|
||||
return Result(
|
||||
authorsRequested = allAuthors.size,
|
||||
kind10002Received = relayCounts.kind10002,
|
||||
kind3Received = relayCounts.kind3,
|
||||
kind0Received = relayCounts.kind0,
|
||||
outboxCoveredAuthors = authorsWithOutbox.size,
|
||||
fallbackAuthors = fallbackAuthors.size,
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private class FetchCounters {
|
||||
var kind10002 = 0
|
||||
var kind3 = 0
|
||||
var kind0 = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 helper. Returns a map of pubkey → list of (event, relay) so
|
||||
* caller can pick the newest, plus a boolean-per-relay EOSE indicator
|
||||
* (currently ignored but recorded for future retry telemetry).
|
||||
*/
|
||||
private suspend fun runPhase1(
|
||||
pubkeys: Set<HexKey>,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
): Pair<Map<HexKey, List<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>, Int> {
|
||||
val filters =
|
||||
pubkeys.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = listOf(AdvertisedRelayListEvent.KIND),
|
||||
authors = chunk,
|
||||
limit = chunk.size,
|
||||
)
|
||||
}
|
||||
val filterMap = relays.associateWith { filters }
|
||||
|
||||
val received = mutableMapOf<HexKey, MutableList<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>()
|
||||
val gate = BatchEoseGate(scope, target = relays.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (event is AdvertisedRelayListEvent && event.pubKey in pubkeys) {
|
||||
received
|
||||
.getOrPut(event.pubKey) { mutableListOf() }
|
||||
.add(event to relay)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
val subId = newSubId()
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
val eosedCount = gate.awaitAll(perRelayTimeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
return received to eosedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 or Phase 3 helper. Opens a single subscription that
|
||||
* fans out to every relay in [filterMap] (Phase 2 uses per-outbox-
|
||||
* relay filters; Phase 3 uses the index-relay set with a shared
|
||||
* fallback filter). All relays are subscribed in parallel — the
|
||||
* per-relay timeout bounds the total wait regardless of relay count.
|
||||
*/
|
||||
private suspend fun runPhase2Or3(
|
||||
filterMap: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
counters: FetchCounters,
|
||||
) {
|
||||
val gate = BatchEoseGate(scope, target = filterMap.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
when (event.kind) {
|
||||
MetadataEvent.KIND -> counters.kind0++
|
||||
ContactListEvent.KIND -> counters.kind3++
|
||||
}
|
||||
gateway.onDiscoveredEvent(event, relay)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
val subId = newSubId()
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
gate.awaitAll(perRelayTimeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
}
|
||||
|
||||
private fun zeroResult(requested: Int) =
|
||||
Result(
|
||||
authorsRequested = requested,
|
||||
kind10002Received = 0,
|
||||
kind3Received = 0,
|
||||
kind0Received = 0,
|
||||
outboxCoveredAuthors = 0,
|
||||
fallbackAuthors = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* KMP-safe EOSE aggregator (same as FeedMetadataCoordinator's local
|
||||
* one — duplicated locally instead of exported to keep the fix scope
|
||||
* minimal). Per-relay `onEose` callbacks may run on any dispatcher
|
||||
* (typically `Dispatchers.IO`) so we funnel them through a Channel
|
||||
* and let a single consumer coroutine own the `seen` set.
|
||||
*/
|
||||
private class BatchEoseGate(
|
||||
private val scope: CoroutineScope,
|
||||
private val target: Int,
|
||||
) {
|
||||
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
|
||||
private val done = CompletableDeferred<Unit>()
|
||||
|
||||
@Volatile private var lastCount = 0
|
||||
|
||||
fun notifyEose(relay: NormalizedRelayUrl) {
|
||||
incoming.trySend(relay)
|
||||
}
|
||||
|
||||
suspend fun awaitAll(timeoutMs: Long): Int {
|
||||
if (target <= 0) return 0
|
||||
val consumer =
|
||||
scope.launch {
|
||||
val seen = mutableSetOf<NormalizedRelayUrl>()
|
||||
for (relay in incoming) {
|
||||
if (seen.add(relay)) {
|
||||
lastCount = seen.size
|
||||
if (seen.size >= target && !done.isCompleted) {
|
||||
done.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
withTimeoutOrNull(timeoutMs) { done.await() }
|
||||
incoming.close()
|
||||
consumer.join()
|
||||
return lastCount
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
* ## Reactivity model
|
||||
*
|
||||
* Scores are exposed via a Compose-observable [SnapshotStateMap]. Consumers
|
||||
* that read a **single key** (`scores[pubkey]`) recompose only when that
|
||||
* key changes — this is `SnapshotStateMap`'s built-in per-key observation
|
||||
* and applies whether or not the writer wraps in a snapshot block.
|
||||
* Consumers that iterate the map or read `size` recompose on **any**
|
||||
* mutation.
|
||||
*
|
||||
* The writer wraps each op in [Snapshot.withMutableSnapshot] to *coalesce*
|
||||
* an op's writes into a single Compose commit — so a Kind3 op that
|
||||
* touches N reverse-index targets emits one invalidation, not N. It does
|
||||
* not confer additional per-key isolation on top of `SnapshotStateMap`'s
|
||||
* own semantics.
|
||||
*
|
||||
* ## Concurrency
|
||||
*
|
||||
* All internal state is mutated from a single writer coroutine
|
||||
* ([writerLoop]) on [writerDispatcher] (default [Dispatchers.Default]), so
|
||||
* concurrent [applyKind3] / [onFollowSetChange] / [markReadyOnce] calls
|
||||
* from different threads are serialized without extra locking.
|
||||
*
|
||||
* ## Lifecycle
|
||||
*
|
||||
* Call [close] on account switch / logout so the writer coroutine exits
|
||||
* and the ops channel is released. Post-close ops are silently dropped.
|
||||
*/
|
||||
@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,
|
||||
) : AutoCloseable {
|
||||
/**
|
||||
* 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 var disabled = false
|
||||
|
||||
private val _isReady = MutableStateFlow(false)
|
||||
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
|
||||
|
||||
private val _isDisabled = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True when the active user's follow set exceeds [MAX_FOLLOWS] and WoT
|
||||
* scoring has been shut off. Callers that dispatch the batch kind-3
|
||||
* REQ must gate on this — a disabled service silently accepts and
|
||||
* ignores all subsequent [applyKind3] calls, so a caller that keeps
|
||||
* flooding kind-3s wastes bandwidth for nothing.
|
||||
*/
|
||||
val isDisabled: StateFlow<Boolean> = _isDisabled.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?,
|
||||
) {
|
||||
// Guardrail — massive follow lists don't produce a useful WoT signal.
|
||||
// Do this BEFORE assigning myFollows so applyKind3's `follower in
|
||||
// myFollows` gate doesn't accidentally credit anyone once the
|
||||
// caller keeps pumping kind-3s in (a caller that fails to gate on
|
||||
// isDisabled would otherwise fully repopulate reverseIndex/_scores
|
||||
// and defeat the guardrail — see PR #3483 review finding 2).
|
||||
if (newFollows.size > MAX_FOLLOWS) {
|
||||
reverseIndex.clear()
|
||||
perFollowerSnapshot.clear()
|
||||
_scores.clear()
|
||||
myFollows = emptySet()
|
||||
selfPubkey = newSelf
|
||||
disabled = true
|
||||
_isDisabled.value = true
|
||||
handleMarkReady()
|
||||
return
|
||||
}
|
||||
|
||||
val removed = myFollows - newFollows
|
||||
myFollows = newFollows
|
||||
selfPubkey = newSelf
|
||||
// Follow set is back within limits (or was already) — re-enable if
|
||||
// we had previously flipped disabled=true.
|
||||
if (disabled) {
|
||||
disabled = false
|
||||
_isDisabled.value = false
|
||||
}
|
||||
|
||||
// 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 (disabled) return
|
||||
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
|
||||
disabled = false
|
||||
_isDisabled.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the writer coroutine and release the ops channel. Call from
|
||||
* account-switch / logout paths. Post-close [applyKind3] / [onFollowSetChange]
|
||||
* / [markReadyOnce] / [clear] calls are silently dropped (the `trySend`
|
||||
* on a closed [Channel] fails without throwing).
|
||||
*
|
||||
* Idempotent; safe to call multiple times.
|
||||
*/
|
||||
override fun close() {
|
||||
ops.close()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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.relays.index
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* User-configurable set of relays used to fetch profile metadata
|
||||
* (kind 0) and follow lists (kind 3) — the "index relays" set passed
|
||||
* to `FeedMetadataCoordinator` in the Desktop app and to `wot sync`
|
||||
* in `amy`.
|
||||
*
|
||||
* Backed by [java.util.prefs.Preferences] at a fixed node
|
||||
* `com/vitorpamplona/amethyst/relays/index` (JVM-user-scoped). The
|
||||
* shared node means Desktop and `amy` running as the same OS user
|
||||
* observe the same setting without extra plumbing — the same trick
|
||||
* `PreferencesHashtagSpamSettings` uses for the hashtag-spam filter.
|
||||
*
|
||||
* Not per-account: users typically have a single preferred set of
|
||||
* index relays regardless of which account is currently logged in.
|
||||
* If per-account overrides become necessary later, layer a per-user
|
||||
* key on top; this class stays the base.
|
||||
*
|
||||
* CSV serialisation for the persisted value matches what
|
||||
* `DesktopAccountRelays` uses for its categories — no JSON dep, no
|
||||
* `Serializable` contract. URLs are normalised via
|
||||
* [RelayUrlNormalizer.normalizeOrNull] at both write and read time so
|
||||
* malformed entries never enter the effective set.
|
||||
*/
|
||||
class PreferencesIndexRelays(
|
||||
private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME),
|
||||
) {
|
||||
private val mutableRelays: MutableStateFlow<Set<NormalizedRelayUrl>> =
|
||||
MutableStateFlow(parse(prefs.get(KEY_URLS, "")))
|
||||
|
||||
/**
|
||||
* Current user override. Empty when the user has not configured
|
||||
* anything — callers should route through [effective] to get the
|
||||
* defaults-fallback resolved set.
|
||||
*/
|
||||
val relays: StateFlow<Set<NormalizedRelayUrl>> = mutableRelays.asStateFlow()
|
||||
|
||||
fun setRelays(new: Set<NormalizedRelayUrl>) {
|
||||
mutableRelays.value = new
|
||||
prefs.put(KEY_URLS, new.joinToString(",") { it.url })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the set the relay client should actually use — the user
|
||||
* override when non-empty, otherwise [DEFAULT_INDEX_RELAYS]. Never
|
||||
* returns empty (unless the caller has explicitly reset both the
|
||||
* override and the defaults to empty, which would require a code
|
||||
* change here).
|
||||
*/
|
||||
fun effective(): Set<NormalizedRelayUrl> = mutableRelays.value.ifEmpty { DEFAULT_INDEX_RELAYS }
|
||||
|
||||
companion object {
|
||||
const val NODE_NAME = "com/vitorpamplona/amethyst/relays/index"
|
||||
const val KEY_URLS = "urls"
|
||||
|
||||
/**
|
||||
* Byte-for-byte identical to `DefaultRelays.RELAYS` at
|
||||
* `desktopApp/.../network/RelayStatus.kt`. Preserves current
|
||||
* behaviour for users who never open the settings UI.
|
||||
*
|
||||
* Note: `commons/AmethystDefaults.kt` also has
|
||||
* `DefaultIndexerRelayList` (Purple Pages, Coracle …) which is
|
||||
* more purpose-built for indexing. Adopting it is a separate
|
||||
* ticket — see the plan's "Out of Scope" section.
|
||||
*/
|
||||
val DEFAULT_INDEX_RELAYS: Set<NormalizedRelayUrl> =
|
||||
listOf(
|
||||
"wss://nos.lol",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.noswhere.com",
|
||||
"wss://relay.primal.net",
|
||||
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet()
|
||||
|
||||
internal fun parse(csv: String): Set<NormalizedRelayUrl> =
|
||||
csv
|
||||
.split(",")
|
||||
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* 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.relayClient.assemblers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Regression tests for PR #3483 review findings on FeedMetadataCoordinator:
|
||||
*
|
||||
* - Finding 5: `queuedKind3Pubkeys` was marked-on-send, so if every index
|
||||
* relay timed out the pubkeys were permanently marked and subsequent
|
||||
* calls short-circuited — WoT stayed empty for the whole session.
|
||||
* Fix: pubkeys land in `queuedKind3Pubkeys` only after ≥1 EOSE; on
|
||||
* zero-EOSE timeout they roll out of `inFlightBatchedKind3` for retry.
|
||||
*
|
||||
* - Finding 6: `eoseReceived: MutableSet` was mutated from per-relay
|
||||
* `onEose` callbacks running on `Dispatchers.IO` with no sync. Fix:
|
||||
* `BatchEoseGate` funnels EOSE notifications through a `Channel` so a
|
||||
* single consumer coroutine is the sole reader/writer of the `seen`
|
||||
* set.
|
||||
*/
|
||||
class FeedMetadataCoordinatorTest {
|
||||
private lateinit var scope: CoroutineScope
|
||||
private val relay1 = NormalizedRelayUrl("wss://relay1.test/")
|
||||
private val relay2 = NormalizedRelayUrl("wss://relay2.test/")
|
||||
private val relay3 = NormalizedRelayUrl("wss://relay3.test/")
|
||||
private val indexRelays = setOf(relay1, relay2, relay3)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0')
|
||||
|
||||
/**
|
||||
* Fake client that captures subscribe/unsubscribe and lets the test
|
||||
* drive EOSE notifications on any dispatcher we choose.
|
||||
*/
|
||||
private class ControllableClient(
|
||||
private val delegate: INostrClient = EmptyNostrClient(),
|
||||
) : INostrClient by delegate {
|
||||
val subscriptions = mutableMapOf<String, SubscriptionListener?>()
|
||||
val subscribeCalls = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
var unsubscribeCallCount = 0
|
||||
private set
|
||||
|
||||
override fun subscribe(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
) {
|
||||
subscriptions[subId] = listener
|
||||
subscribeCalls.add(filters)
|
||||
}
|
||||
|
||||
override fun unsubscribe(subId: String) {
|
||||
subscriptions.remove(subId)
|
||||
unsubscribeCallCount++
|
||||
}
|
||||
|
||||
fun fireEose(relay: NormalizedRelayUrl) {
|
||||
subscriptions.values.filterNotNull().forEach { it.onEose(relay, forFilters = null) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadKind3Batched retries after zero-EOSE timeout`() =
|
||||
runBlocking {
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = indexRelays,
|
||||
)
|
||||
|
||||
val pubkeys = listOf(pubkey(1), pubkey(2), pubkey(3))
|
||||
|
||||
// Call 1 — no relay EOSEs; must time out.
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(350) // exceed the timeout
|
||||
|
||||
// Call 2 — the same pubkeys must be re-subscribed since call 1
|
||||
// never got a successful EOSE. The old code would silently
|
||||
// short-circuit here.
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(50) // let the launcher run
|
||||
|
||||
assertEquals(
|
||||
"Zero-EOSE timeout must not permanently dedup pubkeys",
|
||||
2,
|
||||
client.subscribeCalls.size,
|
||||
)
|
||||
assertEquals(
|
||||
"Second call must re-request the same author set",
|
||||
pubkeys.size,
|
||||
client.subscribeCalls[1]
|
||||
.values
|
||||
.first()
|
||||
.first()
|
||||
.authors!!
|
||||
.size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadKind3Batched short-circuits after successful EOSE`() =
|
||||
runBlocking {
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = indexRelays,
|
||||
)
|
||||
|
||||
val pubkeys = listOf(pubkey(1), pubkey(2))
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 1_000)
|
||||
// Give the launcher time to register the listener before we fire.
|
||||
delay(50)
|
||||
indexRelays.forEach(client::fireEose)
|
||||
delay(200) // let the coordinator finish + promote to queued
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(50)
|
||||
|
||||
assertEquals(
|
||||
"Successful call must dedup subsequent identical calls",
|
||||
1,
|
||||
client.subscribeCalls.size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadKind3Batched promotes even when only some relays EOSE`() =
|
||||
runBlocking {
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = indexRelays,
|
||||
)
|
||||
|
||||
val pubkeys = listOf(pubkey(1))
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 300)
|
||||
delay(30)
|
||||
// Only 1 of 3 EOSEs — timeout still fires but we made progress.
|
||||
client.fireEose(relay1)
|
||||
delay(400)
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(50)
|
||||
|
||||
assertEquals(
|
||||
"≥1 EOSE = progress = promote to queued (avoid re-asking)",
|
||||
1,
|
||||
client.subscribeCalls.size,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for finding 6 — pumps EOSE from many dispatchers in
|
||||
* parallel. The old MutableSet-based code could drop entries or throw
|
||||
* ConcurrentModificationException on the internal HashSet iterator.
|
||||
* BatchEoseGate must aggregate every distinct relay exactly once.
|
||||
*/
|
||||
@Test
|
||||
fun `EOSE aggregator is safe under concurrent per-relay callbacks`() =
|
||||
runBlocking {
|
||||
val bigIndexSet =
|
||||
(0..19).map { NormalizedRelayUrl("wss://relay$it.test/") }.toSet()
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = bigIndexSet,
|
||||
)
|
||||
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 2_000)
|
||||
delay(50) // wait for subscription
|
||||
|
||||
// Fire EOSEs concurrently from many dispatchers.
|
||||
val jobs =
|
||||
bigIndexSet.map { relay ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
client.fireEose(relay)
|
||||
}
|
||||
}
|
||||
jobs.forEach { it.join() }
|
||||
|
||||
// The 2nd call must short-circuit — every relay EOSE'd, so
|
||||
// pubkey(1) is now in queuedKind3Pubkeys.
|
||||
delay(100)
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
|
||||
delay(50)
|
||||
|
||||
assertEquals(
|
||||
"Under concurrent EOSE from all relays, aggregator must reach target",
|
||||
1,
|
||||
client.subscribeCalls.size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadMetadataBatched follows the same retry semantics`() =
|
||||
runBlocking {
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = indexRelays,
|
||||
)
|
||||
|
||||
val pubkeys = listOf(pubkey(1), pubkey(2))
|
||||
|
||||
// Call 1 — zero EOSE, timeout.
|
||||
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
|
||||
delay(350)
|
||||
// Call 2 — must re-subscribe.
|
||||
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
|
||||
delay(50)
|
||||
|
||||
assertTrue(
|
||||
"Metadata batch also retries on zero-EOSE timeout",
|
||||
client.subscribeCalls.size >= 2,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear releases in-flight dedup so a fresh call always fires`() =
|
||||
runBlocking {
|
||||
val client = ControllableClient()
|
||||
val coordinator =
|
||||
FeedMetadataCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = indexRelays,
|
||||
)
|
||||
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
|
||||
delay(50)
|
||||
// clear() must drop the in-flight tracker even mid-request.
|
||||
coordinator.clear()
|
||||
delay(300) // let call 1 finish + roll back
|
||||
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
|
||||
delay(50)
|
||||
|
||||
assertTrue(client.subscribeCalls.size >= 2)
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.relays.index
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
class PreferencesIndexRelaysTest {
|
||||
private val testNode = "com/vitorpamplona/amethyst/test/relays/index_${System.currentTimeMillis()}"
|
||||
|
||||
private fun prefs(): Preferences = Preferences.userRoot().node(testNode)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
prefs().clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
prefs().removeNode()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsWhenPreferencesUnset() {
|
||||
val store = PreferencesIndexRelays(prefs())
|
||||
assertTrue(store.relays.value.isEmpty())
|
||||
assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setRelaysPersistsAcrossInstances() {
|
||||
val store = PreferencesIndexRelays(prefs())
|
||||
val urls =
|
||||
listOf("wss://relay.example", "wss://index.example")
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet()
|
||||
store.setRelays(urls)
|
||||
assertEquals(urls, store.relays.value)
|
||||
|
||||
val reloaded = PreferencesIndexRelays(prefs())
|
||||
assertEquals(urls, reloaded.relays.value)
|
||||
assertEquals(urls, reloaded.effective())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effectiveFallsBackWhenOverrideCleared() {
|
||||
val store = PreferencesIndexRelays(prefs())
|
||||
val urls =
|
||||
listOf("wss://relay.example")
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet()
|
||||
store.setRelays(urls)
|
||||
store.setRelays(emptySet())
|
||||
assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyEntriesInCsvAreSkipped() {
|
||||
// Plant a URL list with empty tokens (extra commas). The
|
||||
// parser should skip blanks silently.
|
||||
prefs().put(PreferencesIndexRelays.KEY_URLS, "wss://good.example,,wss://also-good.example,")
|
||||
val store = PreferencesIndexRelays(prefs())
|
||||
// Both good URLs should be present; no blank / empty entry.
|
||||
assertEquals(2, store.relays.value.size)
|
||||
assertTrue(store.relays.value.none { it.url.isBlank() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultSetIsNotEmpty() {
|
||||
// Guardrail against a future refactor accidentally clearing the constant.
|
||||
assertTrue(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS.isNotEmpty())
|
||||
}
|
||||
}
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
* 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 com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Coverage for the outbox pipeline defined in
|
||||
* `commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md`.
|
||||
* Scenarios:
|
||||
*
|
||||
* 1. Author has a cached kind-10002 → Phase 1 skipped, Phase 2 REQs
|
||||
* the author's write relay directly.
|
||||
* 2. Author has no cached 10002 → Phase 1 discovers, Phase 2 uses the
|
||||
* discovered write relays.
|
||||
* 3. Author with no 10002 anywhere → Phase 3 fallback to index relays.
|
||||
* 4. Per-relay timeout on Phase 1 doesn't cancel Phase 2 for authors
|
||||
* that already had a cached outbox.
|
||||
* 5. clear() releases dedup so a fresh call always re-runs.
|
||||
*/
|
||||
class OutboxDispatcherTest {
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private val indexRelay1 = NormalizedRelayUrl("wss://index1.test/")
|
||||
private val indexRelay2 = NormalizedRelayUrl("wss://index2.test/")
|
||||
private val indexRelays = setOf(indexRelay1, indexRelay2)
|
||||
|
||||
private val outboxAlice = NormalizedRelayUrl("wss://alice-outbox.test/")
|
||||
private val outboxBob = NormalizedRelayUrl("wss://bob-outbox.test/")
|
||||
|
||||
private val alice = pubkey(1)
|
||||
private val bob = pubkey(2)
|
||||
private val charlie = pubkey(3)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0')
|
||||
|
||||
private fun dummySig() = "0".repeat(128)
|
||||
|
||||
private fun outboxEventFor(
|
||||
author: HexKey,
|
||||
writeRelays: List<NormalizedRelayUrl>,
|
||||
createdAt: Long = 1_700_000_000,
|
||||
): AdvertisedRelayListEvent {
|
||||
val tags = writeRelays.map { arrayOf("r", it.url, "write") }.toTypedArray()
|
||||
return AdvertisedRelayListEvent(
|
||||
id = "out-$author".take(64).padEnd(64, '0'),
|
||||
pubKey = author,
|
||||
createdAt = createdAt,
|
||||
tags = tags,
|
||||
content = "",
|
||||
sig = dummySig(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun kind3For(
|
||||
author: HexKey,
|
||||
follows: List<HexKey>,
|
||||
) = ContactListEvent(
|
||||
id = "k3-$author".take(64).padEnd(64, '0'),
|
||||
pubKey = author,
|
||||
createdAt = 1_700_000_100,
|
||||
tags = follows.map { arrayOf("p", it) }.toTypedArray(),
|
||||
content = "",
|
||||
sig = dummySig(),
|
||||
)
|
||||
|
||||
private class RecordingGateway : OutboxCacheGateway {
|
||||
val cache = mutableMapOf<HexKey, AdvertisedRelayListEvent>()
|
||||
val discoveredOutbox = mutableListOf<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>()
|
||||
val discoveredEvents = mutableListOf<Pair<Event, NormalizedRelayUrl>>()
|
||||
|
||||
override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? = cache[pubkey]
|
||||
|
||||
override fun onOutboxDiscovered(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
cache[event.pubKey] = event
|
||||
discoveredOutbox.add(event to relay)
|
||||
}
|
||||
|
||||
override fun onDiscoveredEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
discoveredEvents.add(event to relay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake INostrClient that replays a scripted set of events + auto-EOSEs
|
||||
* per relay when [subscribe] is called. The script is keyed by the
|
||||
* REQ's `(kinds, relay)` pair so tests can seed different responses
|
||||
* for Phase-1 and Phase-2 subs.
|
||||
*/
|
||||
private class ScriptedClient(
|
||||
private val delegate: INostrClient = EmptyNostrClient(),
|
||||
) : INostrClient by delegate {
|
||||
// (kind, relay) → list of events to return
|
||||
private val script = mutableMapOf<Pair<Int, NormalizedRelayUrl>, List<Event>>()
|
||||
private val eoseNever = mutableSetOf<NormalizedRelayUrl>()
|
||||
val allSubscribeCalls = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
|
||||
fun scriptEvent(
|
||||
kind: Int,
|
||||
relay: NormalizedRelayUrl,
|
||||
events: List<Event>,
|
||||
) {
|
||||
script[kind to relay] = events
|
||||
}
|
||||
|
||||
fun neverEose(relay: NormalizedRelayUrl) {
|
||||
eoseNever.add(relay)
|
||||
}
|
||||
|
||||
override fun subscribe(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
) {
|
||||
allSubscribeCalls.add(filters)
|
||||
filters.forEach { (relay, filterList) ->
|
||||
filterList.forEach { filter ->
|
||||
filter.kinds?.forEach { kind ->
|
||||
script[kind to relay]?.forEach { event ->
|
||||
listener?.onEvent(event, isLive = false, relay = relay, forFilters = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (relay !in eoseNever) {
|
||||
listener?.onEose(relay, forFilters = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unsubscribe(subId: String) { /* no-op */ }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cached outbox skips Phase 1 and fetches directly from write relay`() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
|
||||
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { indexRelays },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 400,
|
||||
overallTimeoutMs = 2_000,
|
||||
)
|
||||
|
||||
val result = dispatcher.fetchKind3Only(setOf(alice))
|
||||
|
||||
assertEquals(1, result.kind3Received)
|
||||
assertEquals(1, result.outboxCoveredAuthors)
|
||||
assertEquals(0, result.fallbackAuthors)
|
||||
assertTrue(
|
||||
"Phase 2 must REQ from Alice's own outbox relay",
|
||||
client.allSubscribeCalls.any { call -> outboxAlice in call.keys },
|
||||
)
|
||||
assertTrue(
|
||||
"No Phase 1 REQ should be sent to index relays when 10002 is cached",
|
||||
client.allSubscribeCalls.none { call -> indexRelays.any { it in call.keys } },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Phase 1 discovers 10002 then Phase 2 fetches from the discovered write relay`() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
val bobOutbox = outboxEventFor(bob, listOf(outboxBob))
|
||||
|
||||
indexRelays.forEach { rel ->
|
||||
client.scriptEvent(AdvertisedRelayListEvent.KIND, rel, listOf(bobOutbox))
|
||||
}
|
||||
client.scriptEvent(ContactListEvent.KIND, outboxBob, listOf(kind3For(bob, listOf(alice))))
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { indexRelays },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 400,
|
||||
overallTimeoutMs = 2_000,
|
||||
)
|
||||
|
||||
val result = dispatcher.fetchKind3Only(setOf(bob))
|
||||
|
||||
assertTrue("Discovered 10002 count > 0", result.kind10002Received > 0)
|
||||
assertEquals(1, result.kind3Received)
|
||||
assertEquals(1, result.outboxCoveredAuthors)
|
||||
assertEquals(0, result.fallbackAuthors)
|
||||
assertTrue(
|
||||
"Gateway was told about the discovered 10002",
|
||||
gateway.discoveredOutbox.any { it.first.pubKey == bob },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `author with no 10002 falls back to index-relay REQ`() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
|
||||
// No 10002 anywhere. Charlie's kind-3 sits only on the index relays.
|
||||
indexRelays.forEach { rel ->
|
||||
client.scriptEvent(ContactListEvent.KIND, rel, listOf(kind3For(charlie, listOf(alice))))
|
||||
}
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { indexRelays },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 400,
|
||||
overallTimeoutMs = 2_000,
|
||||
)
|
||||
|
||||
val result = dispatcher.fetchKind3Only(setOf(charlie))
|
||||
|
||||
assertEquals(1, result.fallbackAuthors)
|
||||
assertEquals(0, result.outboxCoveredAuthors)
|
||||
assertTrue(
|
||||
"Fallback path receives the kind-3",
|
||||
result.kind3Received >= 1,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cached-outbox author still fetched when Phase 1 for other authors times out`() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
|
||||
// Alice has cached outbox — Phase 2 must fetch from her write relay.
|
||||
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
|
||||
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
|
||||
|
||||
// Bob has no cached outbox and index relays never EOSE for Phase 1.
|
||||
indexRelays.forEach(client::neverEose)
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { indexRelays },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 200,
|
||||
overallTimeoutMs = 2_000,
|
||||
)
|
||||
|
||||
val result = dispatcher.fetchKind3Only(setOf(alice, bob))
|
||||
|
||||
// Alice was covered by cached outbox; Bob wasn't but Phase 1 timed
|
||||
// out, so he became a fallback candidate.
|
||||
assertEquals(
|
||||
"Alice always covered by cached outbox",
|
||||
1,
|
||||
result.outboxCoveredAuthors,
|
||||
)
|
||||
assertTrue(result.kind3Received >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear releases dedup so a subsequent identical call refetches`() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
|
||||
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { indexRelays },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 400,
|
||||
overallTimeoutMs = 2_000,
|
||||
)
|
||||
|
||||
dispatcher.fetchKind3Only(setOf(alice))
|
||||
val subCountAfterFirst = client.allSubscribeCalls.size
|
||||
|
||||
// Second call without clear() — should short-circuit.
|
||||
dispatcher.fetchKind3Only(setOf(alice))
|
||||
assertEquals(subCountAfterFirst, client.allSubscribeCalls.size)
|
||||
|
||||
// After clear(), the same call re-runs Phase 2.
|
||||
dispatcher.clear()
|
||||
dispatcher.fetchKind3Only(setOf(alice))
|
||||
assertTrue(client.allSubscribeCalls.size > subCountAfterFirst)
|
||||
}
|
||||
|
||||
/**
|
||||
* BatchEoseGate stress — inside OutboxDispatcher this is a private
|
||||
* class but the observable effect (Phase 1 completes when all index
|
||||
* relays EOSE, and stays within the timeout budget) is what matters.
|
||||
*/
|
||||
@Test
|
||||
fun `EOSE aggregation is safe with many concurrent index-relay callbacks`() =
|
||||
runBlocking {
|
||||
val bigIndexSet = (0..15).map { NormalizedRelayUrl("wss://index$it.test/") }.toSet()
|
||||
val client = ScriptedClient()
|
||||
val gateway = RecordingGateway()
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { bigIndexSet },
|
||||
gateway = gateway,
|
||||
perRelayTimeoutMs = 1_000,
|
||||
overallTimeoutMs = 3_000,
|
||||
)
|
||||
|
||||
// Kick off a fetch and race the subscribe call. ScriptedClient
|
||||
// fires EOSE inline; we simulate concurrent per-relay EOSE by
|
||||
// launching multiple dispatchers as a smoke test.
|
||||
val fetchJob = scope.launch { dispatcher.fetchKind3Only(setOf(alice, bob, charlie)) }
|
||||
|
||||
// Give the launcher a moment to enter Phase 1's subscribe.
|
||||
delay(50)
|
||||
fetchJob.join()
|
||||
// No CME thrown, no hang past the timeout budget.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* 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() })
|
||||
assertTrue(runBlocking { svc.isDisabled.first() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for PR #3483 review finding 2: even after the guardrail
|
||||
* trips, applyKind3 for a follower in the huge follow set used to
|
||||
* repopulate reverseIndex/_scores because myFollows had already been
|
||||
* assigned. Fix clears myFollows AND sets a disabled flag; both gate
|
||||
* handleKind3 so the guardrail actually holds under sustained pump.
|
||||
*/
|
||||
@Test
|
||||
fun guardrailIgnoresApplyKind3AfterTrip() {
|
||||
val huge = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
|
||||
svc.onFollowSetChange(huge, me)
|
||||
drain()
|
||||
|
||||
val anyFollower = huge.first()
|
||||
svc.applyKind3(anyFollower, setOf(c, d, e))
|
||||
drain()
|
||||
|
||||
assertEquals(
|
||||
"Guardrail must block score repopulation via applyKind3",
|
||||
emptyMap<String, Int>(),
|
||||
svc.scoresSnapshot(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guardrailReleasesWhenFollowSetShrinksBack() {
|
||||
val huge = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
|
||||
svc.onFollowSetChange(huge, me)
|
||||
drain()
|
||||
assertTrue(runBlocking { svc.isDisabled.first() })
|
||||
|
||||
// User trims their follow list — dispatcher should re-engage.
|
||||
svc.onFollowSetChange(setOf(a, b), me)
|
||||
drain()
|
||||
assertFalse(runBlocking { svc.isDisabled.first() })
|
||||
|
||||
// And WoT scoring resumes normally.
|
||||
svc.applyKind3(a, setOf(c, d))
|
||||
drain()
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeStopsAcceptingOps() {
|
||||
svc.onFollowSetChange(setOf(a), me)
|
||||
svc.applyKind3(a, setOf(c))
|
||||
drain()
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
|
||||
svc.close()
|
||||
drain()
|
||||
|
||||
// Post-close writes are dropped silently.
|
||||
svc.applyKind3(a, setOf(d))
|
||||
drain()
|
||||
assertEquals(null, svc.scoresSnapshot()[d])
|
||||
// State observed before close remains readable.
|
||||
assertEquals(1, svc.scoresSnapshot()[c])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeIsIdempotent() {
|
||||
svc.close()
|
||||
svc.close() // should not throw
|
||||
}
|
||||
|
||||
@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,13 +78,14 @@ 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
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
||||
import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome
|
||||
@@ -126,7 +127,6 @@ import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
@@ -844,6 +844,17 @@ private fun AppInner(
|
||||
// node so the `amy` CLI binary observes the same toggle.
|
||||
val hashtagSpamSettings = remember { PreferencesHashtagSpamSettings() }
|
||||
|
||||
// Index-relay preference — user-configurable set used to fetch profile
|
||||
// metadata (kind 0) and follow lists (kind 3). Persisted in a shared
|
||||
// java.util.prefs node so `amy wot sync` reads from the same source of
|
||||
// truth. Falls back to PreferencesIndexRelays.DEFAULT_INDEX_RELAYS when
|
||||
// the user hasn't configured anything.
|
||||
val indexRelaysStore =
|
||||
remember {
|
||||
com.vitorpamplona.amethyst.commons.relays.index
|
||||
.PreferencesIndexRelays()
|
||||
}
|
||||
|
||||
// Local relay store — persists events to SQLite per account
|
||||
val localRelayStore =
|
||||
remember {
|
||||
@@ -928,18 +939,16 @@ private fun AppInner(
|
||||
}
|
||||
}
|
||||
|
||||
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
|
||||
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
|
||||
// Subscriptions coordinator — uses the user's configured index relays
|
||||
// (or PreferencesIndexRelays.DEFAULT_INDEX_RELAYS as fallback) for
|
||||
// metadata + follow-list REQs. Changes made via the settings UI take
|
||||
// effect on next relaunch — the coordinator snapshots the set here.
|
||||
val subscriptionsCoordinator =
|
||||
remember(relayManager, localCache) {
|
||||
remember(relayManager, localCache, indexRelaysStore) {
|
||||
DesktopRelaySubscriptionsCoordinator(
|
||||
client = relayManager.client,
|
||||
scope = scope,
|
||||
indexRelays =
|
||||
DefaultRelays.RELAYS
|
||||
.mapNotNull {
|
||||
RelayUrlNormalizer.normalizeOrNull(it)
|
||||
}.toSet(),
|
||||
indexRelays = indexRelaysStore.effective(),
|
||||
localCache = localCache,
|
||||
).also { it.startCleanupLoop() }
|
||||
}
|
||||
@@ -950,6 +959,7 @@ private fun AppInner(
|
||||
when (val state = accountState) {
|
||||
is AccountState.LoggedOut -> {
|
||||
subscriptionsCoordinator.clear()
|
||||
localCache.accountPubkey = null
|
||||
localCache.clear()
|
||||
localRelayMaintenance.stop()
|
||||
localRelayStore.close()
|
||||
@@ -961,11 +971,23 @@ private fun AppInner(
|
||||
if (previousAccountPubKey != null && previousAccountPubKey != currentPubKey) {
|
||||
// Account switched — clear old data so new feed loads fresh
|
||||
subscriptionsCoordinator.clear()
|
||||
localCache.accountPubkey = null
|
||||
localCache.clear()
|
||||
localRelayMaintenance.stop()
|
||||
localRelayStore.close()
|
||||
subscriptionsCoordinator.start()
|
||||
}
|
||||
// Bind the active-user pubkey BEFORE hydration launches. The
|
||||
// hydration coroutine below reads the local relay store on
|
||||
// Dispatchers.IO and calls consumeContactList; without this
|
||||
// ordering, a cached self kind-3 would be stamped without
|
||||
// updating _followedUsers, and a later relay retry of the
|
||||
// same event would be rejected by the createdAt gate,
|
||||
// leaving the follow list empty and FollowAction.follow
|
||||
// publishing a fresh kind-3 that wipes the real one.
|
||||
// See commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
|
||||
// (Fix 1).
|
||||
localCache.accountPubkey = currentPubKey
|
||||
// Open local relay store for the current account and hydrate cache
|
||||
localRelayStore.openForAccount(currentPubKey)
|
||||
localRelayMaintenance.start()
|
||||
@@ -1261,6 +1283,7 @@ private fun AppInner(
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
indexRelaysStore = indexRelaysStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
appScope = scope,
|
||||
torStatus = currentTorStatus,
|
||||
@@ -1383,6 +1406,7 @@ fun MainContent(
|
||||
account: AccountState.LoggedIn,
|
||||
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
indexRelaysStore: com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays,
|
||||
nip11Fetcher: Nip11Fetcher,
|
||||
appScope: CoroutineScope,
|
||||
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
|
||||
@@ -1414,6 +1438,14 @@ fun MainContent(
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays)
|
||||
}
|
||||
|
||||
// When iAccount is replaced (account switch), the previous WoTService's
|
||||
// internal writer coroutine + ops Channel would otherwise leak — the
|
||||
// outer `scope` lives for the whole session. Close the previous
|
||||
// instance on dispose so account-switch is a clean teardown.
|
||||
DisposableEffect(iAccount) {
|
||||
onDispose { iAccount.wotService.close() }
|
||||
}
|
||||
|
||||
// Follow Packs state — single per-account holder for Discover + sidebar + naddr cards
|
||||
val followPacksState =
|
||||
remember(iAccount, localCache, relayManager, scope) {
|
||||
@@ -1426,13 +1458,14 @@ fun MainContent(
|
||||
)
|
||||
}
|
||||
|
||||
// Aggregated relay categories (feed, notifications, search, DM)
|
||||
// Aggregated relay categories (feed, notifications, search, DM, index)
|
||||
val relayCategories =
|
||||
remember(iAccount.nip65RelayList, accountRelays, relayManager) {
|
||||
remember(iAccount.nip65RelayList, accountRelays, relayManager, indexRelaysStore) {
|
||||
DesktopRelayCategories(
|
||||
nip65State = iAccount.nip65RelayList,
|
||||
accountRelays = accountRelays,
|
||||
connectedRelays = relayManager.connectedRelays,
|
||||
indexRelaysStore = indexRelaysStore,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
@@ -1677,6 +1710,71 @@ fun MainContent(
|
||||
relayHealthStore.scanNow()
|
||||
}
|
||||
|
||||
// Web-of-Trust: pubkey is already bound by the outer LaunchedEffect
|
||||
// that also gates hydration ordering (see the LoggedIn branch above).
|
||||
// This effect re-asserts the binding to cover the (rare) case where
|
||||
// MainContent's `account` diverges from the outer accountState mid-
|
||||
// recomposition; it's idempotent when they already match.
|
||||
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. Under the
|
||||
// outbox model (PR #3483 review directive from Vitor) kind-3
|
||||
// fetch goes to each author's declared write relays instead of a
|
||||
// static index-relay broadcast — the OutboxDispatcher does the
|
||||
// NIP-65 discovery, transposes with RelayListRecommendationProcessor
|
||||
// and issues per-outbox-relay REQs. Falls back to index relays
|
||||
// for authors that never returned a 10002.
|
||||
launch {
|
||||
localCache.followedUsers.collect { follows ->
|
||||
iAccount.wotService.onFollowSetChange(follows, account.pubKeyHex)
|
||||
when {
|
||||
iAccount.wotService.isDisabled.value -> {
|
||||
// Guardrail — mega-follow accounts skip WoT
|
||||
// entirely so we don't dispatch a batch that
|
||||
// would be discarded anyway.
|
||||
iAccount.wotService.markReadyOnce()
|
||||
}
|
||||
follows.isEmpty() -> {
|
||||
iAccount.wotService.markReadyOnce()
|
||||
}
|
||||
else -> {
|
||||
launch {
|
||||
val result = subscriptionsCoordinator.loadKind3ViaOutbox(follows)
|
||||
Log.d("WotOutbox") {
|
||||
"fetchKind3Only authors=${result.authorsRequested} " +
|
||||
"covered=${result.outboxCoveredAuthors} " +
|
||||
"fallback=${result.fallbackAuthors} " +
|
||||
"kind10002=${result.kind10002Received} " +
|
||||
"kind3=${result.kind3Received}"
|
||||
}
|
||||
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,
|
||||
@@ -1685,6 +1783,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()) {
|
||||
|
||||
Vendored
+95
-8
@@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -66,6 +67,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -91,6 +93,27 @@ class DesktopLocalCache : ICacheProvider {
|
||||
private val _followedUsers = MutableStateFlow<Set<HexKey>>(emptySet())
|
||||
val followedUsers: StateFlow<Set<HexKey>> = _followedUsers.asStateFlow()
|
||||
|
||||
/**
|
||||
* Active user's pubkey (hex). Set from Main.kt on login. When set, only
|
||||
* kind-3 events from this pubkey update [_followedUsers] and
|
||||
* [lastContactListEvent]. Other users' kind-3 events still flow through
|
||||
* [contactListEvents] for consumers like the WoT service.
|
||||
*/
|
||||
@Volatile
|
||||
var accountPubkey: HexKey? = null
|
||||
|
||||
/**
|
||||
* Fires for every accepted kind-3 event (both the active user's and
|
||||
* other users' — filtered downstream). Buffered so slow consumers don't
|
||||
* block the consume path.
|
||||
*/
|
||||
private val _contactListEvents =
|
||||
MutableSharedFlow<ContactListEvent>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val contactListEvents: SharedFlow<ContactListEvent> = _contactListEvents.asSharedFlow()
|
||||
|
||||
/** Increments on each metadata update — observe to recompose when user names change. */
|
||||
private val _metadataVersion = MutableStateFlow(0L)
|
||||
val metadataVersion: StateFlow<Long> = _metadataVersion.asStateFlow()
|
||||
@@ -281,11 +304,43 @@ class DesktopLocalCache : ICacheProvider {
|
||||
consumeComment(event, relay)
|
||||
}
|
||||
|
||||
is AdvertisedRelayListEvent -> {
|
||||
consumeAdvertisedRelayList(event, relay)
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a kind 10002 (NIP-65) advertised relay list event. Stores
|
||||
* the newest per-author copy in [addressableNotes] so the outbox
|
||||
* dispatcher can look up each follow's declared write relays without
|
||||
* a fresh REQ. Emits nothing to the event stream — the UI doesn't
|
||||
* render kind 10002s directly.
|
||||
*/
|
||||
private fun consumeAdvertisedRelayList(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean {
|
||||
val addressableNote = getOrCreateAddressableNote(event.address())
|
||||
val existing = addressableNote.event
|
||||
if (existing != null && existing.createdAt >= event.createdAt) return false
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
addressableNote.loadEvent(event, author, emptyList())
|
||||
relay?.let { addressableNote.addRelay(it) }
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached kind-10002 event for [pubkey], if any. Used by the
|
||||
* outbox dispatcher to skip a Phase-1 REQ for authors whose write-relay
|
||||
* list is already in the store (from a previous session's local relay
|
||||
* hydration or an in-session discovery).
|
||||
*/
|
||||
fun cachedAdvertisedRelayList(pubkey: HexKey): AdvertisedRelayListEvent? = addressableNotes.get(AdvertisedRelayListEvent.createAddress(pubkey).toValue())?.event as? AdvertisedRelayListEvent
|
||||
|
||||
/**
|
||||
* Consumes a kind 1 text note event.
|
||||
* Creates/updates Note in cache and links reply relationships.
|
||||
@@ -472,19 +527,47 @@ class DesktopLocalCache : ICacheProvider {
|
||||
|
||||
/**
|
||||
* Consumes a kind 3 contact list event (replaceable).
|
||||
* Updates the cached followedUsers set.
|
||||
*
|
||||
* Tracks the newest kind-3 per author (not a single global scalar) so
|
||||
* ingesting other users' follow lists (e.g. for WoT scoring) doesn't
|
||||
* corrupt the active user's state. Only the active user's kind-3 updates
|
||||
* [_followedUsers] / [lastContactListEvent]. Every accepted event fans
|
||||
* out on [_contactListEvents] for downstream consumers.
|
||||
*/
|
||||
private var lastContactListCreatedAt = 0L
|
||||
private val lastContactListByAuthor = ConcurrentHashMap<HexKey, Long>()
|
||||
|
||||
var lastContactListEvent: ContactListEvent? = null
|
||||
private set
|
||||
|
||||
private fun consumeContactList(event: ContactListEvent): Boolean {
|
||||
// Replaceable event — only accept newer contact lists
|
||||
if (event.createdAt <= lastContactListCreatedAt) return false
|
||||
lastContactListCreatedAt = event.createdAt
|
||||
lastContactListEvent = event
|
||||
_followedUsers.value = event.verifiedFollowKeySet()
|
||||
// Replaceable event — only accept newer contact lists per author.
|
||||
val prev = lastContactListByAuthor[event.pubKey] ?: 0L
|
||||
if (event.createdAt <= prev) return false
|
||||
|
||||
// Stamp lastContactListByAuthor *only* on branches where we know
|
||||
// whether this event is the active user's own kind-3. If accountPubkey
|
||||
// hasn't been bound yet (login/hydration ordering window), skip the
|
||||
// stamp entirely so a later relay retry — after Main.kt binds
|
||||
// accountPubkey — is not rejected by the createdAt gate. The
|
||||
// _followedUsers state remains untouched in that case; downstream
|
||||
// consumers still get the fan-out via _contactListEvents (WoT etc).
|
||||
val currentAccountPubkey = accountPubkey
|
||||
when {
|
||||
event.pubKey == currentAccountPubkey -> {
|
||||
lastContactListEvent = event
|
||||
_followedUsers.value = event.verifiedFollowKeySet()
|
||||
lastContactListByAuthor[event.pubKey] = event.createdAt
|
||||
}
|
||||
currentAccountPubkey != null -> {
|
||||
// Known-not-self: safe to stamp.
|
||||
lastContactListByAuthor[event.pubKey] = event.createdAt
|
||||
}
|
||||
else -> {
|
||||
// accountPubkey not bound yet — cannot tell if this is self.
|
||||
// Defer stamping so the relay retry that arrives after bind
|
||||
// will still populate _followedUsers.
|
||||
}
|
||||
}
|
||||
|
||||
// Store in addressableNotes too — Kind3FollowListState.getFollowListEvent
|
||||
// reads from getOrCreateAddressableNote(...) and would otherwise see a
|
||||
@@ -493,6 +576,8 @@ class DesktopLocalCache : ICacheProvider {
|
||||
val addressableNote = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
addressableNote.loadEvent(event, author, emptyList())
|
||||
|
||||
_contactListEvents.tryEmit(event)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -786,7 +871,9 @@ class DesktopLocalCache : ICacheProvider {
|
||||
followerCounts.clear()
|
||||
followingCounts.clear()
|
||||
notesByAuthor.clear()
|
||||
lastContactListCreatedAt = 0L
|
||||
lastContactListByAuthor.clear()
|
||||
lastContactListEvent = null
|
||||
accountPubkey = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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,
|
||||
|
||||
+29
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
|
||||
import com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays
|
||||
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
@@ -47,6 +49,13 @@ class DesktopRelayCategories(
|
||||
accountRelays: DesktopAccountRelays,
|
||||
/** Reactive connected relay set — used as fallback when NIP-65 is empty */
|
||||
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
/**
|
||||
* Shared index-relay preference — app-global, backed by
|
||||
* [PreferencesIndexRelays] and visible to `amy` via the same
|
||||
* Preferences node. Used by [indexRelays] and by
|
||||
* `Main.kt` when constructing the subscriptions coordinator.
|
||||
*/
|
||||
private val indexRelaysStore: PreferencesIndexRelays,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
/** Default relays — ALWAYS populated, used as stateIn initial value */
|
||||
@@ -99,6 +108,26 @@ class DesktopRelayCategories(
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
|
||||
|
||||
/**
|
||||
* Index relays: user override → [PreferencesIndexRelays.DEFAULT_INDEX_RELAYS].
|
||||
*
|
||||
* Unlike [feedRelays] / [notificationRelays] / [dmRelays] this
|
||||
* category does *not* combine with connected/NIP-65 sets — it's a
|
||||
* curated user choice about where to look up metadata and follow
|
||||
* lists, not a "what's actually reachable right now" derived set.
|
||||
* No debounce needed: writes are gated by settings-screen UI, not
|
||||
* fanned in from a subscription pipeline.
|
||||
*/
|
||||
val indexRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
indexRelaysStore.relays
|
||||
.map { it.ifEmpty { PreferencesIndexRelays.DEFAULT_INDEX_RELAYS } }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, indexRelaysStore.effective())
|
||||
|
||||
fun setIndexRelays(new: Set<NormalizedRelayUrl>) {
|
||||
indexRelaysStore.setRelays(new)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DEFAULT_SEARCH_RELAYS = DefaultSearchRelayList
|
||||
}
|
||||
|
||||
+71
@@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoo
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter
|
||||
import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert
|
||||
import com.vitorpamplona.amethyst.commons.wot.OutboxCacheGateway
|
||||
import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -33,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -98,6 +101,51 @@ class DesktopRelaySubscriptionsCoordinator(
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Bridges [OutboxDispatcher] to [DesktopLocalCache]. Every event the
|
||||
* dispatcher receives goes through [DesktopLocalCache.consume] so it
|
||||
* lands in the same code path as events arriving from feed
|
||||
* subscriptions — kind-10002 caches into `addressableNotes`; kind-0
|
||||
* updates the user metadata; kind-3 fans out through
|
||||
* `_contactListEvents` for the WoT service.
|
||||
*/
|
||||
private val outboxGateway =
|
||||
object : OutboxCacheGateway {
|
||||
override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? = localCache.cachedAdvertisedRelayList(pubkey)
|
||||
|
||||
override fun onOutboxDiscovered(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
localCache.consume(event, relay)
|
||||
}
|
||||
|
||||
override fun onDiscoveredEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
localCache.consume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-65 outbox model for kind-0 and kind-3 fetching. Per PR #3483
|
||||
* review directive from Vitor: index relays discover each author's
|
||||
* write-relay list, then kind-0/kind-3 REQs go to that author's
|
||||
* declared write relays. See [OutboxDispatcher] for the pipeline.
|
||||
*
|
||||
* Kept as a val (not lazy) because [clear] must reset its dedup
|
||||
* markers on account switch. The dispatcher itself is stateless
|
||||
* across accounts as long as `clear()` is called.
|
||||
*/
|
||||
val outboxDispatcher =
|
||||
OutboxDispatcher(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = { indexRelays },
|
||||
gateway = outboxGateway,
|
||||
)
|
||||
|
||||
// Event bundler: batches consumed notes before emitting to SharedFlow
|
||||
// 250ms for desktop (Android uses 1000ms to save battery)
|
||||
private val eventBundler =
|
||||
@@ -264,6 +312,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 */
|
||||
@@ -374,10 +435,20 @@ class DesktopRelaySubscriptionsCoordinator(
|
||||
|
||||
unsubscribeFromDms()
|
||||
feedMetadata.clear()
|
||||
outboxDispatcher.clear()
|
||||
rateLimiter.reset()
|
||||
cleanupJob?.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch kind-3 (follow lists) for [pubkeys] via each author's outbox
|
||||
* relay per NIP-65 (see [OutboxDispatcher]). Suspends until every
|
||||
* phase EOSEs or times out. Callers typically launch this on a
|
||||
* scope-owned coroutine and mark the WoT service ready in the
|
||||
* continuation. Returns per-phase counters for observability.
|
||||
*/
|
||||
suspend fun loadKind3ViaOutbox(pubkeys: Set<HexKey>): OutboxDispatcher.Result = outboxDispatcher.fetchKind3Only(pubkeys)
|
||||
|
||||
// ----- Memory Cleanup -----
|
||||
|
||||
private val memoryBean = ManagementFactory.getMemoryMXBean()
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* 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.relay
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Editor for the shared "index relays" — the set used by
|
||||
* `FeedMetadataCoordinator` (Desktop) and `amy wot sync` (CLI) to fetch
|
||||
* profile metadata (kind 0) and follow lists (kind 3).
|
||||
*
|
||||
* Matches the buffered-Save UX of the sibling relay editors
|
||||
* (Search / DM / Blocked). Add/Remove mutate a local buffer; Save
|
||||
* commits the buffer to `PreferencesIndexRelays`. Reset restores the
|
||||
* built-in defaults into the buffer (still requires Save to persist).
|
||||
*
|
||||
* The running Desktop coordinator continues using its constructor-time
|
||||
* snapshot until the app is relaunched, so persisted changes take effect
|
||||
* on next launch.
|
||||
*/
|
||||
@Composable
|
||||
fun IndexRelaysEditor(
|
||||
categories: DesktopRelayCategories,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val persisted by categories.indexRelays.collectAsState()
|
||||
val localRelays = remember { mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>() }
|
||||
var newRelayUrl by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var savedMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(persisted) {
|
||||
localRelays.clear()
|
||||
localRelays.addAll(persisted.sortedBy { it.url })
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Relays queried for profile metadata and follow lists (Web-of-Trust). Changes take effect on next relaunch. Shared with the `amy` CLI.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newRelayUrl,
|
||||
onValueChange = {
|
||||
newRelayUrl = it
|
||||
error = null
|
||||
},
|
||||
label = { Text("wss://relay.example.com") },
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
supportingText = error?.let { { Text(it) } },
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.key == Key.Enter && event.type == KeyEventType.KeyDown) {
|
||||
error = tryAddSimpleRelay(newRelayUrl, localRelays)
|
||||
if (error == null) newRelayUrl = ""
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
error = tryAddSimpleRelay(newRelayUrl, localRelays)
|
||||
if (error == null) newRelayUrl = ""
|
||||
},
|
||||
) {
|
||||
Icon(MaterialSymbols.Add, contentDescription = "Add relay")
|
||||
}
|
||||
}
|
||||
|
||||
if (localRelays.isNotEmpty()) {
|
||||
Text(
|
||||
"${localRelays.size} relay(s) configured",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
localRelays.toList().forEach { url ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
url.displayUrl(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
IconButton(onClick = { localRelays.remove(url) }, modifier = Modifier.size(28.dp)) {
|
||||
Icon(
|
||||
MaterialSymbols.Close,
|
||||
contentDescription = "Remove",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (newRelayUrl.isNotBlank()) {
|
||||
val addError = tryAddSimpleRelay(newRelayUrl, localRelays)
|
||||
if (addError != null) {
|
||||
error = addError
|
||||
return@Button
|
||||
}
|
||||
newRelayUrl = ""
|
||||
}
|
||||
if (localRelays.isEmpty()) {
|
||||
error = "Add at least one relay before saving (or Reset to defaults)"
|
||||
return@Button
|
||||
}
|
||||
categories.setIndexRelays(localRelays.toSet())
|
||||
scope.launch {
|
||||
savedMessage = "Saved ${localRelays.size} relay(s) — restart to apply"
|
||||
delay(3000)
|
||||
savedMessage = null
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
localRelays.clear()
|
||||
localRelays.addAll(
|
||||
PreferencesIndexRelays.DEFAULT_INDEX_RELAYS.sortedBy { it.url },
|
||||
)
|
||||
error = null
|
||||
},
|
||||
) {
|
||||
Text("Reset to defaults")
|
||||
}
|
||||
|
||||
savedMessage?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -162,6 +162,18 @@ fun RelayConfigTab(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// 6. Index Relays — app-global (not per-account); shared with `amy wot sync`.
|
||||
CollapsibleSection(
|
||||
title = "Index Relays",
|
||||
description = "Where the Web-of-Trust and profile lookups fetch kind 0/3 events",
|
||||
) {
|
||||
IndexRelaysEditor(
|
||||
categories = LocalRelayCategories.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
@@ -55,12 +55,16 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
|
||||
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
|
||||
import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady
|
||||
import com.vitorpamplona.amethyst.commons.wot.LocalWoTService
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadge
|
||||
import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
|
||||
@@ -116,6 +120,7 @@ fun SearchResultsList(
|
||||
UserSearchCard(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
badge = wotBadgeFor(user.pubkeyHex),
|
||||
)
|
||||
}
|
||||
if (people.size > 5) {
|
||||
@@ -126,6 +131,7 @@ fun SearchResultsList(
|
||||
UserSearchCard(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
badge = wotBadgeFor(user.pubkeyHex),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -286,6 +292,34 @@ fun SearchResultsList(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a WoT-badge lambda for the given pubkey, or null when the
|
||||
* badge should be hidden. Same gates as [WoTBadgedAvatar]:
|
||||
* - WoT service is provided
|
||||
* - initial batch fetch has finished (or 2 s startup timeout fired)
|
||||
* - the pubkey is not exempt (self or already followed)
|
||||
* - the score is > 0
|
||||
* Inlined here (rather than wrapped in a new composable) because it's
|
||||
* only used at the two SearchResultsList person-result call sites.
|
||||
*/
|
||||
@Composable
|
||||
private fun wotBadgeFor(userHex: String): (@Composable androidx.compose.foundation.layout.BoxScope.() -> Unit)? {
|
||||
val service = LocalWoTService.current
|
||||
val ready = LocalWoTReady.current
|
||||
val exempt = LocalSpamExemptKeys.current
|
||||
val score =
|
||||
if (service != null && ready && userHex !in exempt) {
|
||||
service.scores[userHex] ?: 0
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return if (score > 0) {
|
||||
{ WoTBadge(count = score, modifier = Modifier.align(Alignment.BottomEnd)) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortableHeader(
|
||||
title: String,
|
||||
|
||||
Vendored
+8
-8
@@ -152,7 +152,7 @@ class CoordinatorPipelineTest {
|
||||
fun `consumeEvent routes text note into cache and triggers ViewModel update`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
|
||||
@@ -191,7 +191,7 @@ class CoordinatorPipelineTest {
|
||||
fun `consumeEvent updates lastEventAt timestamp`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
assertTrue(coordinator.lastEventAt.value == null, "lastEventAt should be null initially")
|
||||
@@ -217,7 +217,7 @@ class CoordinatorPipelineTest {
|
||||
fun `contact list consumed via coordinator updates followedUsers`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
val contactEvent =
|
||||
@@ -244,7 +244,7 @@ class CoordinatorPipelineTest {
|
||||
fun `following feed shows notes after contact list and text notes arrive via coordinator`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
// Step 1: Contact list arrives
|
||||
@@ -294,7 +294,7 @@ class CoordinatorPipelineTest {
|
||||
fun `following feed remains empty when no contact list has been consumed`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
// No contact list consumed — followedUsers is empty
|
||||
@@ -334,7 +334,7 @@ class CoordinatorPipelineTest {
|
||||
fun `duplicate events are not double-counted in feed`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
|
||||
@@ -372,7 +372,7 @@ class CoordinatorPipelineTest {
|
||||
fun `requestInteractions opens subscription on client`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val (coordinator, client) = createCoordinator(cache, scope)
|
||||
|
||||
val noteIds = listOf("n1".padEnd(64, '0'))
|
||||
@@ -393,7 +393,7 @@ class CoordinatorPipelineTest {
|
||||
fun `requestInteractions with empty noteIds returns without opening subscription`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val (coordinator, client) = createCoordinator(cache, scope)
|
||||
|
||||
coordinator.requestInteractions(emptyList(), setOf(relayUrl))
|
||||
|
||||
Vendored
+90
-27
@@ -124,7 +124,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `consume text note creates Note in cache`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val event = textNote("note1".padEnd(64, '0'), userPubKey)
|
||||
|
||||
val consumed = cache.consume(event, relayUrl, wasVerified = true)
|
||||
@@ -137,7 +137,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `consume same note twice returns false`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val event = textNote("note1".padEnd(64, '0'), userPubKey)
|
||||
|
||||
cache.consume(event, relayUrl, wasVerified = true)
|
||||
@@ -148,7 +148,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `consume contact list updates followedUsers`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey))
|
||||
|
||||
cache.consume(event, relayUrl, wasVerified = true)
|
||||
@@ -158,7 +158,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `newer contact list replaces older`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100)
|
||||
val newer =
|
||||
contactList(
|
||||
@@ -176,7 +176,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `older contact list is rejected`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val newer = contactList("cl2".padEnd(64, '0'), userPubKey, listOf(followedPubKey, unfollowedPubKey), createdAt = 200)
|
||||
val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100)
|
||||
|
||||
@@ -192,7 +192,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `consume reaction links to target note`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val noteId = "note1".padEnd(64, '0')
|
||||
val note = textNote(noteId, userPubKey)
|
||||
val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId)
|
||||
@@ -204,6 +204,69 @@ class DesktopCachePipelineTest {
|
||||
assertTrue(cachedNote.countReactions() > 0, "Note should have reactions after consuming reaction event")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1b. accountPubkey race regression (PR #3483 review finding 1)
|
||||
//
|
||||
// Reproduces the "hydration before pubkey bind" data-loss race: if a
|
||||
// self kind-3 arrives while accountPubkey is null (e.g. from disk during
|
||||
// login), the cache used to stamp lastContactListByAuthor without
|
||||
// populating _followedUsers. Then the same event arriving from a relay
|
||||
// AFTER pubkey binding was rejected by the createdAt gate, leaving the
|
||||
// follow set empty. FollowAction.follow then called createFromScratch
|
||||
// and wiped the real follow list. Fix: skip the stamp when
|
||||
// accountPubkey is null so the later relay retry can populate cleanly.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `self kind-3 hydrated before pubkey bind does not poison later relay retry`() {
|
||||
val cache = DesktopLocalCache() // accountPubkey deliberately unset
|
||||
val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100)
|
||||
|
||||
// Phase A — hydration path: consume with accountPubkey unbound.
|
||||
cache.consume(event, relayUrl, wasVerified = true)
|
||||
assertEquals(
|
||||
emptySet(),
|
||||
cache.followedUsers.value,
|
||||
"Follow set stays empty until accountPubkey is bound",
|
||||
)
|
||||
|
||||
// Phase B — Main.kt binds accountPubkey.
|
||||
cache.accountPubkey = userPubKey
|
||||
|
||||
// Phase C — relay replay of the SAME event. Must NOT be rejected by
|
||||
// the createdAt gate; must populate _followedUsers.
|
||||
cache.consume(event, relayUrl, wasVerified = true)
|
||||
assertEquals(
|
||||
setOf(followedPubKey),
|
||||
cache.followedUsers.value,
|
||||
"Later relay retry of same self kind-3 must populate follow set",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-self kind-3 hydrated before pubkey bind still stamps and does not touch followedUsers`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val other = contactList("cl2".padEnd(64, '0'), followedPubKey, listOf(unfollowedPubKey), createdAt = 100)
|
||||
|
||||
cache.consume(other, relayUrl, wasVerified = true)
|
||||
cache.accountPubkey = userPubKey
|
||||
|
||||
// followedUsers is for the active user only; a non-self kind-3
|
||||
// should never touch it. followedUsers must stay empty.
|
||||
assertEquals(emptySet(), cache.followedUsers.value)
|
||||
|
||||
// And the newer version of the same non-self kind-3 must still be
|
||||
// accepted (stamping happened in the known-not-self branch would
|
||||
// reject; here we skipped stamping when pubkey was null so a
|
||||
// newer replay lands cleanly).
|
||||
val newer = contactList("cl2b".padEnd(64, '0'), followedPubKey, listOf(unfollowedPubKey, userPubKey), createdAt = 200)
|
||||
cache.consume(newer, relayUrl, wasVerified = true)
|
||||
// No direct assertion on internal state; the fact that this
|
||||
// returns without throwing + does not affect followedUsers is
|
||||
// the invariant. The next line documents intent.
|
||||
assertEquals(emptySet(), cache.followedUsers.value)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Event stream emission
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -211,7 +274,7 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `consume emits to eventStream`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val collected = mutableListOf<Set<Note>>()
|
||||
|
||||
val job =
|
||||
@@ -240,7 +303,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `GlobalFeedFilter includes all text notes`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val filter = DesktopGlobalFeedFilter(cache)
|
||||
|
||||
// Add notes from different authors
|
||||
@@ -254,7 +317,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `FollowingFeedFilter only includes notes from followed users`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
|
||||
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true)
|
||||
@@ -269,7 +332,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `FollowingFeedFilter returns empty when no follows`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl, wasVerified = true)
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { emptySet() }
|
||||
@@ -280,7 +343,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `ProfileFeedFilter only shows notes from target pubkey`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true)
|
||||
cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl, wasVerified = true)
|
||||
|
||||
@@ -293,7 +356,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `ThreadFilter returns root and replies`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val rootId = "root".padEnd(64, '0')
|
||||
val replyId = "reply".padEnd(64, '0')
|
||||
|
||||
@@ -308,7 +371,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `NotificationFeedFilter shows events tagging user`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val noteId = "note1".padEnd(64, '0')
|
||||
cache.consume(textNote(noteId, userPubKey, createdAt = 100), relayUrl, wasVerified = true)
|
||||
|
||||
@@ -333,7 +396,7 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `ViewModel starts in Loading then transitions to Loaded after refresh`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true)
|
||||
|
||||
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
|
||||
@@ -352,7 +415,7 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `ViewModel shows Empty when cache has no matching notes`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
|
||||
|
||||
waitForBundler()
|
||||
@@ -365,7 +428,7 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `ViewModel updates when new notes arrive via eventStream`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
|
||||
|
||||
waitForBundler()
|
||||
@@ -388,7 +451,7 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `Following ViewModel only shows followed users notes via eventStream`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
@@ -418,7 +481,7 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `Following ViewModel feed is empty when followedUsers is empty`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
// No contact list consumed — followedUsers remains empty
|
||||
|
||||
val e1 = textNote("n1".padEnd(64, '0'), followedPubKey)
|
||||
@@ -441,7 +504,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `clear resets all cache state`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true)
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
|
||||
|
||||
@@ -458,7 +521,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `global feed is sorted newest first`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl, wasVerified = true)
|
||||
cache.consume(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl, wasVerified = true)
|
||||
cache.consume(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl, wasVerified = true)
|
||||
@@ -476,7 +539,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `consumeMetadata updates user info`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val metadata =
|
||||
com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent(
|
||||
id = "meta1".padEnd(64, '0'),
|
||||
@@ -501,7 +564,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `GlobalFeedFilter applyFilter only accepts TextNoteEvents`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val filter = DesktopGlobalFeedFilter(cache)
|
||||
|
||||
// Create a text note
|
||||
@@ -522,7 +585,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `FollowingFeedFilter applyFilter respects follow set`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
@@ -547,7 +610,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `profile follower count is cached and survives clear of note cache`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
|
||||
assertEquals(0, cache.getCachedFollowerCount(userPubKey))
|
||||
|
||||
@@ -561,7 +624,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `profile following count is cached`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
|
||||
cache.cacheFollowingCount(userPubKey, 150)
|
||||
assertEquals(150, cache.getCachedFollowingCount(userPubKey))
|
||||
@@ -569,7 +632,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `clear resets profile count caches`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.cacheFollowerCount(userPubKey, 42)
|
||||
cache.cacheFollowingCount(userPubKey, 150)
|
||||
|
||||
@@ -581,7 +644,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
@Test
|
||||
fun `metadata is available from cache after consumption`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val metadata =
|
||||
com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent(
|
||||
id = "meta1".padEnd(64, '0'),
|
||||
|
||||
+5
-5
@@ -140,7 +140,7 @@ class LocalRelayStoreHydrationTest {
|
||||
@Test
|
||||
fun hydratingAnEmptyDatabaseSucceedsAndLeavesCacheEmpty() =
|
||||
runTest {
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
|
||||
val store = newStore()
|
||||
try {
|
||||
store.hydrate(cache)
|
||||
@@ -165,7 +165,7 @@ class LocalRelayStoreHydrationTest {
|
||||
// empty when phase 2 ran and the metadata would never load.
|
||||
seedDatabase(listOf(followeeMetadata, contactList))
|
||||
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
|
||||
val store = newStore()
|
||||
|
||||
// Pin a strong reference to the followee's User for the duration of the
|
||||
@@ -206,7 +206,7 @@ class LocalRelayStoreHydrationTest {
|
||||
val recentNote = makeTextNote(author, "recent", createdAt = nowSeconds() - 3600)
|
||||
seedDatabase(listOf(recentNote))
|
||||
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
|
||||
val store = newStore()
|
||||
try {
|
||||
store.hydrate(cache)
|
||||
@@ -226,7 +226,7 @@ class LocalRelayStoreHydrationTest {
|
||||
val oldNote = makeTextNote(author, "stale", createdAt = eightDaysAgo)
|
||||
seedDatabase(listOf(oldNote))
|
||||
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
|
||||
val store = newStore()
|
||||
try {
|
||||
store.hydrate(cache)
|
||||
@@ -256,7 +256,7 @@ class LocalRelayStoreHydrationTest {
|
||||
val note = makeTextNote(author, "round-trip")
|
||||
seedDatabase(listOf(note))
|
||||
|
||||
val cache = DesktopLocalCache()
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
|
||||
val store = newStore()
|
||||
try {
|
||||
store.hydrate(cache)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,701 @@
|
||||
---
|
||||
title: WoT follow-ups — search-result badges + shared index relays
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-07-01
|
||||
origin: docs/plans/2026-07-01-feat-desktop-wot-score-plan.md
|
||||
deepened: 2026-07-01
|
||||
---
|
||||
|
||||
# WoT follow-ups — search-result badges + shared index relays
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-07-01 (same day as plan write).
|
||||
|
||||
**Agents used:** code-simplicity-reviewer, targeted repo verification sweep.
|
||||
|
||||
### Key corrections vs first draft
|
||||
|
||||
1. **Split into two PRs.** Item 1 (search badges) is mechanical and has
|
||||
zero coupling to Items 2+3. Ship it alone. Items 2 + 3 stay bundled
|
||||
because the UI (Item 3) is the write path for the persistence
|
||||
(Item 2) — reviewing them separately means reviewing dead code or a
|
||||
headless feature.
|
||||
2. **App-global (not per-account) index-relay override.** First draft
|
||||
made this per-account to match `searchRelays` / `dmRelays`. But
|
||||
`searchRelays` / `dmRelays` are per-account because they're NIP-51 /
|
||||
NIP-17 identity-scoped semantics; index relays are a user preference
|
||||
about where profile-metadata lookups go, and users have a single
|
||||
preferred set regardless of which account they're logged into.
|
||||
App-global halves the API surface and matches user mental model.
|
||||
3. **`PreferencesIndexRelays` in `commons/jvmMain/`, not extending
|
||||
`DesktopAccountRelays`.** Verification found `DesktopAccountRelays`
|
||||
uses `Preferences.userNodeForPackage(DesktopAccountRelays::class.java)`,
|
||||
which is a per-class node — **not visible to amy** running from a
|
||||
different classpath. To achieve the "one truth for Desktop and amy"
|
||||
goal, the shared node must be an explicit
|
||||
`Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`,
|
||||
which is exactly the pattern `PreferencesHashtagSpamSettings` uses.
|
||||
New small class mirrors that shape.
|
||||
4. **Drop `WoTBadgedSearchCard`.** Only two call sites; the 6-line
|
||||
score computation inlines cleanly. New wrapper composable earns its
|
||||
keep at 3+ call sites, not 2.
|
||||
5. **Drop `DefaultIndexRelays.kt` in commons.** Speculative — no
|
||||
Android caller. amy can duplicate the 4 URLs (they change ~never)
|
||||
or read a single constant from a shared location. Extracting to
|
||||
commons is architectural neatness without a consumer.
|
||||
6. **`DesktopRelayCategories.indexRelays` uses `override ?: default`
|
||||
only.** Not the full combine used by `searchRelays` (which
|
||||
intersects with NIP-65 discovery). Index relays are a curated user
|
||||
choice, not a "what's actually reachable right now" derived set. No
|
||||
`debounce` / `stateIn` combine needed — a straight-through StateFlow
|
||||
from the Preferences read is enough.
|
||||
7. **Drop "Reset to defaults" button in Item 3.** Removing all relays
|
||||
from the UI already falls back to `DefaultRelays.RELAYS`. Delete-all
|
||||
IS the reset.
|
||||
8. **Drop integration scenarios 2 and 6.** #2 (badge respects
|
||||
exemptions) is covered by existing `WoTBadgedAvatar` tests — same
|
||||
code path. #6 (empty override falls back) is a single unit test on
|
||||
`PreferencesIndexRelays`, not a manual scenario.
|
||||
9. **`RelaySettingsScreen` current content** was mischaracterised — it
|
||||
already has 6 sections (Wallet Connect, Media Server, Image
|
||||
Compression, Tor, Namecoin, Local Relay, Content Filters). Index
|
||||
Relays fits between Local Relay and Content Filters (both have
|
||||
dividers).
|
||||
10. **Adopt-not-in-this-PR discovery: `commons/AmethystDefaults.kt`
|
||||
already has `DefaultIndexerRelayList`** (Purple Pages, Coracle,
|
||||
etc). Desktop today uses the wrong list (`DefaultRelays.RELAYS` =
|
||||
general-purpose relays) for its index REQs. That's a real
|
||||
behavioural bug worth a separate ticket — not this one — because
|
||||
changing default index relays is a user-visible behaviour shift and
|
||||
deserves its own review.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Three small follow-ups to the just-shipped Web-of-Trust score feature
|
||||
(branch `feat/desktop-wot-score`, closed for manual testing):
|
||||
|
||||
1. **Badges on search-result person cards.** The main NoteCard header
|
||||
already renders `WoTBadgedAvatar`, but the Search screen's person
|
||||
picker uses a different composable (`UserSearchCard`) that doesn't
|
||||
currently accept a badge.
|
||||
2. **Unify amy `wot sync` with Desktop on the same relay set.** Desktop
|
||||
currently uses a hard-coded `DefaultRelays.RELAYS` list as its
|
||||
`indexRelays`; amy uses whatever the user's NIP-65 outbox/inbox lists
|
||||
contain. When the two disagree, `amy wot get` after `amy wot sync`
|
||||
returns a different score than the Desktop UI would compute.
|
||||
3. **Add an Index Relays section to the Relays settings screen** so
|
||||
users can customise which relays back both surfaces from one place.
|
||||
|
||||
**Shipping plan:** two PRs.
|
||||
|
||||
- **PR A — Search badges (Item 1).** ~40 LOC, one commons param
|
||||
addition, two Desktop call-site inline changes. Independent of the
|
||||
other work. Ships first.
|
||||
- **PR B — Shared index relays (Items 2 + 3).** Introduces a small
|
||||
Preferences-backed class in `commons/jvmMain/`, wires the coordinator
|
||||
to read from it, adds a settings-screen section, and updates
|
||||
`amy wot sync` to read the same node. ~300 LOC. Ships second.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Three concrete regressions/gaps from the manual-testing pass of the WoT
|
||||
PR:
|
||||
|
||||
- **Item 1.** When searching for a person in the Desktop search screen,
|
||||
their result card is a stranger 90% of the time (that's the point of
|
||||
searching), but there's no trust cue on the card. Users who find WoT
|
||||
badges useful on feed avatars want the same signal here.
|
||||
- **Item 2.** amy's `wot sync` uses `ctx.outboxRelays()` (NIP-65 write
|
||||
list) with a fall-back to inbox. Those are legitimate relays for
|
||||
publishing / receiving events, but they are *not* what Desktop uses
|
||||
to fetch profile metadata and follow lists — Desktop hits a
|
||||
hard-coded `indexRelays` set (nos.lol, nostr.wine,
|
||||
relay.noswhere.com, relay.primal.net today). Result: `amy wot get`
|
||||
after a fresh `amy wot sync` can produce a score that lags or
|
||||
diverges from the Desktop UI for the same account.
|
||||
- **Item 3.** The Relays settings screen already contains six
|
||||
sections; there's no UI to inspect or change which relays are
|
||||
considered "index relays" — the values live only in the hard-coded
|
||||
default list in `RelayStatus.kt`.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### PR A — Item 1: Badge slot on `UserSearchCard`
|
||||
|
||||
`UserSearchCard` in
|
||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt`
|
||||
gets an optional `badge` slot that forwards to its embedded
|
||||
`UserAvatar` (which already has the slot from the WoT PR):
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun UserSearchCard(
|
||||
user: User,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
badge: @Composable (BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
// Existing layout, unchanged, except:
|
||||
UserAvatar(
|
||||
userHex = user.pubkeyHex,
|
||||
pictureUrl = user.profilePicture(),
|
||||
size = 40.dp,
|
||||
contentDescription = stringResource(Res.string.accessibility_user_avatar),
|
||||
badge = badge,
|
||||
)
|
||||
// …rest of the Row unchanged
|
||||
}
|
||||
```
|
||||
|
||||
Backward compatible — default `null` means no visual change for callers
|
||||
that don't opt in. The layout impact is zero: `UserAvatar` handles the
|
||||
badge's `Box` overlay itself; the badge lives on the avatar's bottom-
|
||||
right corner, and the `ArrowForward` icon at the row's trailing edge
|
||||
doesn't collide with it.
|
||||
|
||||
Two Desktop call sites in
|
||||
`desktopApp/.../ui/search/SearchResultsList.kt:116,126` inline the score
|
||||
computation directly at the call:
|
||||
|
||||
```kotlin
|
||||
val service = LocalWoTService.current
|
||||
val ready = LocalWoTReady.current
|
||||
val exempt = LocalSpamExemptKeys.current
|
||||
val score = if (service != null && ready && user.pubkeyHex !in exempt) {
|
||||
service.scores[user.pubkeyHex] ?: 0
|
||||
} else 0
|
||||
|
||||
UserSearchCard(
|
||||
user = user,
|
||||
onClick = { … },
|
||||
badge = if (score > 0) {
|
||||
{ WoTBadge(count = score, modifier = Modifier.align(Alignment.BottomEnd)) }
|
||||
} else null,
|
||||
)
|
||||
```
|
||||
|
||||
The two sites are 4 lines apart; a small local `remember` block above
|
||||
them can factor the read if we want (optional micro-cleanup — not
|
||||
required).
|
||||
|
||||
**Not migrated in PR A:**
|
||||
|
||||
- `desktopApp/.../ui/chats/NewDmDialog.kt` (three sites) — the DM
|
||||
recipient picker. Same rationale as before: when picking a DM
|
||||
recipient you're already committing to messaging that person; a
|
||||
trust badge is more noise than signal. Add later if testing calls
|
||||
for it.
|
||||
|
||||
### PR B — Item 2: Shared `indexRelays` between Desktop and amy
|
||||
|
||||
#### Persist via `java.util.prefs`, node shared with amy
|
||||
|
||||
`Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`.
|
||||
Same JVM-user-wide `java.util.prefs` trick the hashtag-spam PR (#3431)
|
||||
uses — Desktop and amy running as the same OS user see the same node.
|
||||
|
||||
**Not stored per-account.** Users have a single preferred set of index
|
||||
relays regardless of which account is logged in. Halves the API
|
||||
surface and matches user intuition. If a user with two accounts
|
||||
genuinely needs separate index relays per account, we add per-account
|
||||
overlay later on demand — YAGNI now.
|
||||
|
||||
**Not persisted via `DesktopAccountRelays`.** That class uses
|
||||
`Preferences.userNodeForPackage(DesktopAccountRelays::class.java)`,
|
||||
which resolves to a per-class node that `cli/` running from a different
|
||||
classpath **would not see**. Extending it would give us Desktop-local
|
||||
config with no amy visibility — the opposite of what we want.
|
||||
|
||||
#### New shared class
|
||||
|
||||
`commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt`
|
||||
(new file, mirrors `PreferencesHashtagSpamSettings` shape):
|
||||
|
||||
```kotlin
|
||||
class PreferencesIndexRelays(
|
||||
private val prefs: Preferences =
|
||||
Preferences.userRoot().node(NODE_NAME),
|
||||
) {
|
||||
private val _relays =
|
||||
MutableStateFlow(parse(prefs.get(KEY_URLS, "")))
|
||||
val relays: StateFlow<Set<NormalizedRelayUrl>> = _relays.asStateFlow()
|
||||
|
||||
fun setRelays(new: Set<NormalizedRelayUrl>) {
|
||||
_relays.value = new
|
||||
prefs.put(KEY_URLS, new.joinToString(",") { it.url })
|
||||
}
|
||||
|
||||
/** Resolves the effective set — user override if non-empty, else defaults. */
|
||||
fun effective(): Set<NormalizedRelayUrl> =
|
||||
_relays.value.ifEmpty { DEFAULT_INDEX_RELAYS }
|
||||
|
||||
companion object {
|
||||
const val NODE_NAME = "com/vitorpamplona/amethyst/relays/index"
|
||||
const val KEY_URLS = "urls"
|
||||
|
||||
/**
|
||||
* Byte-for-byte identical to `DefaultRelays.RELAYS` at
|
||||
* `desktopApp/.../network/RelayStatus.kt`. Preserves current
|
||||
* behaviour for users who never open the settings UI.
|
||||
*
|
||||
* Note: `commons/AmethystDefaults.kt` also has
|
||||
* `DefaultIndexerRelayList` (Purple Pages, Coracle, …) which
|
||||
* is more purpose-built. Adopting it is a separate ticket —
|
||||
* see Out of Scope.
|
||||
*/
|
||||
val DEFAULT_INDEX_RELAYS: Set<NormalizedRelayUrl> = setOf(
|
||||
"wss://nos.lol",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.noswhere.com",
|
||||
"wss://relay.primal.net",
|
||||
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
|
||||
private fun parse(csv: String): Set<NormalizedRelayUrl> =
|
||||
csv.split(",")
|
||||
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
CSV serialisation matches what `DesktopAccountRelays` uses for its
|
||||
categories (`prefs.put(key, relays.joinToString(",") { it.url })`) — no
|
||||
JSON, no `Serializable`, no dependencies beyond `RelayUrlNormalizer`.
|
||||
|
||||
#### Desktop wiring
|
||||
|
||||
Add `indexRelays: StateFlow<Set<NormalizedRelayUrl>>` to
|
||||
`DesktopRelayCategories`, backed by the new class. Simple straight-
|
||||
through, no combine:
|
||||
|
||||
```kotlin
|
||||
class DesktopRelayCategories(
|
||||
// existing params
|
||||
private val indexRelaysStore: PreferencesIndexRelays,
|
||||
) {
|
||||
// existing categories…
|
||||
|
||||
val indexRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
indexRelaysStore.relays
|
||||
.map { it.ifEmpty { PreferencesIndexRelays.DEFAULT_INDEX_RELAYS } }
|
||||
.stateIn(scope, SharingStarted.Eagerly, indexRelaysStore.effective())
|
||||
|
||||
fun setIndexRelays(new: Set<NormalizedRelayUrl>) = indexRelaysStore.setRelays(new)
|
||||
}
|
||||
```
|
||||
|
||||
`Main.kt` — the constructor at
|
||||
`desktopApp/.../Main.kt:847-859` swaps the hard-coded literal for the
|
||||
current effective set:
|
||||
|
||||
```kotlin
|
||||
// before:
|
||||
indexRelays = DefaultRelays.RELAYS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet(),
|
||||
|
||||
// after:
|
||||
indexRelays = indexRelaysStore.effective(),
|
||||
```
|
||||
|
||||
`indexRelaysStore` is instantiated once at App() root (before the
|
||||
coordinator) and provided into `DesktopRelayCategories`. UI reads from
|
||||
`LocalRelayCategories.current.indexRelays`.
|
||||
|
||||
**Changes take effect on next relaunch.** Documented in the settings
|
||||
section's help text. The existing coordinator has no re-target API for
|
||||
`indexRelays`; teaching it one is out of scope. Rationale: index-relay
|
||||
churn is expected to be rare, and users who edit the list generally
|
||||
expect to restart anyway.
|
||||
|
||||
#### amy wiring
|
||||
|
||||
New helper on `cli/.../Context.kt`:
|
||||
|
||||
```kotlin
|
||||
fun indexRelays(): Set<NormalizedRelayUrl> {
|
||||
val prefs = Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")
|
||||
val csv = prefs.get("urls", "")
|
||||
val user = csv.split(",")
|
||||
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet()
|
||||
return user.ifEmpty {
|
||||
// Same defaults as PreferencesIndexRelays.DEFAULT_INDEX_RELAYS
|
||||
// Duplicated here (4 URLs) — they change ~never.
|
||||
setOf(
|
||||
"wss://nos.lol", "wss://nostr.wine",
|
||||
"wss://relay.noswhere.com", "wss://relay.primal.net",
|
||||
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`WotCommand.sync` swaps:
|
||||
|
||||
```kotlin
|
||||
// before:
|
||||
val relays = ctx.outboxRelays().ifEmpty { ctx.inboxRelays() }
|
||||
// after:
|
||||
val relays = ctx.indexRelays()
|
||||
```
|
||||
|
||||
The 4-URL duplication is fine per the simplicity review — the list
|
||||
changes ~never; a single shared commons constant would be architectural
|
||||
neatness with no material win. Adding a whole
|
||||
`commons/defaults/DefaultIndexRelays.kt` for a 4-line constant fails
|
||||
YAGNI on a plan we're specifically told to keep small.
|
||||
|
||||
#### Optional: `amy relay index …` verbs — deferred
|
||||
|
||||
v1 configuration lives in the Desktop settings section. If someone
|
||||
running amy headless wants to seed the Preferences node, they can do
|
||||
so with a five-line JVM one-liner:
|
||||
|
||||
```
|
||||
java -cp … -e 'Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index").put("urls","wss://foo,wss://bar")'
|
||||
```
|
||||
|
||||
CLI verbs are a follow-up ticket if demand appears.
|
||||
|
||||
### PR B — Item 3: Index Relays section in `RelaySettingsScreen`
|
||||
|
||||
Insert a new section in `RelaySettingsScreen`
|
||||
(`desktopApp/.../Main.kt` line 1797 onward). Current sections in order:
|
||||
|
||||
1. Wallet Connect (NWC)
|
||||
2. Media Server Settings
|
||||
3. Image Compression Settings
|
||||
4. Tor Settings
|
||||
5. Namecoin Settings
|
||||
6. Local Relay (conditional)
|
||||
7. Content Filters (hashtag-spam)
|
||||
|
||||
Insert **between Local Relay and Content Filters** — both already have
|
||||
a `HorizontalDivider` around them.
|
||||
|
||||
Section renders:
|
||||
|
||||
- Title: "Index Relays"
|
||||
- One-line explainer: "Used to fetch profile metadata and follow lists
|
||||
(Web-of-Trust). Changes take effect on next relaunch."
|
||||
- `LazyColumn` of `Text(relay.url) + IconButton(Icons.Default.Close, onClick = onRemove)` — 30 LOC ballpark.
|
||||
- Add-row: `OutlinedTextField + Button("Add")`. Normalises input via
|
||||
`RelayUrlNormalizer.normalizeOrNull`; ignores nulls silently (or
|
||||
surfaces "invalid relay URL" if trivial).
|
||||
- No "Reset to defaults" button — removing all entries falls back to
|
||||
defaults automatically (delete-all is the reset).
|
||||
|
||||
Reads:
|
||||
|
||||
```kotlin
|
||||
val indexRelays by LocalRelayCategories.current.indexRelays.collectAsState()
|
||||
val categories = LocalRelayCategories.current
|
||||
// then in add/remove handlers:
|
||||
categories.setIndexRelays(indexRelays + newUrl)
|
||||
categories.setIndexRelays(indexRelays - existingUrl)
|
||||
```
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Recomposition + reactivity (Item 1)
|
||||
|
||||
Inlining the score computation at each `UserSearchCard` call still gets
|
||||
per-key snapshot tracking — `service.scores[pubkey]` is a
|
||||
`SnapshotStateMap` read that Compose tracks per-key. Only the row for
|
||||
the changed pubkey recomposes when its score updates. Identical
|
||||
behaviour to what we shipped in `WoTBadgedAvatar`; the wrapper
|
||||
composable would have added a subscriber node with no gain.
|
||||
|
||||
### Live re-targeting of the coordinator (Item 2)
|
||||
|
||||
Existing `DesktopRelaySubscriptionsCoordinator` reads `indexRelays`
|
||||
once at construction and holds it. Teaching it to swap
|
||||
`indexRelays` mid-flight is a real refactor (in-flight subscription
|
||||
state, cross-EOSE semantics). Ship "changes take effect on next
|
||||
relaunch" for v1; add live re-targeting in a follow-up if users notice.
|
||||
|
||||
### Preferences node identity across Desktop and amy (Item 2)
|
||||
|
||||
Both processes use
|
||||
`Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`.
|
||||
Because `java.util.prefs.Preferences` is JVM-user-scoped
|
||||
(per OS user, per prefs backend — plist on macOS, dconf on Linux,
|
||||
registry on Windows), both processes end up looking at the same
|
||||
physical store. `PreferencesHashtagSpamSettings` already relies on this
|
||||
guarantee in shipped code.
|
||||
|
||||
### CSV vs JSON serialisation (Item 2)
|
||||
|
||||
CSV (`joinToString(",") { it.url }`) matches what `DesktopAccountRelays`
|
||||
does for its categories. No dependency on Jackson or Serialisation at
|
||||
the storage boundary. Trade-off: URLs cannot contain commas (they
|
||||
can't per RFC anyway — commas are reserved). We normalise through
|
||||
`RelayUrlNormalizer.normalizeOrNull` at both write time (in
|
||||
`setRelays`) and read time (in `parse` / `Context.indexRelays()`), so
|
||||
persisted CSV never contains an invalid URL.
|
||||
|
||||
### Default list ergonomics (deferred)
|
||||
|
||||
Verification surfaced a real bug: `commons/AmethystDefaults.kt`
|
||||
already contains `DefaultIndexerRelayList` (Purple Pages, Coracle,
|
||||
etc.) — a purpose-built index-relay set — but Desktop currently uses
|
||||
`DefaultRelays.RELAYS` (nos.lol, nostr.wine, relay.noswhere.com,
|
||||
relay.primal.net), which are general-purpose. That default mismatch is
|
||||
a real behaviour improvement to be made, but it's a user-visible
|
||||
behavioural change that deserves its own PR + review. **This plan
|
||||
preserves byte-parity with today's default** and flags the improvement
|
||||
in Out of Scope.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction graph
|
||||
|
||||
```
|
||||
PR A (Item 1):
|
||||
User opens Search column → types query
|
||||
→ SearchResultsList renders LazyColumn of user results
|
||||
→ Each result inlines: read LocalWoTService.scores, gate on ready/exempt
|
||||
→ pass a WoTBadge lambda to UserSearchCard(badge=...)
|
||||
→ UserSearchCard forwards to UserAvatar(badge=...)
|
||||
→ UserAvatar renders Box overlay with WoTBadge chip
|
||||
|
||||
PR B (Items 2+3):
|
||||
User opens Relays settings → Index Relays section
|
||||
→ List rendered from LocalRelayCategories.indexRelays
|
||||
→ User adds / removes a relay
|
||||
→ categories.setIndexRelays(newSet)
|
||||
→ indexRelaysStore.setRelays(newSet)
|
||||
→ prefs.put("urls", csv) at
|
||||
com/vitorpamplona/amethyst/relays/index
|
||||
→ indexRelays StateFlow emits new value
|
||||
|
||||
amy wot sync (later):
|
||||
→ ctx.indexRelays() reads the same prefs node
|
||||
→ identical relay set — Desktop and amy compute the same score
|
||||
|
||||
Desktop app next launch:
|
||||
→ indexRelaysStore.effective() returns user set (or defaults)
|
||||
→ coordinator constructed with that set
|
||||
```
|
||||
|
||||
### Error & failure propagation
|
||||
|
||||
- **Empty override set** (user removed all entries): fall back to
|
||||
defaults at both `indexRelaysStore.effective()` and
|
||||
`ctx.indexRelays()`. Never allow an empty batch REQ — WoT would
|
||||
silently break.
|
||||
- **Malformed URL entry** (e.g. old persisted CSV with a URL that no
|
||||
longer normalises): filter through
|
||||
`RelayUrlNormalizer.normalizeOrNull` at read time, drop nulls.
|
||||
- **Preferences read failure** (`BackingStoreException`): treat as
|
||||
"unset → use defaults". Log at debug, do not surface to the user.
|
||||
|
||||
### State lifecycle risks
|
||||
|
||||
- **Cross-account leak:** App-global preference, no per-account
|
||||
identity in the key — by design.
|
||||
- **Coordinator using stale set after user changes indexRelays:** Yes,
|
||||
in v1 the coordinator keeps its constructor-time set until relaunch.
|
||||
Documented in the UI. Not a data-integrity risk — just a UX quirk.
|
||||
|
||||
### API surface parity
|
||||
|
||||
- **PR A:** `UserSearchCard` badge slot — commonMain, backward-
|
||||
compatible. Android call sites (if any exist post-merge) unchanged;
|
||||
badge slot stays null.
|
||||
- **PR B, new:** `PreferencesIndexRelays` class in `commons/jvmMain/`.
|
||||
- **PR B, modified:** `DesktopRelayCategories` gains an `indexRelays`
|
||||
StateFlow + `setIndexRelays(...)`. `Main.kt` coordinator
|
||||
construction. `Context.kt` gains `indexRelays()`.
|
||||
`WotCommand.sync` swaps its relay source. `RelaySettingsScreen`
|
||||
gains an "Index Relays" section.
|
||||
- **Nothing** in `amethyst/` (Android) is touched — this is Desktop +
|
||||
amy only.
|
||||
|
||||
### Integration test scenarios
|
||||
|
||||
1. **Search badge shows.** Load a search result for a stranger scored
|
||||
≥ 1 in the WoT map — the badge renders bottom-right of the avatar.
|
||||
2. **Index Relays default state.** Fresh install → open Relays
|
||||
settings → Index Relays section lists the four
|
||||
`DEFAULT_INDEX_RELAYS` entries as read-only (or marked "(default)").
|
||||
3. **Index Relays override persists.** Add a new relay → close the
|
||||
app → relaunch → new relay still present. Remove one → close →
|
||||
relaunch → still gone.
|
||||
4. **amy sees the same override.** After the Desktop override above,
|
||||
`amy wot sync` uses the new relay set. Verify by observing which
|
||||
relays receive the kind-3 REQ (packet capture or a debug print
|
||||
inside `WotCommand.sync`).
|
||||
5. **Bad URL doesn't crash.** Manually plant an invalid entry in the
|
||||
Preferences node → app restart → invalid entries filtered out, UI
|
||||
shows only valid entries.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### PR A — Functional (Item 1)
|
||||
|
||||
- [ ] `UserSearchCard` in commons accepts optional
|
||||
`badge: @Composable (BoxScope.() -> Unit)? = null` and forwards
|
||||
it to its `UserAvatar` call.
|
||||
- [ ] Both call sites in
|
||||
`desktopApp/.../ui/search/SearchResultsList.kt` (currently at
|
||||
lines ~116 and ~126) inline the WoT-score computation and pass a
|
||||
`WoTBadge` lambda when score > 0 and pubkey not in
|
||||
`LocalSpamExemptKeys`.
|
||||
- [ ] `NewDmDialog` call sites remain unchanged.
|
||||
|
||||
### PR B — Functional (Items 2+3)
|
||||
|
||||
- [ ] `PreferencesIndexRelays` created at
|
||||
`commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt`.
|
||||
Persists to `Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`
|
||||
key `urls` as CSV. Exposes
|
||||
`relays: StateFlow<Set<NormalizedRelayUrl>>`,
|
||||
`setRelays(new)`, `effective(): Set<NormalizedRelayUrl>`, and
|
||||
`DEFAULT_INDEX_RELAYS` constant that matches `DefaultRelays.RELAYS`
|
||||
byte-for-byte.
|
||||
- [ ] `DesktopRelayCategories.indexRelays: StateFlow<Set<NormalizedRelayUrl>>`
|
||||
exposed — straight-through from `PreferencesIndexRelays.relays`,
|
||||
empty falls back to defaults. `setIndexRelays(new)` delegates.
|
||||
- [ ] `Main.kt:847-859` constructs
|
||||
`DesktopRelaySubscriptionsCoordinator` with
|
||||
`indexRelays = indexRelaysStore.effective()` instead of the
|
||||
hard-coded `DefaultRelays.RELAYS.mapNotNull { … }.toSet()`.
|
||||
Behaviour on fresh install identical to today.
|
||||
- [ ] `cli/.../Context.kt` gains `indexRelays(): Set<NormalizedRelayUrl>`
|
||||
reading the same Preferences node, falling back to the same 4
|
||||
defaults inline.
|
||||
- [ ] `WotCommand.sync` uses `ctx.indexRelays()` instead of
|
||||
`outboxRelays()/inboxRelays()`.
|
||||
- [ ] `RelaySettingsScreen` has an "Index Relays" section between
|
||||
Local Relay and Content Filters, with:
|
||||
- Title + one-line explainer including "Changes take effect on
|
||||
next relaunch."
|
||||
- List of current relays with per-row remove button.
|
||||
- Add-row: URL input + Add button, normalises via
|
||||
`RelayUrlNormalizer.normalizeOrNull`, silently drops nulls.
|
||||
- No "Reset to defaults" button (remove-all is the reset).
|
||||
|
||||
### Non-functional (both PRs)
|
||||
|
||||
- [ ] `./gradlew spotlessApply` — no diff.
|
||||
- [ ] `./gradlew :commons:compileKotlinJvm :desktopApp:compileKotlin
|
||||
:cli:compileKotlin` — clean.
|
||||
- [ ] `./gradlew test` — full suite passes.
|
||||
- [ ] No new `Preferences` writes on any render path — only on
|
||||
settings-screen mutations.
|
||||
|
||||
### Quality gates
|
||||
|
||||
- [ ] **PR A:** manual smoke — open Search, type a query, verify
|
||||
badges appear on results scored ≥ 1 in the WoT map; none on
|
||||
follows/self.
|
||||
- [ ] **PR B:** unit test for `PreferencesIndexRelays` round-trip
|
||||
(write set → new instance → same set out).
|
||||
- [ ] **PR B:** unit test for `PreferencesIndexRelays.effective()`
|
||||
fallback when Preferences is unset.
|
||||
- [ ] **PR B:** unit test for `ctx.indexRelays()` fallback behaviour
|
||||
when Preferences is unset.
|
||||
- [ ] **PR B:** three new manual scenarios added to the WoT testing
|
||||
sheet — search-badge visibility, Preferences override
|
||||
persistence across restart, amy sync uses override (packet
|
||||
capture or debug log).
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- Search results have the same at-a-glance trust cue as feed cards.
|
||||
- `amy wot get <target>` after `amy wot sync` returns a score identical
|
||||
to the Desktop UI within ~2 s of the same relay set having been
|
||||
configured.
|
||||
- Users who add / remove index relays in the settings UI see their
|
||||
change reflected on next relaunch, verified via a debug log line.
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|------|------------|------------|
|
||||
| Coordinator snapshot of `indexRelays` leaks stale set until relaunch | high (accepted v1) | Document as "changes on relaunch" in the UI; follow-up ticket for live re-targeting. |
|
||||
| Empty override silently kills WoT | medium | Fallback-to-defaults guard at *both* `indexRelaysStore.effective()` and `ctx.indexRelays()`. Unit-tested. |
|
||||
| CSV serialisation confuses a user who hand-edits the prefs file | low | Documented as internal; users are expected to use the UI. Hand-edit path stays functional as long as URLs don't contain commas (they can't per RFC). |
|
||||
| Two Desktop and cli defaults drift out of sync (4 URLs duplicated in two places) | low | Comment in both files pointing to each other. If the list ever needs to change, both places must update. Realistically the list changes ~never. |
|
||||
| `commons/AmethystDefaults.DefaultIndexerRelayList` continues to be the "correct" index-relay set while we're shipping the "general-purpose" defaults | ok (deferred) | Out-of-scope. Separate ticket to adopt as default. |
|
||||
|
||||
## Out of Scope (deferred)
|
||||
|
||||
- **`amy relay index add / remove / list` verbs.** Defer until there's
|
||||
demand from headless workflows.
|
||||
- **Live re-targeting of index-relay subscriptions** without app
|
||||
relaunch. Separate coordinator refactor.
|
||||
- **NIP-51 kind 30002 based index-relay list** for cross-Nostr-client
|
||||
portability.
|
||||
- **DM-recipient-picker badges** in `NewDmDialog`. Ship only if manual
|
||||
testing complains.
|
||||
- **Adopting `commons/AmethystDefaults.DefaultIndexerRelayList` as the
|
||||
Desktop / amy default.** Real improvement, but a user-visible
|
||||
behavioural change. Standalone ticket + review.
|
||||
- **Per-account index-relay overrides.** YAGNI now — single-user
|
||||
preference dominates. Add later if demand shows up.
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **WoT PR plan:** `docs/plans/2026-07-01-feat-desktop-wot-score-plan.md`
|
||||
- **Manual testing sheet:**
|
||||
`desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md`
|
||||
|
||||
### Internal references
|
||||
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt:51-108`
|
||||
— target for badge slot (Item 1).
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt:82`
|
||||
— badge slot already exists here (from the WoT PR).
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt:116,126`
|
||||
— the two person-result call sites to migrate.
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:847-859`
|
||||
— where the hard-coded `indexRelays` is passed to the coordinator.
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt:40-47`
|
||||
— `DefaultRelays.RELAYS` (byte-parity target for
|
||||
`DEFAULT_INDEX_RELAYS`).
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt:80-89`
|
||||
— `searchRelays` pattern (reference; index relays uses a *simpler*
|
||||
shape).
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt`
|
||||
— per-class Preferences node pattern **we're deliberately not
|
||||
reusing** (would not be visible to amy).
|
||||
- `commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettings.kt`
|
||||
— pattern for shared Preferences node used across Desktop + amy;
|
||||
`PreferencesIndexRelays` mirrors this shape.
|
||||
- `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt:329-362`
|
||||
— where `outboxRelays()` / `inboxRelays()` live.
|
||||
- `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt`
|
||||
— swap `sync` to `ctx.indexRelays()`.
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/AmethystDefaults.kt:62-63`
|
||||
— `DefaultIndexerRelayList` (Purple Pages, Coracle …) — flagged as
|
||||
future default adoption, **not touched** in this plan.
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt`
|
||||
— `indexRelays` constructor param (already exists, no change needed).
|
||||
|
||||
### Skill references
|
||||
|
||||
- `relay-client` — DesktopRelayCategories composition + StateFlow
|
||||
patterns.
|
||||
- `compose-expert` — CompositionLocal readers + badge slot forwarding.
|
||||
- `amy-expert` — Context helper pattern (`outboxRelays()` etc.), CLI
|
||||
verb shape, shared JVM `Preferences` node semantics.
|
||||
- `kotlin-flow-state-event-modeling` — StateFlow<Set<…>> straight-
|
||||
through vs combine semantics.
|
||||
|
||||
### Related work
|
||||
|
||||
- WoT PR branch: `feat/desktop-wot-score` (closed for manual testing).
|
||||
- Hashtag-spam PR: https://github.com/vitorpamplona/amethyst/pull/3431
|
||||
(merged) — the `java.util.prefs` shared-node pattern
|
||||
`PreferencesIndexRelays` mirrors.
|
||||
- Feature backlog:
|
||||
`desktopApp/plans/_desktop-feature-backlog.md` item #2 (parent WoT
|
||||
feature).
|
||||
Reference in New Issue
Block a user