From d45c2f2e5a3a7c7e50b701a82c15866a8dc96811 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 15 Aug 2026 12:53:23 -0700 Subject: The social layer grows up: a header magnifier finds anyone on nostr, ⚡ zaps your saved amount in one tap (asking once if you haven't picked one), and tapping a note opens its thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv --- src/app.js | 32 +++++++++- src/features/ark.js | 69 ++++++++++++++------- src/features/messages.js | 152 +++++++++++++++++++++++++++++++++++++++++++---- src/features/zaps.js | 52 +++++++++++++++- src/i18n.js | 12 ++++ 5 files changed, 280 insertions(+), 37 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 4e46f30..babd095 100644 --- a/src/app.js +++ b/src/app.js @@ -1644,13 +1644,31 @@ function messagesBtn() { class: 'header-msgs' + (unread ? ' unread' : ''), title: t('msgDmsTitle'), 'aria-label': t('msgDmsTitle'), - onClick: () => { ui.profilePk = null; ui.chatOpen = true; ui.msgView = 'home'; render(); }, + onClick: () => { ui.profilePk = null; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null; ui.chatOpen = true; ui.msgView = 'home'; render(); }, }, h('span', { class: 'hm-ico', html: '', })); } +// Find anyone on nostr from the header — results open their profile. +function searchBtn() { + if (!featureHook('userSearchAvailable')) return null; + return h('button', { + class: 'header-msgs', + title: t('searchUsers'), + 'aria-label': t('searchUsers'), + onClick: () => { + featureHook('openUserSearch'); + render(); + setTimeout(() => document.querySelector('.user-search-input')?.focus(), 60); + }, + }, h('span', { + class: 'hm-ico', + html: '', + })); +} + function brandHeader(withLock) { const acc = activeAccount(); return h( @@ -1663,6 +1681,7 @@ function brandHeader(withLock) { ), withLock ? h('div', { class: 'row gap6', style: 'align-items:center' }, + searchBtn(), messagesBtn(), h('button', { class: 'btn-sm', onClick: () => { ui.screen = 'accounts'; render(); } }, acc ? acc.label : t('accounts')), @@ -1928,6 +1947,9 @@ function goHome() { ui.chatOpen = false; ui.profilePk = null; ui.profEdit = null; + ui.userSearch = null; + ui.noteThread = null; + ui.zapSetup = null; ui.arkExitPage = null; ui.settingsPage = null; ui.addrScan = false; @@ -3773,6 +3795,14 @@ async function importSnapshotFile(e) { const ctx = { h, ui, render, wallet, toast, copy, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal, fmtAmount, unitLabel, unitTag, parseAmount, getUnit: () => unit, toggleUnit, download, + // the one-tap zap amount, synced across devices with the rest of the state + zapDefaultSat: () => (wallet.loadFeatureState ? (wallet.loadFeatureState('prefs', {}).zapSat || 0) : 0), + setZapDefaultSat: (n) => { + const p = wallet.loadFeatureState('prefs', {}); + p.zapSat = Math.max(1, Math.floor(n) || 0); + wallet.saveFeatureState('prefs', p); + try { wallet.saveCache(); } catch {} + }, brandHeader, activeAccount, setAccounts: (list) => { accounts = list; }, getAccounts: () => accounts, claimTargets, enterWallet, activateAccount, commitAccount, diff --git a/src/features/ark.js b/src/features/ark.js index aa9623b..6ce95ca 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -960,19 +960,31 @@ export function arkFeature(ctx) { // 2. lightning: profile lud16/lud06, via the zap flow // 3. an on-chain address from the BIP-353 record const NAMES_REGISTRAR = 'https://names.coinos.io'; - function startNpubPay(pk, npub, eventId = null) { - const z = (ui.arkZap = { npub, pk, eventId, amount: '', comment: '', status: 'lookup' }); + function startNpubPay(pk, npub, eventId = null, autoSat = 0) { + const z = (ui.arkZap = { npub, pk, eventId, amount: '', comment: '', status: 'lookup', autoSat }); ui.zap = null; // a stale Lightning-zap card must not resurface behind ours ui.sendError = ''; render(); const live = () => ui.arkZap === z; + // an instant zap: resolution succeeded — pay the default amount now, no + // form, and report by toast; failures fall back to the classic form + const auto = async (label) => { + try { + await performArkZap(z, z.autoSat); + if (ui.arkZap === z) ui.arkZap = null; + toast('⚡ ' + t('zapSentShort', { n: fmtAmount(z.autoSat) + ' ' + unitLabel() })); + render(); + } catch (e) { + z.autoSat = 0; ui.tab = 'send'; ui.sendError = e.message; render(); + } + }; (async () => { connectArk().catch(() => {}); // 1a. ark advert const adv = await lookupArkZapTarget(pk).catch(() => ({ status: 'noark' })); if (!live()) return; - if (adv.status === 'ready') { Object.assign(z, adv); render(); return; } - if (adv.status === 'wrongnet') { Object.assign(z, adv); render(); return; } + if (adv.status === 'ready') { Object.assign(z, adv); if (z.autoSat) return auto(); render(); return; } + if (adv.status === 'wrongnet') { Object.assign(z, adv); if (z.autoSat) { z.autoSat = 0; ui.tab = 'send'; } render(); return; } // 1b. BIP-353 with an ark instruction; remember on-chain as last resort const uris = []; try { @@ -994,24 +1006,30 @@ export function arkFeature(ctx) { const dec = parseBip21Uri(uri); if (!dec) continue; const arkAddr = dec.params && dec.params.ark; - if (arkAddr && isArkAddress(arkAddr)) { Object.assign(z, { status: 'ready', address: arkAddr }); render(); return; } + if (arkAddr && isArkAddress(arkAddr)) { + Object.assign(z, { status: 'ready', address: arkAddr }); + if (z.autoSat) return auto(); + render(); + return; + } if (dec.onchain && !onchain) onchain = dec.onchain; } // 2. lightning via the zap flow (connect first — canLnPay needs it) if (profile && (profile.lud16 || profile.lud06)) { await connectArk().catch(() => {}); if (!live()) return; - if (ctx.hook('canLnZap')) { ctx.hook('lnZapNpub', pk, npub, z.eventId); return; } + if (ctx.hook('canLnZap')) { ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; } } // 3. on-chain fallback if (onchain) { ui.arkZap = null; ui.send.recipients[0].address = onchain; + if (z.autoSat) ui.tab = 'send'; render(); return; } - if (live()) { z.status = 'noark'; render(); } - })().catch((e) => { if (live()) { z.status = 'noark'; ui.sendError = e.message; render(); } }); + if (live()) { z.status = 'noark'; if (z.autoSat) { z.autoSat = 0; ui.tab = 'send'; } render(); } + })().catch((e) => { if (live()) { z.status = 'noark'; if (z.autoSat) { z.autoSat = 0; ui.tab = 'send'; } ui.sendError = e.message; render(); } }); } // shared by the ark and lightning zap flows (see zaps.js for the twin) @@ -1039,25 +1057,30 @@ export function arkFeature(ctx) { return { status: 'ready', address: addr }; } + // The zap itself, shared by the form and the one-tap default-amount path. + async function performArkZap(z, sats) { + const mgr = await connectArk(); + const actionId = await mgr.send(z.address, sats); + const action = mgr.state.actions.find((a) => a.id === actionId); + if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed')); + const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id; + noteZap('to:' + z.address, z.pk); + // the receipt is best-effort: the sats are already delivered via mailbox + await wallet.nostrPublish({ + kind: ARK_ZAP_KIND, + content: (z.comment || '').slice(0, 280), + tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()]], + }).catch(() => {}); + } + async function doArkZap() { const z = ui.arkZap; const sats = ctx.parseAmount(z.amount, ctx.getUnit()); if (!sats || sats <= 0) { ui.sendError = t('enterValidAmtForN', { n: 1 }); render(); return; } ui.busy = true; ui.sendError = ''; render(); try { - const mgr = await connectArk(); - const actionId = await mgr.send(z.address, sats); - const action = mgr.state.actions.find((a) => a.id === actionId); - if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed')); - const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id; - noteZap('to:' + z.address, z.pk); - // the receipt is best-effort: the sats are already delivered via mailbox - await wallet.nostrPublish({ - kind: ARK_ZAP_KIND, - content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), - ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()]], - }).catch(() => {}); + await performArkZap(z, sats); ui.arkZapped = { amountSat: sats, npub: z.npub }; ui.arkZap = null; } catch (e) { @@ -2136,9 +2159,9 @@ export function arkFeature(ctx) { // ⚡ button) — through the same resolution ladder as a pasted npub. The // event id rides into the receipt's e tag; callers fall back to the // lnZapNpub hook when ark can't serve this build/wallet. - zapNpub(pk, npub, eventId) { + zapNpub(pk, npub, eventId, autoSat) { if (!arkAvailable() || !wallet.nostrFetch) return false; - startNpubPay(pk, npub, eventId || null); + startNpubPay(pk, npub, eventId || null, autoSat || 0); return true; }, historyEntries() { diff --git a/src/features/messages.js b/src/features/messages.js index 912f71b..ec77a43 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1100,24 +1100,60 @@ export function messagesFeature(ctx) { return out; } - // Zap a specific note: ark first (instant, free, e-tagged receipt), else - // the Lightning zap flow — same ladder as paying the person directly. + // Zap a specific note. With a default amount configured this is ONE TAP: + // the zap fires instantly (ark first, Lightning fallback) and reports by + // toast, no form, no leaving the page. Without one, a small setup screen + // asks once and remembers. function zapNote(pk, ev) { const npubStr = npubOf(pk); - ui.profilePk = null; - ui.chatOpen = false; - ui.tab = 'send'; - render(); - if (!hook('zapNpub', pk, npubStr, ev.id)) hook('lnZapNpub', pk, npubStr, ev.id); + const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; + if (!def) { ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21' }; render(); return; } + if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) { + // no instant path in this build — the classic form flow + ui.profilePk = null; + ui.chatOpen = false; + ui.tab = 'send'; + render(); + if (!hook('zapNpub', pk, npubStr, ev.id)) hook('lnZapNpub', pk, npubStr, ev.id); + } + } + + // First ⚡ tap ever: pick the amount one time, then every zap is one tap. + function zapSetupScreen() { + const s = ui.zapSetup; + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, '⚡ ' + t('zapSetupTitle')), + h('div', { class: 'small muted' }, t('zapSetupDesc')), + h('div', { class: 'input-group' }, + h('input', { type: 'number', min: '1', value: s.amount, onInput: (e) => { s.amount = e.target.value; } }), + h('span', { class: 'small muted', style: 'align-self:center;padding:0 8px' }, 'sats')), + h('button', { class: 'btn-primary btn-block', onClick: () => { + const n = parseInt(s.amount, 10); + if (!n || n <= 0) { toast(t('enterValidAmtForN', { n: 1 })); return; } + ctx.setZapDefaultSat(n); + const { pk, npub, eventId } = s; + ui.zapSetup = null; + render(); + if (!hook('zapNpub', pk, npub, eventId, n)) hook('lnZapNpub', pk, npub, eventId, n); + } }, t('zapSetupSave'))), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.zapSetup = null; render(); } }, t('back'))); } // One post as a feed row (avatar · name · time · body), twitter/jumble // style: rows share a scrollable container and are split by hairlines - // rather than floating in their own cards. - function noteRow(pk, ev, name) { + // rather than floating in their own cards. Tapping a row opens its thread. + function noteRow(pk, ev, name, { open = true, focus = false } = {}) { const isReply = ev.tags.some((x) => x[0] === 'e'); const canZap = !isMe(pk) && !!(hook('arkReady') || hook('canLnZap')); - return h('div', { class: 'row', style: 'gap:10px;align-items:flex-start;padding:10px 0' }, + return h('div', { + class: 'row', + style: 'gap:10px;align-items:flex-start;padding:10px 0' + + (open ? ';cursor:pointer' : '') + + (focus ? ';box-shadow:inset 3px 0 0 var(--accent,#7c3aed);padding-left:8px;margin-left:-8px' : ''), + onClick: open ? () => openNoteThread(ev) : undefined, + }, avatar(pk, 'chat-avatar', false), h('div', { class: 'col grow', style: 'min-width:0;gap:3px' }, h('div', { class: 'row between', style: 'align-items:center;gap:8px' }, @@ -1125,10 +1161,65 @@ export function messagesFeature(ctx) { h('span', { style: 'font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' }, name), h('span', { class: 'small faint', style: 'white-space:nowrap' }, (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000))), - canZap ? h('button', { class: 'btn-sm', title: t('zapTitle'), onClick: () => zapNote(pk, ev) }, '⚡') : null), + canZap ? h('button', { class: 'btn-sm', title: t('zapTitle'), onClick: (e) => { e.stopPropagation(); zapNote(pk, ev); } }, '⚡') : null), h('div', { class: 'small', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content)))); } + // ---- thread view: a note in its conversation ---------------------------- + const threadCache = new Map(); // root id -> { status, root, replies } + const noteSep = () => h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' }); + function rootIdOf(ev) { + const es = ev.tags.filter((x) => x[0] === 'e'); + const marked = es.find((x) => x[3] === 'root'); + return (marked || es[0] || [])[1] || ev.id; + } + function threadFor(seed) { + const rootId = rootIdOf(seed); + let c = threadCache.get(rootId); + if (c) return c; + c = { status: 'loading', rootId, root: seed.id === rootId ? seed : null, replies: [] }; + threadCache.set(rootId, c); + (async () => { + const [roots, replies] = await Promise.all([ + c.root ? Promise.resolve([]) : queryOn(NOTE_RELAYS, { kinds: [1], ids: [rootId] }, 4000), + queryOn(NOTE_RELAYS, { kinds: [1], '#e': [rootId], limit: 80 }, 4500), + ]); + if (!c.root) c.root = (roots || [])[0] || null; + const seen = new Set([rootId]); + c.replies = (replies || []) + .filter((e) => !seen.has(e.id) && seen.add(e.id)) + .sort((a, b) => a.created_at - b.created_at); + c.status = 'ready'; + if (ui.noteThread && ui.noteThread.rootId === rootId) render(); + })().catch(() => { + c.status = 'ready'; + if (ui.noteThread && ui.noteThread.rootId === rootId) render(); + }); + return c; + } + function openNoteThread(ev) { + ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev }; + render(); + } + function threadScreen() { + const s = ui.noteThread; + const c = threadFor(s.seed); + const row = (ev) => noteRow(ev.pubkey, ev, displayName(ev.pubkey), { open: false, focus: ev.id === s.focusId && c.replies.length > 0 }); + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:0;padding:2px 14px' }, + c.root ? row(c.root) + : h('div', { class: 'small faint', style: 'padding:10px 0' }, + c.status === 'loading' ? '…' : t('threadRootMissing')), + ...c.replies.flatMap((ev) => [noteSep(), row(ev)]), + c.status === 'loading' + ? h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' })) + : !c.replies.length + ? h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies')) + : null), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.noteThread = null; render(); } }, t('back'))); + } + async function saveProfile() { const id = await identity(); if (!id) { toast(t('msgNoIdentity')); return; } @@ -1268,6 +1359,34 @@ export function messagesFeature(ctx) { const backBtn = (onClick) => h('button', { class: 'iconbtn chat-back', onClick }, '‹'); + // ---- user search: the header magnifier ---------------------------------- + // Same engine the Send form and DMs use (registrar names + Primal cache + + // NIP-50 relays); tapping a result opens their profile, and profile-Back + // lands here again since the search state survives. + const userSearcher = makeSearcher((q, rows) => { + if (ui.userSearch && ui.userSearch.q === q) { ui.userSearch.rows = rows; render(); } + }); + function userSearchScreen() { + const s = ui.userSearch; + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, t('searchUsers')), + h('input', { + type: 'text', class: 'user-search-input', placeholder: t('searchUsersHint'), value: s.q, + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + onInput: (e) => { s.q = e.target.value; userSearcher.update(s.q); }, + }), + s.rows && s.rows.length + ? h('div', { class: 'list' }, resultRows(h, s.rows, (r) => { + if (r.pk) { openProfile(r.pk); render(); } + })) + : s.rows && s.q.trim().length >= 2 + ? h('div', { class: 'small faint', style: 'text-align:center;padding:6px' }, t('searchNoResults')) + : h('div', { class: 'small faint', style: 'text-align:center;padding:6px' }, t('searchUsersEmpty'))), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.userSearch = null; render(); } }, t('back'))); + } + // ---- presence & typing -------------------------------------------------- // Both ride the channel stream as 21059 wraps, so they stay inside the // community's encryption — who's around is members-only, not a public @@ -1873,12 +1992,23 @@ export function messagesFeature(ctx) { notifySettingsCards() { return [notifyCard()]; }, screenView() { if (ui.screen !== 'wallet') return null; + if (ui.zapSetup) return zapSetupScreen(); + if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); + if (ui.userSearch && !ui.chatOpen) return userSearchScreen(); if (!ui.chatOpen) return null; return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(true), messagesTab()); }, + // The header magnifier: search anyone on nostr, results open profiles. + userSearchAvailable() { return true; }, + openUserSearch() { + ui.chatOpen = false; + ui.profilePk = null; + ui.userSearch = { q: '', rows: null }; + return true; + }, // Anyone (ark's history, other features) can open a profile or render a // small clickable identity chip. showProfile(pk) { openProfile(pk); return true; }, diff --git a/src/features/zaps.js b/src/features/zaps.js index 3f1b599..0c76631 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -16,7 +16,7 @@ import { npubOf } from '../nostr.js'; import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice } from '../lnurl.js'; export function zapsFeature(ctx) { - const { h, ui, render, wallet, blankSend, parseAmount, getUnit, unitTag, hook } = ctx; + const { h, ui, render, wallet, blankSend, parseAmount, getUnit, unitTag, hook, toast, fmtAmount, unitLabel } = ctx; // Lightning payment has to be possible for a zap to make sense. const canPay = () => !wallet.watchOnly && !!hook('canLnPay'); @@ -52,6 +52,36 @@ export function zapsFeature(ctx) { render(); } + // One-tap zap at the user's default amount: resolve, invoice, pay through + // the ark seam (its quote + retries included), and report by toast — the + // whole flow without a single screen. + async function autoZap(target, sats) { + const profile = await wallet.nostrProfile(target.pk); + const lnTarget = zapTargetFromProfile(profile, target.pk); + if (!lnTarget) throw new Error(t('lnZapNoAddress')); + const t2 = { ...lnTarget, eventId: target.eventId || null }; + const p = await fetchPayParams(t2.url); + const msat = sats * 1000; + if (msat < p.minSendable || msat > p.maxSendable) { + throw new Error(t('lnZapRange', { + min: Math.ceil(p.minSendable / 1000).toLocaleString(), + max: Math.floor(p.maxSendable / 1000).toLocaleString(), + })); + } + let zapRequest = null; + if (p.allowsNostr && t2.pk && wallet.nostrSign) { + zapRequest = buildZapRequest({ + amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: t2.lnurlBech32, + recipientPk: t2.pk, eventId: t2.eventId || null, comment: '', + signFn: (partial) => wallet.nostrSign(partial), + }); + } + const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: t2.lnurlBech32 }); + await hook('arkPayInvoice', invoice, { maxAmountSat: sats + 50 }); + toast('⚡ ' + t('zapSentShort', { n: fmtAmount(sats) + ' ' + unitLabel() })); + render(); + } + async function confirm() { const z = ui.zap; if (!z || z.status !== 'ready') return; @@ -201,11 +231,29 @@ export function zapsFeature(ctx) { // Ark's "no Ark address published" screen offers a Lightning fallback, // which lands here with the recipient's pubkey (and, when the zap was // aimed at a note, its event id for the zap request's e tag). - lnZapNpub(pk, npub, eventId) { + lnZapNpub(pk, npub, eventId, autoSat) { if (!canPay() || !wallet.nostrProfile) return false; + if (autoSat && hook('arkReady')) { + autoZap({ kind: 'npub', pk, eventId: eventId || null }, autoSat) + .catch((e) => toast('⚡ ' + e.message)); + return true; + } begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk))); return true; }, sendView() { return zapView(); }, + // Settings → Payments: the one-tap zap amount. + settingsCards() { + const cur = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; + return [h('div', { class: 'card col', style: 'gap:8px' }, + h('div', { class: 'row between', style: 'align-items:center;gap:10px' }, + h('span', { class: 'small' }, '⚡ ' + t('zapDefaultLabel')), + h('div', { class: 'input-group', style: 'width:130px' }, + h('input', { type: 'number', min: '1', value: cur || '', placeholder: '21', + style: 'text-align:right', + onInput: (e) => { const n = parseInt(e.target.value, 10); if (n > 0) ctx.setZapDefaultSat(n); } }), + h('span', { class: 'small muted', style: 'align-self:center;padding:0 6px' }, 'sats'))), + h('div', { class: 'small faint' }, t('zapDefaultHint')))]; + }, }; } diff --git a/src/i18n.js b/src/i18n.js index d21ee36..357f59f 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -668,6 +668,18 @@ const STR = { nwcPaidTitle: 'Payment sent', nwcPaidBody: '-{amount} sats via {name}', settingsNotifications: 'Notifications', + searchUsers: 'Search', + zapSentShort: 'Zapped {n}', + zapSetupTitle: 'Set your zap amount', + zapSetupDesc: 'One tap on ⚡ sends this amount instantly — no form, no confirmation. You can change it any time in Settings → Payments.', + zapSetupSave: 'Save and zap', + zapDefaultLabel: 'One-tap zap amount', + zapDefaultHint: 'What a single tap of the ⚡ button sends.', + threadNoReplies: 'No replies yet.', + threadRootMissing: 'The original post could not be found.', + searchUsersHint: 'Name, npub, or payment address…', + searchUsersEmpty: 'Find anyone on nostr and open their profile.', + searchNoResults: 'No one found.', notifTitle: 'Notifications', notifEnable: 'Enable notifications on this device', notifEnabled: 'Notifications enabled', -- cgit v1.2.3