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/features | |
| 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/features')
| -rw-r--r-- | src/features/messages.js | 64 |
1 files changed, 46 insertions, 18 deletions
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 }) => |
