From c41081838b87ffaa0ac95694a9c3167fa1ec3749 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 1 Aug 2026 21:36:27 -0700 Subject: Username onboarding + the payment address leads Receive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/features/names.js | 108 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 23 deletions(-) (limited to 'src/features') 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: '', + 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 -- cgit v1.2.3