aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-15 12:53:23 -0700
committerAdam Soltys <[email protected]>2026-08-15 12:53:23 -0700
commitd45c2f2e5a3a7c7e50b701a82c15866a8dc96811 (patch)
treee3c539e761678bd3eee3eb63cdbf33e4de0a9141 /src/features
parent19ec7b5493009e36a5232bf3a7d13caf17330f83 (diff)
downloadcoinosv3-d45c2f2e5a3a7c7e50b701a82c15866a8dc96811.tar.gz
coinosv3-d45c2f2e5a3a7c7e50b701a82c15866a8dc96811.zip
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
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js69
-rw-r--r--src/features/messages.js152
-rw-r--r--src/features/zaps.js52
3 files changed, 237 insertions, 36 deletions
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')))];
+ },
};
}