1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
|
// 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';
// 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).
export const punkUrl = (pk) =>
`https://coinos.io/api/public/punks/${Math.floor((parseInt(pk.slice(-2), 16) / 256) * 64) + 1}.webp`;
export const fallbackAvatar = (h, pk, name, cls) =>
h('div', { class: cls + ' fallback' },
h('img', { class: 'punk', src: punkUrl(pk), alt: '', onError: (e) => { e.target.style.display = 'none'; } }),
(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.
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
// 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 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 {}
};
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);
})(),
]);
// 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 || r.nip05 || '').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);
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: '' })
: fallbackAvatar(h, r.pk, r.name, 'chat-avatar'),
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) + '…'))));
}
|