aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 18:43:56 -0700
committerAdam Soltys <[email protected]>2026-08-02 18:43:56 -0700
commit375bec5f2ee22b1f69bb9f4a963efb12a68aef05 (patch)
tree3b8f3fe0c9c78a82996ad08375760c7281111fcb /src
parent8da44392be513929802a5f4329b29bfa125aaa04 (diff)
downloadcoinosv3-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')
-rw-r--r--src/app.js2
-rw-r--r--src/features/ark.js21
-rw-r--r--src/features/messages.js158
-rw-r--r--src/features/names.js2
-rw-r--r--src/features/zaps.js2
-rw-r--r--src/i18n.js7
-rw-r--r--src/nostr.js1
-rw-r--r--src/style.css4
8 files changed, 185 insertions, 12 deletions
diff --git a/src/app.js b/src/app.js
index 322383f..b7a5ab0 100644
--- a/src/app.js
+++ b/src/app.js
@@ -334,7 +334,7 @@ wallet.subscribe(scheduleRender);
// buttons (and Android/system back) move between screens we've actually viewed.
// We snapshot only the navigation-relevant `ui` fields, so incidental re-renders
// (typing, polling, balance updates) don't create history entries.
-const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'msgCommunity', 'msgPeer', 'settingsPage'];
+const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'msgCommunity', 'msgPeer', 'profilePk', 'settingsPage'];
function navSnapshot() {
const s = {};
for (const f of NAV_FIELDS) s[f] = ui[f] ?? null;
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);
diff --git a/src/i18n.js b/src/i18n.js
index 09c9b07..cd802e3 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -169,6 +169,13 @@ const STR = {
msgOpen: 'Open',
msgNpubPlaceholder: 'npub…',
msgSearchPlaceholder: 'Name or npub…',
+ profEdit: 'Edit profile',
+ profPay: '⚡ Pay',
+ profName: 'Name',
+ profAbout: 'About',
+ profPicture: 'Picture URL',
+ profLud16: 'Lightning address',
+ profSaved: 'Profile published',
msgSearching: 'Searching…',
msgNoMatches: 'No matches',
msgBadNpub: 'Not a valid npub',
diff --git a/src/nostr.js b/src/nostr.js
index 3d0ea2f..1a4a0c5 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -86,6 +86,7 @@ export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) {
return {
name: m.display_name || m.name || null,
picture: m.picture || null,
+ about: typeof m.about === 'string' ? m.about : null,
nip05: m.nip05 || null,
// NIP-57/LUD-16/06: where to send a Lightning zap. lud16 is a
// lightning address (name@domain); lud06 is a bech32 lnurl.
diff --git a/src/style.css b/src/style.css
index 82a8c09..5453420 100644
--- a/src/style.css
+++ b/src/style.css
@@ -389,3 +389,7 @@ hr.divider { margin: 16px 0; }
.balance-move .seg button { flex: 1; }
.balance-seg { display: inline-flex; margin-bottom: 6px; }
.send-suggest { max-height: 260px; overflow-y: auto; }
+.clickable { cursor: pointer; }
+.profile-avatar { width: 64px; height: 64px; font-size: 18px; }
+.zap-chip { display: inline-flex; gap: 5px; align-items: center; cursor: pointer; background: var(--surface2); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px 1px 2px; }
+.chat-avatar.mini { width: 18px; height: 18px; font-size: 8px; flex-shrink: 0; }