// 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'; import { assetUrl } from './asset-url.js'; import { cacheStorage, onCacheReady } from './cache-store.js'; // coinos punks: the deterministic default avatar — same formula the coinos // app uses (last byte of the pubkey picks one of 64). Initials show through // if the image can't load (offline). // Self-hosted (dist/punks) — coinos.io's own copies 502 for a third of the // set. Same deterministic formula the coinos app uses. const punkN = (pk) => Math.floor((parseInt(pk.slice(-2), 16) / 256) * 64) + 1; export const punkImageUrl = (n, small = false) => assetUrl(`/punks${small ? '-sm' : ''}/${n}.webp`); export const punkUrl = (pk) => punkImageUrl(punkN(pk)); // The art is 240px, which is what a punk published as someone's nostr // picture should be — and eight times what a 30px circle needs. We ship a // 128px copy of each (a fifth of the bytes, 20KB → 4KB) and paint the circles // from that, so a brand-new wallet with nothing cached fills its faces in // straight away. Only the 64px profile avatar takes the full-size one. export const punkSmallUrl = (pk) => punkImageUrl(punkN(pk), true); export const punkSmallN = (n) => punkImageUrl(n, true); const failedPunks = new Map(); export const fallbackAvatar = (h, pk, name, cls) => { const src = cls.includes('profile-avatar') ? punkUrl(pk) : punkSmallUrl(pk); return h('div', { class: cls + ' fallback' }, Date.now() - (failedPunks.get(src) || 0) < 60_000 ? null : h('img', { class: 'punk', alt: '', src, onError: (e) => { failedPunks.set(src, Date.now()); e.target.remove(); }, }), (name || npubOf(pk) || '??').slice(0, 2)); }; const REGISTRAR = 'https://names.coinos.io'; 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. // One socket serves the whole typing session — the per-query handshake used // to cost more than the query itself. Idle for a minute, it closes itself. let pws = null, pwsN = 0, pwsIdle = null; const pwsSubs = new Map(); // sub id -> { rows, finish } // Any of Primal's cache calls over the shared socket; resolves with every // event it answered (its own stats kinds included) at EOSE or the timeout. export function primalCache(cmd, args, timeoutMs = 3500) { return new Promise((resolve) => { const sid = 'rs' + ++pwsN; const rows = []; const finish = () => { clearTimeout(to); pwsSubs.delete(sid); resolve(rows); }; const to = setTimeout(finish, timeoutMs); pwsSubs.set(sid, { rows, finish }); clearTimeout(pwsIdle); pwsIdle = setTimeout(() => { try { pws?.close(); } catch {} pws = null; }, 60_000); const send = () => { try { pws.send(JSON.stringify(['REQ', sid, { cache: [cmd, args] }])); } catch { finish(); } }; if (pws && pws.readyState === 1) return send(); if (!ensurePws()) return finish(); pws.addEventListener('open', send, { once: true }); }); } const primalSearch = (q, limit = MAX_RESULTS) => primalCache('user_search', { query: q, limit }).then((evs) => evs.filter((e) => e.kind === 0)); function ensurePws() { if (pws && pws.readyState <= 1) return true; try { pws = new WebSocket(PRIMAL_CACHE); } catch { pws = null; return false; } pws.onmessage = (ev) => { try { const m = JSON.parse(ev.data); const sub = pwsSubs.get(m[1]); if (!sub) return; if (m[0] === 'EVENT' && m[2]) sub.rows.push(m[2]); if (m[0] === 'EOSE') sub.finish(); } catch {} }; pws.onerror = pws.onclose = () => { pws = null; const waiting = [...pwsSubs.values()]; pwsSubs.clear(); for (const s of waiting) s.finish(); }; return true; } // Pre-open every search transport BEFORE the first keystroke: the Primal // socket, the registrar's TLS session, and the NIP-50 relay connections // each cost a handshake that otherwise lands ON the first query — the // "cold" second of dead air after typing begins. Call on focus of any // search-capable field; throttled, and the idle timer still reclaims the // socket a minute after the last real use. let warmedAt = 0; export function warmSearch() { if (Date.now() - warmedAt < 15_000) return; warmedAt = Date.now(); ensurePws(); clearTimeout(pwsIdle); pwsIdle = setTimeout(() => { try { pws?.close(); } catch {} pws = null; }, 60_000); fetch(`${REGISTRAR}/health`).catch(() => {}); queryOn(SEARCH_RELAYS, { kinds: [0], limit: 1 }, 800).catch(() => {}); } // Caches survive reloads: a name searched yesterday paints instantly today. // Profiles cap at 200 by recency; whole-query results keep for 12 hours. const PROF_KEY = 'btc-wallet-search-profiles'; // Discard old results that identified migrated accounts by their wallet key. const QUERY_KEY = 'btc-wallet-search-queries-v2'; const QUERY_TTL = 12 * 3600_000; const profileCache = new Map(); // pk -> { name, picture } const queryCache = new Map(); // q -> rows const queryAt = new Map(); // q -> when the rows were fetched // the profile half lives in IndexedDB (cache-store.js): read again once it // opens, keeping whatever this session already learned const loadSearchProfiles = () => { try { for (const [pk, v] of Object.entries(JSON.parse(cacheStorage.getItem(PROF_KEY) || '{}'))) if (!profileCache.has(pk)) profileCache.set(pk, v); } catch {} }; loadSearchProfiles(); onCacheReady(loadSearchProfiles); try { const now = Date.now(); for (const [q, e] of Object.entries(JSON.parse(localStorage.getItem(QUERY_KEY) || '{}'))) // cached rows are final: their profile lookup already finished if (e && Array.isArray(e.rows) && now - (e.t || 0) < QUERY_TTL) { queryCache.set(q, e.rows.map((r) => ({ ...r, known: true }))); queryAt.set(q, e.t); } } catch {} let persistTimer = null; function persistCaches() { clearTimeout(persistTimer); persistTimer = setTimeout(() => { try { const prof = {}; for (const [pk, v] of [...profileCache].slice(-200)) prof[pk] = { name: v.name || null, picture: v.picture || null }; cacheStorage.setItem(PROF_KEY, JSON.stringify(prof)); const qs = {}; for (const [q, rows] of [...queryCache].slice(-60)) qs[q] = { t: queryAt.get(q) || Date.now(), rows }; localStorage.setItem(QUERY_KEY, JSON.stringify(qs)); } catch {} }, 800); } // 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 // a display name is words: "jordan miller", "José", not just a handle return /^[\p{L}\p{N}._-]+(?:\s+[\p{L}\p{N}._-]+)*$/u.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; } } } // onPartial (optional) receives the current best rows as each source lands, // so the UI paints the fast sources (registrar, cached profiles) without // waiting for the slow ones. The returned promise still resolves with the // final, profile-filled rows. export async function searchRecipients(qRaw, onPartial = null) { const q = qRaw.trim().toLowerCase().replace(/\s+/g, ' '); 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 }); }; // Within a source, a name that actually contains the query outranks the // search relay's looser relevance matches. const score = (r) => ((r.name || '').toLowerCase().replace(/\s+/g, ' ').includes(q) || (r.address || r.nip05 || '').toLowerCase().includes(q)) ? 0 : 1; // A row is `known` once its profile has been looked up (or arrived with the // search itself): only then does a missing picture mean "has none". const snapshot = () => { const rows = [...out.values()].sort((a, b) => a.pri - b.pri || score(a) - score(b)).slice(0, MAX_RESULTS); for (const r of rows) { const p = profileCache.get(r.pk); if (p) { r.picture ||= p.picture; r.name ||= p.name; r.known = true; } } return rows; }; const emit = () => { if (onPartial && out.size) onPartial(snapshot()); }; const exact = parseNostrPubkey(q); if (exact) { add(exact, {}, 0); emit(); } 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)); emit(); }), (async () => { if (exact) return; // an npub needs no full-text search 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, nip05: typeof m.nip05 === 'string' ? m.nip05.replace(/^_@/, '') : undefined }, pri); } catch {} }; // Primal matches "jordanmiller" but not "jordan miller": ask both ways const joined = q.replace(/ /g, ''); const primal = (await Promise.all(joined === q ? [primalSearch(q)] : [primalSearch(q), primalSearch(joined)])).flat(); for (const e of primal) noteKind0(e, 2); if (!primal.length) { const evs = await queryOn(SEARCH_RELAYS, { kinds: [0], search: q, limit: MAX_RESULTS }, 3500); for (const e of evs) noteKind0(e, 3); } emit(); })(), ]); const rows = snapshot(); await fillProfiles(rows).catch(() => {}); for (const r of rows) r.known = true; // looked: no picture now means none if (queryCache.size > 200) { queryCache.clear(); queryAt.clear(); } queryCache.set(q, rows); queryAt.set(q, Date.now()); persistCaches(); 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 s = q.trim().toLowerCase().replace(/\s+/g, ' '); // Get something on screen fast, then relax: the first short query fires // almost immediately (that's the wait the user actually feels), while // longer ones — mid-word keystrokes — wait long enough to skip the // characters still being typed. Cached prefixes are instant. const wait = queryCache.has(s) ? 0 : s.length <= 3 ? 120 : 300; timer = setTimeout(async () => { // partial results paint as each source lands; the final call settles it const rows = await searchRecipients(q, (partial) => { if (my === seq) onUpdate(q, partial); }).catch(() => []); if (my === seq) onUpdate(q, rows); }, wait); }, clear(notify = true) { seq++; clearTimeout(timer); if (notify) onUpdate('', null); }, }; } // Candidate rows (avatar + name + address/npub), styled with the chat list // classes both pages already ship. export function resultRows(h, rows, onPick, wrapAvatar) { return rows.map((r) => { const ava = r.picture // background, not : paints synchronously from cache, so rows // re-rendered per keystroke don't flash their avatars ? h('div', { class: 'chat-avatar ava-img', style: `background-image:url(${JSON.stringify(r.picture)})` }) // A registrar hit lands before its profile does. Punk art means "has // no picture", never "picture not here yet" — a new account that // picked another punk or uploaded a photo would flash the wrong face. : !r.known ? h('div', { class: 'chat-avatar fallback loading' }) : fallbackAvatar(h, r.pk, r.name, 'chat-avatar'); return h('div', { class: 'item chat-thread-row', onClick: () => onPick(r) }, (wrapAvatar && wrapAvatar(r.pk, ava)) || ava, h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, h('div', { class: 'chat-name' }, r.name || (npubOf(r.pk) || '').slice(0, 12)), h('div', { class: 'muted small chat-preview' }, r.address || r.nip05 || (npubOf(r.pk) || '').slice(0, 24) + '…'))); }); }