aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js8
-rw-r--r--src/features/names.js2
-rw-r--r--src/features/nostrlogin.js130
-rw-r--r--src/features/pos.js30
4 files changed, 153 insertions, 17 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 318d1cc..d5d286a 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -18104,7 +18104,7 @@ export function messagesFeature(ctx) {
// the message's own custom emoji (an edit brings its own set)
const em = emojiTagMap((edit && edit.author === m.author ? edit.rumor : m.rumor).tags);
return h(
- 'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') },
+ 'div', { class: 'chat-row group' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') },
grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author),
h('div', { class: 'chat-body' },
grouped ? null : h('div', { class: 'chat-meta' },
@@ -18146,8 +18146,7 @@ export function messagesFeature(ctx) {
title: t('msgReact'),
onClick: (e) => { e.stopPropagation(); sendReaction(room, chId, m, emoji); },
}, reactNode(emoji), n > 1 ? ' ' + n : '')))
- : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null, onHold: () => openZapSettings(m.author, m.rumor.id) }))),
- msgZapBtn(m.author, m.rumor.id)))
+ : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null, onHold: () => openZapSettings(m.author, m.rumor.id) })))))
);
});
}
@@ -18767,8 +18766,7 @@ export function messagesFeature(ctx) {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
}),
- }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)),
- m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)),
+ }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m))),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
: [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])),
dmReplyBar(),
diff --git a/src/features/names.js b/src/features/names.js
index 3f23a0c..81b64e6 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -683,6 +683,8 @@ export function namesFeature(ctx) {
onClick: () => copy(addr),
html: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>',
}))),
+ // ask for a sum: the point of sale's amount pad and invoice, simplified
+ hook('posAvailable') ? h('button', { class: 'btn-block', id: 'recv-amount', onClick: () => hook('openAmountRequest') }, t('recvAmount')) : null,
moreSection());
}
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index 4a811fc..6f700b1 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -158,6 +158,17 @@ export function nostrLoginFeature(ctx) {
// and the payment name too — the Receive pane should open with its QR
const nameHintP = ctx.hook('namesLookupByPk', signer.pubkey);
const res = await walletForSigner(signer);
+ // A way back in (added from Settings): the key holds a copy of an
+ // account's seed, and opens that account as ITSELF — its own name,
+ // profile and identity. Nothing about the key is adopted or kept.
+ if (res.mode === 'restored' && res.recovery) {
+ try { if (signer.kind === 'bunker' && signer.close) signer.close(); } catch {}
+ ctx.onbNostrLogin(true);
+ ui.navAnimSkip = true;
+ await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { spendingHint: !!res.spending, spendingOnly: !!res.spendingOnly });
+ busy(false);
+ return;
+ }
if (res.mode === 'new') {
// No wallet on this identity yet — make one and walk straight in.
// The interstitial that used to ask first read as an error screen to
@@ -681,6 +692,124 @@ export function nostrLoginFeature(ctx) {
resumeLogin().then((s) => { if (s) render(); }).catch(() => {});
}
+ // ---- ways back in ------------------------------------------------------
+ // Settings → Wallet: the sign-in methods that can open THIS account on
+ // another device, added after the fact — a passkey, Google, a nostr key —
+ // next to the recovery phrase. Each one gets a copy of the seed encrypted
+ // to its own key (kind 30078, marked `recovery`), so signing in with it
+ // anywhere opens this account as itself. Unlike linking a login, the
+ // account's identity doesn't change.
+ async function addRecovery(makeSigner, via, label = '') {
+ ui.waysBackError = ''; ui.waysBackBusy = via; ui.nostrLoginBusy = true; render();
+ let signer = null;
+ try {
+ if (!wallet.mnemonic) throw new Error(t('nlNeedSeed'));
+ signer = await makeSigner();
+ const st = load();
+ if (st.pubkey && st.pubkey === signer.pubkey) throw new Error(t('waysBackIsLogin'));
+ // a key that already opens a DIFFERENT wallet keeps it: never overwrite
+ const existing = await fetchWalletBackup(signer);
+ if (existing && existing.mnemonic
+ && (existing.mnemonic !== wallet.mnemonic || (existing.passphrase || '') !== (wallet.passphrase || ''))) {
+ throw new Error(t('waysBackTaken'));
+ }
+ await publishWalletBackup(signer, {
+ mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '',
+ spending: true, recovery: true, spendingOnly: !!ctx.spendingOnlyAccount(),
+ });
+ ctx.addRecoveryMethod({ via, id: signer.pubkey, label });
+ ui.waysBackNostr = false;
+ toast(t('waysBackAdded'));
+ } catch (e) {
+ const msg = String((e && e.message) || e || '');
+ // a dismissed passkey or Google dialog is a plain cancel
+ if (!/NotAllowed|abort/i.test(String((e && e.name) || '') + ' ' + msg)) ui.waysBackError = msg;
+ } finally {
+ try { if (signer && signer.kind === 'bunker' && signer.close) signer.close(); } catch {}
+ ui.waysBackBusy = null; ui.nostrLoginBusy = false;
+ render();
+ }
+ }
+ async function passkeyRecovery() {
+ const pass = await import('../passkey.js');
+ const name = ctx.hook('namesAddress') || 'coinos';
+ await addRecovery(async () => keySigner(await pass.passkeyCreate({ name, displayName: name })), 'passkey');
+ }
+ async function googleRecovery() {
+ // opened in the click itself (user activation) — see googleLogin
+ const popup = window.open('about:blank', 'OAuth', 'width=600,height=600');
+ ui.waysBackError = ''; ui.waysBackBusy = 'google'; render();
+ try {
+ const pome = await import('../pomegranate.js');
+ const { uri, email } = await pome.loginWithGoogle(popup, {});
+ await addRecovery(() => bunkerSigner(uri, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }), 'google', email || '');
+ } catch (e) {
+ try { popup && popup.close(); } catch {}
+ ui.waysBackError = (e && e.message) || String(e);
+ ui.waysBackBusy = null;
+ render();
+ }
+ }
+ // pen over a sheet of paper, in the line style of the passkey mark
+ const PAPER_MARK = '<svg width="19" height="19" style="display:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M13 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M8 9h4"/><path d="M8 13h3"/><path d="M8 17h6"/><path d="M18.4 2.6a2 2 0 0 1 2.8 2.8L14 12.6l-3.6.9.9-3.6z"/></svg>';
+ const fmtDay = (ms) => { try { return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } catch { return ''; } };
+ function waysBackCard() {
+ if (wallet.watchOnly || !wallet.mnemonic) return null;
+ const methods = ctx.recoveryMethods() || [];
+ const st = load();
+ const busyVia = ui.waysBackBusy;
+ const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials;
+ const done = (mark, title, detail, key) => h('div', { class: 'row gap6 ways-back-row', 'data-way': key, style: 'align-items:center;padding:8px 2px' },
+ h('span', { style: 'display:flex;flex-shrink:0;width:23px;justify-content:center', html: mark }),
+ h('span', { class: 'col', style: 'flex:1;gap:1px;min-width:0' },
+ h('span', { style: 'font-weight:600' }, title),
+ detail ? h('span', { class: 'small muted break' }, detail) : null),
+ h('span', { class: 'ways-back-check', 'aria-label': t('waysBackOn') }, '✓'));
+ const add = (mark, label, via, onClick) => h('button', {
+ class: 'btn-block ways-back-add', 'data-way': via, disabled: !!busyVia, onClick,
+ }, markedLabel(mark, label, busyVia === via));
+ const rows = [];
+ // the sign-in this account came through is a way back in already
+ if (st.pubkey) {
+ const via = st.via || 'nostr';
+ rows.push(done(via === 'google' ? GOOGLE_MARK : via === 'passkey' ? PASSKEY_MARK : OSTRICH,
+ t('waysBackLogin', { via: via === 'google' ? 'Google' : via === 'passkey' ? t('doorPasskey') : 'Nostr' }),
+ via === 'google' || via === 'passkey' ? '' : (npubOf(st.pubkey) || '').slice(0, 20) + '…', 'login'));
+ }
+ for (const m of methods) {
+ if (m.via === 'passkey') rows.push(done(PASSKEY_MARK, t('doorPasskey'), t('waysBackAddedOn', { date: fmtDay(m.at) }), 'passkey'));
+ else if (m.via === 'google') rows.push(done(GOOGLE_MARK, 'Google', m.label || t('waysBackAddedOn', { date: fmtDay(m.at) }), 'google'));
+ else rows.push(done(OSTRICH, 'Nostr', (npubOf(m.id) || '').slice(0, 20) + '…', 'nostr'));
+ }
+ const seen = ctx.seedSeen();
+ const have = (via) => methods.some((m) => m.via === via) || (via !== 'nostr' && st.via === via);
+ const adds = [
+ hasPasskey && !have('passkey') ? add(PASSKEY_MARK, t('waysBackAddPasskey'), 'passkey', () => passkeyRecovery()) : null,
+ !have('google') ? add(GOOGLE_MARK, t('waysBackAddGoogle'), 'google', () => googleRecovery()) : null,
+ ui.waysBackNostr
+ ? h('div', { class: 'col', style: 'gap:8px' },
+ h('div', { class: 'row between' },
+ h('strong', { class: 'small' }, t('waysBackAddNostr')),
+ h('span', { class: 'linklike small', onClick: () => { ui.waysBackNostr = false; ui.waysBackError = ''; render(); } }, t('cancel'))),
+ signerButtons((make) => addRecovery(make, 'nostr'), { nostrOnly: true }))
+ : add(OSTRICH, t('waysBackAddNostr'), 'nostr', () => { ui.waysBackNostr = true; ui.waysBackError = ''; ui.nostrLoginError = ''; render(); }),
+ // the recovery phrase, as one more button (Adam, 2026-11-15): it opens
+ // the masked words below, ticked once they've been seen
+ h('button', { class: 'btn-block ways-back-add', 'data-way': 'phrase', disabled: !!busyVia, onClick: () => ctx.showRecoveryPhrase() },
+ h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' },
+ h('span', { style: 'display:flex;flex-shrink:0', html: PAPER_MARK }),
+ t('waysBackPaper'),
+ seen ? h('span', { class: 'ways-back-check', 'aria-label': t('waysBackOn') }, '✓') : null)),
+ ].filter(Boolean);
+ return h('div', { class: 'card col ways-back', style: 'gap:6px' },
+ h('h3', {}, t('waysBackTitle')),
+ h('p', { class: 'small muted', style: 'margin:0 0 4px' }, t('waysBackDesc')),
+ h('div', { class: 'col', style: 'gap:0' }, ...rows),
+ adds.length ? h('div', { class: 'col', style: 'gap:8px;margin-top:6px' }, ...adds) : null,
+ ui.waysBackError ? h('div', { class: 'notice err' }, ui.waysBackError) : null,
+ h('p', { class: 'small faint', style: 'margin:4px 0 0' }, t('waysBackNote')));
+ }
+
function settingsCard() {
if (wallet.watchOnly || !wallet.mnemonic) return null;
const st = load();
@@ -879,5 +1008,6 @@ export function nostrLoginFeature(ctx) {
].filter(Boolean);
},
nostrSettingsCards() { return [settingsCard()]; },
+ waysBackCard() { return waysBackCard(); },
};
}
diff --git a/src/features/pos.js b/src/features/pos.js
index 3727862..eb4b0e9 100644
--- a/src/features/pos.js
+++ b/src/features/pos.js
@@ -44,8 +44,11 @@ export function posFeature(ctx) {
// ---- flow ----------------------------------------------------------------
// ui.pos: { step: 'amount'|'tip'|'invoice'|'paid', amount, note, tipMode, tipCustom, sale, error, busy }
- function open() {
- ui.pos = { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '' };
+ // `request`: Receive → Amount (Adam, 2026-11-15) — the same amount pad
+ // and invoice for asking someone for a sum, without the till around it:
+ // no tip prompt, no tip settings or day's sales, and Back is Receive.
+ function open(opts = {}) {
+ ui.pos = { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '', request: !!opts.request };
render();
}
// Digits on screen, into the same field a keyboard or a paste would fill.
@@ -90,7 +93,7 @@ export function posFeature(ctx) {
if (!bill) { p.error = t('posEnterAmount'); render(); return; }
p.error = '';
if (!hook('arkReady')) { p.error = t('posNeedsSpending'); render(); return; }
- if (st().askTip) { p.step = 'tip'; p.tipMode = null; p.tipCustom = ''; render(); return; }
+ if (st().askTip && !p.request) { p.step = 'tip'; p.tipMode = null; p.tipCustom = ''; render(); return; }
makeSale(bill, 0, 0);
}
function tipSat() {
@@ -107,6 +110,7 @@ export function posFeature(ctx) {
id: 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
at: Date.now(), billSat: bill, tipSat: tip, tipPct, note: (p.note || '').trim().slice(0, 80),
status: 'open', fiat: rateNow() ? { code: getCurrency(), rate: rateNow() } : null,
+ ...(p.request ? { request: true } : {}),
};
try {
const memo = (sale.note ? sale.note + ' · ' : '') + t('posMemo', { bill: fmtAmount(bill) + ' ' + unitLabel(), tip: tip ? fmtAmount(tip) + ' ' + unitLabel() : '0' });
@@ -145,7 +149,7 @@ export function posFeature(ctx) {
function newSale() {
const p = ui.pos;
if (p.sale && p.sale.status === 'open') voidSale(p.sale);
- Object.assign(p, { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '' });
+ Object.assign(p, { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '', request: p.request });
render();
}
@@ -194,7 +198,7 @@ export function posFeature(ctx) {
return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card col', style: 'gap:12px' },
- h('h3', { style: 'margin:0' }, t('posTitle')),
+ h('h3', { style: 'margin:0' }, t(p.request ? 'recvAmountTitle' : 'posTitle')),
h('div', { class: 'input-group' },
h('input', {
type: 'text', inputmode: 'decimal', class: 'pos-amount', placeholder: '0', value: p.amount,
@@ -204,12 +208,12 @@ export function posFeature(ctx) {
unitTag()),
getUnit() !== 'fiat' && billSat() && fiatLine(billSat()) ? h('div', { class: 'small muted', style: 'text-align:right' }, fiatLine(billSat())) : null,
numpad(() => p.amount, (v) => { p.amount = v; }),
- h('input', { type: 'text', placeholder: t('posNoteHint'), value: p.note, maxlength: '80', onInput: (e) => { p.note = e.target.value; } }),
+ h('input', { type: 'text', placeholder: t(p.request ? 'recvAmountNote' : 'posNoteHint'), value: p.note, maxlength: '80', onInput: (e) => { p.note = e.target.value; } }),
p.error ? h('div', { class: 'notice error small' }, p.error) : null,
- h('button', { class: 'btn-primary btn-block', disabled: !!p.busy, onClick: charge }, p.busy ? h('span', { class: 'spinner sm' }) : t('posCharge')),
+ h('button', { class: 'btn-primary btn-block', id: 'pos-charge', disabled: !!p.busy, onClick: charge }, p.busy ? h('span', { class: 'spinner sm' }) : t(p.request ? 'recvAmountGo' : 'posCharge')),
p.busy ? h('div', { class: 'small muted', style: 'text-align:center' }, t('posMakingInvoice')) : null),
- tipSettingsCard(s),
- salesCard(s),
+ p.request ? null : tipSettingsCard(s),
+ p.request ? null : salesCard(s),
back());
}
function tipSettingsCard(s) {
@@ -231,7 +235,7 @@ export function posFeature(ctx) {
}
function salesCard(s) {
adoptPaid(s);
- const sales = [...s.sales].reverse();
+ const sales = s.sales.filter((x) => !x.request).reverse(); // Receive → Amount isn't a sale
if (!sales.length) return null;
const today = dayKey(Date.now());
const paidToday = sales.filter((x) => x.status === 'paid' && dayKey(x.paidAt || x.at) === today);
@@ -310,8 +314,8 @@ export function posFeature(ctx) {
h('h3', { style: 'margin:0' }, t('posPaid')),
money(sale.billSat + sale.tipSat, sale.fiat && sale.fiat.rate),
sale.tipSat ? h('div', { class: 'small muted' }, t('posBreakdown', { bill: fmtAmount(sale.billSat), tip: fmtAmount(sale.tipSat), u: unitLabel() })) : null,
- h('button', { class: 'btn-primary btn-block', onClick: newSale }, t('posNewSale'))),
- back());
+ p.request ? null : h('button', { class: 'btn-primary btn-block', onClick: newSale }, t('posNewSale'))),
+ p.request ? h('button', { class: 'btn-primary btn-block', onClick: close }, t('recvAmountDone')) : back());
}
function posScreen() {
const p = ui.pos;
@@ -346,6 +350,8 @@ export function posFeature(ctx) {
h('p', { class: 'small faint', style: 'margin:0' }, t('posLinkHint', { url })))];
},
openPos: open,
+ openAmountRequest: () => open({ request: true }),
+ posAvailable: () => !!hook('arkReady'),
// The history detail of a till payment says what was the bill and what the tip.
arkMoveDetailExtra(m, row) {
const sale = saleOfMovement(m);