aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 19:39:00 -0700
committerAdam Soltys <[email protected]>2026-08-02 19:39:00 -0700
commit78ab79e7527bdab08c59e5ee8ee5dd701c0856e0 (patch)
treea93a405f8c23e7158d47375127a2be68dd4b28c2 /src
parent0f47a16fd81091f03ab1f4e4e8143fedf94d1f68 (diff)
downloadcoinosv3-78ab79e7527bdab08c59e5ee8ee5dd701c0856e0.tar.gz
coinosv3-78ab79e7527bdab08c59e5ee8ee5dd701c0856e0.zip
Punk default avatars, header profile button, owner channel creation
Everyone gets a face: profiles without a picture fall back to a coinos punk, picked deterministically by the pubkey's last byte — the same 64 webps and formula the coinos app uses (initials show through if the image can't load). Your own avatar moves from the chat home to the app header next to the wallet selector, opening your editable profile from anywhere. Community owners can add channels from the room's + panel: one owner-signed ChannelMetadata edition (CORD-03), and every member's channel switcher grows when the fold lands. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
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; }