chore(commons,cli,amethyst): three correctness wins + caller-responsibility kdoc

Closes the remaining items from the comparative review of the extracted
actions against the in-app Amethyst flows. All small, all surfaced by the
review.

  * amy follow now stamps the relay hint on new contact-list `p` tags.
    Best-effort read from the target's cached kind:10002 advertised
    relay list (first writeRelaysNorm). Mirrors User.bestRelayHint() —
    follows added via amy no longer have empty relayUri.

  * amy search user now dedups by pubkey (sorted newest-first) instead
    of by event id, matching the App Functions adapter. Multiple relays
    surfacing different kind:0 revisions for the same author collapse
    to one hit.

  * AmethystAppFunctions.searchProfiles captures the active account AND
    the relay client at function entry, then never touches sessionManager
    or Amethyst.instance again during the drain. Closes the account-
    switch race surfaced in the review.

  * FollowActions / SearchActions / ZapActions kdoc now lists the
    caller-side responsibilities each builder leaves to the consumer
    (publish, writeable check, relay hint, pseudo-kind filtering,
    LN round-trip, receipt verification, etc.). Documents the design
    rather than letting it leak through reviews.
This commit is contained in:
Claude
2026-05-24 21:35:49 +00:00
parent 54b09ea6e2
commit 29236d7801
6 changed files with 88 additions and 20 deletions
@@ -78,8 +78,13 @@ class AmethystAppFunctions {
val cappedLimit = limit.coerceIn(1, 50)
val filter = SearchActions.searchProfilesFilter(query, cappedLimit) ?: return SearchProfilesResult.empty()
val app = Amethyst.instance
val account = app.sessionManager.loggedInAccount() ?: return SearchProfilesResult.empty()
// Snapshot the active account + relay set + client at function entry
// and never touch sessionManager again from this dispatch. If the
// user switches account mid-drain, this snapshot keeps the request
// routed to the relays we originally queried — caller still gets a
// coherent result rather than events mixed across accounts.
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchProfilesResult.empty()
val client = Amethyst.instance.client
// SearchRelayListState's flow already resolves to a concrete relay
// set: NIP-44-decrypted private entries + public entries, or the
@@ -88,7 +93,7 @@ class AmethystAppFunctions {
val relays = account.searchRelayList.flow.value
if (relays.isEmpty()) return SearchProfilesResult.empty()
val events = drain(app, relays, filter, GEMINI_DRAIN_TIMEOUT_MS)
val events = drain(client, relays, filter, GEMINI_DRAIN_TIMEOUT_MS)
val hits =
events
@@ -108,12 +113,11 @@ class AmethystAppFunctions {
* Account exposes a live `INostrClient` rather than a drain helper.
*/
private suspend fun drain(
app: com.vitorpamplona.amethyst.AppModules,
client: com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient,
relays: Set<NormalizedRelayUrl>,
filter: Filter,
timeoutMs: Long,
): List<Event> {
val client = app.client
val incoming = Channel<Event>(UNLIMITED)
val done = mutableSetOf<NormalizedRelayUrl>()
val subId = newSubId()
@@ -85,6 +85,21 @@ object FollowCommand {
val latest = fetchLatestContactList(ctx, self, outbox, timeoutSecs * 1000)
val previouslyFollowed = latest?.isTaggedUser(target) ?: false
// Relay hint embedded in the `p` tag for new follows — points
// readers at a relay where they'll find the target's events.
// Best-effort: first write relay from the target's cached
// kind:10002 advertised relay list, null if we've never seen
// one. Mirrors User.bestRelayHint() in the Android UI.
val targetRelayHint =
if (op == FollowOp.FOLLOW) {
ctx
.relaysOf(target)
?.writeRelaysNorm()
?.firstOrNull()
} else {
null
}
val newEvent: ContactListEvent? =
when (op) {
FollowOp.FOLLOW ->
@@ -92,6 +107,7 @@ object FollowCommand {
signer = ctx.signer,
pubkeyToFollow = target,
currentContactList = latest,
relayHint = targetRelayHint,
)
FollowOp.UNFOLLOW ->
FollowActions.buildUnfollow(
@@ -79,6 +79,12 @@ object SearchCommand {
return runSearch(dataDir, query, filter, timeoutMs) { events ->
events
.mapNotNull { it as? MetadataEvent }
// Dedup by pubkey, not event id — multiple relays may return
// different kind:0 revisions for the same author; keep only
// the freshest. Matches the App Functions adapter so amy
// and Gemini surface the same profile count for a query.
.sortedByDescending { it.createdAt }
.distinctBy { it.pubKey }
.map { ev ->
val parsed =
try {
@@ -29,9 +29,24 @@ import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
/**
* Pure event-building "verbs" for the NIP-02 kind:3 contact list.
*
* Builds a signed [ContactListEvent] but does NOT publish it — callers are
* responsible for relay delivery (e.g. `Account.sendMyPublicAndPrivateOutbox`
* on Android, `Context.publish` in amy).
* Builds a signed [ContactListEvent] but does NOT publish it. The Amethyst
* Android UI flow does more than these builders — non-UI callers are
* responsible for the rest:
*
* * **Publish.** Hand the returned event to your relay client. Android
* uses `Account.sendMyPublicAndPrivateOutbox`, amy uses `Context.publish`.
* * **Writeable check.** Skip the call when the active signer is read-only
* (e.g. an npub-only login). Building will fail at the sign step
* otherwise.
* * **Relay hint.** Pass [relayHint] pointing at one of the target's
* advertised kind:10002 write relays so readers can find the followed
* user. The in-app flow does this via `User.bestRelayHint()`.
* * **No-op detection.** When the user already follows the target, the
* underlying builder short-circuits to the same [currentContactList].
* Compare `result.id == currentContactList?.id` to detect this.
* * **Local cache update.** If your caller has a local event cache, feed
* the new event back in so the UI / next read sees the update without
* a relay round-trip.
*
* Canonical entry point for non-UI callers (CLI commands, Android App
* Functions adapters, automation scripts): takes pubkeys as [HexKey] rather
@@ -31,13 +31,25 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
/**
* Pure NIP-50 search-filter assembly + search-relay resolution.
*
* Like [FollowActions], these are non-UI verbs callable from any context
* (amy CLI, future Android App Functions adapter for Gemini, automation).
* The actions only build [Filter]s and pick relays — caller drives the
* actual subscription / drain via its own relay client.
* Builds [Filter]s and picks relays — caller drives subscription / drain.
* Non-UI callers should layer the following on top to match Amethyst's
* in-app search behavior:
*
* For client-side post-filtering (dedup, pseudo-kinds like `reply` / `media`)
* see [com.vitorpamplona.amethyst.commons.search.SearchResultFilter].
* * **Drain / subscribe.** A function-call API (amy `search`, Gemini App
* Functions) usually wants `client.subscribe(...)` until every relay
* sends EOSE or a short timeout elapses, then unsubscribe. The Amethyst
* foreground UI uses a live subscription instead — it stays open as the
* user types.
* * **Dedup.** Profile search dedups by `pubKey` (multiple kind:0 events
* per author); note search dedups by event id. Both pick the freshest
* revision via `sortedByDescending { createdAt }.distinctBy { … }`.
* * **Pseudo-kind filtering.** When you let callers ask for `reply` /
* `media` / exclusion terms, apply
* [com.vitorpamplona.amethyst.commons.search.SearchResultFilter] after
* the drain. This filter is NOT exposed in the filter API itself.
* * **Debounce.** Interactive callers should debounce input. The
* Amethyst UI uses 300 ms before issuing a new subscription; one-shot
* callers (amy, App Functions) skip this.
*/
object SearchActions {
/** Default kinds for "search notes" — kind:1 short text notes. */
@@ -32,14 +32,29 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
* NIP-57 zap-request building + LN address extraction.
*
* Returns a signed [LnZapRequestEvent] (kind:9734) — the artifact a caller
* hands to a LNURL-pay callback to receive a BOLT11 invoice. The Lightning
* round-trip (LNURL fetch, invoice retrieval, optional NWC payment) is
* intentionally out of scope here; callers compose this with
* `LightningAddressResolver` (commons/jvmAndroid) or their own LN client.
* hands to a LNURL-pay callback to receive a BOLT11 invoice.
*
* **Caller responsibilities** that the Amethyst Android flow handles but
* these builders do not:
*
* * **Use [buildEventZapRequestsForSplits] for events.** A naive call to
* [buildEventZapRequest] on a note carrying NIP-57 zap-split tags, NIP-53
* live-activity host tags, or NIP-89 app metadata silently misroutes
* funds to a single recipient. The splits variant is what the
* foreground UI uses and what amy `zap event` calls.
* * **Lightning round-trip.** LNURL endpoint fetch, BOLT11 invoice
* retrieval, and optional NIP-47 NWC payment all live outside these
* builders. `LightningAddressResolver` (in commons/jvmAndroid) covers
* the LNURL + invoice steps.
* * **Receipt verification.** When the kind:9735 receipt arrives, validate
* it against the LNURL provider's `nostrPubkey` (NIP-57 Appendix F) —
* primed via `LnurlEndpointCache` on Android.
* * **Onchain zaps** (NIP-BC) are a separate flow — see `OnchainZapSender`
* in commons. These builders only cover Lightning.
*
* Pattern matches [FollowActions] and [SearchActions]: shared, pure logic
* usable from amy CLI, the future Android App Functions adapter for Gemini,
* and any other non-UI consumer.
* usable from amy CLI, the Android App Functions adapter for Gemini, and
* any other non-UI consumer.
*/
object ZapActions {
/** Convert sats to millisats — LN-side amount unit. */