diff options
| author | Adam Soltys <[email protected]> | 2026-08-02 18:43:56 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-02 18:43:56 -0700 |
| commit | 375bec5f2ee22b1f69bb9f4a963efb12a68aef05 (patch) | |
| tree | 3b8f3fe0c9c78a82996ad08375760c7281111fcb /src/features | |
| parent | 8da44392be513929802a5f4329b29bfa125aaa04 (diff) | |
| download | coinosv3-375bec5f2ee22b1f69bb9f4a963efb12a68aef05.tar.gz coinosv3-375bec5f2ee22b1f69bb9f4a963efb12a68aef05.zip | |
Profiles: view pages, editable own kind 0, zap recipients in history
Tapping any avatar or author name (chat rooms, DM threads, the chat
home) opens a profile page: avatar, name, nip05, lightning address,
about, npub, with Message and Pay actions (Pay runs the npub payment
cascade). Your own profile — reachable from your avatar on the chat
home — gets an editor for name, about, picture, and lightning address
(prefilled with your coinos address); saving merges over the newest
published kind 0 so fields we don't understand survive the round trip.
Zaps now remember who they went to: the ark feature keys the recipient
by what the movement already records (destination address or invoice)
and history rows wear a clickable avatar chip that opens the profile.
The messages feature exposes showProfile/profileChip seams so any
feature can do the same.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 21 | ||||
| -rw-r--r-- | src/features/messages.js | 158 | ||||
| -rw-r--r-- | src/features/names.js | 2 | ||||
| -rw-r--r-- | src/features/zaps.js | 2 |
4 files changed, 172 insertions, 11 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 3613b02..c81faa5 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -645,6 +645,7 @@ export function arkFeature(ctx) { const settle = (a) => { if (a.step === 'done') { ui.arkLnPaid = { amountSat: a.amountSat, meta: p.meta }; + if (p.meta && p.meta.pk) noteZap('inv:' + p.invoice, p.meta.pk); ui.arkLnPay = null; } else if (ui.arkLnPay) { ui.arkLnPay.status = 'ready'; @@ -732,6 +733,22 @@ export function arkFeature(ctx) { } + // Who a zap went to, keyed by what the movement records (the ark address + // for direct zaps, the invoice for Lightning ones) — enough to put a face + // on history rows without touching the manager's movement schema. + const zapNotes = () => wallet.loadFeatureState('zapnotes', {}); + function noteZap(key, pk) { + const s = zapNotes(); + s[key] = pk; + const keys = Object.keys(s); + if (keys.length > 200) for (const k of keys.slice(0, keys.length - 200)) delete s[k]; + wallet.saveFeatureState('zapnotes', s); + } + const zapNoteFor = (m) => + m.type === 'send' && m.to ? zapNotes()['to:' + m.to] + : m.type === 'ln-send' && m.invoice ? zapNotes()['inv:' + m.invoice] + : null; + function arkHistoryItem(m) { const incoming = !['send', 'offboard', 'exit', 'ln-send'].includes(m.type); const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') @@ -746,8 +763,9 @@ export function arkFeature(ctx) { ? h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, '⚡') : h('div', { class: `ico ${incoming ? 'in' : 'out'}`, html: ARK_MARK(15) }), h('div', { class: 'grow' }, - h('div', { class: 'row gap6' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, label, + (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })(), m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), h('div', { style: 'text-align:right' }, @@ -973,6 +991,7 @@ export function arkFeature(ctx) { 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, diff --git a/src/features/messages.js b/src/features/messages.js index ed8cefd..ef70ccd 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -15,6 +15,7 @@ import { subscribeOn, publishOn, queryOn, fetchNostrProfile, fetchInboxRelays, npubOf, parseNostrPubkey, generateSecretKey, getPublicKey, finalizeEvent, nip44, + PROFILE_RELAYS, } from '../nostr.js'; import { channelKey, controlKey, guestbookKey, openWrap, wrapRumor, @@ -744,13 +745,135 @@ export function messagesFeature(ctx) { ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); }; - const avatar = (pk, cls = 'chat-avatar') => { + const avatar = (pk, cls = 'chat-avatar', clickable = true) => { + const attrs = clickable + ? { class: cls + ' clickable', onClick: (e) => { e.stopPropagation(); openProfile(pk); } } + : { class: cls }; const p = profileOf(pk); return p && p.picture - ? h('img', { class: cls, src: p.picture, alt: '' }) - : h('div', { class: cls + ' fallback' }, displayName(pk).slice(0, 2)); + ? h('img', { ...attrs, src: p.picture, alt: '' }) + : h('div', { ...attrs, class: attrs.class + ' fallback' }, displayName(pk).slice(0, 2)); }; + // ---- profiles: view + own kind-0 editor --------------------------------- + + const fullProfiles = new Map(); // pk -> { raw kind0 content object, fetched_at } + function openProfile(pk) { + ui.profilePk = pk; + ui.profEdit = null; + render(); + if (!fullProfiles.has(pk)) { + queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3500).then((evs) => { + const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; + let m = {}; + try { m = newest ? JSON.parse(newest.content) : {}; } catch {} + fullProfiles.set(pk, m); + profiles.set(pk, { name: m.display_name || m.name || null, picture: m.picture || null }); + if (ui.profilePk === pk) render(); + }).catch(() => fullProfiles.set(pk, {})); + } + } + + async function saveProfile() { + const id = await identity(); + if (!id) { toast(t('msgNoIdentity')); return; } + const e = ui.profEdit; + ui.profSaving = true; + render(); + try { + // merge over the newest published kind 0 so unknown fields round-trip + 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 }; + for (const [k, v] of [['name', e.name], ['about', e.about], ['picture', e.picture], ['lud16', e.lud16]]) { + if (v.trim()) merged[k] = v.trim(); + else delete merged[k]; + } + if (merged.name) merged.display_name = merged.name; + 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 }); + ui.profEdit = null; + toast(t('profSaved')); + } catch (err) { + toast(err.message || String(err)); + } finally { + ui.profSaving = false; + render(); + } + } + + function profileScreen() { + const pk = ui.profilePk; + const mine = isMe(pk); + const full = fullProfiles.get(pk); + const name = displayName(pk); + const npub = npubOf(pk) || pk; + const field = (label, key, ph = '') => h('label', { class: 'field' }, + h('span', { class: 'lab' }, label), + h('input', { + type: 'text', placeholder: ph, value: ui.profEdit[key], + onInput: (ev) => { ui.profEdit[key] = ev.target.value; }, + })); + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:12px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + backBtn(() => { ui.profilePk = null; ui.profEdit = null; render(); }), + avatar(pk, 'chat-avatar profile-avatar', false), + h('div', { class: 'col grow', style: 'min-width:0;gap:2px' }, + h('div', { class: 'chat-title' }, name), + full && full.nip05 ? h('div', { class: 'muted small' }, String(full.nip05).replace(/^_@/, '')) : null, + full && full.lud16 ? h('div', { class: 'muted small' }, '⚡ ' + full.lud16) : null)), + full === undefined + ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' })) + : full.about ? h('p', { class: 'small', style: 'margin:0;white-space:pre-wrap' }, String(full.about).slice(0, 1000)) : null, + h('div', { class: 'addr-box break', style: 'font-size:11px' }, npub), + ui.profEdit + ? h('div', { class: 'col', style: 'gap:8px' }, + field(t('profName'), 'name'), + field(t('profAbout'), 'about'), + field(t('profPicture'), 'picture', 'https://…'), + field(t('profLud16'), 'lud16', '[email protected]'), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost grow', onClick: () => { ui.profEdit = null; render(); } }, t('cancel')), + h('button', { class: 'btn-primary grow', disabled: ui.profSaving, onClick: saveProfile }, + ui.profSaving ? h('span', { class: 'spinner sm' }) : t('save')))) + : h('div', { class: 'row gap6 wrap' }, + mine ? null : h('button', { class: 'btn-primary grow', onClick: () => { + const peer = pk; + ui.profilePk = null; + ui.chatOpen = true; + ui.msgView = 'dm'; + ui.msgPeer = peer; + ui.msgStick = true; + render(); + } }, t('msgDmsTitle')), + mine ? null : h('button', { class: 'grow', onClick: () => { + const npubStr = npubOf(pk); + ui.profilePk = null; + ui.chatOpen = false; + ui.tab = 'send'; + render(); + hook('matchSendText', npubStr); + } }, t('profPay')), + mine ? h('button', { class: 'btn-primary grow', onClick: () => { + const f = fullProfiles.get(pk) || {}; + ui.profEdit = { + name: f.display_name || f.name || '', + about: f.about || '', + picture: f.picture || '', + lud16: f.lud16 || (hook('namesAddress') || ''), + }; + render(); + } }, t('profEdit')) : null))); + } + const backBtn = (onClick) => h('button', { class: 'iconbtn chat-back', onClick }, '‹'); // Recipient search for New message: debounced, sequenced, cached upstream. @@ -802,9 +925,12 @@ export function messagesFeature(ctx) { const kids = []; // Chat takes the whole screen, so home carries the way back to the wallet. - kids.push(h('div', { class: 'row gap6', style: 'align-items:center' }, - backBtn(() => { ui.chatOpen = false; render(); }), - h('h3', { style: 'margin:0' }, t('tabMessages')))); + const me = myPubkeys()[0]; + kids.push(h('div', { class: 'row between', style: 'align-items:center' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + backBtn(() => { ui.chatOpen = false; render(); }), + h('h3', { style: 'margin:0' }, t('tabMessages'))), + me ? avatar(me) : null)); if (pendingLink) kids.push(linkInviteCard()); for (const [rid, inv] of pendingDirect) kids.push(directInviteCard(rid, inv)); @@ -952,7 +1078,10 @@ export function messagesFeature(ctx) { grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author), h('div', { class: 'chat-body' }, grouped ? null : h('div', { class: 'chat-meta' }, - h('span', { class: 'chat-name' + (m.author === room.jm.owner ? ' owner' : '') }, + h('span', { + class: 'chat-name clickable' + (m.author === room.jm.owner ? ' owner' : ''), + onClick: () => openProfile(m.author), + }, displayName(m.author), m.author === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null), h('span', { class: 'chat-time' }, timeLabel(tms))), @@ -1056,7 +1185,7 @@ export function messagesFeature(ctx) { h('div', { class: 'row chat-head gap6', style: 'align-items:center' }, backBtn(() => { ui.msgView = 'home'; render(); }), avatar(peer), - h('div', { class: 'col', style: 'gap:2px;min-width:0' }, + h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => openProfile(peer) }, h('div', { class: 'chat-title' }, displayName(peer)), h('div', { class: 'muted small' }, t('msgDmEncrypted')))), h('div', { @@ -1095,11 +1224,22 @@ export function messagesFeature(ctx) { }, h('span', { html: CHAT_ICON }))]; }, screenView() { - if (!ui.chatOpen || ui.screen !== 'wallet') return null; + if (ui.screen !== 'wallet') return null; + if (ui.profilePk) return profileScreen(); + if (!ui.chatOpen) return null; return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), messagesTab()); }, + // Anyone (ark's history, other features) can open a profile or render a + // small clickable identity chip. + showProfile(pk) { openProfile(pk); return true; }, + profileChip(pk) { + return h('span', { + class: 'zap-chip', + onClick: (e) => { e.stopPropagation(); openProfile(pk); }, + }, avatar(pk, 'chat-avatar mini', false), h('span', { class: 'small' }, displayName(pk))); + }, init() { if (urlInvite && !pendingLink) { loadLinkInvite(urlInvite); diff --git a/src/features/names.js b/src/features/names.js index 54f15f7..d6acc6a 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -308,6 +308,8 @@ export function namesFeature(ctx) { id: 'names', init() { checked = false; lastError = null; refresh(); }, namesAdoptIdentity(signer, npub) { return adoptIdentity(signer, npub); }, + // the claimed payment address, for anyone prefilling a lightning address + namesAddress() { const st = load(); return st.name ? `${st.name}@${st.domain || DOMAIN}` : null; }, stop() { clearTimeout(retryTimer); clearTimeout(deadline); deadline = null; }, receiveModes() { if (!available()) return []; diff --git a/src/features/zaps.js b/src/features/zaps.js index 71a94af..9aabf54 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -75,7 +75,7 @@ export function zapsFeature(ctx) { }); } const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment }); - const meta = { name: z.name, address: z.address, comment: canZap ? z.comment : '', isZap: !!zapRequest }; + const meta = { name: z.name, address: z.address, pk: z.target.pk || null, comment: canZap ? z.comment : '', isZap: !!zapRequest }; ui.zap = null; // Hand the bolt11 to the swaps feature's pay flow (review → confirm). hook('startLnPay', invoice, meta); |
