aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-02 18:56:04 -0700
committerAdam Soltys <[email protected]>2026-09-02 18:56:04 -0700
commitc4357ab5563aacc3614bfaf8f54cfb5c79a7f1ba (patch)
tree242d39680e551d034fae6576a8ddc7c8f67cb12e /src
parentd5e590e655c989cb855328e28349df2129adcd18 (diff)
downloadcoinosv3-c4357ab5563aacc3614bfaf8f54cfb5c79a7f1ba.tar.gz
coinosv3-c4357ab5563aacc3614bfaf8f54cfb5c79a7f1ba.zip
Recipient search warms its pipes on focus, not first keystroke: the Primal cache socket, the registrar's TLS session, and the NIP-50 relay connections each cost a handshake that used to land on the first query — the cold second of dead air. Touching the send recipient field, opening user search, or unfolding New message now pre-opens all three (throttled; the idle reaper still closes the socket a minute after real use)
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
Diffstat (limited to 'src')
-rw-r--r--src/app.js8
-rw-r--r--src/features/messages.js5
-rw-r--r--src/recipient-search.js57
3 files changed, 47 insertions, 23 deletions
diff --git a/src/app.js b/src/app.js
index a818d67..da724dc 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6,7 +6,7 @@
import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js';
import { qrSvg } from './qr.js';
-import { makeSearcher, resultRows, searchable, punkUrl } from './recipient-search.js';
+import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js';
import { npubOf } from './nostr.js';
import { nip98Header } from './nip98.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
@@ -3870,8 +3870,10 @@ function recipientRow(s, r, i) {
type: 'text', class: 'mono-input grow', placeholder: i === 0 ? t('destPlaceholder') : 'bc1q…',
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: r.address,
// the delay lets the keyboard start opening (and any render() swap of
- // this input) before parkSendField measures the live activeElement
- onFocus: () => setTimeout(parkSendField, 300),
+ // this input) before parkSendField measures the live activeElement.
+ // warmSearch pre-opens the search transports so the first keystroke's
+ // results don't pay three cold handshakes.
+ onFocus: () => { warmSearch(); setTimeout(parkSendField, 300); },
onInput: (e) => {
const v = e.target.value;
r.address = v; syncCheck();
diff --git a/src/features/messages.js b/src/features/messages.js
index ce1975e..3f494e4 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -24,7 +24,7 @@ import {
} from '../concord.js';
import { makeDMRumor, unwrapDM, wrapDM } from '../dm.js';
import { saveInbox } from '../dm-inbox.js';
-import { makeSearcher, resultRows, fallbackAvatar } from '../recipient-search.js';
+import { makeSearcher, resultRows, fallbackAvatar, warmSearch } from '../recipient-search.js';
import { getNetwork } from '../api.js';
import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
import { t } from '../i18n.js';
@@ -2299,7 +2299,7 @@ export function messagesFeature(ctx) {
// ---- DMs
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'))));
+ h('button', { class: 'btn-sm', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'newdm' ? null : 'newdm'; if (ui.msgHomePanel === 'newdm') warmSearch(); render(); } }, t('msgNewDm'))));
if (ui.msgHomePanel === 'newdm') {
const openThread = (pk) => {
dmSearcher.clear();
@@ -2750,6 +2750,7 @@ export function messagesFeature(ctx) {
ui.chatOpen = false;
ui.profilePk = null;
ui.userSearch = { q: '', rows: null };
+ warmSearch(); // the field is about to be typed into — open the pipes
return true;
},
// nostr-login just connected or resumed a signer: wraps the backfill
diff --git a/src/recipient-search.js b/src/recipient-search.js
index bd1767b..a5c5f46 100644
--- a/src/recipient-search.js
+++ b/src/recipient-search.js
@@ -46,28 +46,49 @@ function primalSearch(q, limit = MAX_RESULTS) {
pwsIdle = setTimeout(() => { try { pws?.close(); } catch {} pws = null; }, 60_000);
const send = () => { try { pws.send(JSON.stringify(['REQ', sid, { cache: ['user_search', { query: q, limit }] }])); } catch { finish(); } };
if (pws && pws.readyState === 1) return send();
- if (!pws || pws.readyState > 1) {
- try { pws = new WebSocket(PRIMAL_CACHE); } catch { pws = null; return finish(); }
- 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] && m[2].kind === 0) 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();
- };
- }
+ if (!ensurePws()) return finish();
pws.addEventListener('open', send, { once: true });
});
}
+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] && m[2].kind === 0) 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';