diff options
| author | Adam Soltys <[email protected]> | 2026-08-01 21:36:27 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-01 21:36:27 -0700 |
| commit | c41081838b87ffaa0ac95694a9c3167fa1ec3749 (patch) | |
| tree | 750cd53750ef2b652888ff1c8a20cf56cf4c12d9 /src | |
| parent | c17f3fb4dcaf8037eafdbd8d85c655da75d0c7d5 (diff) | |
| download | coinosv3-c41081838b87ffaa0ac95694a9c3167fa1ec3749.tar.gz coinosv3-c41081838b87ffaa0ac95694a9c3167fa1ec3749.zip | |
Username onboarding + the payment address leads Receive
New wallets must pick a username before entering the wallet: the
onboarding gate renders a claim screen until a name exists. Imported
seeds skip it — the registrar's new /pubkey lookup recovers the name
owned by the wallet's key, so nothing is retyped. If claiming errors
(registrar or ark unreachable) a do-it-later escape appears rather than
locking anyone away from funds; the prompt returns next open.
The Receive tab now defaults to the payment address — name QR + copy —
with the on-chain address and other modes behind it, and the claim form
inline for wallets that skipped onboarding.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 15 | ||||
| -rw-r--r-- | src/features/names.js | 108 | ||||
| -rw-r--r-- | src/i18n.js | 4 |
3 files changed, 101 insertions, 26 deletions
@@ -1878,6 +1878,12 @@ function vaultScreen() { } function walletScreen() { + // A feature can hold the wallet behind a required onboarding step (picking + // a username). Imported wallets skip it once their name is recovered. + const ob = featureHook('onboardingView'); + if (ob) { + return h('div', { class: 'col', style: 'gap:0' }, brandHeader(false), h('div', { class: 'mt16' }, ob)); + } return h( 'div', { class: 'col', style: 'gap:0' }, @@ -2048,11 +2054,14 @@ function receiveTab() { // the enabled features contribute (Lightning, silent payment, Ark, ...). const fresh = wallet.freshReceive(); const featModes = featureAll('receiveModes'); - let mode = ui.receiveType || 'address'; - if (mode !== 'address' && !featModes.some((m) => m.id === mode)) mode = 'address'; + // The payment name leads: it's the address most people should hand out. + const nameMode = featModes.find((m) => m.id === 'name'); + let mode = ui.receiveType || (nameMode ? 'name' : 'address'); + if (mode !== 'address' && !featModes.some((m) => m.id === mode)) mode = nameMode ? 'name' : 'address'; const opts = [ + ...(nameMode ? [{ id: 'name', label: nameMode.label, icon: nameMode.icon }] : []), { id: 'address', label: t('receiveAddressTab'), icon: BITCOIN_ICON(20) }, - ...featModes.map((m) => ({ id: m.id, label: m.label, icon: m.icon })), + ...featModes.filter((m) => m.id !== 'name').map((m) => ({ id: m.id, label: m.label, icon: m.icon })), ]; const seg = opts.length > 1 ? iconSelect(opts, mode, (id) => { ui.receiveType = id; render(); }) diff --git a/src/features/names.js b/src/features/names.js index f7efb89..a8d762e 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -12,6 +12,7 @@ // record falls back to the Lightning-address flow (zaps feature). import { resolveBip353, parsePaymentName, parseBip21 } from '../bip353.js'; +import { qrSvg } from '../qr.js'; import { isArkAddress } from './ark.js'; import { getNetwork } from '../api.js'; import { t } from '../i18n.js'; @@ -66,14 +67,31 @@ export function namesFeature(ctx) { save({}); } + // An imported seed doesn't know its username — the registrar does. Ask by + // the wallet's nostr pubkey and adopt what it says before deciding whether + // onboarding needs to prompt. + let checked = false; // lookup completed (gate stays closed until then) + async function lookupMine() { + try { + const pk = wallet.nostrPubkey && wallet.nostrPubkey(); + if (!pk) return; + const r = await fetch(`${REGISTRAR}/pubkey/${pk}`).then((x) => x.json()); + if (r && r.name) save({ ...load(), name: r.name, domain: r.domain || DOMAIN, uri: r.uri }); + } catch {} + } + // The record must track the wallet: re-register when the ark address moved. async function refresh() { try { - const st = load(); - if (!st.name || !available()) return; + if (!available()) { checked = true; render(); return; } + let st = load(); + if (!st.name) { await lookupMine(); st = load(); } + checked = true; + render(); + if (!st.name) return; const uri = await currentUri(); if (uri && uri !== st.uri) await claim(st.name); - } catch (e) { console.warn('names: refresh failed', e.message); } + } catch (e) { checked = true; console.warn('names: refresh failed', e.message); } } // ---- sending to a name ------------------------------------------------- @@ -122,6 +140,30 @@ export function namesFeature(ctx) { // ---- UI --------------------------------------------------------------- + function claimForm(big) { + return h('div', { class: 'col', style: 'gap:8px;width:100%' }, + ui.nameClaimError ? h('div', { class: 'notice err' }, ui.nameClaimError) : null, + h('div', { class: 'row gap6' }, + h('input', { + type: 'text', placeholder: t('namesPlaceholder'), style: 'flex:1' + (big ? ';font-size:18px' : ''), + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + value: ui.nameClaim || '', + onInput: (e) => { ui.nameClaim = e.target.value.toLowerCase().trim(); }, + }), + h('span', { class: 'muted', style: 'align-self:center' }, '@' + DOMAIN)), + h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: async () => { + const name = (ui.nameClaim || '').toLowerCase().trim(); + if (!name) return; + ui.busy = true; ui.nameClaimError = null; render(); + try { + await claim(name); + ui.nameClaim = ''; + toast(t('namesClaimed', { name: `${name}@${DOMAIN}` })); + } catch (e) { ui.nameClaimError = e.message; } + ui.busy = false; render(); + } }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim'))); + } + function namesCard() { if (!available()) return null; const st = load(); @@ -152,30 +194,50 @@ export function namesFeature(ctx) { return h('div', { class: 'card col' }, h('h3', {}, t('namesTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('namesDesc')), - ui.nameClaimError ? h('div', { class: 'notice err' }, ui.nameClaimError) : null, - h('div', { class: 'row gap6' }, - h('input', { - type: 'text', placeholder: t('namesPlaceholder'), style: 'flex:1', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', - value: ui.nameClaim || '', - onInput: (e) => { ui.nameClaim = e.target.value.toLowerCase().trim(); }, - }), - h('button', { class: 'btn-primary', disabled: ui.busy, onClick: async () => { - const name = (ui.nameClaim || '').toLowerCase().trim(); - if (!name) return; - ui.busy = true; ui.nameClaimError = null; render(); - try { - await claim(name); - ui.nameClaim = ''; - toast(t('namesClaimed', { name: `${name}@${DOMAIN}` })); - } catch (e) { ui.nameClaimError = e.message; } - ui.busy = false; render(); - } }, t('namesClaim')))); + claimForm(false)); + } + + // The Receive tab's default pane: your name, big and scannable. + function namePane(seg) { + const st = load(); + if (!st.name) { + return h('div', { class: 'card col', style: 'gap:10px' }, + seg, + h('p', { class: 'small muted', style: 'margin:0' }, t('namesDesc')), + claimForm(false)); + } + const addr = `${st.name}@${st.domain || DOMAIN}`; + return h('div', { class: 'card col', style: 'align-items:center;gap:14px' }, + seg, + h('div', { html: qrSvg(addr) }), + h('div', { class: 'addr-box', style: 'width:100%;text-align:center;font-size:16px' }, addr), + copyBtn(addr, t('namesCopy'))); } return { id: 'names', - init() { refresh(); }, + init() { checked = false; refresh(); }, + receiveModes() { + if (!available()) return []; + return [{ + id: 'name', label: t('receiveNameTab'), + icon: '<svg viewBox="0 0 72 72" width="18" height="18" fill="currentColor"><path fill-rule="evenodd" d="M36 4.2C18.5 4.2 4.2 18.5 4.2 36S18.5 67.8 36 67.8 67.8 53.5 67.8 36 53.5 4.2 36 4.2ZM0 36C0 16.1 16.1 0 36 0s36 16.1 36 36-16.1 36-36 36S0 55.9 0 36Z"/><path fill-rule="evenodd" d="M36 58.6c12.5 0 22.6-10.1 22.6-22.6S48.5 13.4 36 13.4 13.4 23.5 13.4 36 23.5 58.6 36 58.6ZM36 54c9.9 0 18-8.1 18-18s-8.1-18-18-18-18 8.1-18 18 8.1 18 18 18Z"/><path d="M36 22.9c-7.2 0-13.1 5.9-13.1 13.1S28.8 49.1 36 49.1V22.9Z"/></svg>', + render: (seg) => namePane(seg), + }]; + }, + // New wallets must pick a username before using the wallet; imported + // seeds recover theirs from the registrar and skip straight through. + onboardingView() { + if (!available() || !checked) return null; + if (load().name || ui.namesLater) return null; + return h('div', { class: 'card col', style: 'gap:12px' }, + h('h2', { style: 'margin:0' }, t('namesOnboardTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t('namesOnboardDesc')), + claimForm(true), + ui.nameClaimError + ? h('button', { class: 'linklike small', onClick: () => { ui.namesLater = true; render(); } }, t('namesOnboardLater')) + : null); + }, // BEFORE zaps in the registry: a pasted user@domain tries DNS first. matchSendText(text, typed) { if (typed) return false; // don't yank the form away mid-keystroke diff --git a/src/i18n.js b/src/i18n.js index 0c78e3d..1b95753 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -429,6 +429,10 @@ const STR = { arkLnRefunded: 'Payment failed — funds returned to your Ark balance.', arkLnPaidTitle: 'Paid!', namesTitle: 'Payment address', + receiveNameTab: 'Payment address', + namesOnboardTitle: 'Pick your username', + namesOnboardDesc: 'Your username becomes your payment address — like [email protected]. People can pay you just by typing it.', + namesOnboardLater: 'I’ll do this later', namesDesc: 'Claim your payment address — like [email protected]. People can pay you just by typing it, no codes to copy or QR to scan.', namesPlaceholder: 'username', namesClaim: 'Claim', |
