aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js19
-rw-r--r--src/features/messages.js107
-rw-r--r--src/i18n.js12
-rw-r--r--src/nip29-client.js361
-rw-r--r--src/style.css13
5 files changed, 500 insertions, 12 deletions
diff --git a/src/app.js b/src/app.js
index 50aa5c8..e6e6843 100644
--- a/src/app.js
+++ b/src/app.js
@@ -3213,7 +3213,7 @@ function settingsTab() {
// matters if the phone is lost tonight
case 'wallet':
if (ui.delAcct && a) return deleteAccountView(a);
- return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []);
+ return page(a ? [recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []);
case 'payments': return page(featureAll('settingsCards').reverse());
case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's
case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now
@@ -3906,6 +3906,23 @@ function recoveryCard(a) {
);
}
+// The balance cards' gears, reachable from Settings → Wallet too: "I simply
+// cannot find this option" (Nils, 2026-11-12) about the Spending card's gear.
+function balanceSettingsCard(a) {
+ if (a.id !== activeId) return null;
+ const views = a.kind ? [a.kind] : spendingActive() ? ['spending', 'savings'] : ['savings'];
+ const row = (view) => h('button', {
+ class: 'btn-ghost btn-block balance-settings-row', 'data-view': view,
+ style: 'display:flex;align-items:center;gap:10px;text-align:left;padding:10px 4px',
+ onClick: () => openWalletSettings(view),
+ },
+ h('span', { style: 'display:flex;color:var(--muted)', html: GEAR_ICON(18) }),
+ h('span', { class: 'col', style: 'flex:1;gap:2px' },
+ h('span', { style: 'font-weight:600' }, viewLabel(a, view)),
+ h('span', { class: 'small muted' }, t(view === 'spending' ? 'spendingSettingsDesc' : 'savingsSettingsDesc'))),
+ h('span', { class: 'muted' }, '›'));
+ return h('div', { class: 'card col', style: 'gap:4px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row));
+}
function walletNameCard(a) {
const saveName = () => {
const v = (ui.editLabel || '').trim();
diff --git a/src/features/messages.js b/src/features/messages.js
index a42396c..0573def 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1607,6 +1607,7 @@ export function messagesFeature(ctx) {
for (const room of wanted) subscribeRoom(room);
if (wn) wn.resubscribe();
if (cd) cd.resubscribe();
+ if (ng) ng.resubscribe();
}
// ---- subscription watchdog ------------------------------------------------
@@ -3735,8 +3736,43 @@ export function messagesFeature(ctx) {
bumpMsgRev();
scheduleRepaint();
}
- const gClient = (g) => (g && g.cordn ? cd : wn);
- const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || null;
+ // NIP-29 groups (nip29-client.js): a group on one relay that keeps its
+ // members and signs its metadata; messages are plain signed events with
+ // an `h` tag. Not encrypted. Joined by address (host'id, a client's link),
+ // and the list of them is the NIP-51 kind 10009 other clients keep too.
+ let ng = null, ngLoading = null;
+ function nip29Chat() {
+ const pk = identityPk();
+ if (!pk || !wallet._cacheKey) return Promise.resolve(null);
+ if (ng && ng.pubkey === pk) return Promise.resolve(ng);
+ if (ngLoading && ngLoading.pk === pk) return ngLoading.p;
+ if (ng) { try { ng.stop(); } catch {} ng = null; }
+ const p = import('../nip29-client.js').then(async (m) => {
+ if (identityPk() !== pk) return null;
+ const c = m.nip29Client({
+ scope: wallet._cacheKey() + ':' + pk, pubkey: pk, identity, on: onNg,
+ listRelays: async (deep) => deep
+ ? [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...(await relaysOf(pk).catch(() => []))])]
+ : zapRelays(),
+ });
+ ng = c;
+ ngLoading = null;
+ await c.start();
+ return c;
+ }).catch((e) => { console.warn('chat: relay groups unavailable', e); ngLoading = null; return null; });
+ ngLoading = { pk, p };
+ return p;
+ }
+ function onNg(type, g, data) {
+ if (!ng) return;
+ if (type === 'message') { try { noteEmoji(data); } catch {} }
+ if (g) wnFolds.delete(g.id);
+ bumpMsgRev();
+ scheduleRepaint();
+ }
+ const gClient = (g) => (g && g.nip29 ? ng : g && g.cordn ? cd : wn);
+ const gOn = (g) => (g.nip29 ? onNg : g.cordn ? onCd : onWn);
+ const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || (ng && ng.groups.get(id)) || null;
// One delivered event of a direct group, into that person's thread.
function wnIntoThread(g, peer, m) {
@@ -3886,14 +3922,14 @@ export function messagesFeature(ctx) {
const inner = wn.event(7, emoji, [['e', m.rumor.id], ['p', m.rumor.pubkey], ['k', String(m.rumor.kind)], ...reactEmojiTags(emoji)]);
const ok = await wn.send(g, inner).catch(() => false);
if (!ok) { toast(t('msgSendFailed')); return; }
- onWn('message', g, inner);
+ gOn(g)('message', g, inner);
}
// ---- groups ----
const wnRead = (id) => 'wn:' + id;
// groups with a screen of their own (a direct group lives in its DM thread)
- const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : [])];
+ const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : []), ...(ng ? ng.groups.values() : [])];
function wnTitle(g) {
const c = gClient(g);
@@ -4008,7 +4044,7 @@ export function messagesFeature(ctx) {
const sent = wn.send(g, inner).catch(() => false);
wnFolds.delete(g.id);
render();
- if (!(await sent)) { wnFolds.delete(g.id); toast(t('msgSendFailed')); render(); }
+ if (!(await sent)) { wnFolds.delete(g.id); toast(g.nip29 && g.sendRefused ? t('msgSendFailed') + ': ' + g.sendRefused : t('msgSendFailed')); render(); }
}
async function wnAct(fn) {
@@ -17488,6 +17524,7 @@ export function messagesFeature(ctx) {
// login is asked when it first opens a Cordn group or invites someone).
if (ui.msgView !== 'grp' && cd) cd.focus(null);
cordnChat().then((c) => { if (c && !cdReadied) { cdReadied = true; c.ready(); } });
+ nip29Chat();
// Group chats ride along: the client loads with the wallet, and standing
// on this screen is what makes us invitable (a KeyPackage on the relays).
whiteNoise().then(async (c) => {
@@ -17614,7 +17651,10 @@ export function messagesFeature(ctx) {
// one list, newest first: a group chat is a conversation like any other
const chatRows = [
...dmRows.map((x) => ({ at: x.last.rumor.created_at, dm: x })),
- ...wnGroups().filter((g) => !g.fresh).map((g) => ({ at: (wnFold(g).rows.at(-1) || {}).rumor?.created_at || g.at, g })),
+ // a relay group with nothing said yet sorts by when it was joined
+ // HERE: one that only arrived on the 10009 list (joined elsewhere,
+ // maybe years ago, maybe on a relay long gone) waits at the bottom
+ ...wnGroups().filter((g) => !g.fresh).map((g) => ({ at: (wnFold(g).rows.at(-1) || {}).rumor?.created_at || (g.nip29 ? g.joinedAt || 0 : g.at), g })),
].sort((a, b) => b.at - a.at);
const shownDms = ui.msgAllDms ? chatRows : chatRows.slice(0, LIST_PREVIEW);
kids.push(
@@ -18081,6 +18121,7 @@ export function messagesFeature(ctx) {
h('div', { class: 'muted small chat-preview' },
g.removed ? t('msgGroupGone')
: last ? (last.mine ? t('msgYouPrefix') : displayName(last.rumor.pubkey) + ':') + ' ' + (last.rumor.content || '📎 ' + t('msgGroupAttachment'))
+ : g.nip29 && !wn.accounts(g).length ? t(g.unreachable ? 'msgNip29Unreachable' : 'msgNip29Waiting', { host: g.relay.replace(/^wss?:\/\//, '').replace(/\/$/, '') })
: t('msgMembers', { n: wn.accounts(g).length }))),
unread ? h('i', { class: 'thread-dot' }) : null);
}
@@ -18136,7 +18177,42 @@ export function messagesFeature(ctx) {
// which protocol the group speaks: White Noise (Marmot) or Cordn
h('div', { class: 'seg' },
h('button', { class: proto === 'wn' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'wn'; render(); } }, t('msgGroupProtoWn')),
- h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd'))),
+ h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd')),
+ h('button', { class: proto === 'n29' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'n29'; render(); } }, t('msgGroupProtoN29'))),
+ proto === 'n29' ? nip29JoinPanel() : null,
+ proto === 'n29' ? null : wnNewGroupForm(proto, picks, ready));
+ }
+ // Join a NIP-29 group by its address (they're made in other clients).
+ async function nip29Join() {
+ const raw = (ui.msgN29Addr || '').trim();
+ if (!raw || ui.msgBusy) return;
+ const m = await import('../nip29-client.js');
+ const a = m.parseGroupAddress(raw);
+ if (!a) { toast(t('msgNip29BadAddress')); return; }
+ ui.msgBusy = true; render();
+ try {
+ const c = await nip29Chat();
+ if (!c) throw new Error('no signer');
+ const g = await c.join(a);
+ ui.msgN29Addr = ''; ui.msgHomePanel = null;
+ ui.msgView = 'grp'; ui.msgPeer = g.id; ui.msgStick = true;
+ if (g.joinRefused) toast(t('msgNip29JoinRefused', { why: g.joinRefused }));
+ } catch (e) { wnFail(e); } finally { ui.msgBusy = false; render(); }
+ }
+ function nip29JoinPanel() {
+ return h('div', { class: 'col gap6' },
+ h('div', { class: 'small muted' }, t('msgGroupProtoN29Hint')),
+ h('div', { class: 'row gap6' },
+ h('input', {
+ class: 'grow nip29-address', type: 'text', placeholder: t('msgNip29Address'), autocapitalize: 'none', spellcheck: 'false',
+ value: ui.msgN29Addr || '', onInput: (e) => { ui.msgN29Addr = e.target.value; },
+ onKeydown: (e) => { if (e.key === 'Enter') nip29Join(); },
+ }),
+ h('button', { class: 'btn-primary btn-sm', disabled: ui.msgBusy, onClick: nip29Join },
+ ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgJoin'))));
+ }
+ function wnNewGroupForm(proto, picks, ready) {
+ return h('div', { class: 'col gap6' },
proto === 'cd' ? h('div', { class: 'small muted' }, t('msgGroupProtoCdHint')) : null,
h('input', {
type: 'text', placeholder: t('msgGroupName'), maxlength: '64',
@@ -18155,7 +18231,11 @@ export function messagesFeature(ctx) {
const accounts = wn.accounts(g);
const picks = ui.msgGroupAdd || (ui.msgGroupAdd = []);
return h('div', { class: 'col chat-members' },
- h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')),
+ h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, g.nip29 ? t('msgNip29Info', { relay: wn.groupRef(g) }) : t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')),
+ g.nip29 && v.about ? h('div', { class: 'small', style: 'padding:2px 12px 6px' }, v.about) : null,
+ g.nip29 ? h('div', { class: 'row gap6', style: 'padding:0 12px 6px' }, h('button', { class: 'btn-ghost btn-sm', onClick: async () => {
+ try { await navigator.clipboard.writeText(wn.groupRef(g)); toast(t('copied')); } catch {}
+ } }, t('msgNip29CopyAddress'))) : null,
...accounts.map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) },
avatar(pk),
h('div', { class: 'row gap6 grow', style: 'align-items:center;min-width:0' },
@@ -18215,15 +18295,20 @@ export function messagesFeature(ctx) {
!ui.emojiPick && m.mine && !g.removed ? item('×', t('msgDelete'), async () => {
close();
const del = wn.event(5, '', [['e', m.rumor.id], ['k', '9']]);
- if (await wn.send(g, del).catch(() => false)) (g.cordn ? onCd : onWn)('message', g, del);
+ if (await wn.send(g, del).catch(() => false)) gOn(g)('message', g, del);
}) : null));
}
+ // Outside wnView: its own `const wn = gClient(g)` shadows the module's wn
+ // for the whole function, so a `!wn` in there read the inner one before
+ // it was set — a ReferenceError the moment a group was left from its own
+ // screen (Adam's phone, 2026-11-13).
+ const groupClientsLoading = () => !wn || wnLoading || !cd || cdLoading || !ng || ngLoading;
function wnView() {
const g = findGroup(ui.msgPeer);
if (!g) {
// the groups are still loading (a reload straight into this screen)
- if (!wn || wnLoading || !cd || cdLoading) { Promise.all([whiteNoise(), cordnChat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); }
+ if (groupClientsLoading()) { Promise.all([whiteNoise(), cordnChat(), nip29Chat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); }
ui.msgView = 'home';
return homeView();
}
@@ -18332,7 +18417,7 @@ export function messagesFeature(ctx) {
h('span', { class: 'small muted chat-quote-text' }, ...snippetNodes(reply.rumor))),
h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')) : null,
live
- ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn ? null : (f) => wnSendFile(g, f))
+ ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn || g.nip29 ? null : (f) => wnSendFile(g, f))
: h('div', { class: 'muted small', style: 'text-align:center;padding:12px' }, t('msgGroupGone')),
ui.msgSheet ? wnSheet(g) : null);
}
diff --git a/src/i18n.js b/src/i18n.js
index d1de016..dde69c9 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -790,6 +790,15 @@ const STR = {
msgGroupProtoWn: 'Marmot (MLS)',
msgGroupProtoCd: 'Cordn',
msgGroupProtoCdHint: 'Cordn groups work with Amethyst and the Cordn app; messages travel through a coordinator server that cannot read them.',
+ msgGroupProtoN29: 'Relay group',
+ msgGroupProtoN29Hint: 'Join a NIP-29 group made in Chachi, Flotilla, 0xchat or another client: paste its address (relay\'id) or a link to it. Relay groups are not encrypted; the relay keeps the members and can read the messages.',
+ msgNip29Address: 'relay.example.com\'groupid or a link',
+ msgNip29BadAddress: 'That isn\'t a group address. It looks like relay.example.com\'groupid',
+ msgNip29JoinRefused: 'The relay didn\'t let you in yet ({why}). You can still read the group if it\'s open.',
+ msgNip29Info: 'Relay group {relay} · not encrypted · the relay keeps the members',
+ msgNip29CopyAddress: 'Copy group address',
+ msgNip29Unreachable: "{host} isn't answering",
+ msgNip29Waiting: 'On {host}',
msgCordnInfo: 'Cordn group · end-to-end encrypted · delivered by a coordinator',
msgCordnNoFiles: 'Attachments are not available in Cordn groups yet',
msgRouteTitle: 'Send messages by',
@@ -1217,6 +1226,9 @@ const STR = {
explorerUrlHint: 'Esplora / electrs REST base, e.g. https://mempool.space/api',
walletName: 'Account name',
walletSettings: 'Account settings',
+ balanceSettingsTitle: 'Balance settings',
+ spendingSettingsDesc: 'Coins, renewals, who holds them, moving them out',
+ savingsSettingsDesc: 'Lock, password, recovery phrase, public key',
securityTitle: 'Security',
walletSettingsDesc: 'Name, recovery phrase, public key, and auto-logout for this account.',
autolockTitle: 'Log out automatically',
diff --git a/src/nip29-client.js b/src/nip29-client.js
new file mode 100644
index 0000000..cfbd171
--- /dev/null
+++ b/src/nip29-client.js
@@ -0,0 +1,361 @@
+// NIP-29 relay-based groups in the app: the fourth way a group chat can
+// travel, beside White Noise, Cordn and Concord. A group lives on ONE relay,
+// which keeps its membership and signs its metadata (kinds 39000 name,
+// 39001 admins, 39002 members); its messages are ordinary signed events
+// (kind 9 chat, 7 reactions, 5 deletions) carrying an `h` tag with the
+// group's id. Nothing is encrypted: the relay (and, in an open group,
+// anyone) can read it. Membership is asked for with kind 9021 and given up
+// with 9022; the relay decides.
+//
+// The same interface as marmot-client.js and cordn-client.js, so the group
+// screens in features/messages.js drive all three (`g.nip29` says which).
+// Groups joined here and in other clients are one list: NIP-51 kind 10009
+// ("simple groups"), read at start and written back on join and leave —
+// replaceable, so never written without first reading the newest copy.
+
+import { subscribeAuthed, publishOn, queryOn, finalizeEvent } from './nostr.js';
+import { decode as nip19decode } from 'nostr-tools/nip19';
+import { getEventHash } from 'nostr-tools/pure';
+import { marmotStore } from './marmot-store.js';
+import { dlog } from './debug.js';
+
+const LIST = 10009;
+const CHAT_KINDS = [9, 7, 5, 9000, 9001, 9021, 9022];
+const META_KINDS = [39000, 39001, 39002];
+const LOG_MAX = 600; // events held per group in memory
+const KEEP = 200; // ...and on disk, so a reload paints at once
+const now = () => Math.floor(Date.now() / 1000);
+const GID = /^[A-Za-z0-9_-]{1,128}$/;
+
+// The relay's URL, normalized: wss://host[/path], no trailing slash.
+export function normRelay(u) {
+ let x = String(u || '').trim();
+ if (!x) return null;
+ if (!/^wss?:\/\//i.test(x)) x = 'wss://' + x.replace(/^\/+/, '');
+ try {
+ const url = new URL(x);
+ if (!/^wss?:$/.test(url.protocol) || !url.hostname.includes('.') && url.hostname !== 'localhost' && !/^\d/.test(url.hostname)) return null;
+ return (url.protocol + '//' + url.host + url.pathname).replace(/\/+$/, '').toLowerCase();
+ } catch { return null; }
+}
+
+// A group's address in whatever form someone pastes it: host'id (the
+// NIP-29 form), wss://host'id, an naddr of its kind 39000, or a web link
+// from a client (chachi.chat/<host>/<id>, flotilla's /spaces/<host>/<id>, …).
+export function parseGroupAddress(input) {
+ let s = String(input || '').trim().replace(/^nostr:/i, '');
+ if (!s) return null;
+ if (/^naddr1/i.test(s)) {
+ try {
+ const d = nip19decode(s);
+ if (d.type === 'naddr' && d.data.kind === 39000 && GID.test(d.data.identifier) && d.data.relays?.length) {
+ const relay = normRelay(d.data.relays[0]);
+ return relay ? { relay, gid: d.data.identifier } : null;
+ }
+ } catch {}
+ return null;
+ }
+ const q = s.match(/^(wss?:\/\/)?([^'\s/]+(?:\/[^'\s]*)?)'([A-Za-z0-9_-]+)$/);
+ if (q) {
+ const relay = normRelay((q[1] || 'wss://') + q[2]);
+ return relay && GID.test(q[3]) ? { relay, gid: q[3] } : null;
+ }
+ if (/^https?:\/\//i.test(s)) {
+ try {
+ const u = new URL(s);
+ const parts = u.pathname.split('/').filter(Boolean).map(decodeURIComponent);
+ for (let i = 0; i < parts.length - 1; i++) {
+ const host = parts[i].replace(/^wss?:\/\//i, '');
+ if (host.includes('.') && GID.test(parts[i + 1])) {
+ const relay = normRelay(host);
+ if (relay) return { relay, gid: parts[i + 1] };
+ }
+ }
+ } catch {}
+ }
+ return null;
+}
+
+const keyOf = (relay, gid) => relay + "'" + gid;
+
+// listRelays(deep): where our kind 10009 is read and written.
+export function nip29Client({ scope, pubkey, identity, on, listRelays, store = marmotStore(scope + '|nip29') }) {
+ const groups = new Map(); // key -> record
+ let loaded = null, stopped = false, list = null; // list: { tags, at }
+ const subs = new Map(); // key -> unsubscribe
+ const leftNow = new Set(); // left this session: a list read still in flight mustn't bring them back
+ const emit = (type, g, data) => { try { on(type, g, data); } catch (e) { dlog('nip29: listener', e); } };
+
+ // ---- signing ----
+ async function sign(tmpl, { interactive = true } = {}) {
+ const id = await identity();
+ if (!id || !id.signer || id.pubkey !== pubkey) throw new Error('no signer');
+ if (id.signer instanceof Uint8Array) return finalizeEvent(tmpl, id.signer);
+ if (id.signer.interactive && !interactive) throw new Error('signer is interactive');
+ return id.signer.signEvent(tmpl);
+ }
+ // NIP-42: a private group shows itself, and any group takes a post, only
+ // from a signed-in member. Never a trip through another app unasked.
+ const onauth = (tmpl) => sign(tmpl, { interactive: false });
+
+ // ---- storage ----
+ function record(relay, gid) {
+ const id = keyOf(relay, gid);
+ let g = groups.get(id);
+ if (!g) {
+ g = { id, nip29: true, relay, gid, log: [], at: now(), meta: {}, admins: [], members: [], metaAt: {} };
+ groups.set(id, g);
+ }
+ return g;
+ }
+ let flushTimer = 0;
+ const dirty = new Set();
+ const touch = (g) => { dirty.add(g.id); clearTimeout(flushTimer); flushTimer = setTimeout(flush, 600); };
+ async function flush() {
+ clearTimeout(flushTimer);
+ const ids = [...dirty];
+ dirty.clear();
+ await Promise.all(ids.map((id) => {
+ const g = groups.get(id);
+ if (!g) return store.del('g:' + id);
+ const { log, ...rest } = g;
+ return store.put('g:' + id, { ...rest, log: log.filter((e) => !e.pending).slice(-KEEP) });
+ })).catch((e) => dlog('nip29: save', e));
+ }
+ function load() {
+ if (!loaded)
+ loaded = store.all().then((all) => {
+ for (const [k, v] of all) if (k.startsWith('g:') && v && v.relay && v.gid) groups.set(v.id, { ...v, log: v.log || [], unreachable: '' });
+ const l = all.get('list');
+ if (l && Array.isArray(l.tags)) list = l;
+ }).catch((e) => dlog('nip29: store', e));
+ return loaded;
+ }
+
+ // ---- events in ----
+ // A reply in NIP-29 chat quotes what it answers (`q`); the group screens
+ // read a reply's target from its first `e`, so one is added for display.
+ function display(ev) {
+ if (ev.kind !== 9 || ev.tags.some((x) => x[0] === 'e')) return ev;
+ const q = ev.tags.find((x) => x[0] === 'q' && /^[0-9a-f]{64}$/.test(x[1] || ''));
+ return q ? { ...ev, tags: [...ev.tags, ['e', q[1]]] } : ev;
+ }
+ function take(g, ev) {
+ if (META_KINDS.includes(ev.kind)) {
+ if ((g.metaAt[ev.kind] || 0) >= ev.created_at) return;
+ g.metaAt[ev.kind] = ev.created_at;
+ const tag = (k) => (ev.tags.find((x) => x[0] === k) || [])[1];
+ if (ev.kind === 39000) {
+ g.meta = { name: tag('name') || '', about: tag('about') || '', picture: tag('picture') || '',
+ closed: ev.tags.some((x) => x[0] === 'closed'), private: ev.tags.some((x) => x[0] === 'private') };
+ } else {
+ const pks = [...new Set(ev.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))];
+ if (ev.kind === 39001) g.admins = pks; else g.members = pks;
+ }
+ touch(g);
+ emit('meta', g);
+ return;
+ }
+ if (g.log.some((e) => e.id === ev.id && !e.pending)) return;
+ const i = g.log.findIndex((e) => e.id === ev.id);
+ if (i >= 0) g.log.splice(i, 1); // our own, confirmed by the relay
+ const shown = display(ev);
+ g.log.push(shown);
+ g.log.sort((a, b) => a.created_at - b.created_at);
+ if (g.log.length > LOG_MAX) g.log.splice(0, g.log.length - LOG_MAX);
+ if (!ev.pending) g.seen = Math.max(g.seen || 0, ev.created_at);
+ touch(g);
+ emit('message', g, shown);
+ }
+
+ function subscribe(g) {
+ if (subs.has(g.id) || stopped) return;
+ const since = g.seen ? g.seen - 60 : undefined;
+ // a relay that can't be reached (groups.0xchat.com stopped resolving)
+ // is said on the group's row instead of "0 members"
+ const reached = () => { if (g.unreachable) { g.unreachable = ''; touch(g); emit('loaded', g); } };
+ const a = subscribeAuthed([g.relay], { kinds: CHAT_KINDS, '#h': [g.gid], limit: 300, ...(since ? { since } : {}) },
+ (ev) => { reached(); if (ev.tags.some((x) => x[0] === 'h' && x[1] === g.gid)) take(g, ev); },
+ { onauth, onclose: (why) => { if (!g.log.length && !g.members.length) { g.unreachable = String((why && why[0]) || 'unreachable'); touch(g); emit('loaded', g); } } });
+ // metadata: only what the group's own relay signed (the copies other
+ // relays carry are read nowhere)
+ const b = subscribeAuthed([g.relay], { kinds: META_KINDS, '#d': [g.gid] },
+ (ev) => { if (ev.tags.some((x) => x[0] === 'd' && x[1] === g.gid)) take(g, ev); }, { onauth });
+ subs.set(g.id, () => { a(); b(); });
+ }
+ function unsubscribe(g) { const u = subs.get(g.id); if (u) { u(); subs.delete(g.id); } }
+
+ // ---- the joined-groups list (kind 10009) ----
+ const listGroups = (tags) => (tags || []).filter((x) => x[0] === 'group' && GID.test(x[1] || '') && normRelay(x[2]))
+ .map((x) => ({ gid: x[1], relay: normRelay(x[2]), name: x[3] || '' }));
+ async function readList(deep = false) {
+ const relays = await listRelays(deep);
+ const evs = await queryOn(relays, { kinds: [LIST], authors: [pubkey] }, deep ? 7000 : 5000);
+ const newest = (evs || []).filter((e) => e.pubkey === pubkey).sort((a, b) => b.created_at - a.created_at)[0];
+ if (newest && (!list || newest.created_at > list.at)) {
+ list = { tags: newest.tags || [], content: newest.content || '', at: newest.created_at };
+ store.put('list', list).catch(() => {});
+ }
+ return list;
+ }
+ // Apply one change to the newest list the relays hold (never to a copy
+ // we only assume is current), then publish it.
+ async function writeList(change) {
+ const cur = await readList(true).catch(() => list);
+ const base = cur || { tags: [], content: '', at: 0 };
+ const tags = change(base.tags);
+ if (!tags) return;
+ const created_at = Math.max(now(), base.at + 1);
+ const ev = await sign({ kind: LIST, created_at, tags, content: base.content || '' });
+ // everywhere a reader looks: a phone that asks only the relays a copy
+ // missed would never learn of a group joined on the laptop
+ const ok = await publishOn(await listRelays(true), ev);
+ if (ok) { list = { tags, content: base.content || '', at: created_at }; store.put('list', list).catch(() => {}); }
+ }
+ // The list as another device changed it: read at start, again whenever the
+ // app comes back (resubscribe), and followed live while it's open, so a
+ // group joined on the laptop shows up on the phone (Adam, 2026-11-12).
+ let adoptedAt = 0, listSub = null;
+ async function adoptList() {
+ adoptedAt = Date.now();
+ try {
+ await readList(true);
+ const l = list; // the newest, a leave's own write included
+ if (!l || stopped) return;
+ for (const x of listGroups(l.tags)) {
+ if (groups.has(keyOf(x.relay, x.gid)) || leftNow.has(keyOf(x.relay, x.gid))) continue;
+ const g = record(x.relay, x.gid);
+ if (x.name && !g.meta.name) g.meta = { ...g.meta, name: x.name };
+ touch(g);
+ subscribe(g);
+ emit('loaded', g);
+ }
+ } catch (e) { dlog('nip29: list', e); }
+ }
+
+ // ---- actions ----
+ // Ask the relay to let us in, and hold the group here either way: an
+ // open group can be read before (or without) the relay's yes.
+ async function join(address, { code = '' } = {}) {
+ const a = typeof address === 'string' ? parseGroupAddress(address) : address;
+ if (!a) throw new Error('bad address');
+ await load();
+ leftNow.delete(keyOf(a.relay, a.gid));
+ const g = record(a.relay, a.gid);
+ g.joinedAt = now(); // sorts a group joined here, still quiet, as new
+ touch(g);
+ subscribe(g);
+ emit('joined', g);
+ let refused = '';
+ const req = await sign({ kind: 9021, created_at: now(), content: '', tags: [['h', a.gid], ...(code ? [['code', code]] : [])] });
+ const ok = await publishOn([a.relay], req, { onauth, onrefused: (_, why) => { refused = why; } });
+ // "duplicate"/"already a member" is a yes
+ if (!ok && !/already|duplicate/i.test(refused)) g.joinRefused = refused || 'unreachable';
+ else g.joinRefused = '';
+ touch(g);
+ writeList((tags) => tags.some((x) => x[0] === 'group' && x[1] === a.gid && normRelay(x[2]) === a.relay) ? null
+ : [...tags, ['group', a.gid, a.relay, g.meta.name || ''].filter((v, i) => i < 3 || v)]).catch((e) => dlog('nip29: list write', e));
+ return g;
+ }
+
+ // The NIP-29 "previous" tags: the first 8 characters of a few recent
+ // events this relay sent us, so it can tell we've seen the timeline.
+ const previous = (g) => g.log.filter((e) => !e.pending && e.pubkey !== pubkey).slice(-3).map((e) => ['previous', e.id.slice(0, 8)]);
+
+ function event(kind, content, tags = []) {
+ const ev = { kind, content, tags, created_at: now(), pubkey };
+ return { ...ev, id: getEventHash(ev) };
+ }
+ // A reply's `e` becomes the `q` NIP-29 chat clients read.
+ function outTags(g, inner) {
+ let tags = inner.tags || [];
+ if (inner.kind === 9) tags = tags.map((x) => {
+ if (x[0] !== 'e') return x;
+ const target = g.log.find((e) => e.id === x[1]);
+ return ['q', x[1], g.relay, ...(target ? [target.pubkey] : [])];
+ });
+ return [['h', g.gid], ...tags.filter((x) => x[0] !== 'h'), ...previous(g)];
+ }
+ async function send(g, inner) {
+ const ev = await sign({ kind: inner.kind, content: inner.content || '', created_at: inner.created_at || now(), tags: outTags(g, inner) });
+ take(g, { ...ev, pending: true });
+ let refused = '';
+ const ok = await publishOn([g.relay], ev, { onauth, onrefused: (_, why) => { refused = why; } });
+ const i = g.log.findIndex((e) => e.id === ev.id);
+ if (!ok) {
+ if (i >= 0) g.log.splice(i, 1);
+ g.sendRefused = refused;
+ emit('message', g, ev);
+ return false;
+ }
+ if (i >= 0) { const { pending, ...clean } = g.log[i]; g.log[i] = clean; }
+ g.sendRefused = '';
+ touch(g);
+ emit('message', g, ev);
+ return true;
+ }
+
+ async function leave(g) {
+ unsubscribe(g);
+ leftNow.add(g.id);
+ groups.delete(g.id);
+ touch(g);
+ emit('left', g);
+ try {
+ const ev = await sign({ kind: 9022, created_at: now(), content: '', tags: [['h', g.gid]] });
+ publishOn([g.relay], ev, { onauth }).catch(() => {});
+ } catch {}
+ await writeList((tags) => {
+ const next = tags.filter((x) => !(x[0] === 'group' && x[1] === g.gid && normRelay(x[2]) === g.relay));
+ return next.length === tags.length ? null : next;
+ }).catch((e) => dlog('nip29: list write', e));
+ }
+
+ function adoptLive(ev) {
+ if (stopped || ev.pubkey !== pubkey || ev.kind !== LIST) return;
+ if (list && ev.created_at <= list.at) return;
+ list = { tags: ev.tags || [], content: ev.content || '', at: ev.created_at };
+ store.put('list', list).catch(() => {});
+ adoptList();
+ }
+ async function followList() {
+ if (listSub || stopped) return;
+ const relays = await listRelays(false);
+ if (stopped || listSub) return;
+ listSub = subscribeAuthed(relays, { kinds: [LIST], authors: [pubkey], limit: 1 }, adoptLive);
+ }
+ async function start() {
+ await load();
+ if (stopped) return;
+ for (const g of groups.values()) subscribe(g);
+ emit('loaded', null);
+ adoptList();
+ followList().catch(() => {});
+ }
+ function resubscribe() {
+ for (const g of groups.values()) { unsubscribe(g); subscribe(g); }
+ if (listSub) { try { listSub(); } catch {} listSub = null; }
+ followList().catch(() => {});
+ if (Date.now() - adoptedAt > 30_000) adoptList();
+ }
+ function stop() {
+ stopped = true;
+ for (const g of groups.values()) unsubscribe(g);
+ if (listSub) { try { listSub(); } catch {} listSub = null; }
+ flush();
+ }
+ const no = async () => { throw new Error('not supported'); };
+
+ return {
+ pubkey, groups, load, start, stop, resubscribe, join, send, leave,
+ forget: leave, accept: () => {}, focus: () => {}, ready: () => {}, poll: () => {}, lookup: async () => null,
+ create: no, add: no, kick: no, rename: no,
+ event,
+ view: (g) => ({ name: g.meta.name || g.gid, about: g.meta.about || '', picture: g.meta.picture || '', admins: g.admins, closed: !!g.meta.closed, private: !!g.meta.private }),
+ peer: () => null,
+ accounts: (g) => g.members.length ? g.members : [...new Set(g.log.filter((e) => e.kind === 9).map((e) => e.pubkey))],
+ isAdmin: () => false,
+ groupRef: (g) => g.relay.replace(/^wss:\/\//, '') + "'" + g.gid,
+ isSetUp: () => true,
+ };
+}
diff --git a/src/style.css b/src/style.css
index 803979f..ec9fd18 100644
--- a/src/style.css
+++ b/src/style.css
@@ -95,6 +95,8 @@
* { box-sizing: border-box; }
html {
+ /* a post scrolled into view lands below the sticky top bar */
+ scroll-padding-top: 72px;
/* Reserve the scrollbar's gutter always, so it doesn't appear the instant
the history list fills the page and shrink the viewport — that width
change re-centered the balance carousel's snapped card, the "slight move
@@ -176,6 +178,17 @@ input { scroll-margin-top: 12px; }
.logo-full { height: 40px; flex: none; color: var(--ink); display: grid; place-items: center; }
.logo-full svg { height: 40px; width: auto; display: block; }
.brand h1 { font-size: 19px; }
+/* The top bar (logo, search, settings, avatar) stays on screen while the
+ page scrolls under it (Adam, 2026-11-12). A frosted strip the width of the
+ column, padded out by the same amount it's pulled in, so the layout at
+ rest doesn't move; the safe-area inset keeps it clear of a notch. */
+.brand-header {
+ position: sticky; top: 0; z-index: 30;
+ margin: -8px -16px; padding: 8px 16px;
+ padding-top: max(8px, env(safe-area-inset-top));
+ background: color-mix(in srgb, var(--bg) 84%, transparent);
+ -webkit-backdrop-filter: saturate(1.4) blur(14px); backdrop-filter: saturate(1.4) blur(14px);
+}
.badge {
font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 999px;