diff options
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 158 |
1 files changed, 149 insertions, 9 deletions
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); |
