mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
+9
-5
@@ -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 {
|
||||
|
||||
+18
-3
@@ -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
|
||||
|
||||
+18
-6
@@ -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. */
|
||||
|
||||
+21
-6
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user