diff options
| author | Adam Soltys <[email protected]> | 2026-08-02 17:51:37 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-02 17:51:37 -0700 |
| commit | d33643e0683dfac4037c0b61d4aa823782f582e7 (patch) | |
| tree | ff185690f42cbb07d3fa925b2b7e5f8db5e1e53c /src/recipient-search.js | |
| parent | 64f96e031e6ea219cc03eb8aa2cc4d0cf7e1f4e4 (diff) | |
| download | coinosv3-d33643e0683dfac4037c0b61d4aa823782f582e7.tar.gz coinosv3-d33643e0683dfac4037c0b61d4aa823782f582e7.zip | |
Recipient search: usernames and npubs, in chat and the Send form
A shared engine (src/recipient-search.js) merges three ranked sources:
an exact npub/hex paste, coinos usernames from the registrar's new
/search prefix endpoint, and NIP-50 kind-0 full-text search on
relay.nostr.band — the approach the major web clients take, since
ordinary relays don't index profiles. Results dedupe by pubkey, fill
avatars in one batched kind-0 fetch, and cache per query so narrowing
never re-hits the network. Input is debounced 300ms and gated by a
searchable() heuristic so addresses, invoices, and keys never trigger
a search.
Chat's New message panel narrows as you type and opens the thread on
pick; the Send form shows the same candidates under the destination
input and picking one runs the normal paste path (payment address →
BIP-353, npub → zap).
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src/recipient-search.js')
| -rw-r--r-- | src/recipient-search.js | 121 |
1 files changed, 121 insertions, 0 deletions
diff --git a/src/recipient-search.js b/src/recipient-search.js new file mode 100644 index 0000000..ae40a85 --- /dev/null +++ b/src/recipient-search.js @@ -0,0 +1,121 @@ +// Recipient search, shared by chat (New message) and the Send form. +// +// Sources, best first: an exact npub/hex paste; coinos usernames from the +// names registrar (prefix search, authoritative for our users); NIP-50 +// full-text profile search on a search relay (the approach Coracle/Snort +// take — most relays don't index, so a dedicated one is queried). Results +// merge by pubkey, profiles fill in one batched kind-0 fetch, and per-query +// results are cached so narrowing a query never re-hits the network for +// prefixes already seen. + +import { queryOn, parseNostrPubkey, npubOf, PROFILE_RELAYS } from './nostr.js'; + +const REGISTRAR = 'https://names.coinos.io'; +const SEARCH_RELAYS = ['wss://relay.nostr.band']; +const MAX_RESULTS = 8; + +const profileCache = new Map(); // pk -> { name, picture } +const queryCache = new Map(); // q -> rows + +// Worth searching? A partial username or npub — never an address, invoice, +// or key the Send form knows how to handle whole. Known payment prefixes are +// excluded so typing an address never triggers a search. +export function searchable(qRaw) { + const q = (qRaw || '').trim().toLowerCase(); + if (/^(npub1|nprofile1)[a-z0-9]{6,}$/.test(q)) return true; + if (q.length < 2 || q.length > 30) return false; + if (/^(bc1|tb1|bcrt1|lnbc|lntb|lnurl|bitcoin:|lightning:|sp1q|ark1|xpub|zpub|xprv|zprv|nsec1|bunker:)/.test(q)) return false; + if (/^[0-9a-f]{40,}$/.test(q)) return false; // raw hex key/txid + return /^[a-z0-9._-]+$/.test(q); +} + +async function fillProfiles(rows) { + const missing = rows.filter((r) => !r.picture && !profileCache.has(r.pk)); + if (missing.length) { + const evs = await queryOn(PROFILE_RELAYS, { kinds: [0], authors: missing.map((r) => r.pk) }, 2500); + const newest = new Map(); + for (const e of evs) if (!newest.has(e.pubkey) || e.created_at > newest.get(e.pubkey).created_at) newest.set(e.pubkey, e); + for (const [pk, e] of newest) { + try { + const m = JSON.parse(e.content); + profileCache.set(pk, { name: m.display_name || m.name || null, picture: m.picture || null }); + } catch {} + } + } + for (const r of rows) { + const p = profileCache.get(r.pk); + if (p) { r.picture ||= p.picture; r.name ||= p.name; } + } +} + +export async function searchRecipients(qRaw) { + const q = qRaw.trim().toLowerCase(); + if (queryCache.has(q)) return queryCache.get(q); + const out = new Map(); // pk -> { pk, name, address, picture, pri } + const add = (pk, row, pri) => { + if (!/^[0-9a-f]{64}$/.test(pk || '')) return; + const cur = out.get(pk); + if (cur) Object.assign(cur, { ...row, ...Object.fromEntries(Object.entries(cur).filter(([, v]) => v != null)), pri: Math.min(cur.pri, pri) }); + else out.set(pk, { pk, ...row, pri }); + }; + + const exact = parseNostrPubkey(q); + if (exact) add(exact, {}, 0); + + await Promise.allSettled([ + fetch(`${REGISTRAR}/search?q=${encodeURIComponent(q)}`) + .then((r) => r.json()) + .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) { + 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); + } catch {} + } + })(), + ]); + + const rows = [...out.values()].sort((a, b) => a.pri - b.pri).slice(0, MAX_RESULTS); + await fillProfiles(rows).catch(() => {}); + if (queryCache.size > 200) queryCache.clear(); + queryCache.set(q, rows); + return rows; +} + +// Debounced controller: one per call site. onUpdate(q, rows|null) fires with +// null when the query isn't searchable (hide the panel), [] while nothing +// matched, rows otherwise. Stale responses are dropped by sequence. +export function makeSearcher(onUpdate) { + let timer = null; + let seq = 0; + return { + update(q) { + clearTimeout(timer); + const my = ++seq; + if (!searchable(q)) { onUpdate(q, null); return; } + const cached = queryCache.has(q.trim().toLowerCase()); + timer = setTimeout(async () => { + const rows = await searchRecipients(q).catch(() => []); + if (my === seq) onUpdate(q, rows); + }, cached ? 0 : 300); + }, + clear() { seq++; clearTimeout(timer); onUpdate('', null); }, + }; +} + +// Candidate rows (avatar + name + address/npub), styled with the chat list +// classes both pages already ship. +export function resultRows(h, rows, onPick) { + return rows.map((r) => + h('div', { class: 'item chat-thread-row', onClick: () => onPick(r) }, + r.picture + ? h('img', { class: 'chat-avatar', src: r.picture, alt: '' }) + : h('div', { class: 'chat-avatar fallback' }, (r.name || npubOf(r.pk) || '??').slice(0, 2)), + h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, + h('div', { class: 'chat-name' }, r.name || (npubOf(r.pk) || '').slice(0, 14) + '…'), + h('div', { class: 'muted small chat-preview' }, r.address || (npubOf(r.pk) || '').slice(0, 24) + '…')))); +} |
