diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 2e67499e73..d300b9a7bc 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -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, filter: Filter, timeoutMs: Long, ): List { - val client = app.client val incoming = Channel(UNLIMITED) val done = mutableSetOf() val subId = newSubId() diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt index 5de60d2097..c96234c8f4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt @@ -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( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt index 00b29ff9b7..0c52732a96 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt @@ -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 { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt index 6b8c501a8e..76ca908515 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt index 78f6acbcef..2fb16fd2be 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt @@ -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. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt index 36f9cc96a8..5c6bd6ecc9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt @@ -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. */