diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 63 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/recipient-search.js | 13 | ||||
| -rw-r--r-- | src/style.css | 4 |
4 files changed, 66 insertions, 16 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index ef70ccd..c70964b 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -23,7 +23,7 @@ import { communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle, } from '../concord.js'; import { makeDM, unwrapDM, wrapDM } from '../dm.js'; -import { makeSearcher, resultRows } from '../recipient-search.js'; +import { makeSearcher, resultRows, fallbackAvatar } from '../recipient-search.js'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { t } from '../i18n.js'; @@ -107,7 +107,7 @@ export function messagesFeature(ctx) { if (repaintTimer) return; repaintTimer = setTimeout(() => { repaintTimer = null; - if (ui.screen === 'wallet' && ui.chatOpen) render(); + if (ui.screen === 'wallet') render(); }, 80); }; @@ -360,6 +360,25 @@ export function messagesFeature(ctx) { render(); } + // A new channel is one owner-signed ChannelMetadata edition (CORD-03 §2); + // the control-plane fold picks it up and every member's switcher grows. + async function createChannel(room, name) { + name = (name || '').trim().slice(0, 64); + if (!name) return; + const id = await identity(); + if (!id || id.pubkey !== room.jm.owner) { toast(t('msgOwnerOnly')); return; } + const chId = bytesToHex(crypto.getRandomValues(new Uint8Array(32))); + const edition = { + ...makeEdition({ vsk: 2, eid: chId, version: 1, content: JSON.stringify({ name: name.replace(/^#/, ''), private: false }) }, Date.now()), + pubkey: id.pubkey, + }; + const wrap = await wrapRumor(edition, id.signer, room.control, { plaintext: true }); + const ok = await publishOn(room.relays, wrap); + if (!ok) { toast(t('msgSendFailed')); return; } + ui.msgNewChannel = ''; + toast('#' + name.replace(/^#/, '')); + } + // ---- invites ------------------------------------------------------------ function bundleFor(room, creatorPk) { @@ -746,13 +765,15 @@ export function messagesFeature(ctx) { }; 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', { ...attrs, src: p.picture, alt: '' }) - : h('div', { ...attrs, class: attrs.class + ' fallback' }, displayName(pk).slice(0, 2)); + const node = p && p.picture + ? h('img', { class: cls, src: p.picture, alt: '' }) + : fallbackAvatar(h, pk, p && p.name, cls); + if (clickable) { + node.classList.add('clickable'); + node.addEventListener('click', (e) => { e.stopPropagation(); openProfile(pk); }); + } + return node; }; // ---- profiles: view + own kind-0 editor --------------------------------- @@ -925,12 +946,9 @@ export function messagesFeature(ctx) { const kids = []; // Chat takes the whole screen, so home carries the way back to the wallet. - 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)); + kids.push(h('div', { class: 'row gap6', style: 'align-items:center' }, + backBtn(() => { ui.chatOpen = false; render(); }), + h('h3', { style: 'margin:0' }, t('tabMessages')))); if (pendingLink) kids.push(linkInviteCard()); for (const [rid, inv] of pendingDirect) kids.push(directInviteCard(rid, inv)); @@ -1162,6 +1180,15 @@ export function messagesFeature(ctx) { class: 'btn-sm', onClick: () => { sendDirectInvite(room, ui.msgInviteTo); ui.msgInviteTo = ''; render(); }, }, t('msgSend'))), + // owners can add channels (an owner-signed control edition) + myPubkeys().includes(room.jm.owner) + ? h('div', { class: 'row gap6' }, + h('input', { + class: 'grow', type: 'text', placeholder: t('msgChannelPlaceholder'), maxlength: '64', + value: ui.msgNewChannel || '', onInput: (e) => { ui.msgNewChannel = e.target.value; }, + }), + h('button', { class: 'btn-sm', onClick: () => createChannel(room, ui.msgNewChannel) }, t('msgCreate'))) + : null, // leaving discards the keys on this identity — two taps, default community exempt builtin ? null : h('div', { class: 'row' }, h('button', { @@ -1218,10 +1245,16 @@ export function messagesFeature(ctx) { // Chat lives behind a header button and takes over the whole screen — // no balance card, no tabs; each view carries its own way back. headerButtons() { - return [h('button', { + const btns = [h('button', { class: 'btn-sm', title: t('tabMessages'), onClick: () => { ui.chatOpen = true; render(); }, }, h('span', { html: CHAT_ICON }))]; + const me = myPubkeys()[0]; + if (me) btns.push(h('button', { + class: 'header-avatar', title: t('profEdit'), + onClick: () => openProfile(me), + }, avatar(me, 'chat-avatar header-ava', false))); + return btns; }, screenView() { if (ui.screen !== 'wallet') return null; diff --git a/src/i18n.js b/src/i18n.js index 8a39f3d..9f23b14 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -169,6 +169,8 @@ const STR = { msgOpen: 'Open', msgNpubPlaceholder: 'npub…', msgSearchPlaceholder: 'Name or npub…', + msgChannelPlaceholder: 'New channel name', + msgOwnerOnly: 'Only the community owner can do that', profEdit: 'Edit profile', profPay: '⚡ Pay', profName: 'Name', diff --git a/src/recipient-search.js b/src/recipient-search.js index d3e69eb..5cc8315 100644 --- a/src/recipient-search.js +++ b/src/recipient-search.js @@ -10,6 +10,17 @@ import { queryOn, parseNostrPubkey, npubOf, PROFILE_RELAYS } from './nostr.js'; +// coinos punks: the deterministic default avatar — same formula the coinos +// app uses (last byte of the pubkey picks one of 64). Initials show through +// if the image can't load (offline). +export const punkUrl = (pk) => + `https://coinos.io/api/public/punks/${Math.floor((parseInt(pk.slice(-2), 16) / 256) * 64) + 1}.webp`; + +export const fallbackAvatar = (h, pk, name, cls) => + h('div', { class: cls + ' fallback' }, + h('img', { class: 'punk', src: punkUrl(pk), alt: '', onError: (e) => { e.target.style.display = 'none'; } }), + (name || npubOf(pk) || '??').slice(0, 2)); + const REGISTRAR = 'https://names.coinos.io'; const SEARCH_RELAYS = ['wss://search.nos.today', 'wss://relay.nostr.band']; const PRIMAL_CACHE = 'wss://cache2.primal.net/v1'; @@ -144,7 +155,7 @@ export function resultRows(h, rows, onPick) { h('div', { class: 'item chat-thread-row', onClick: () => onPick(r) }, r.picture ? h('img', { class: 'chat-avatar', src: r.picture, alt: '' }) - : h('div', { class: 'chat-avatar fallback' }, (r.name || npubOf(r.pk) || '??').slice(0, 2)), + : fallbackAvatar(h, r.pk, r.name, 'chat-avatar'), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, h('div', { class: 'chat-name' }, r.name || (npubOf(r.pk) || '').slice(0, 14) + '…'), h('div', { class: 'muted small chat-preview' }, r.address || r.nip05 || (npubOf(r.pk) || '').slice(0, 24) + '…')))); diff --git a/src/style.css b/src/style.css index e5cc666..2323635 100644 --- a/src/style.css +++ b/src/style.css @@ -391,3 +391,7 @@ hr.divider { margin: 16px 0; } .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; } +.chat-avatar.fallback { position: relative; overflow: hidden; } +.chat-avatar.fallback .punk { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.header-avatar { padding: 0; border: none; background: none; cursor: pointer; display: flex; } +.chat-avatar.header-ava { width: 30px; height: 30px; font-size: 11px; } |
