diff options
| author | Adam Soltys <[email protected]> | 2026-08-02 18:02:12 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-02 18:02:12 -0700 |
| commit | df01e3fb245c3e8d75a335d94e95df5f76a3b30a (patch) | |
| tree | 07e0679183b281b3fae049b98b44578f5e5d841a /src | |
| parent | d33643e0683dfac4037c0b61d4aa823782f582e7 (diff) | |
| download | coinosv3-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
Diffstat (limited to 'src')
| -rw-r--r-- | src/recipient-search.js | 42 |
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); |
