aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js63
-rw-r--r--src/i18n.js2
-rw-r--r--src/recipient-search.js13
-rw-r--r--src/style.css4
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; }