diff options
| author | Adam Soltys <[email protected]> | 2026-08-15 19:16:42 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-15 19:16:42 -0700 |
| commit | 0fb0496109aee2ec0bb0c46f29f17392138978e8 (patch) | |
| tree | e7a575876bf9f042bfbfb642b209f6f3153a3a3f | |
| parent | 79d360e0f4221256675b274127e0abacaf54be28 (diff) | |
| download | coinosv3-0fb0496109aee2ec0bb0c46f29f17392138978e8.tar.gz coinosv3-0fb0496109aee2ec0bb0c46f29f17392138978e8.zip | |
A new name offers its hand to the address: saving a renamed profile proposes the matching @coinos.io, and any rename repoints the kind-0 lightning address so zaps never chase a released name
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv
| -rw-r--r-- | src/features/messages.js | 96 | ||||
| -rw-r--r-- | src/features/names.js | 4 | ||||
| -rw-r--r-- | src/i18n.js | 4 |
3 files changed, 77 insertions, 27 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index fd08760..d489128 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1129,6 +1129,7 @@ export function messagesFeature(ctx) { function openProfile(pk) { ui.profilePk = pk; ui.profEdit = null; ui.profEditFilled = false; + ui.nameOffer = null; render(); fetchFullProfile(pk); notesFor(pk); @@ -1393,6 +1394,45 @@ export function messagesFeature(ctx) { h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.noteThread = null; render(); } }, t('back'))); } + async function publishProfileFields(fields, opts = {}) { + const id = await identity(); + if (!id) throw new Error(t('msgNoIdentity')); + const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); + const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; + let base = {}; + try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be + const merged = { ...base }; + // fillOnly fields are offers, not orders: the onboarding wizard suggests + // a name and a punk for profiles that have none — it must never RENAME + // (or re-face) an identity that already has one. + const fill = new Set(opts.fillOnly || []); + const taken = (k) => (k === 'name' ? !!(base.name || base.display_name) : !!base[k]); + for (const [k, v] of Object.entries(fields || {})) { + if (!v) continue; + if (fill.has(k) && taken(k)) continue; + merged[k] = v; + } + if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name; + if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say + const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); + if (!ok) throw new Error(t('msgSendFailed')); + fullProfiles.set(id.pubkey, merged); + profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() }); + return true; + } + + // A display name reduced to registrar rules (/^[a-z0-9][a-z0-9._-]{0,29}$/), + // or null when nothing address-shaped survives. + const usernameCandidate = (raw) => { + const x = String(raw || '').toLowerCase().normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '').replace(/\s+/g, '') + .replace(/[^a-z0-9._-]/g, '').replace(/^[._-]+/, '').slice(0, 30); + return /^[a-z0-9][a-z0-9._-]{0,29}$/.test(x) ? x : null; + }; + async function saveProfile() { const id = await identity(); if (!id) { toast(t('msgNoIdentity')); return; } @@ -1426,6 +1466,13 @@ export function messagesFeature(ctx) { profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null }); ui.profEdit = null; ui.profEditFilled = false; toast(t('profSaved')); + // A new name usually wants a matching payment address. Offer — never + // assume: display names aren't always usernames, and the old address + // may be printed somewhere. + const curAddr = hook('namesAddress'); + const cand = usernameCandidate(merged.name); + if (curAddr && cand && cand !== curAddr.split('@')[0]) + ui.nameOffer = { cand, addr: cand + '@' + curAddr.split('@')[1], old: curAddr }; } catch (err) { toast(err.message || String(err)); } finally { @@ -1493,6 +1540,20 @@ export function messagesFeature(ctx) { h('div', { class: 'chat-title' }, name), nip05 ? h('div', { class: 'muted small break' }, nip05) : null, showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null)), + mine && ui.nameOffer ? h('div', { class: 'notice', style: 'display:flex;flex-direction:column;gap:8px' }, + h('span', { class: 'small' }, t('profAddrOffer', { addr: ui.nameOffer.addr })), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost grow', disabled: ui.nameOfferBusy, + onClick: () => { ui.nameOffer = null; render(); } }, t('profAddrKeep')), + h('button', { class: 'btn-primary grow', disabled: ui.nameOfferBusy, onClick: async () => { + ui.nameOfferBusy = true; render(); + try { + await hook('namesClaimName', ui.nameOffer.cand); + ui.nameOffer = null; + toast(t('profAddrUpdated')); + } catch (err) { toast(err.message || String(err)); } + finally { ui.nameOfferBusy = false; render(); } + } }, ui.nameOfferBusy ? h('span', { class: 'spinner sm' }) : t('profAddrUpdate')))) : null, // no spinner while the kind 0 loads — prefetch keeps this rare, and // an empty beat reads calmer than a spinner showAbout && !ui.profEdit ? h('p', { class: 'small', style: 'margin:0;white-space:pre-wrap' }, about.slice(0, 1000)) : null, @@ -2219,33 +2280,14 @@ export function messagesFeature(ctx) { showProfile(pk) { openProfile(pk); return true; }, // Publish (merge) kind-0 fields for the current identity — the onboarding // wizard sets name + picture through this. - async publishProfile(fields, opts = {}) { - const id = await identity(); - if (!id) throw new Error(t('msgNoIdentity')); - const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); - const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; - let base = {}; - try { base = newest ? JSON.parse(newest.content) : {}; } catch {} - const merged = { ...base }; - // fillOnly fields are offers, not orders: the onboarding wizard suggests - // a name and a punk for profiles that have none — it must never RENAME - // (or re-face) an identity that already has one. - const fill = new Set(opts.fillOnly || []); - const taken = (k) => (k === 'name' ? !!(base.name || base.display_name) : !!base[k]); - for (const [k, v] of Object.entries(fields || {})) { - if (!v) continue; - if (fill.has(k) && taken(k)) continue; - merged[k] = v; - } - if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name; - if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say - const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; - const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); - const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); - if (!ok) throw new Error(t('msgSendFailed')); - fullProfiles.set(id.pubkey, merged); - profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() }); - return true; + publishProfile(fields, opts = {}) { return publishProfileFields(fields, opts); }, + // A payment-address rename released the old name: repoint the kind 0's + // lud16 — but only when it pointed at the released address (or was empty). + // A deliberately different lightning address is not ours to touch. + addressRenamed(oldAddr, newAddr) { + return publishProfileFields({ lud16: newAddr }, { + onlyWhen: (base) => !base.lud16 || base.lud16 === oldAddr, + }).catch(() => {}); }, profileChip(pk, size) { const big = size === 'lg'; diff --git a/src/features/names.js b/src/features/names.js index f386a47..b021956 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -78,6 +78,10 @@ export function namesFeature(ctx) { save({ name, domain: DOMAIN, uri, offerPk: hook('nwcOfferPubkey') || null, updated: Date.now() }); if (prev && prev !== name) { post('/register', 'DELETE', { name: prev, domain: DOMAIN }).catch(() => {}); + // the old address just died — a kind 0 still pointing at it would send + // zaps into the void, so the profile's lud16 follows the rename + // (messages only touches it if it pointed at the released name) + Promise.resolve(hook('addressRenamed', `${prev}@${DOMAIN}`, `${name}@${DOMAIN}`)).catch(() => {}); } return j; } diff --git a/src/i18n.js b/src/i18n.js index 4538894..e3412fe 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -77,6 +77,10 @@ const STR = { pwChanged: 'Password changed', enterPassword: 'Enter your password', enterPasswordDesc: 'Unlock your saved wallets to continue.', + profAddrOffer: 'Update your payment address to match — {addr}?', + profAddrUpdate: 'Update address', + profAddrKeep: 'Keep current', + profAddrUpdated: 'Payment address updated', lockSaveDesc: "This wallet isn't saved on this device yet. Enter your password to save it before locking — otherwise unlocking couldn't bring it back.", password: 'Password', confirmPassword: 'Confirm password', |
