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 | |
| 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')
| -rw-r--r-- | src/app.js | 20 | ||||
| -rw-r--r-- | src/features/messages.js | 64 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | src/recipient-search.js | 121 | ||||
| -rw-r--r-- | src/style.css | 1 |
5 files changed, 191 insertions, 18 deletions
@@ -6,6 +6,8 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; import { qrSvg } from './qr.js'; +import { makeSearcher, resultRows, searchable } from './recipient-search.js'; +import { npubOf } from './nostr.js'; import { scanQr } from './scan.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; import { buildFeatures } from './features/index.js'; @@ -2403,6 +2405,11 @@ function destReady(a) { return !!a && (wallet.isOnchainAddress(a) || FEATURES.some((f) => f.isSendDest && f.isSendDest(a))); } +// Recipient search under the destination input: usernames and npubs resolve +// to candidates with avatars; picking one runs the same path as pasting. +const sendSearch = { rows: null }; +const sendSearcher = makeSearcher((q, rows) => { sendSearch.rows = rows; render(); }); + // One recipient: address + amount. Max is only offered for a single recipient. function recipientRow(s, r, i) { const single = s.recipients.length === 1; @@ -2433,12 +2440,22 @@ function recipientRow(s, r, i) { r.address = v; syncCheck(); const hadError = !!ui.sendError; ui.sendError = ''; // editing the destination starts over — drop stale errors + if (i === 0) sendSearcher.update(v); // candidates render when results land if (tryFeature(v, true)) return; // a bolt11 advances to its own confirmation // reveal/hide amount + controls as validity flips, an annotation // (zap button) appears/disappears, or an error clears if (hadError || destReady(v) !== r._ready || !!featureHook('sendFormNote', v) !== r._note) render(); }, }); + const pickRecipient = (cand) => { + const v = cand.address || npubOf(cand.pk); + sendSearcher.clear(); + addrInput.value = v; + r.address = v; + ui.sendError = ''; + syncCheck(); + if (!featureMatchSend(v)) render(); + }; const row = h( 'div', { class: 'col gap6' }, @@ -2452,6 +2469,9 @@ function recipientRow(s, r, i) { !single && h('button', { type: 'button', class: 'btn-sm', title: t('remove'), onClick: () => { s.recipients.splice(i, 1); render(); } }, '✕') ), check, + i === 0 && sendSearch.rows && sendSearch.rows.length && searchable(r.address) + ? h('div', { class: 'list send-suggest' }, resultRows(h, sendSearch.rows, pickRecipient)) + : null, r._ready ? h('div', { class: 'input-group' }, h('input', { type: 'number', step: unit === 'sats' ? '1' : '0.00000001', min: '0', diff --git a/src/features/messages.js b/src/features/messages.js index eedeb6a..ed8cefd 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -22,6 +22,7 @@ import { communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle, } from '../concord.js'; import { makeDM, unwrapDM, wrapDM } from '../dm.js'; +import { makeSearcher, resultRows } from '../recipient-search.js'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { t } from '../i18n.js'; @@ -752,6 +753,20 @@ export function messagesFeature(ctx) { const backBtn = (onClick) => h('button', { class: 'iconbtn chat-back', onClick }, '‹'); + // Recipient search for New message: debounced, sequenced, cached upstream. + const dmSearch = { rows: null, busy: false }; + const dmSearcher = makeSearcher((q, rows) => { + dmSearch.rows = rows; + dmSearch.busy = false; + scheduleRepaint(); + }); + const dmSearcherUpdate = dmSearcher.update.bind(dmSearcher); + dmSearcher.update = (q) => { + const willSearch = q && q.trim().length >= 2; + if (willSearch) { dmSearch.busy = dmSearch.rows === null || !dmSearch.rows.length; } + dmSearcherUpdate(q); + }; + const stickToBottom = () => { queueMicrotask(() => { const log = document.querySelector('.chat-log'); @@ -798,24 +813,37 @@ export function messagesFeature(ctx) { kids.push(h('div', { class: 'row between', style: 'align-items:baseline' }, h('h3', { style: 'margin:0' }, t('msgDmsTitle')), h('button', { class: 'btn-sm', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'newdm' ? null : 'newdm'; render(); } }, t('msgNewDm')))); - if (ui.msgHomePanel === 'newdm') - kids.push(h('div', { class: 'row gap6' }, - h('input', { - class: 'grow', type: 'text', placeholder: t('msgNpubPlaceholder'), - value: ui.msgNewDmTo || '', onInput: (e) => { ui.msgNewDmTo = e.target.value; }, - }), - h('button', { - class: 'btn-sm', onClick: () => { - const pk = parseNostrPubkey(ui.msgNewDmTo); - if (!pk) { toast(t('msgBadNpub')); return; } - ui.msgNewDmTo = ''; - ui.msgHomePanel = null; - ui.msgView = 'dm'; - ui.msgPeer = pk; - ui.msgStick = true; - render(); - }, - }, t('msgOpen')))); + if (ui.msgHomePanel === 'newdm') { + const openThread = (pk) => { + dmSearcher.clear(); + ui.msgNewDmTo = ''; + ui.msgHomePanel = null; + ui.msgView = 'dm'; + ui.msgPeer = pk; + ui.msgStick = true; + render(); + }; + kids.push(h('div', { class: 'col gap6' }, + h('div', { class: 'row gap6' }, + h('input', { + class: 'grow', type: 'text', placeholder: t('msgSearchPlaceholder'), + value: ui.msgNewDmTo || '', + onInput: (e) => { ui.msgNewDmTo = e.target.value; dmSearcher.update(e.target.value); }, + }), + h('button', { + class: 'btn-sm', onClick: () => { + const pk = parseNostrPubkey(ui.msgNewDmTo); + if (!pk) { toast(t('msgBadNpub')); return; } + openThread(pk); + }, + }, t('msgOpen'))), + dmSearch.rows === null ? null + : dmSearch.busy ? h('div', { class: 'row gap6', style: 'align-items:center;padding:4px 0' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('msgSearching'))) + : dmSearch.rows.length + ? h('div', { class: 'list' }, resultRows(h, dmSearch.rows, (r) => openThread(r.pk))) + : h('div', { class: 'small muted' }, t('msgNoMatches')))); + } kids.push( dmRows.length ? h('div', { class: 'list' }, dmRows.map(({ peer, last }) => diff --git a/src/i18n.js b/src/i18n.js index d8e5ce0..8ca63cc 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -168,6 +168,9 @@ const STR = { msgYouPrefix: 'You:', msgOpen: 'Open', msgNpubPlaceholder: 'npub…', + msgSearchPlaceholder: 'Name or npub…', + msgSearching: 'Searching…', + msgNoMatches: 'No matches', msgBadNpub: 'Not a valid npub', msgSignerNoDm: 'This signer cannot encrypt DMs — log in again', msgJoin: 'Join', 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) + '…')))); +} diff --git a/src/style.css b/src/style.css index 7c30d81..82a8c09 100644 --- a/src/style.css +++ b/src/style.css @@ -388,3 +388,4 @@ hr.divider { margin: 16px 0; } .balance-move .seg { display: flex; width: 100%; } .balance-move .seg button { flex: 1; } .balance-seg { display: inline-flex; margin-bottom: 6px; } +.send-suggest { max-height: 260px; overflow-y: auto; } |
