aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 17:51:37 -0700
committerAdam Soltys <[email protected]>2026-08-02 17:51:37 -0700
commitd33643e0683dfac4037c0b61d4aa823782f582e7 (patch)
treeff185690f42cbb07d3fa925b2b7e5f8db5e1e53c /src/features
parent64f96e031e6ea219cc03eb8aa2cc4d0cf7e1f4e4 (diff)
downloadcoinosv3-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.js64
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 }) =>