aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 13:40:54 -0700
committerAdam Soltys <[email protected]>2026-08-02 13:40:54 -0700
commit95263c02a13a825a8d02dbe760f2a74de8f93f77 (patch)
tree1383d4ca48ec24eedb26fd7740f13e1295d55bdf /src/features
parent6416bd5dd79c3bdde5dfa7e7ec95fc811fbcdb28 (diff)
downloadcoinosv3-95263c02a13a825a8d02dbe760f2a74de8f93f77.tar.gz
coinosv3-95263c02a13a825a8d02dbe760f2a74de8f93f77.zip
Chat v2: DMs, invites, and multi-community — coinos is the default, not the only
- NIP-17 private DMs (src/dm.js): kind-14 rumors in sender-signed seals under ephemeral wraps; interops with other nostr DM clients. Login signers gained encryptTo/decryptFrom so extensions and bunkers can seal to a peer; the inbox listens for both the login npub and the wallet key and publishes a kind-10050 DM-relay list for the wallet key when none exists. - Concord invites (CORD-05): mint shareable links (33301 bundle on relays, keys only in the URL fragment), join by pasting a link or opening v3.coinos.io/invite/…, and direct invites giftwrapped to an npub with the k-tag index hint. Bundles self-certify the founder before anything joins. - Multi-community: home view lists DM threads and communities; found your own community in-app (owner-signed genesis). coinos stays baked in as default. - Welcome DM: the names registrar DMs every first-time registrant from Adam's npub on claim (config: welcome{sk,message}; deduped in registrar state). Every new wallet auto-claims a payment address, so every new user gets one. - relay.coinos.io write policy now allows 33301/13302/13303. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js932
1 files changed, 709 insertions, 223 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 0914755..c46bb70 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1,23 +1,28 @@
-// Messages — encrypted community chat over the Concord protocol (CORD-01..04),
-// the successor to Marmot/White Noise for Discord-shaped rooms on nostr.
+// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
-// coinos ships with one built-in community: "coinos". Its join material (below)
-// includes the community_root, so holding the app is holding membership —
-// relays only ever see kind-1059 wraps signed by derived stream keys. The
-// community was founded by Adam's npub, which makes him owner (position 0)
-// and sole authority for the control plane fold.
+// Communities are end-to-end private: there is no global discovery in
+// Concord, membership travels by invite. coinos ships with one built-in
+// community (below) so every user starts somewhere; beyond it users join by
+// invite link, by direct invite (giftwrapped to their npub), or by founding
+// their own community in-app.
//
-// Messages are signed by the session's nostr login identity when a signer is
-// live (extension, or a pasted key before reload), else by the wallet's
-// NIP-06 key — both only ever *sign* the seal; all encryption happens under
-// stream keys the app already holds, so remote signers need no nip44 round-trips.
+// Identity: the session's nostr login when a signer is live, else the
+// wallet's NIP-06 key. Community traffic encrypts under stream keys the app
+// holds (signers only sign seals); DMs seal to the peer, which is what the
+// widened signer adapters (encryptTo/decryptFrom) exist for. The DM inbox
+// listens for both identities and decrypts with whichever keys are present.
-import { subscribeOn, publishOn, fetchNostrProfile, npubOf } from '../nostr.js';
+import {
+ subscribeOn, publishOn, queryOn, fetchNostrProfile, fetchInboxRelays,
+ npubOf, parseNostrPubkey, generateSecretKey, getPublicKey, finalizeEvent,
+} from '../nostr.js';
import {
channelKey, controlKey, guestbookKey, openWrap, wrapRumor,
- foldControl, foldGuestbook, observeAuthor, eventMs, msTags,
+ foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition,
+ communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle,
} from '../concord.js';
-import { hexToBytes } from '@noble/hashes/utils';
+import { makeDM, unwrapDM, wrapDM } from '../dm.js';
+import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
import { t } from '../i18n.js';
// Genesis output of tools/concord-genesis.js — the coinos community's join
@@ -35,45 +40,34 @@ const COMMUNITY = {
};
const EPOCH = 0;
-const CACHE_MAX = 50; // messages kept per channel in feature state
+const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol'];
+const APP_BASE = 'https://v3.coinos.io';
+const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state
export function messagesFeature(ctx) {
const { h, ui, render, wallet, toast, hook } = ctx;
- const root = hexToBytes(COMMUNITY.community_root);
- const cid = COMMUNITY.community_id;
- const control = controlKey(root, cid, EPOCH);
- const guestbook = guestbookKey(root, cid, EPOCH);
- const chStream = (id) => channelKey(root, id, EPOCH);
-
- // ---- state ------------------------------------------------------------
+ // ---- persisted state ----------------------------------------------------
- let folded = null; // control-plane fold: { metadata, channels, banned, ... }
- let controlEntries = [];
- let guestEntries = [];
- let byChannel = new Map(); // channelId -> Map(rumorId -> {rumor, author})
- let edits = new Map(); // target rumor id -> latest edit rumor
- let deletes = new Set(); // rumor ids deleted by their author
- let reactions = new Map(); // target rumor id -> Map(author -> emoji)
- let seenWraps = new Set();
- let unsubs = [];
- let started = false;
-
- const profiles = new Map(); // pubkey -> {name, picture} | null while loading
- const st = () => wallet.loadFeatureState('messages', { joined: {}, cache: {} });
+ const st = () => {
+ const s = wallet.loadFeatureState('messages', {});
+ s.joined ||= {}; // { [cid]: { [pubkey]: true } }
+ s.cache ||= {}; // { [channelId]: [msgs] }
+ s.communities ||= []; // join material beyond the built-in
+ s.invites ||= {}; // { [cid]: { sk, token, url } } — minted links
+ s.dms ||= {}; // { [peerPk]: [{ id, from, text, t }] }
+ s.declined ||= {}; // direct-invite rumor ids dismissed
+ // pre-multi-community shape: joined was { [pubkey]: true } for coinos
+ for (const k of Object.keys(s.joined))
+ if (s.joined[k] === true) { (s.joined[COMMUNITY.community_id] ||= {})[k] = true; delete s.joined[k]; }
+ return s;
+ };
const save = (s) => wallet.saveFeatureState('messages', s);
- const channels = () => {
- // The control plane is the authority; the baked-in list covers the gap
- // until the first fold lands (offline, cold relays).
- if (folded && folded.channels.size)
- return [...folded.channels.entries()].map(([id, c]) => ({ id, name: c.name }));
- return COMMUNITY.channels;
- };
- const currentChannel = () =>
- channels().find((c) => c.id === ui.msgChannel) || channels()[0];
+ const communities = () => [COMMUNITY, ...st().communities];
+ const communityById = (cid) => communities().find((c) => c.community_id === cid);
- // ---- identity ----------------------------------------------------------
+ // ---- identity -----------------------------------------------------------
async function identity() {
const id = hook('nostrLoginIdentity');
@@ -84,60 +78,25 @@ export function messagesFeature(ctx) {
if (wallet.nostr && wallet.nostr.sk) return { pubkey: wallet.nostr.pk, signer: wallet.nostr.sk };
return null;
}
-
- // ---- inbound -----------------------------------------------------------
-
- const refold = () => {
- folded = foldControl(controlEntries, { ownerHex: COMMUNITY.owner, cid });
+ const myPubkeys = () => {
+ const pks = [];
+ const id = hook('nostrLoginIdentity');
+ if (id) pks.push(id.pubkey);
+ if (wallet.nostr && wallet.nostr.pk && !pks.includes(wallet.nostr.pk)) pks.push(wallet.nostr.pk);
+ return pks;
};
+ const isMe = (pk) => myPubkeys().includes(pk);
- const onChat = (channelId) => (wrap) => {
- if (seenWraps.has(wrap.id)) return;
- seenWraps.add(wrap.id);
- const opened = openWrap(wrap, chStream(channelId));
- if (!opened) return;
- const { rumor, author } = opened;
- const tag = (k) => rumor.tags?.find((x) => x[0] === k);
- // CORD-03 §3: the rumor must commit to the channel/epoch that decrypted it
- if (tag('channel')?.[1] !== channelId || tag('epoch')?.[1] !== String(EPOCH)) return;
- if (folded && folded.banned.has(author)) return;
- if (rumor.kind === 9) {
- let msgs = byChannel.get(channelId) || byChannel.set(channelId, new Map()).get(channelId);
- msgs.set(rumor.id, { rumor, author });
- } else if (rumor.kind === 5) {
- for (const e of rumor.tags.filter((x) => x[0] === 'e')) {
- const m = byChannel.get(channelId)?.get(e[1]);
- if (!m || m.author === author) deletes.add(e[1]);
- }
- } else if (rumor.kind === 3302) {
- const target = tag('e')?.[1];
- if (target) {
- const cur = edits.get(target);
- if (!cur || eventMs(rumor) > eventMs(cur.rumor)) edits.set(target, { rumor, author });
- }
- } else if (rumor.kind === 7) {
- const target = tag('e')?.[1];
- if (target) {
- let r = reactions.get(target) || reactions.set(target, new Map()).get(target);
- r.set(author, rumor.content);
- }
- }
- const tms = eventMs(rumor);
- if (tms) observeAuthor(members, author, tms);
- scheduleRepaint();
- };
+ // ---- shared runtime -----------------------------------------------------
- let members = new Map();
- const refoldGuestbook = () => {
- members = foldGuestbook(guestEntries, { nowMs: Date.now(), banned: folded?.banned });
- for (const [, msgs] of byChannel)
- for (const { rumor, author } of msgs.values()) {
- const tms = eventMs(rumor);
- if (tms) observeAuthor(members, author, tms);
- }
- };
+ const rooms = new Map(); // cid -> room runtime
+ const threads = new Map(); // peerPk -> Map(rumorId -> { rumor, mine })
+ const pendingDirect = new Map(); // rumor id -> { bundle, from }
+ const profiles = new Map(); // pubkey -> profile | null while loading
+ const seenWraps = new Set();
+ let dmStarted = false;
+ let allUnsubs = [];
- // Coalesce repaints: relays replay history in a burst on subscribe.
let repaintTimer = null;
const scheduleRepaint = () => {
if (repaintTimer) return;
@@ -147,79 +106,163 @@ export function messagesFeature(ctx) {
}, 80);
};
- function start() {
- if (started) return;
- started = true;
- const relays = COMMUNITY.relays;
- unsubs.push(
- subscribeOn(relays, { kinds: [1059], authors: [control.pk], limit: 500 }, (wrap) => {
+ function profileOf(pk) {
+ if (profiles.has(pk)) return profiles.get(pk);
+ profiles.set(pk, null);
+ fetchNostrProfile(pk).then((p) => {
+ profiles.set(pk, p || {});
+ scheduleRepaint();
+ }).catch(() => profiles.set(pk, {}));
+ return null;
+ }
+ const displayName = (pk) => {
+ const p = profileOf(pk);
+ if (p && p.name) return p.name;
+ const npub = npubOf(pk);
+ return npub ? npub.slice(0, 12) + '…' : pk.slice(0, 12) + '…';
+ };
+
+ // ---- community rooms ----------------------------------------------------
+
+ function ensureRoom(jm) {
+ let room = rooms.get(jm.community_id);
+ if (room) return room;
+ const root = hexToBytes(jm.community_root);
+ room = {
+ jm,
+ control: controlKey(root, jm.community_id, jm.root_epoch || EPOCH),
+ guestbook: guestbookKey(root, jm.community_id, jm.root_epoch || EPOCH),
+ chStream: (id) => channelKey(root, id, EPOCH),
+ folded: null,
+ controlEntries: [],
+ guestEntries: [],
+ members: new Map(),
+ byChannel: new Map(),
+ edits: new Map(),
+ deletes: new Set(),
+ reactions: new Map(),
+ subbed: new Set(),
+ relays: jm.relays && jm.relays.length ? jm.relays : DM_RELAYS,
+ };
+ rooms.set(jm.community_id, room);
+
+ const refold = () => { room.folded = foldControl(room.controlEntries, { ownerHex: jm.owner, cid: jm.community_id }); };
+ const refoldGuestbook = () => {
+ room.members = foldGuestbook(room.guestEntries, { nowMs: Date.now(), banned: room.folded?.banned });
+ for (const [, msgs] of room.byChannel)
+ for (const { rumor, author } of msgs.values()) {
+ const tms = eventMs(rumor);
+ if (tms) observeAuthor(room.members, author, tms);
+ }
+ };
+
+ allUnsubs.push(
+ subscribeOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, (wrap) => {
if (seenWraps.has(wrap.id)) return;
seenWraps.add(wrap.id);
- const opened = openWrap(wrap, control);
+ const opened = openWrap(wrap, room.control);
if (!opened || opened.rumor.kind !== 3308) return;
- controlEntries.push(opened);
+ room.controlEntries.push(opened);
refold();
scheduleRepaint();
- })
- );
- unsubs.push(
- subscribeOn(relays, { kinds: [1059], authors: [guestbook.pk], limit: 500 }, (wrap) => {
+ }),
+ subscribeOn(room.relays, { kinds: [1059], authors: [room.guestbook.pk], limit: 500 }, (wrap) => {
if (seenWraps.has(wrap.id)) return;
seenWraps.add(wrap.id);
- const opened = openWrap(wrap, guestbook);
+ const opened = openWrap(wrap, room.guestbook);
if (!opened) return;
- guestEntries.push(opened);
+ room.guestEntries.push(opened);
refoldGuestbook();
scheduleRepaint();
})
);
- for (const c of COMMUNITY.channels) subChannel(c.id);
- // warm the timeline from the local cache so the page paints instantly
+ for (const c of jm.channels || []) subChannel(room, c.id);
+ // warm from local cache so the page paints before relays answer
const cached = st().cache;
- for (const [chId, list] of Object.entries(cached || {}))
- for (const m of list) {
- let msgs = byChannel.get(chId) || byChannel.set(chId, new Map()).get(chId);
+ for (const c of jm.channels || [])
+ for (const m of cached[c.id] || []) {
+ const msgs = room.byChannel.get(c.id) || room.byChannel.set(c.id, new Map()).get(c.id);
if (!msgs.has(m.rumor.id)) msgs.set(m.rumor.id, m);
}
+ return room;
+ }
+
+ function subChannel(room, id) {
+ if (room.subbed.has(id)) return;
+ room.subbed.add(id);
+ allUnsubs.push(subscribeOn(room.relays, { kinds: [1059], authors: [room.chStream(id).pk], limit: 200 }, (wrap) => {
+ if (seenWraps.has(wrap.id)) return;
+ seenWraps.add(wrap.id);
+ const opened = openWrap(wrap, room.chStream(id));
+ if (!opened) return;
+ onChat(room, id, opened);
+ }));
}
- const subbed = new Set();
- function subChannel(id) {
- if (subbed.has(id)) return;
- subbed.add(id);
- unsubs.push(subscribeOn(COMMUNITY.relays, { kinds: [1059], authors: [chStream(id).pk], limit: 200 }, onChat(id)));
+ function onChat(room, channelId, { rumor, author }) {
+ const tag = (k) => rumor.tags?.find((x) => x[0] === k);
+ // CORD-03 §3: the rumor must commit to the channel/epoch that decrypted it
+ if (tag('channel')?.[1] !== channelId || tag('epoch')?.[1] !== String(EPOCH)) return;
+ if (room.folded && room.folded.banned.has(author)) return;
+ if (rumor.kind === 9) {
+ const msgs = room.byChannel.get(channelId) || room.byChannel.set(channelId, new Map()).get(channelId);
+ msgs.set(rumor.id, { rumor, author });
+ } else if (rumor.kind === 5) {
+ for (const e of rumor.tags.filter((x) => x[0] === 'e')) {
+ const m = room.byChannel.get(channelId)?.get(e[1]);
+ if (!m || m.author === author) room.deletes.add(e[1]);
+ }
+ } else if (rumor.kind === 3302) {
+ const target = tag('e')?.[1];
+ if (target) {
+ const cur = room.edits.get(target);
+ if (!cur || eventMs(rumor) > eventMs(cur.rumor)) room.edits.set(target, { rumor, author });
+ }
+ } else if (rumor.kind === 7) {
+ const target = tag('e')?.[1];
+ if (target) {
+ const r = room.reactions.get(target) || room.reactions.set(target, new Map()).get(target);
+ r.set(author, rumor.content);
+ }
+ }
+ const tms = eventMs(rumor);
+ if (tms) observeAuthor(room.members, author, tms);
+ scheduleRepaint();
}
- function persistCache() {
+ const roomChannels = (room) => {
+ if (room.folded && room.folded.channels.size)
+ return [...room.folded.channels.entries()].map(([id, c]) => ({ id, name: c.name }));
+ return room.jm.channels || [];
+ };
+
+ function persistCache(room) {
const s = st();
- s.cache = {};
- for (const [chId, msgs] of byChannel) {
- const list = [...msgs.values()]
- .filter((m) => !deletes.has(m.rumor.id))
+ for (const [chId, msgs] of room.byChannel) {
+ s.cache[chId] = [...msgs.values()]
+ .filter((m) => !room.deletes.has(m.rumor.id))
.sort((a, b) => eventMs(a.rumor) - eventMs(b.rumor))
.slice(-CACHE_MAX);
- s.cache[chId] = list;
}
save(s);
}
- // ---- outbound ----------------------------------------------------------
-
- async function ensureJoined(id) {
+ async function ensureJoined(room, id) {
const s = st();
- if (s.joined[id.pubkey]) return;
+ const j = (s.joined[room.jm.community_id] ||= {});
+ if (j[id.pubkey]) return;
const { created_at, ms } = msTags(Date.now());
- const rumor = { kind: 3306, pubkey: id.pubkey, content: 'join', tags: [ms], created_at };
- const wrap = await wrapRumor(rumor, id.signer, guestbook);
- publishOn(COMMUNITY.relays, wrap);
- s.joined[id.pubkey] = true;
+ const tags = [ms];
+ if (room.jm.invitedBy) tags.push(['invite', room.jm.invitedBy, room.jm.inviteLabel || '']);
+ const wrap = await wrapRumor({ kind: 3306, pubkey: id.pubkey, content: 'join', tags, created_at }, id.signer, room.guestbook);
+ publishOn(room.relays, wrap);
+ j[id.pubkey] = true;
save(s);
}
- async function sendMessage() {
+ async function sendMessage(room, chId) {
const text = (ui.msgDraft || '').trim();
- const ch = currentChannel();
- if (!text || !ch || ui.msgSending) return;
+ if (!text || ui.msgSending) return;
const id = await identity();
if (!id) { toast(t('msgNoIdentity')); return; }
ui.msgSending = true;
@@ -228,21 +271,21 @@ export function messagesFeature(ctx) {
const { created_at, ms } = msTags(Date.now());
const rumor = {
kind: 9, pubkey: id.pubkey, content: text,
- tags: [['channel', ch.id], ['epoch', String(EPOCH)], ms], created_at,
+ tags: [['channel', chId], ['epoch', String(EPOCH)], ms], created_at,
};
- const wrap = await wrapRumor(rumor, id.signer, chStream(ch.id));
+ const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId));
// optimistic insert (the relay echo will dedupe on wrap id)
- const opened = openWrap(wrap, chStream(ch.id));
+ const opened = openWrap(wrap, room.chStream(chId));
if (opened) {
seenWraps.add(wrap.id);
- let msgs = byChannel.get(ch.id) || byChannel.set(ch.id, new Map()).get(ch.id);
+ const msgs = room.byChannel.get(chId) || room.byChannel.set(chId, new Map()).get(chId);
msgs.set(opened.rumor.id, opened);
}
ui.msgDraft = '';
- const ok = await publishOn(COMMUNITY.relays, wrap);
+ const ok = await publishOn(room.relays, wrap);
if (!ok) toast(t('msgSendFailed'));
- ensureJoined(id).catch(() => {});
- persistCache();
+ ensureJoined(room, id).catch(() => {});
+ persistCache(room);
} catch (e) {
toast(e.message || String(e));
} finally {
@@ -252,41 +295,278 @@ export function messagesFeature(ctx) {
}
}
- async function deleteMessage(m) {
+ async function deleteMessage(room, chId, m) {
const id = await identity();
if (!id || id.pubkey !== m.author) return;
- const ch = currentChannel();
const { created_at, ms } = msTags(Date.now());
const rumor = {
kind: 5, pubkey: id.pubkey, content: '',
- tags: [['channel', ch.id], ['epoch', String(EPOCH)], ['e', m.rumor.id], ['k', '9'], ms], created_at,
+ tags: [['channel', chId], ['epoch', String(EPOCH)], ['e', m.rumor.id], ['k', '9'], ms], created_at,
};
- const wrap = await wrapRumor(rumor, id.signer, chStream(ch.id));
- deletes.add(m.rumor.id);
+ const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId));
+ room.deletes.add(m.rumor.id);
render();
- publishOn(COMMUNITY.relays, wrap);
- persistCache();
+ publishOn(room.relays, wrap);
+ persistCache(room);
}
- // ---- profiles ----------------------------------------------------------
+ // ---- founding a community (CORD-02 genesis) -----------------------------
- function profileOf(pk) {
- if (profiles.has(pk)) return profiles.get(pk);
- profiles.set(pk, null);
- fetchNostrProfile(pk).then((p) => {
- profiles.set(pk, p || {});
- scheduleRepaint();
- }).catch(() => profiles.set(pk, {}));
- return null;
+ async function createCommunity(name) {
+ const id = await identity();
+ if (!id) { toast(t('msgNoIdentity')); return; }
+ name = name.trim().slice(0, 64);
+ if (!name) return;
+ const ownerSalt = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
+ const rootBytes = crypto.getRandomValues(new Uint8Array(32));
+ const cid = communityId(id.pubkey, ownerSalt);
+ const generalId = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
+ const control = controlKey(rootBytes, cid, 0);
+ const guestbook = guestbookKey(rootBytes, cid, 0);
+ const now = Date.now();
+ const relays = DM_RELAYS;
+ const meta = { ...makeEdition({ vsk: 0, eid: cid, version: 1, content: JSON.stringify({ name, relays }) }, now), pubkey: id.pubkey };
+ const general = { ...makeEdition({ vsk: 2, eid: generalId, version: 1, content: JSON.stringify({ name: 'general', private: false }) }, now + 1), pubkey: id.pubkey };
+ const { created_at, ms } = msTags(now + 2);
+ const join = { kind: 3306, pubkey: id.pubkey, content: 'join', tags: [ms], created_at };
+ const events = [
+ await wrapRumor(meta, id.signer, control, { plaintext: true }),
+ await wrapRumor(general, id.signer, control, { plaintext: true }),
+ await wrapRumor(join, id.signer, guestbook),
+ ];
+ for (const e of events) await publishOn(relays, e);
+ const jm = {
+ community_id: cid, owner: id.pubkey, owner_salt: ownerSalt,
+ community_root: bytesToHex(rootBytes), root_epoch: 0,
+ channels: [{ id: generalId, name: 'general' }], relays, name,
+ };
+ const s = st();
+ s.communities.push(jm);
+ (s.joined[cid] ||= {})[id.pubkey] = true;
+ save(s);
+ ensureRoom(jm);
+ ui.msgView = 'room';
+ ui.msgCommunity = cid;
+ ui.msgChannel = null;
+ ui.msgNewName = '';
+ ui.msgHomePanel = null;
+ render();
}
- const displayName = (pk) => {
- const p = profileOf(pk);
- if (p && p.name) return p.name;
- return npubOf(pk).slice(0, 12) + '…';
- };
+ // ---- invites ------------------------------------------------------------
+
+ function bundleFor(room, creatorPk) {
+ return {
+ community_id: room.jm.community_id, owner: room.jm.owner, owner_salt: room.jm.owner_salt,
+ community_root: room.jm.community_root, root_epoch: room.jm.root_epoch || 0,
+ channels: roomChannels(room).map((c) => ({ id: c.id, name: c.name })),
+ relays: room.relays, name: (room.folded?.metadata?.name) || room.jm.name,
+ creator_npub: creatorPk,
+ };
+ }
+
+ async function mintInviteLink(room) {
+ const id = await identity();
+ if (!id) { toast(t('msgNoIdentity')); return null; }
+ const s = st();
+ const existing = s.invites[room.jm.community_id];
+ if (existing) return existing.url;
+ const linkSk = generateSecretKey();
+ const token = crypto.getRandomValues(new Uint8Array(16));
+ const evt = makeInviteBundleEvent(linkSk, bundleFor(room, id.pubkey), token);
+ const ok = await publishOn(room.relays, evt);
+ if (!ok) { toast(t('msgSendFailed')); return null; }
+ const url = makeInviteLink(APP_BASE, getPublicKey(linkSk), room.relays, token);
+ s.invites[room.jm.community_id] = { sk: bytesToHex(linkSk), token: bytesToHex(token), url };
+ save(s);
+ return url;
+ }
+
+ async function sendDirectInvite(room, input) {
+ const peer = parseNostrPubkey(input);
+ if (!peer) { toast(t('msgBadNpub')); return; }
+ const id = await identity();
+ if (!id) { toast(t('msgNoIdentity')); return; }
+ if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
+ const rumor = {
+ kind: 3313, pubkey: id.pubkey,
+ content: JSON.stringify(bundleFor(room, id.pubkey)),
+ tags: [], created_at: Math.floor(Date.now() / 1000),
+ };
+ const wrap = await wrapDM(id.signer, peer, rumor, [['k', '3313']]);
+ const inbox = (await fetchInboxRelays(peer)).slice(0, 4);
+ const ok = await publishOn([...new Set([...inbox, ...DM_RELAYS])], wrap);
+ toast(ok ? t('msgInviteSent') : t('msgSendFailed'));
+ }
+
+ function acceptBundle(b, { invitedBy, inviteLabel } = {}) {
+ const s = st();
+ if (!communityById(b.community_id)) {
+ const jm = {
+ community_id: b.community_id, owner: b.owner, owner_salt: b.owner_salt,
+ community_root: b.community_root, root_epoch: b.root_epoch || 0,
+ channels: (b.channels || []).map((c) => ({ id: c.id, name: c.name })),
+ relays: (b.relays || []).slice(0, 5), name: b.name || 'community',
+ invitedBy, inviteLabel,
+ };
+ s.communities.push(jm);
+ save(s);
+ }
+ const room = ensureRoom(communityById(b.community_id));
+ identity().then((id) => id && ensureJoined(room, id)).catch(() => {});
+ ui.msgView = 'room';
+ ui.msgCommunity = b.community_id;
+ ui.msgChannel = null;
+ ui.msgHomePanel = null;
+ pendingLink = null;
+ render();
+ }
+
+ // A link invite being previewed (pasted or arrived via /invite/<naddr>#…).
+ let pendingLink = null; // { parsed, state: 'loading'|'ready'|'error', bundle?, error? }
+
+ async function loadLinkInvite(parsed) {
+ pendingLink = { parsed, state: 'loading' };
+ scheduleRepaint();
+ const relays = [...new Set([...(parsed.relays || []), ...DM_RELAYS])];
+ const evts = await queryOn(relays, { kinds: [33301], authors: [parsed.signerPk] }, 4000);
+ const newest = evts.sort((a, b) => b.created_at - a.created_at)[0];
+ const b = newest && openInviteBundle(newest, parsed.token);
+ if (!b) pendingLink = { parsed, state: 'error', error: t('msgInviteNotFound') };
+ else if (b.revoked) pendingLink = { parsed, state: 'error', error: t('msgInviteRevoked') };
+ else if (b.expired) pendingLink = { parsed, state: 'error', error: t('msgInviteExpired') };
+ else pendingLink = { parsed, state: 'ready', bundle: b };
+ scheduleRepaint();
+ }
+
+ function joinFromText(text) {
+ const parsed = parseInviteLink(text);
+ if (!parsed) { toast(t('msgBadInvite')); return; }
+ ui.msgJoinText = '';
+ ui.msgHomePanel = null;
+ loadLinkInvite(parsed);
+ }
+
+ // An invite link opened in the browser lands here before the wallet exists.
+ const urlInvite = typeof location !== 'undefined' ? parseInviteLink(location.href) : null;
+ if (urlInvite) { try { history.replaceState(null, '', '/'); } catch {} }
+
+ // ---- DMs ----------------------------------------------------------------
+
+ const threadOf = (peer) => threads.get(peer) || threads.set(peer, new Map()).get(peer);
+
+ function noteDM(peer, rumor, mine) {
+ if (!peer || !rumor.id) return;
+ threadOf(peer).set(rumor.id, { rumor, mine });
+ scheduleRepaint();
+ }
+
+ async function handleInboxWrap(wrap) {
+ if (seenWraps.has(wrap.id)) return;
+ seenWraps.add(wrap.id);
+ const decryptors = [];
+ if (wallet.nostr && wallet.nostr.sk) decryptors.push(wallet.nostr.sk);
+ const login = hook('nostrLoginIdentity');
+ if (login && login.signer && login.signer.decryptFrom) decryptors.push(login.signer);
+ for (const d of decryptors) {
+ const got = await unwrapDM(wrap, d).catch(() => null);
+ if (!got) continue;
+ if (got.rumor.kind === 14) {
+ noteDM(got.peer, got.rumor, isMe(got.author));
+ persistDms();
+ } else if (got.rumor.kind === 3313 && !isMe(got.author)) {
+ try {
+ const b = openDirectBundle(got.rumor.content);
+ if (b && !st().declined[got.rumor.id] && !communityById(b.community_id))
+ pendingDirect.set(got.rumor.id, { bundle: b, from: got.author, rid: got.rumor.id });
+ scheduleRepaint();
+ } catch {}
+ }
+ return;
+ }
+ }
+
+ function openDirectBundle(json) {
+ const b = JSON.parse(json);
+ if (communityId(b.owner, b.owner_salt) !== b.community_id) return null;
+ if (!/^[0-9a-f]{64}$/.test(b.community_root || '')) return null;
+ if (!Array.isArray(b.channels) || b.channels.length > 256) return null;
+ if (b.expires_at && Date.now() > b.expires_at) return null;
+ return b;
+ }
+
+ function startDMs() {
+ if (dmStarted) return;
+ const pks = myPubkeys();
+ if (!pks.length) return;
+ dmStarted = true;
+ // warm threads from the local cache
+ const s = st();
+ for (const [peer, list] of Object.entries(s.dms))
+ for (const m of list)
+ threadOf(peer).set(m.id, { rumor: { id: m.id, pubkey: m.from, content: m.text, created_at: m.t, kind: 14 }, mine: isMe(m.from) });
+ allUnsubs.push(subscribeOn(DM_RELAYS, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => {
+ handleInboxWrap(wrap).catch(() => {});
+ }));
+ ensureDmRelayList().catch(() => {});
+ }
+
+ // Publish a kind 10050 DM-relay list for the wallet key if none exists, so
+ // other NIP-17 clients can find our inbox. Never touch a login npub's list
+ // — the user's other clients own that.
+ async function ensureDmRelayList() {
+ if (!wallet.nostr || !wallet.nostr.sk) return;
+ const pk = wallet.nostr.pk;
+ const existing = await queryOn(DM_RELAYS, { kinds: [10050], authors: [pk] }, 2500);
+ if (existing.length) return;
+ const evt = finalizeEvent({
+ kind: 10050,
+ content: '',
+ tags: DM_RELAYS.map((r) => ['relay', r]),
+ created_at: Math.floor(Date.now() / 1000),
+ }, wallet.nostr.sk);
+ publishOn(DM_RELAYS, evt);
+ }
+
+ async function sendDM(peer) {
+ const text = (ui.msgDraft || '').trim();
+ if (!text || ui.msgSending) return;
+ const id = await identity();
+ if (!id) { toast(t('msgNoIdentity')); return; }
+ if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
+ ui.msgSending = true;
+ render();
+ try {
+ const { rumor, toPeer, toSelf } = await makeDM(id.signer, peer, text);
+ noteDM(peer, rumor, true);
+ ui.msgDraft = '';
+ const inbox = (await fetchInboxRelays(peer)).slice(0, 4);
+ const ok = await publishOn([...new Set([...inbox, ...DM_RELAYS])], toPeer);
+ publishOn(DM_RELAYS, toSelf);
+ if (!ok) toast(t('msgSendFailed'));
+ persistDms();
+ } catch (e) {
+ toast(e.message || String(e));
+ } finally {
+ ui.msgSending = false;
+ ui.msgStick = true;
+ render();
+ }
+ }
+
+ function persistDms() {
+ const s = st();
+ const byRecent = [...threads.entries()]
+ .map(([peer, m]) => [peer, [...m.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at)])
+ .sort((a, b) => (b[1].at(-1)?.rumor.created_at || 0) - (a[1].at(-1)?.rumor.created_at || 0))
+ .slice(0, 30);
+ s.dms = {};
+ for (const [peer, list] of byRecent)
+ s.dms[peer] = list.slice(-CACHE_MAX).map((m) => ({ id: m.rumor.id, from: m.rumor.pubkey, text: m.rumor.content, t: m.rumor.created_at }));
+ save(s);
+ }
- // ---- view --------------------------------------------------------------
+ // ---- views --------------------------------------------------------------
const timeLabel = (tms) => {
const d = new Date(tms);
@@ -297,46 +577,191 @@ export function messagesFeature(ctx) {
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
};
- function messageRows(chId, myPk) {
- const msgs = [...(byChannel.get(chId)?.values() || [])]
- .filter((m) => !deletes.has(m.rumor.id))
- .filter((m) => !(folded && folded.banned.has(m.author)))
+ const avatar = (pk, cls = 'chat-avatar') => {
+ 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));
+ };
+
+ const backBtn = (onClick) => h('button', { class: 'iconbtn chat-back', onClick }, '‹');
+
+ const stickToBottom = () => {
+ queueMicrotask(() => {
+ const log = document.querySelector('.chat-log');
+ if (log && ui.msgStick !== false) log.scrollTop = log.scrollHeight;
+ });
+ };
+
+ const composer = (placeholder, onSend) =>
+ h('div', { class: 'chat-compose' },
+ h('input', {
+ class: 'grow', type: 'text', id: 'msg-draft', placeholder,
+ value: ui.msgDraft || '', maxlength: '2000',
+ onInput: (e) => { ui.msgDraft = e.target.value; },
+ onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend(); } },
+ }),
+ h('button', { class: 'btn-primary btn-sm', disabled: ui.msgSending, onClick: onSend },
+ ui.msgSending ? h('span', { class: 'spinner sm' }) : t('msgSend')));
+
+ // ---- home ---------------------------------------------------------------
+
+ function homeView() {
+ startDMs();
+ for (const jm of communities()) ensureRoom(jm);
+
+ const dmRows = [...threads.entries()]
+ .map(([peer, m]) => {
+ const last = [...m.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at).at(-1);
+ return { peer, last };
+ })
+ .filter((x) => x.last)
+ .sort((a, b) => b.last.rumor.created_at - a.last.rumor.created_at);
+
+ const kids = [];
+
+ if (pendingLink) kids.push(linkInviteCard());
+ for (const [rid, inv] of pendingDirect) kids.push(directInviteCard(rid, inv));
+
+ // ---- DMs
+ kids.push(h('div', { class: 'row between', style: 'align-items:baseline' },
+ h('h3', { style: 'margin:0' }, t('msgDmsTitle')),
+ h('button', { class: 'linklike', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'newdm' ? null : 'newdm'; render(); } }, t('msgNewDm'))));
+ if (ui.msgHomePanel === 'newdm')
+ kids.push(h('div', { class: 'row gap6' },
+ h('input', {
+ class: 'grow', type: 'text', placeholder: t('msgNpubPlaceholder'),
+ value: ui.msgNewDmTo || '', onInput: (e) => { ui.msgNewDmTo = e.target.value; },
+ }),
+ h('button', {
+ class: 'btn-sm', onClick: () => {
+ const pk = parseNostrPubkey(ui.msgNewDmTo);
+ if (!pk) { toast(t('msgBadNpub')); return; }
+ ui.msgNewDmTo = '';
+ ui.msgHomePanel = null;
+ ui.msgView = 'dm';
+ ui.msgPeer = pk;
+ ui.msgStick = true;
+ render();
+ },
+ }, t('msgOpen'))));
+ kids.push(
+ dmRows.length
+ ? h('div', { class: 'list' }, dmRows.map(({ peer, last }) =>
+ h('div', {
+ class: 'item chat-thread-row',
+ onClick: () => { ui.msgView = 'dm'; ui.msgPeer = peer; ui.msgStick = true; render(); },
+ },
+ avatar(peer),
+ h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
+ h('div', { class: 'row between' },
+ h('span', { class: 'chat-name' }, displayName(peer)),
+ h('span', { class: 'chat-time' }, timeLabel(last.rumor.created_at * 1000))),
+ h('div', { class: 'muted small chat-preview' }, (last.mine ? t('msgYouPrefix') + ' ' : '') + last.rumor.content)))))
+ : h('div', { class: 'muted small' }, t('msgNoDms')));
+
+ // ---- communities
+ kids.push(h('div', { class: 'row between mt16', style: 'align-items:baseline' },
+ h('h3', { style: 'margin:0' }, t('msgCommunitiesTitle')),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'linklike', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'join' ? null : 'join'; render(); } }, t('msgJoin')),
+ h('button', { class: 'linklike', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'create' ? null : 'create'; render(); } }, t('msgCreate')))));
+ if (ui.msgHomePanel === 'join')
+ kids.push(h('div', { class: 'row gap6' },
+ h('input', {
+ class: 'grow', type: 'text', placeholder: t('msgInvitePlaceholder'),
+ value: ui.msgJoinText || '', onInput: (e) => { ui.msgJoinText = e.target.value; },
+ }),
+ h('button', { class: 'btn-sm', onClick: () => joinFromText(ui.msgJoinText) }, t('msgJoin'))));
+ if (ui.msgHomePanel === 'create')
+ kids.push(h('div', { class: 'row gap6' },
+ h('input', {
+ class: 'grow', type: 'text', placeholder: t('msgNamePlaceholder'), maxlength: '64',
+ value: ui.msgNewName || '', onInput: (e) => { ui.msgNewName = e.target.value; },
+ }),
+ h('button', { class: 'btn-sm', onClick: () => createCommunity(ui.msgNewName || '') }, t('msgCreate'))));
+ kids.push(h('div', { class: 'list' }, communities().map((jm) => {
+ const room = rooms.get(jm.community_id);
+ const name = room?.folded?.metadata?.name || jm.name;
+ const memberCount = room ? [...room.members.values()].filter((m) => m.state === 'join').length : 0;
+ return h('div', {
+ class: 'item chat-thread-row',
+ onClick: () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; render(); },
+ },
+ h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)),
+ h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
+ h('div', { class: 'chat-name' }, name),
+ h('div', { class: 'muted small' },
+ memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted'))));
+ })));
+
+ return h('div', { class: 'card col', style: 'gap:10px' }, ...kids);
+ }
+
+ function linkInviteCard() {
+ const pl = pendingLink;
+ return h('div', { class: 'notice info col', style: 'gap:8px' },
+ pl.state === 'loading' ? h('div', { class: 'row gap6' }, h('span', { class: 'spinner sm' }), t('msgInviteLoading'))
+ : pl.state === 'error' ? h('div', { class: 'row between' },
+ h('span', {}, pl.error),
+ h('button', { class: 'linklike', onClick: () => { pendingLink = null; render(); } }, t('msgDismiss')))
+ : h('div', { class: 'col', style: 'gap:8px' },
+ h('div', {}, t('msgInviteTo', { name: pl.bundle.name || 'community' })),
+ h('div', { class: 'muted small' }, t('msgInviteFounder', { npub: (npubOf(pl.bundle.owner) || '').slice(0, 16) + '…' })),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-primary btn-sm', onClick: () => acceptBundle(pl.bundle, { invitedBy: pl.bundle.creator_npub }) }, t('msgJoin')),
+ h('button', { class: 'btn-ghost btn-sm', onClick: () => { pendingLink = null; render(); } }, t('msgDismiss')))));
+ }
+
+ function directInviteCard(rid, inv) {
+ return h('div', { class: 'notice info col', style: 'gap:8px' },
+ h('div', {}, t('msgDirectInvite', { name: inv.bundle.name || 'community', from: displayName(inv.from) })),
+ h('div', { class: 'row gap6' },
+ h('button', {
+ class: 'btn-primary btn-sm',
+ onClick: () => { pendingDirect.delete(rid); acceptBundle(inv.bundle, { invitedBy: inv.from }); },
+ }, t('msgJoin')),
+ h('button', {
+ class: 'btn-ghost btn-sm',
+ onClick: () => { const s = st(); s.declined[rid] = 1; save(s); pendingDirect.delete(rid); render(); },
+ }, t('msgDismiss'))));
+ }
+
+ // ---- room ---------------------------------------------------------------
+
+ function messageRows(room, chId) {
+ const my = myPubkeys();
+ const msgs = [...(room.byChannel.get(chId)?.values() || [])]
+ .filter((m) => !room.deletes.has(m.rumor.id))
+ .filter((m) => !(room.folded && room.folded.banned.has(m.author)))
.sort((a, b) => eventMs(a.rumor) - eventMs(b.rumor));
if (!msgs.length)
return [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgEmpty'))];
let lastAuthor = null, lastT = 0;
return msgs.map((m) => {
const tms = eventMs(m.rumor);
- const mine = m.author === myPk;
- const edit = edits.get(m.rumor.id);
+ const mine = my.includes(m.author);
+ const edit = room.edits.get(m.rumor.id);
const text = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content;
const grouped = m.author === lastAuthor && tms - lastT < 5 * 60_000;
lastAuthor = m.author; lastT = tms;
- const p = profileOf(m.author);
- const reacts = reactions.get(m.rumor.id);
+ const reacts = room.reactions.get(m.rumor.id);
const counts = new Map();
if (reacts) for (const emoji of reacts.values()) counts.set(emoji, (counts.get(emoji) || 0) + 1);
return h(
'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') },
- grouped
- ? h('div', { class: 'chat-avatar spacer' })
- : p && p.picture
- ? h('img', { class: 'chat-avatar', src: p.picture, alt: '' })
- : h('div', { class: 'chat-avatar fallback' }, displayName(m.author).slice(0, 2)),
+ 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 === COMMUNITY.owner ? ' owner' : '') },
+ h('span', { class: 'chat-name' + (m.author === room.jm.owner ? ' owner' : '') },
displayName(m.author),
- m.author === COMMUNITY.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null),
+ m.author === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null),
h('span', { class: 'chat-time' }, timeLabel(tms))),
h('div', { class: 'chat-bubble' },
text,
edit ? h('span', { class: 'chat-edited' }, ' ', t('msgEdited')) : null,
mine
- ? h('button', {
- class: 'chat-del', title: t('msgDelete'),
- onClick: () => deleteMessage(m),
- }, '×')
+ ? h('button', { class: 'chat-del', title: t('msgDelete'), onClick: () => deleteMessage(room, chId, m) }, '×')
: null),
counts.size
? h('div', { class: 'chat-reacts' },
@@ -347,54 +772,107 @@ export function messagesFeature(ctx) {
});
}
- function messagesTab() {
- start();
- const ch = currentChannel();
- const myId = hook('nostrLoginIdentity');
- const myPk = myId ? myId.pubkey : wallet.nostr && wallet.nostr.pk;
- const name = (folded && folded.metadata && folded.metadata.name) || COMMUNITY.name;
- const memberCount = [...members.values()].filter((m) => m.state === 'join').length;
-
- // pin to bottom once the burst of history lands, unless the user scrolled up
- queueMicrotask(() => {
- const log = document.querySelector('.chat-log');
- if (log && ui.msgStick !== false) log.scrollTop = log.scrollHeight;
- });
+ function roomView() {
+ const jm = communityById(ui.msgCommunity) || COMMUNITY;
+ const room = ensureRoom(jm);
+ const chans = roomChannels(room);
+ const ch = chans.find((c) => c.id === ui.msgChannel) || chans[0];
+ const name = room.folded?.metadata?.name || jm.name;
+ const memberCount = [...room.members.values()].filter((m) => m.state === 'join').length;
+ stickToBottom();
return h('div', { class: 'card col chat-card' },
h('div', { class: 'row between chat-head' },
- h('div', { class: 'col', style: 'gap:2px' },
- h('div', { class: 'chat-title' }, name),
- h('div', { class: 'muted small' },
- memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted'))),
- channels().length > 1
- ? h('div', { class: 'seg' },
- channels().map((c) =>
+ h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
+ backBtn(() => { ui.msgView = 'home'; render(); }),
+ h('div', { class: 'col', style: 'gap:2px;min-width:0' },
+ h('div', { class: 'chat-title' }, name),
+ h('div', { class: 'muted small' },
+ memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted')))),
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ chans.length > 1
+ ? h('div', { class: 'seg' }, chans.map((c) =>
h('button', {
- class: c.id === ch.id ? 'active' : '',
- onClick: () => { ui.msgChannel = c.id; ui.msgStick = true; subChannel(c.id); render(); },
+ class: c.id === ch?.id ? 'active' : '',
+ onClick: () => { ui.msgChannel = c.id; ui.msgStick = true; subChannel(room, c.id); render(); },
}, '#' + c.name)))
- : h('div', { class: 'tag' }, '#' + ch.name)),
+ : h('div', { class: 'tag' }, '#' + (ch ? ch.name : '')),
+ h('button', {
+ class: 'iconbtn', title: t('msgInviteTitle'),
+ onClick: () => { ui.msgInvitePanel = !ui.msgInvitePanel; render(); },
+ }, '+'))),
+ ui.msgInvitePanel ? invitePanel(room) : null,
h('div', {
class: 'chat-log',
onScroll: (e) => {
const el = e.target;
ui.msgStick = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
},
- }, ...(ch ? messageRows(ch.id, myPk) : [])),
- h('div', { class: 'chat-compose' },
+ }, ...(ch ? messageRows(room, ch.id) : [])),
+ composer(t('msgPlaceholder', { channel: ch ? ch.name : '' }), () => ch && sendMessage(room, ch.id)));
+ }
+
+ function invitePanel(room) {
+ return h('div', { class: 'col chat-invite', style: 'gap:8px' },
+ h('div', { class: 'row gap6' },
+ h('button', {
+ class: 'btn-sm', disabled: ui.msgMinting,
+ onClick: async () => {
+ ui.msgMinting = true; render();
+ try {
+ const url = await mintInviteLink(room);
+ if (url) { await navigator.clipboard.writeText(url); toast(t('msgLinkCopied')); }
+ } finally { ui.msgMinting = false; render(); }
+ },
+ }, ui.msgMinting ? h('span', { class: 'spinner sm' }) : t('msgCopyInvite')),
h('input', {
- class: 'grow', type: 'text', id: 'msg-draft',
- placeholder: t('msgPlaceholder', { channel: ch ? ch.name : '' }),
- value: ui.msgDraft || '',
- maxlength: '2000',
- onInput: (e) => { ui.msgDraft = e.target.value; },
- onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } },
+ class: 'grow', type: 'text', placeholder: t('msgNpubPlaceholder'),
+ value: ui.msgInviteTo || '', onInput: (e) => { ui.msgInviteTo = e.target.value; },
}),
h('button', {
- class: 'btn-primary btn-sm', disabled: ui.msgSending,
- onClick: sendMessage,
- }, ui.msgSending ? h('span', { class: 'spinner sm' }) : t('msgSend'))));
+ class: 'btn-sm',
+ onClick: () => { sendDirectInvite(room, ui.msgInviteTo); ui.msgInviteTo = ''; render(); },
+ }, t('msgSend'))));
+ }
+
+ // ---- dm thread ----------------------------------------------------------
+
+ function dmView() {
+ startDMs();
+ const peer = ui.msgPeer;
+ if (!peer) { ui.msgView = 'home'; return homeView(); }
+ const msgs = [...(threads.get(peer)?.values() || [])].sort((a, b) => a.rumor.created_at - b.rumor.created_at);
+ stickToBottom();
+ return h('div', { class: 'card col chat-card' },
+ 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: 'chat-title' }, displayName(peer)),
+ h('div', { class: 'muted small' }, t('msgDmEncrypted')))),
+ h('div', {
+ class: 'chat-log',
+ onScroll: (e) => {
+ const el = e.target;
+ ui.msgStick = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
+ },
+ },
+ ...(msgs.length
+ ? msgs.map((m) =>
+ h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') },
+ h('div', { class: 'chat-body' },
+ h('div', { class: 'chat-bubble' + (m.mine ? ' me' : '') }, m.rumor.content),
+ h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
+ : [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])),
+ composer(t('msgDmPlaceholder'), () => sendDM(peer)));
+ }
+
+ // ---- feature ------------------------------------------------------------
+
+ function messagesTab() {
+ if (ui.msgView === 'room') return roomView();
+ if (ui.msgView === 'dm') return dmView();
+ return homeView();
}
return {
@@ -404,12 +882,20 @@ export function messagesFeature(ctx) {
if (tab !== 'messages') return null;
return messagesTab();
},
+ init() {
+ if (urlInvite && !pendingLink) {
+ loadLinkInvite(urlInvite);
+ setTimeout(() => { ui.tab = 'messages'; ui.msgView = 'home'; render(); }, 0);
+ }
+ startDMs();
+ },
stop() {
- for (const u of unsubs) { try { u(); } catch {} }
- unsubs = [];
- started = false;
- subbed.clear();
- if (byChannel.size) { try { persistCache(); } catch {} }
+ for (const u of allUnsubs) { try { u(); } catch {} }
+ allUnsubs = [];
+ rooms.clear();
+ threads.clear();
+ pendingDirect.clear();
+ dmStarted = false;
},
};
}