diff options
| author | Adam Soltys <[email protected]> | 2026-09-02 18:56:04 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-02 18:56:04 -0700 |
| commit | c4357ab5563aacc3614bfaf8f54cfb5c79a7f1ba (patch) | |
| tree | 242d39680e551d034fae6576a8ddc7c8f67cb12e /src | |
| parent | d5e590e655c989cb855328e28349df2129adcd18 (diff) | |
| download | coinosv3-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.js | 8 | ||||
| -rw-r--r-- | src/features/messages.js | 5 | ||||
| -rw-r--r-- | src/recipient-search.js | 57 |
3 files changed, 47 insertions, 23 deletions
@@ -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'; |
