aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 18:02:12 -0700
committerAdam Soltys <[email protected]>2026-08-02 18:02:12 -0700
commitdf01e3fb245c3e8d75a335d94e95df5f76a3b30a (patch)
tree07e0679183b281b3fae049b98b44578f5e5d841a
parentd33643e0683dfac4037c0b61d4aa823782f582e7 (diff)
downloadcoinosv3-df01e3fb245c3e8d75a335d94e95df5f76a3b30a.tar.gz
coinosv3-df01e3fb245c3e8d75a335d94e95df5f76a3b30a.zip
Recipient search: Primal cache as the global source
search.nos.today's fuzzy index couldn't even surface fiatjaf by exact name, and relay.nostr.band's websocket is down. Primal's public cache API (the ranking Primal's own client uses) answers a user_search REQ with plain kind-0 events and gets it right; the NIP-50 relays remain as fallback when the cache returns nothing. Also rank name-substring matches above the search relay's looser hits. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
-rw-r--r--src/recipient-search.js42
1 files changed, 36 insertions, 6 deletions
diff --git a/src/recipient-search.js b/src/recipient-search.js
index ae40a85..32319ab 100644
--- a/src/recipient-search.js
+++ b/src/recipient-search.js
@@ -11,9 +11,32 @@
import { queryOn, parseNostrPubkey, npubOf, PROFILE_RELAYS } from './nostr.js';
const REGISTRAR = 'https://names.coinos.io';
-const SEARCH_RELAYS = ['wss://relay.nostr.band'];
+const SEARCH_RELAYS = ['wss://search.nos.today', 'wss://relay.nostr.band'];
+const PRIMAL_CACHE = 'wss://cache2.primal.net/v1';
const MAX_RESULTS = 8;
+// Primal's public cache API has the best-ranked user search on nostr (it's
+// what the Primal client itself uses): a custom REQ that answers with plain
+// kind-0 events. Nonstandard, so the NIP-50 relays stay as fallback.
+function primalSearch(q, limit = MAX_RESULTS) {
+ return new Promise((resolve) => {
+ const rows = [];
+ let ws;
+ const finish = () => { clearTimeout(to); try { ws.close(); } catch {} resolve(rows); };
+ const to = setTimeout(finish, 3500);
+ try { ws = new WebSocket(PRIMAL_CACHE); } catch { clearTimeout(to); return resolve(rows); }
+ ws.onopen = () => ws.send(JSON.stringify(['REQ', 'rs', { cache: ['user_search', { query: q, limit }] }]));
+ ws.onmessage = (ev) => {
+ try {
+ const m = JSON.parse(ev.data);
+ if (m[0] === 'EVENT' && m[2] && m[2].kind === 0) rows.push(m[2]);
+ if (m[0] === 'EOSE') finish();
+ } catch {}
+ };
+ ws.onerror = finish;
+ });
+}
+
const profileCache = new Map(); // pk -> { name, picture }
const queryCache = new Map(); // q -> rows
@@ -68,18 +91,25 @@ export async function searchRecipients(qRaw) {
.then((j) => (j.results || []).forEach((r) => add(r.pubkey, { name: r.name, address: r.address }, 1))),
(async () => {
if (exact) return; // an npub needs no full-text search
- const evs = await queryOn(SEARCH_RELAYS, { kinds: [0], search: q, limit: MAX_RESULTS }, 3500);
- for (const e of evs) {
+ const noteKind0 = (e, pri) => {
try {
const m = JSON.parse(e.content);
profileCache.set(e.pubkey, { name: m.display_name || m.name || null, picture: m.picture || null });
- add(e.pubkey, { name: m.display_name || m.name, picture: m.picture, address: typeof m.nip05 === 'string' ? m.nip05.replace(/^_@/, '') : undefined }, 2);
+ add(e.pubkey, { name: m.display_name || m.name, picture: m.picture, address: typeof m.nip05 === 'string' ? m.nip05.replace(/^_@/, '') : undefined }, pri);
} catch {}
- }
+ };
+ const primal = await primalSearch(q);
+ for (const e of primal) noteKind0(e, 2);
+ if (primal.length) return; // fallback only when the cache is empty/down
+ const evs = await queryOn(SEARCH_RELAYS, { kinds: [0], search: q, limit: MAX_RESULTS }, 3500);
+ for (const e of evs) noteKind0(e, 3);
})(),
]);
- const rows = [...out.values()].sort((a, b) => a.pri - b.pri).slice(0, MAX_RESULTS);
+ // Within a source, a name that actually contains the query outranks the
+ // search relay's looser relevance matches.
+ const score = (r) => ((r.name || '').toLowerCase().includes(q) || (r.address || '').toLowerCase().includes(q)) ? 0 : 1;
+ const rows = [...out.values()].sort((a, b) => a.pri - b.pri || score(a) - score(b)).slice(0, MAX_RESULTS);
await fillProfiles(rows).catch(() => {});
if (queryCache.size > 200) queryCache.clear();
queryCache.set(q, rows);