diff options
| author | Adam Soltys <[email protected]> | 2026-08-02 10:09:42 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-02 10:09:42 -0700 |
| commit | 3205acf157b7f1cfb857a383f374ef4d739d82cc (patch) | |
| tree | c61dd9e51b230106bc3134a22eef774dc1decd8c /src | |
| parent | 6ebae6bce794c8f2db8337cb189b9095975e36ad (diff) | |
| download | coinosv3-3205acf157b7f1cfb857a383f374ef4d739d82cc.tar.gz coinosv3-3205acf157b7f1cfb857a383f374ef4d739d82cc.zip | |
Chat: Concord encrypted community messaging, with a default coinos room
Implements the Concord protocol (CORD-01..04) — private streams, the
control-plane fold, guestbook membership, owner-rooted authority — and
ships a built-in "coinos" community founded by Adam's npub (owner/admin).
Messages sign with the nostr login identity when a signer is live, else
the wallet's NIP-06 key; all encryption rides derived stream keys so
remote signers only ever sign seals.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 2 | ||||
| -rw-r--r-- | src/concord.js | 234 | ||||
| -rw-r--r-- | src/features/index.js | 3 | ||||
| -rw-r--r-- | src/features/messages.js | 415 | ||||
| -rw-r--r-- | src/i18n.js | 11 | ||||
| -rw-r--r-- | src/style.css | 56 |
6 files changed, 720 insertions, 1 deletions
@@ -1954,6 +1954,7 @@ function tabsBar() { // Watch-only wallets show Send too — it prompts to load the seed to spend. ['send', t('tabSend')], ['history', t('tabHistory')], + ...featureAll('tabs'), // feature-provided tabs (e.g. messages) sit before Settings ['settings', t('tabSettings')], ]; return h( @@ -1982,6 +1983,7 @@ function tabContent() { case 'send': return sendTab(); case 'history': return historyTab(); case 'settings': return settingsTab(); + default: return featureHook('tabContent', ui.tab); } } diff --git a/src/concord.js b/src/concord.js new file mode 100644 index 0000000..909fbd1 --- /dev/null +++ b/src/concord.js @@ -0,0 +1,234 @@ +// Concord (CORD-01..06) — private communities on nostr +// https://github.com/concord-protocol/concord +// +// Core derivations and envelope crypto. Everything here is protocol-pure: +// no DOM, no app state. The messages feature builds on top of this. + +import { sha256 } from "@noble/hashes/sha256"; +import { hkdf } from "@noble/hashes/hkdf"; +import { schnorr, secp256k1 } from "@noble/curves/secp256k1"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; +import { finalizeEvent, generateSecretKey, getPublicKey, getEventHash, verifyEvent } from "nostr-tools/pure"; +import { nip44 } from "nostr-tools"; + +const utf8 = (s) => new TextEncoder().encode(s); + +export const PERM = { + MANAGE_ROLES: 1n << 0n, + MANAGE_CHANNELS: 1n << 1n, + MANAGE_METADATA: 1n << 2n, + KICK: 1n << 3n, + BAN: 1n << 4n, + MANAGE_MESSAGES: 1n << 5n, + CREATE_INVITE: 1n << 6n, + VIEW_AUDIT_LOG: 1n << 8n, + MENTION_EVERYONE: 1n << 9n, +}; + +// ---- Appendix A derivations (frozen) ---- + +const be64 = (n) => { + let b = new Uint8Array(8); + new DataView(b.buffer).setBigUint64(0, BigInt(n)); + return b; +}; + +const concat = (...arrs) => { + let out = new Uint8Array(arrs.reduce((n, a) => n + a.length, 0)); + let o = 0; + for (let a of arrs) out.set(a, o), (o += a.length); + return out; +}; + +// A.1: info = utf8(label) || 0x00 || id[32] || epoch_be[8]? (|| counter byte on scalar retry) +const hkdf32 = (secret, label, id, epoch, counter) => { + let info = concat(utf8(label), new Uint8Array([0]), id); + if (epoch !== undefined) info = concat(info, be64(epoch)); + if (counter !== undefined) info = concat(info, new Uint8Array([counter])); + return hkdf(sha256, secret, undefined, info, 32); +}; + +const isValidScalar = (b) => { + let n = BigInt("0x" + bytesToHex(b)); + return n > 0n && n < secp256k1.CURVE.n; +}; + +// A.2 + A.3: derive a keypair whose x-only pubkey is the stream address +export const groupKey = (label, secret, id, epoch) => { + let sk = hkdf32(secret, label, id, epoch); + for (let c = 0; !isValidScalar(sk); c++) sk = hkdf32(secret, label, id, epoch, c); + let pk = bytesToHex(schnorr.getPublicKey(sk)); + return { sk, pk, convKey: nip44.getConversationKey(sk, pk) }; +}; + +// A.4 +export const communityId = (ownerHex, saltHex) => + bytesToHex(sha256(concat(utf8("concord/community"), hexToBytes(ownerHex), hexToBytes(saltHex)))); + +const ZERO32 = new Uint8Array(32); + +export const controlKey = (root, cid, epoch = 0) => + groupKey("concord/control", root, hexToBytes(cid), epoch); +export const guestbookKey = (root, cid, epoch = 0) => + groupKey("concord/guestbook", root, hexToBytes(cid), epoch); +export const channelKey = (secret, channelId, epoch = 0) => + groupKey("concord/channel", secret, hexToBytes(channelId), epoch); +export const grantEid = (cid, memberHex) => + bytesToHex(hkdf32(hexToBytes(cid), "concord/grant", hexToBytes(memberHex))); +export const banlistEid = (cid) => bytesToHex(hkdf32(hexToBytes(cid), "concord/banlist", ZERO32)); + +// ---- CORD-01 envelope ---- +// wrap(1059, signed by stream key) > seal(20013 encrypted | 20014 plaintext, author-signed) > rumor + +const rumorWithId = (rumor) => ({ ...rumor, id: getEventHash({ ...rumor, tags: rumor.tags || [] }) }); + +// Sign a seal around a rumor. `signer` is a raw secret key (Uint8Array) or a +// { signEvent } adapter (nostr-login extension/bunker). Plaintext seals +// (control plane) carry the rumor JSON verbatim; encrypted seals +// nip44-encrypt it under the stream conversation key. +export const makeSeal = async (rumor, signer, streamConvKey, { plaintext = false } = {}) => { + let r = rumorWithId(rumor); + let content = plaintext ? JSON.stringify(r) : nip44.encrypt(JSON.stringify(r), streamConvKey); + let seal = { kind: plaintext ? 20014 : 20013, content, tags: [], created_at: rumor.created_at }; + return signer instanceof Uint8Array ? finalizeEvent(seal, signer) : signer.signEvent(seal); +}; + +export const makeWrap = (seal, stream, { ephemeral = false } = {}) => { + let wrap = { + kind: ephemeral ? 21059 : 1059, + content: nip44.encrypt(JSON.stringify(seal), stream.convKey), + tags: [["p", getPublicKey(generateSecretKey())]], + created_at: seal.created_at, + }; + return finalizeEvent(wrap, stream.sk); +}; + +export const wrapRumor = async (rumor, signer, stream, opts = {}) => + makeWrap(await makeSeal(rumor, signer, stream.convKey, opts), stream, opts); + +// Open a 1059/21059 wrap fetched at a stream address. Returns { rumor, author, seal } +// or null if any layer fails to parse, decrypt, or verify. +export const openWrap = (wrap, stream) => { + try { + let seal = JSON.parse(nip44.decrypt(wrap.content, stream.convKey)); + if (seal.kind !== 20013 && seal.kind !== 20014) return null; + if (!verifyEvent(seal)) return null; + let raw = seal.kind === 20014 ? seal.content : nip44.decrypt(seal.content, stream.convKey); + let rumor = JSON.parse(raw); + if (rumor.pubkey !== seal.pubkey) return null; + if (rumor.id !== getEventHash({ ...rumor, tags: rumor.tags || [] })) return null; + return { rumor, author: seal.pubkey, seal, raw }; + } catch { + return null; + } +}; + +// millisecond ordering: created_at * 1000 + ms tag (CORD-02 §4) +export const eventMs = (rumor) => { + let ms = +(rumor.tags?.find((t) => t[0] === "ms")?.[1] ?? 0); + if (!(ms >= 0 && ms <= 999)) return null; + return rumor.created_at * 1000 + ms; +}; + +export const msTags = (nowMs) => { + let created_at = Math.floor(nowMs / 1000); + return { created_at, ms: ["ms", String(nowMs % 1000)] }; +}; + +// ---- CORD-04 editions ---- + +// edition_hash: sha256 over the length-prefixed, domain-separated preimage +export const editionHash = (eidHex, version, prevHex, contentStr) => { + let label = utf8("vector-community/v1/edition"); + let content = utf8(contentStr); + return bytesToHex( + sha256( + concat( + be64(label.length), label, + hexToBytes(eidHex), + be64(version), + prevHex ? concat(new Uint8Array([1]), hexToBytes(prevHex)) : concat(new Uint8Array([0]), ZERO32), + be64(content.length), content + ) + ) + ); +}; + +export const makeEdition = ({ vsk, eid, version, prev, content, vac }, nowMs) => { + let { created_at, ms } = msTags(nowMs); + let tags = [["vsk", String(vsk)], ["eid", eid], ["ev", String(version)], ms]; + if (prev) tags.push(["ep", prev]); + if (vac) tags.push(["vac", ...vac]); + return { kind: 3308, content, tags, created_at }; +}; + +// Fold control-plane rumors into per-entity heads. +// Authority: owner-rooted; we accept owner-signed editions always, and +// non-owner editions only when a fold of grants/roles authorizes them. +// Returns { metadata, channels: Map(channelId -> meta), roles, grants, banned:Set }. +export const foldControl = (entries, { ownerHex, cid }) => { + // entries: [{ rumor, author }] — group by (vsk, eid), take highest intact version + let byEntity = new Map(); + for (let e of entries) { + let tag = (k) => e.rumor.tags?.find((t) => t[0] === k)?.[1]; + let vsk = tag("vsk"), eid = tag("eid"), ev = +tag("ev"); + if (vsk === undefined || !eid || !(ev >= 1)) continue; + let key = vsk + ":" + eid; + let cur = byEntity.get(key); + // Highest version wins; ties: authority first (owner beats non-owner), then lower rumor id. + let better = + !cur || + ev > cur.ev || + (ev === cur.ev && + ((e.author === ownerHex) > (cur.author === ownerHex) || + (e.author === ownerHex) === (cur.author === ownerHex) && e.rumor.id < cur.rumor.id)); + if (better) byEntity.set(key, { ...e, vsk, eid, ev }); + } + + let state = { metadata: null, channels: new Map(), roles: new Map(), grants: new Map(), banned: new Set() }; + let parse = (e) => { try { return JSON.parse(e.rumor.content); } catch { return null; } }; + + // Roles and grants first so later authority checks could use them; for now + // hal only honors owner-signed authority (the coinos community's owner is + // its sole admin) plus grants the owner signed. + for (let e of byEntity.values()) { + if (e.author !== ownerHex) continue; // non-owner authority: future work (vac/roster walk) + let c = parse(e); + if (e.vsk === "1" && c) state.roles.set(e.eid, c); + else if (e.vsk === "3" && c) state.grants.set(e.eid, c); + } + for (let e of byEntity.values()) { + if (e.author !== ownerHex) continue; + let c = parse(e); + if (e.vsk === "0" && c && e.eid === cid) state.metadata = c; + else if (e.vsk === "2" && c) state.channels.set(e.eid, c); + else if (e.vsk === "4" && Array.isArray(c)) state.banned = new Set(c); + } + for (let [, ch] of [...state.channels]) if (ch.deleted) state.channels.delete(ch.channel_id); + return state; +}; + +// ---- CORD-02 §5 guestbook fold ---- +// entries: [{ rumor, author }], returns Map(npub -> { state: 'join'|'leave'|'kick', t }) +export const foldGuestbook = (entries, { nowMs, banned = new Set() } = {}) => { + let members = new Map(); + let consider = (npub, state, t, id) => { + let cur = members.get(npub); + if (!cur || t > cur.t || (t === cur.t && id < cur.id)) members.set(npub, { state, t, id }); + }; + for (let { rumor, author } of entries) { + let t = eventMs(rumor); + if (t === null || (nowMs && t > nowMs + 3600_000)) continue; + if (rumor.kind === 3306 && (rumor.content === "join" || rumor.content === "leave")) + consider(author, rumor.content, t, rumor.id); + // kicks (3309) and snapshots (3312): honored later with roster ranks; skipped for now + } + for (let b of banned) members.delete(b); + return members; +}; + +// Observed presence: any decrypted chat author newer than their latest leave re-enters (CORD-02 §5) +export const observeAuthor = (members, author, t) => { + let cur = members.get(author); + if (!cur || (cur.state !== "join" && t > cur.t)) members.set(author, { state: "join", t, id: "" }); +}; diff --git a/src/features/index.js b/src/features/index.js index 70f8421..6a8bb9f 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -11,6 +11,7 @@ import { namesFeature } from './names.js'; import { zapsFeature } from './zaps.js'; import { syncFeature } from './sync.js'; import { nwcFeature } from './nwc.js'; +import { messagesFeature } from './messages.js'; // NB order is meaningful where hooks stack: gifts' receive takeover and // balance line come before ark's, matching the pre-plugin layout. zaps sits @@ -20,5 +21,5 @@ import { nwcFeature } from './nwc.js'; // names sits between ark and zaps: a pasted user@domain resolves over DNS // (BIP-353) before the zaps feature treats it as a plain Lightning address. export function buildFeatures(ctx) { - return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)]; + return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx), messagesFeature(ctx)]; } diff --git a/src/features/messages.js b/src/features/messages.js new file mode 100644 index 0000000..3843b36 --- /dev/null +++ b/src/features/messages.js @@ -0,0 +1,415 @@ +// Messages — encrypted community chat over the Concord protocol (CORD-01..04), +// the successor to Marmot/White Noise for Discord-shaped rooms on nostr. +// +// hal 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. +// +// 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 hal already holds, so remote signers need no nip44 round-trips. + +import { subscribeOn, publishOn, fetchNostrProfile, npubOf } from '../nostr.js'; +import { + channelKey, controlKey, guestbookKey, openWrap, wrapRumor, + foldControl, foldGuestbook, observeAuthor, eventMs, msTags, +} from '../concord.js'; +import { hexToBytes } from '@noble/hashes/utils'; +import { t } from '../i18n.js'; + +// Genesis output of tools/concord-genesis.js — the coinos community's join +// material (CORD-02 §8). The community_root here is deliberately public-ish: +// every hal user is meant to be a member of the default community. +const COMMUNITY = { + community_id: 'b517fb4ba04c4c4eac2bd486ee800d1a8644fcdca5c5643098062e7733ee4986', + owner: '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7', + owner_salt: '466450cd6cd0e6991a5acea091c5bc9e9a1c1ba27a970e64d2af4860e7f60cb1', + community_root: '58b2ce26eba30fbd19d9a57bce4c61e65838686998f10bba1d3e822fb72b372e', + root_epoch: 0, + channels: [{ id: '56bf8b96c1a3768c85444873df507cdbc3275fcbc21996af09e60003f850f85c', name: 'general' }], + relays: ['wss://relay.coinos.io', 'wss://nos.lol'], + name: 'coinos', +}; + +const EPOCH = 0; +const CACHE_MAX = 50; // messages kept per channel 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 ------------------------------------------------------------ + + 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 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]; + + // ---- identity ---------------------------------------------------------- + + async function identity() { + const id = hook('nostrLoginIdentity'); + if (id) { + const signer = id.signer || (await hook('nostrLoginResume')); + if (signer) return { pubkey: id.pubkey, signer }; + } + 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 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(); + }; + + 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); + } + }; + + // Coalesce repaints: relays replay history in a burst on subscribe. + let repaintTimer = null; + const scheduleRepaint = () => { + if (repaintTimer) return; + repaintTimer = setTimeout(() => { + repaintTimer = null; + if (ui.screen === 'wallet' && ui.tab === 'messages') render(); + }, 80); + }; + + function start() { + if (started) return; + started = true; + const relays = COMMUNITY.relays; + unsubs.push( + subscribeOn(relays, { kinds: [1059], authors: [control.pk], limit: 500 }, (wrap) => { + if (seenWraps.has(wrap.id)) return; + seenWraps.add(wrap.id); + const opened = openWrap(wrap, control); + if (!opened || opened.rumor.kind !== 3308) return; + controlEntries.push(opened); + refold(); + scheduleRepaint(); + }) + ); + unsubs.push( + subscribeOn(relays, { kinds: [1059], authors: [guestbook.pk], limit: 500 }, (wrap) => { + if (seenWraps.has(wrap.id)) return; + seenWraps.add(wrap.id); + const opened = openWrap(wrap, guestbook); + if (!opened) return; + 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 + 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); + if (!msgs.has(m.rumor.id)) msgs.set(m.rumor.id, m); + } + } + + 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 persistCache() { + const s = st(); + s.cache = {}; + for (const [chId, msgs] of byChannel) { + const list = [...msgs.values()] + .filter((m) => !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) { + const s = st(); + if (s.joined[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; + save(s); + } + + async function sendMessage() { + const text = (ui.msgDraft || '').trim(); + const ch = currentChannel(); + if (!text || !ch || ui.msgSending) return; + const id = await identity(); + if (!id) { toast(t('msgNoIdentity')); return; } + ui.msgSending = true; + render(); + try { + 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, + }; + const wrap = await wrapRumor(rumor, id.signer, chStream(ch.id)); + // optimistic insert (the relay echo will dedupe on wrap id) + const opened = openWrap(wrap, chStream(ch.id)); + if (opened) { + seenWraps.add(wrap.id); + let msgs = byChannel.get(ch.id) || byChannel.set(ch.id, new Map()).get(ch.id); + msgs.set(opened.rumor.id, opened); + } + ui.msgDraft = ''; + const ok = await publishOn(COMMUNITY.relays, wrap); + if (!ok) toast(t('msgSendFailed')); + ensureJoined(id).catch(() => {}); + persistCache(); + } catch (e) { + toast(e.message || String(e)); + } finally { + ui.msgSending = false; + ui.msgStick = true; + render(); + } + } + + async function deleteMessage(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, + }; + const wrap = await wrapRumor(rumor, id.signer, chStream(ch.id)); + deletes.add(m.rumor.id); + render(); + publishOn(COMMUNITY.relays, wrap); + persistCache(); + } + + // ---- profiles ---------------------------------------------------------- + + 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; + return npubOf(pk).slice(0, 12) + '…'; + }; + + // ---- view -------------------------------------------------------------- + + const timeLabel = (tms) => { + const d = new Date(tms); + const today = new Date().toDateString() === d.toDateString(); + return today + ? d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + + ' ' + 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))) + .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 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 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)), + h('div', { class: 'chat-body' }, + grouped ? null : h('div', { class: 'chat-meta' }, + h('span', { class: 'chat-name' + (m.author === COMMUNITY.owner ? ' owner' : '') }, + displayName(m.author), + m.author === COMMUNITY.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), + }, '×') + : null), + counts.size + ? h('div', { class: 'chat-reacts' }, + [...counts.entries()].map(([emoji, n]) => + h('span', { class: 'chat-react' }, emoji, n > 1 ? ' ' + n : ''))) + : null) + ); + }); + } + + 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; + }); + + 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('button', { + class: c.id === ch.id ? 'active' : '', + onClick: () => { ui.msgChannel = c.id; ui.msgStick = true; subChannel(c.id); render(); }, + }, '#' + c.name))) + : h('div', { class: 'tag' }, '#' + ch.name)), + 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' }, + 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(); } }, + }), + h('button', { + class: 'btn-primary btn-sm', disabled: ui.msgSending, + onClick: sendMessage, + }, ui.msgSending ? h('span', { class: 'spinner sm' }) : t('msgSend')))); + } + + return { + id: 'messages', + tabs: () => [['messages', t('tabMessages')]], + tabContent(tab) { + if (tab !== 'messages') return null; + return messagesTab(); + }, + stop() { + for (const u of unsubs) { try { u(); } catch {} } + unsubs = []; + started = false; + subbed.clear(); + if (byChannel.size) { try { persistCache(); } catch {} } + }, + }; +} diff --git a/src/i18n.js b/src/i18n.js index 5e0138e..bddfeb0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -157,6 +157,17 @@ const STR = { tabSend: 'Send', tabHistory: 'History', tabSettings: 'Settings', + tabMessages: 'Chat', + msgSend: 'Send', + msgEmpty: 'No messages yet — say hi!', + msgPlaceholder: 'Message #{channel}', + msgMembers: '{n} members', + msgEncrypted: 'End-to-end encrypted', + msgAdmin: 'admin', + msgEdited: '(edited)', + msgDelete: 'Delete message', + msgNoIdentity: 'No nostr identity available to sign with', + msgSendFailed: 'No relay accepted the message', copyAddress: 'Copy address', paymentReceived: 'Payment received!', tapToProceed: 'Tap anywhere to proceed', diff --git a/src/style.css b/src/style.css index f9d2124..998cdd7 100644 --- a/src/style.css +++ b/src/style.css @@ -311,3 +311,59 @@ hr.divider { margin: 16px 0; } .icon-select-item.active { background: var(--accent-soft, rgba(247,147,26,.14)); } .sel-ico { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; font-size: 16px; line-height: 1; } .sel-ico svg { display: block; } + +/* Messages — Concord community chat. */ +.chat-card { padding: 0; overflow: hidden; } +.chat-head { padding: 14px 16px 10px; border-bottom: 1px solid var(--line); } +.chat-title { font-weight: 650; } +.chat-log { + height: min(52vh, 460px); overflow-y: auto; padding: 12px 14px; + display: flex; flex-direction: column; gap: 10px; background: var(--surface2); +} +.chat-row { display: flex; gap: 9px; align-items: flex-start; } +.chat-row.grouped { margin-top: -6px; } +.chat-avatar { + width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; object-fit: cover; + background: var(--accent-soft); +} +.chat-avatar.fallback { + display: flex; align-items: center; justify-content: center; + font-size: 11px; font-weight: 650; color: var(--muted); text-transform: uppercase; +} +.chat-avatar.spacer { background: transparent; height: 0; } +.chat-body { min-width: 0; flex: 1; } +.chat-meta { display: flex; align-items: baseline; gap: 7px; margin-bottom: 2px; } +.chat-name { font-size: 13px; font-weight: 650; } +.chat-badge { + font-size: 10px; font-weight: 650; color: var(--accent-fg); background: var(--accent); + border-radius: 5px; padding: 1px 5px; margin-left: 5px; vertical-align: 1px; +} +.chat-time { font-size: 11px; color: var(--faint); } +.chat-bubble { + position: relative; display: inline-block; max-width: 100%; + background: var(--paper); border: 1px solid var(--line); border-radius: 12px; + padding: 7px 11px; font-size: 14px; line-height: 1.45; + overflow-wrap: break-word; white-space: pre-wrap; +} +.chat-edited { font-size: 11px; color: var(--faint); } +.chat-del { + position: absolute; top: -7px; right: -7px; width: 18px; height: 18px; + display: none; align-items: center; justify-content: center; + border: 1px solid var(--line-strong); border-radius: 50%; + background: var(--paper); color: var(--muted); font-size: 12px; line-height: 1; cursor: pointer; +} +.chat-row:hover .chat-del { display: flex; } +.chat-reacts { display: flex; gap: 5px; margin-top: 3px; } +.chat-react { + font-size: 12px; background: var(--paper); border: 1px solid var(--line); + border-radius: 9px; padding: 1px 7px; +} +.chat-compose { + display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); + align-items: center; +} +.chat-compose input { + flex: 1; min-width: 0; font: inherit; color: var(--ink); background: var(--paper); + border: 1px solid var(--line-strong); border-radius: 11px; padding: 10px 13px; +} +.chat-compose input:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; } |
