From c48f9f4006018f2fe28503932caeee1a65446f8d Mon Sep 17 00:00:00 2001 From: maverick Date: Wed, 22 Jul 2026 13:16:16 +1000 Subject: [PATCH] Encrypted group chats + major UI redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groups (E2E via pairwise fan-out — sender encrypts each message separately to every member with existing Signal sessions; server holds only name+membership): - migration 0003: groups + group_members; routes/groups.js (create/list/get/add/leave) - client: unified DM+group chat model, group create modal w/ member chips, fan-out deliver, groupId payload routing on receive, lazy group fetch, sender names in group bubbles, group info + leave UI overhaul — glassmorphism redesign: - animated aurora backdrop, blurred glass panels, violet→cyan gradient brand - color-hashed gradient avatars (per name), group avatars - message bubbles with rise animation + tails, gradient outgoing bubbles - polished auth card w/ gradient border, pill actions, modal pop-in, toast styling - responsive: full-width thread on mobile with back button Verified live: 3-user group message fans out and both members decrypt. SW cache v4->v5. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/0003_groups.sql | 18 ++ public/index.html | 308 +++++++++++++++++----------- public/js/api.js | 7 + public/js/app.js | 397 ++++++++++++++++++++++++++----------- public/js/store.js | 20 +- public/sw.js | 2 +- src/index.js | 2 + src/routes/groups.js | 105 ++++++++++ 8 files changed, 622 insertions(+), 237 deletions(-) create mode 100644 migrations/0003_groups.sql create mode 100644 src/routes/groups.js diff --git a/migrations/0003_groups.sql b/migrations/0003_groups.sql new file mode 100644 index 0000000..00fb74f --- /dev/null +++ b/migrations/0003_groups.sql @@ -0,0 +1,18 @@ +-- Group chats. The server stores group METADATA (name + membership) so members +-- can discover each other, but message content is still E2E: the sender encrypts +-- each message separately to every member (pairwise Signal fan-out). + +CREATE TABLE groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL +); + +CREATE TABLE group_members ( + group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + added_at INTEGER NOT NULL, + PRIMARY KEY (group_id, user_id) +); +CREATE INDEX idx_group_members_user ON group_members(user_id); diff --git a/public/index.html b/public/index.html index b4b9189..84ade36 100644 --- a/public/index.html +++ b/public/index.html @@ -3,145 +3,225 @@ - + Cipher — encrypted messaging diff --git a/public/js/api.js b/public/js/api.js index d7cccf0..28bb686 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -49,6 +49,13 @@ export const api = { provisionNew: (ephemeralPub) => req("POST", "/api/provision/new", { ephemeralPub }), provisionGet: (id) => req("GET", "/api/provision/" + id), provisionComplete: (id, payload) => req("POST", "/api/provision/" + id + "/complete", { payload }), + + // groups (metadata only; messages are E2E fan-out) + createGroup: (name, memberUserIds) => req("POST", "/api/groups", { name, memberUserIds }), + listGroups: () => req("GET", "/api/groups"), + getGroup: (id) => req("GET", "/api/groups/" + id), + addGroupMember: (id, userId) => req("POST", "/api/groups/" + id + "/members", { userId }), + leaveGroup: (id) => req("POST", "/api/groups/" + id + "/leave", {}), uploadMedia: async (ciphertext) => { const res = await req("POST", "/api/media", ciphertext, true); return res.json ? res.json() : res; diff --git a/public/js/app.js b/public/js/app.js index d0ddfc4..525dd82 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -2,7 +2,7 @@ import { api, setToken, MailboxSocket } from "./api.js"; import * as C from "./crypto.js"; -import { useAccount, contacts, messages as msgStore } from "./store.js"; +import { useAccount, contacts, groups, messages as msgStore } from "./store.js"; import * as accounts from "./accounts.js"; import qrcode from "./vendor/qrcode.js"; @@ -17,7 +17,7 @@ const state = { me: null, // { userId, deviceId, username, displayName } token: null, socket: null, - activeContact: null, // contact object + activeChat: null, // { type:"dm"|"group", id, name, contact?, group? } }; // ---------- boot ---------- @@ -36,7 +36,7 @@ async function activateAccount(acct) { if (state.socket) { state.socket.close(); state.socket = null; } state.me = acct; state.token = acct.token; - state.activeContact = null; + state.activeChat = null; useAccount(acct.username); // per-account IndexedDB namespace setToken(acct.token); accounts.setActive(acct.username); @@ -139,12 +139,29 @@ async function onAuthenticated(res) { // ---------- main app ---------- async function enterApp() { renderShell(); - await refreshContacts(); + await syncGroups(); + await refreshChats(); connectSocket(); maybeReplenishPreKeys(); registerServiceWorker(); } +// Deterministic vibrant colour from a string — used for avatars. +function hashHue(str) { + let h = 0; + for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0; + return h % 360; +} +function avatarEl(name, { group = false, size = "" } = {}) { + const initial = (name || "?").trim()[0]?.toUpperCase() || "?"; + const hue = hashHue(name || "?"); + const a = el("div", { className: "avatar" + (size ? " " + size : "") + (group ? " group" : "") }); + a.style.background = `linear-gradient(135deg, hsl(${hue} 70% 52%), hsl(${(hue + 40) % 360} 70% 42%))`; + a.textContent = group ? "" : initial; + if (group) a.append(el("span", { className: "group-glyph", textContent: "⛊" })); + return a; +} + function renderShell() { const root = $("#app"); root.innerHTML = ""; @@ -152,38 +169,71 @@ function renderShell() { // Sidebar const sidebar = el("aside", { className: "sidebar" }); - const header = el("div", { className: "side-header", title: "Accounts", onclick: openAccountMenu }); + const header = el("div", { className: "side-header", title: "Accounts & settings", onclick: openAccountMenu }); header.append( - el("div", { className: "me" }, - document.createTextNode(state.me.displayName || state.me.username), - el("span", { className: "caret", textContent: " ▾" }) - ), - el("div", { className: "handle", textContent: "@" + state.me.username }) + avatarEl(state.me.displayName || state.me.username), + el("div", { className: "side-id" }, + el("div", { className: "me" }, + document.createTextNode(state.me.displayName || state.me.username), + el("span", { className: "caret", textContent: "▾" }) + ), + el("div", { className: "handle", textContent: "@" + state.me.username }) + ) + ); + const actions = el("div", { className: "side-actions" }, + el("button", { className: "pill", onclick: startNewChat }, el("span", { textContent: "+" }), document.createTextNode("Chat")), + el("button", { className: "pill", onclick: startNewGroup }, el("span", { textContent: "⛊" }), document.createTextNode("Group")) ); - const newChat = el("button", { className: "new-chat", textContent: "+ New chat", onclick: startNewChat }); const list = el("div", { className: "chat-list", id: "chatList" }); - sidebar.append(header, newChat, list); + sidebar.append(header, actions, list); // Main pane const main = el("section", { className: "main", id: "main" }); - main.append(el("div", { className: "empty", textContent: "Select a chat or start a new one" })); + main.append(emptyState()); shell.append(sidebar, main); root.append(shell); } -async function refreshContacts() { +function emptyState() { + return el("div", { className: "empty" }, + el("div", { className: "empty-badge", textContent: "🔒" }), + el("div", { className: "empty-title", textContent: "Your messages are end-to-end encrypted" }), + el("div", { className: "empty-sub", textContent: "Pick a conversation, or start a new chat or group." }) + ); +} + +// Pull the latest group list from the server into the local store. +async function syncGroups() { + try { + const { groups: gs } = await api.listGroups(); + for (const g of gs) await groups.put(g); + } catch {} +} + +// Unified chat list: DMs + groups, most-recent first. +async function refreshChats() { const list = $("#chatList"); if (!list) return; + const cs = await contacts.all(); + const gs = await groups.all(); + const items = [ + ...gs.map((g) => ({ type: "group", id: g.groupId, name: g.name, sub: `${g.members.length} members`, group: g })), + ...cs.map((c) => ({ type: "dm", id: c.userId, name: c.displayName || c.username, sub: "@" + c.username, contact: c })), + ]; list.innerHTML = ""; - const all = await contacts.all(); - for (const c of all) { - const item = el("div", { className: "chat-item", onclick: () => openChat(c) }); + if (!items.length) { + list.append(el("div", { className: "list-empty", textContent: "No conversations yet" })); + return; + } + for (const chat of items) { + const active = state.activeChat && state.activeChat.type === chat.type && state.activeChat.id === chat.id; + const item = el("div", { className: "chat-item" + (active ? " active" : ""), onclick: () => openChat(chat) }); item.append( - el("div", { className: "avatar", textContent: (c.displayName || c.username)[0].toUpperCase() }), + avatarEl(chat.name, { group: chat.type === "group" }), el("div", { className: "chat-meta" }, - el("div", { className: "chat-name", textContent: c.displayName || c.username }), - el("div", { className: "chat-sub", textContent: "@" + c.username }) + el("div", { className: "chat-name", textContent: chat.name }), + el("div", { className: "chat-sub", textContent: chat.sub }) ) ); list.append(item); @@ -201,16 +251,11 @@ function startNewChat() { if (uname === state.me.username) return setError("That's you 🙂"); try { const user = await api.lookup(uname); - const contact = { - userId: user.userId, - username: user.username, - displayName: user.displayName, - deviceIds: [], - }; + const contact = { userId: user.userId, username: user.username, displayName: user.displayName, deviceIds: [] }; await contacts.put(contact); - await refreshContacts(); + await refreshChats(); close(); - await openChat(contact); + await openChat({ type: "dm", id: contact.userId, name: contact.displayName || contact.username, sub: "@" + contact.username, contact }); } catch (ex) { setError(ex.message === "not found" ? "No user found: @" + uname : ex.message); } @@ -218,6 +263,65 @@ function startNewChat() { }); } +// ---------- new group ---------- +function startNewGroup() { + const nameIn = el("input", { className: "modal-input", placeholder: "Group name" }); + const memberIn = el("input", { className: "modal-input", placeholder: "Add member by username, then Enter" }); + const chips = el("div", { className: "chips" }); + const err = el("div", { className: "modal-err" }); + const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" }); + const create = el("button", { type: "button", className: "primary", textContent: "Create group" }); + const members = []; // {userId, username, displayName} + + const addMember = async () => { + err.textContent = ""; + const uname = memberIn.value.trim().toLowerCase(); + if (!uname) return; + if (uname === state.me.username) { err.textContent = "You're already in the group"; return; } + if (members.some((m) => m.username === uname)) { memberIn.value = ""; return; } + try { + const user = await api.lookup(uname); + members.push({ userId: user.userId, username: user.username, displayName: user.displayName }); + memberIn.value = ""; + renderChips(); + } catch { + err.textContent = "No user found: @" + uname; + } + }; + const renderChips = () => { + chips.innerHTML = ""; + for (const m of members) { + const chip = el("span", { className: "chip" }, document.createTextNode("@" + m.username)); + chip.append(el("span", { className: "chip-x", textContent: "×", onclick: () => { members.splice(members.indexOf(m), 1); renderChips(); } })); + chips.append(chip); + } + }; + + const { close } = makeOverlay( + el("h2", { className: "modal-title", textContent: "New group" }), + nameIn, memberIn, chips, err, + el("div", { className: "modal-actions" }, cancel, create) + ); + memberIn.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); addMember(); } }; + cancel.onclick = close; + create.onclick = async () => { + err.textContent = ""; + if (!nameIn.value.trim()) return (err.textContent = "Group name required"); + if (!members.length) return (err.textContent = "Add at least one member"); + create.disabled = true; create.textContent = "Creating…"; + try { + const group = await api.createGroup(nameIn.value.trim(), members.map((m) => m.userId)); + await groups.put(group); + await refreshChats(); + close(); + await openChat({ type: "group", id: group.groupId, name: group.name, sub: `${group.members.length} members`, group }); + } catch (ex) { + err.textContent = ex.message; + create.disabled = false; create.textContent = "Create group"; + } + }; +} + // Lightweight in-app modal (replaces window.prompt, which Brave/PWAs suppress). function openModal({ title, placeholder, action, onSubmit }) { const input = el("input", { className: "modal-input", placeholder, autocomplete: "off" }); @@ -546,47 +650,57 @@ function toast(message, kind = "error") { } // ---------- chat view ---------- -async function openChat(contact) { - state.activeContact = contact; +// ---- conversation helpers (unify DMs + groups) ---- +function chatConvo(chat) { + return chat.type === "group" ? "g:" + chat.id : state.me.userId + ":" + chat.id; +} +function senderName(userId, group) { + if (userId === state.me.userId) return "You"; + const m = group?.members?.find((x) => x.userId === userId); + return m ? m.displayName || m.username : userId.slice(0, 6); +} + +async function openChat(chat) { + state.activeChat = chat; $(".shell")?.classList.add("chat-open"); + await refreshChats(); // reflect active highlight const main = $("#main"); main.innerHTML = ""; const back = el("button", { - className: "back-btn", - textContent: "‹", - title: "Back", - onclick: () => { - state.activeContact = null; - $(".shell")?.classList.remove("chat-open"); - }, + className: "back-btn", textContent: "‹", title: "Back", + onclick: () => { state.activeChat = null; $(".shell")?.classList.remove("chat-open"); refreshChats(); showEmpty(); }, }); + const sub = chat.type === "group" + ? `${chat.group.members.length} members · 🔒 encrypted` + : "🔒 end-to-end encrypted"; const head = el("div", { className: "chat-head" }); head.append( back, - el("div", { className: "avatar", textContent: (contact.displayName || contact.username)[0].toUpperCase() }), - el("div", {}, - el("div", { className: "chat-name", textContent: contact.displayName || contact.username }), - el("div", { className: "chat-sub", textContent: "🔒 end-to-end encrypted" }) + avatarEl(chat.name, { group: chat.type === "group" }), + el("div", { className: "chat-head-meta" }, + el("div", { className: "chat-name", textContent: chat.name }), + el("div", { className: "chat-sub", textContent: sub }) ) ); + if (chat.type === "group") { + head.append(el("button", { className: "head-action", title: "Group info", textContent: "ⓘ", onclick: () => showGroupInfo(chat) })); + } const thread = el("div", { className: "thread", id: "thread" }); const composer = el("form", { className: "composer" }); - const input = el("input", { placeholder: "Type a message", autocomplete: "off" }); - const attach = el("label", { className: "attach", textContent: "📎" }); + const attach = el("label", { className: "attach", title: "Attach", textContent: "+" }); const fileInput = el("input", { type: "file", style: "display:none" }); attach.append(fileInput); - const sendBtn = el("button", { type: "submit", className: "send", textContent: "Send" }); + const input = el("input", { className: "composer-input", placeholder: "Message", autocomplete: "off" }); + const sendBtn = el("button", { type: "submit", className: "send", title: "Send", textContent: "➤" }); composer.append(attach, input, sendBtn); main.append(head, thread, composer); - // load history - const convo = state.me.userId + ":" + contact.userId; - const history = await msgStore.forConvo(convo); - for (const m of history) renderMessage(m); + const history = await msgStore.forConvo(chatConvo(chat)); + for (const m of history) renderMessage(m, chat); scrollThread(); composer.onsubmit = async (e) => { @@ -595,32 +709,56 @@ async function openChat(contact) { if (!text) return; input.value = ""; try { - await sendText(contact, text); + await sendText(chat, text); } catch (ex) { console.error("send failed", ex); toast("Couldn't send: " + ex.message); - input.value = text; // restore so the message isn't lost + input.value = text; } }; - fileInput.onchange = async () => { const file = fileInput.files[0]; if (file) { - try { - await sendMedia(contact, file); - } catch (ex) { - console.error("media send failed", ex); - toast("Couldn't send file: " + ex.message); - } + try { await sendMedia(chat, file); } + catch (ex) { console.error("media send failed", ex); toast("Couldn't send file: " + ex.message); } } fileInput.value = ""; }; } -function convoKey(otherUserId) { - return state.me.userId + ":" + otherUserId; +function showEmpty() { + const main = $("#main"); + if (main) { main.innerHTML = ""; main.append(emptyState()); } } +function showGroupInfo(chat) { + const rows = chat.group.members.map((m) => + el("div", { className: "member-row" }, + avatarEl(m.displayName || m.username), + el("div", {}, + el("div", { className: "chat-name", textContent: (m.displayName || m.username) + (m.userId === state.me.userId ? " (you)" : "") }), + el("div", { className: "chat-sub", textContent: "@" + m.username }) + ) + ) + ); + const leave = el("button", { type: "button", className: "acct-action danger", textContent: "Leave group" }); + const { close } = makeOverlay( + el("h2", { className: "modal-title", textContent: chat.name }), + el("div", { className: "modal-note", textContent: `${chat.group.members.length} members` }), + ...rows, leave + ); + leave.onclick = async () => { + try { await api.leaveGroup(chat.id); } catch {} + await groups.del(chat.id); + close(); + state.activeChat = null; + showEmpty(); + await refreshChats(); + toast("Left " + chat.name, "info"); + }; +} + +// ---- sending ---- async function ensureRecipientDevices(contact) { const bundle = await api.bundle(contact.userId); contact.deviceIds = bundle.bundles.map((b) => b.deviceId); @@ -629,42 +767,52 @@ async function ensureRecipientDevices(contact) { return contact.deviceIds; } -async function sendText(contact, text) { - // structured payload lets us carry media refs in the same E2E channel - const payload = JSON.stringify({ k: "text", text }); - await deliver(contact, payload); - await persistAndRender(contact.userId, { dir: "out", kind: "text", text, ts: Date.now() }); +// Encrypt+send a payload to one user (all their devices). +async function deliverToUser(userId, deviceIdsCache, plaintext) { + let deviceIds = deviceIdsCache; + if (!deviceIds || !deviceIds.length) { + const bundle = await api.bundle(userId); + deviceIds = bundle.bundles.map((b) => b.deviceId); + await C.ensureSessions(bundle); + } + const messages = await C.encryptForUser(userId, deviceIds, plaintext); + await api.send(userId, messages); + return deviceIds; } -async function sendMedia(contact, file) { +// Deliver a payload to a chat: one recipient for DMs, fan-out for groups. +async function deliver(chat, payloadObj) { + if (chat.type === "group") { + const plaintext = JSON.stringify({ ...payloadObj, groupId: chat.id }); + const others = chat.group.members.filter((m) => m.userId !== state.me.userId); + // Fan out — encrypt separately to each member (pairwise Signal sessions). + await Promise.all(others.map((m) => deliverToUser(m.userId, null, plaintext).catch((e) => { + console.error("fan-out to", m.username, "failed", e); + toast("Couldn't reach @" + m.username); + }))); + } else { + const contact = chat.contact; + let deviceIds = contact.deviceIds; + if (!deviceIds || !deviceIds.length) deviceIds = await ensureRecipientDevices(contact); + contact.deviceIds = await deliverToUser(contact.userId, deviceIds, JSON.stringify(payloadObj)); + await contacts.put(contact); + } +} + +async function sendText(chat, text) { + await deliver(chat, { k: "text", text }); + await persistAndRender(chatConvo(chat), chat.id, { dir: "out", kind: "text", text, senderId: state.me.userId, ts: Date.now() }); +} + +async function sendMedia(chat, file) { const buf = await file.arrayBuffer(); const enc = await C.encryptMedia(buf); const { key } = await api.uploadMedia(enc.ciphertext); - const payload = JSON.stringify({ - k: "media", - key, - keyB64: enc.keyB64, - ivB64: enc.ivB64, - name: file.name, - type: file.type, - size: file.size, + await deliver(chat, { k: "media", key, keyB64: enc.keyB64, ivB64: enc.ivB64, name: file.name, type: file.type, size: file.size }); + await persistAndRender(chatConvo(chat), chat.id, { + dir: "out", kind: "media", name: file.name, mime: file.type, + localUrl: URL.createObjectURL(file), senderId: state.me.userId, ts: Date.now(), }); - await deliver(contact, payload); - await persistAndRender(contact.userId, { - dir: "out", - kind: "media", - name: file.name, - mime: file.type, - localUrl: URL.createObjectURL(file), - ts: Date.now(), - }); -} - -async function deliver(contact, plaintextPayload) { - let deviceIds = contact.deviceIds; - if (!deviceIds || !deviceIds.length) deviceIds = await ensureRecipientDevices(contact); - const messages = await C.encryptForUser(contact.userId, deviceIds, plaintextPayload); - await api.send(contact.userId, messages); } // ---------- receiving ---------- @@ -675,62 +823,69 @@ function connectSocket() { } async function onEnvelope(envelope) { - // Look up / create the sender contact. - let contact = await contactByUserId(envelope.fromUserId); let plaintext; try { plaintext = await C.decryptEnvelope(envelope, envelope.fromUserId); } catch (ex) { console.error("decrypt failed", ex); toast("Couldn't decrypt a message (session mismatch)"); - throw ex; // leave it unacked so it can retry after the session heals + throw ex; // leave unacked to retry after the session heals } let data; - try { - data = JSON.parse(plaintext); - } catch { - data = { k: "text", text: plaintext }; - } + try { data = JSON.parse(plaintext); } catch { data = { k: "text", text: plaintext }; } - if (!contact) { - // Unknown sender — create a stub contact so the message has a home. - contact = { userId: envelope.fromUserId, username: envelope.fromUserId.slice(0, 8), displayName: "New contact", deviceIds: [] }; - await contacts.put(contact); - await refreshContacts(); - } + const msg = data.k === "media" + ? { dir: "in", kind: "media", name: data.name, mime: data.type, mediaRef: { key: data.key, keyB64: data.keyB64, ivB64: data.ivB64 }, senderId: envelope.fromUserId, ts: envelope.sentAt || Date.now() } + : { dir: "in", kind: "text", text: data.text, senderId: envelope.fromUserId, ts: envelope.sentAt || Date.now() }; - if (data.k === "media") { - await persistAndRender(envelope.fromUserId, { - dir: "in", - kind: "media", - name: data.name, - mime: data.type, - mediaRef: { key: data.key, keyB64: data.keyB64, ivB64: data.ivB64 }, - ts: envelope.sentAt || Date.now(), - }); + if (data.groupId) { + // group message — make sure we know the group, then file it there + let g = await groups.get(data.groupId); + if (!g) { + try { g = await api.getGroup(data.groupId); await groups.put(g); await refreshChats(); } catch { return; } + } + await persistAndRender("g:" + data.groupId, data.groupId, msg); } else { - await persistAndRender(envelope.fromUserId, { dir: "in", kind: "text", text: data.text, ts: envelope.sentAt || Date.now() }); + // DM — ensure a contact exists + let contact = await contacts.get(envelope.fromUserId); + if (!contact) { + let profile = null; + try { profile = await api.lookup(envelope.fromUserId); } catch {} + contact = { userId: envelope.fromUserId, username: profile?.username || envelope.fromUserId.slice(0, 8), displayName: profile?.displayName || "New contact", deviceIds: [] }; + await contacts.put(contact); + await refreshChats(); + } + await persistAndRender(convoKeyDM(envelope.fromUserId), envelope.fromUserId, msg); } } -async function contactByUserId(userId) { - return contacts.get(userId); +function convoKeyDM(otherUserId) { + return state.me.userId + ":" + otherUserId; } // ---------- persistence + render ---------- -async function persistAndRender(otherUserId, msg) { - const record = { ...msg, convo: convoKey(otherUserId), otherUserId }; +async function persistAndRender(convo, chatId, msg) { + const record = { ...msg, convo }; await msgStore.add(record); - if (state.activeContact && state.activeContact.userId === otherUserId) { - renderMessage(record); + if (state.activeChat && state.activeChat.id === chatId) { + renderMessage(record, state.activeChat); scrollThread(); } } -function renderMessage(m) { +function renderMessage(m, chat) { const thread = $("#thread"); if (!thread) return; - const bubble = el("div", { className: "bubble " + (m.dir === "out" ? "out" : "in") }); + const out = m.dir === "out"; + const bubble = el("div", { className: "bubble " + (out ? "out" : "in") }); + // sender label for incoming group messages + if (!out && chat && chat.type === "group") { + const name = senderName(m.senderId, chat.group); + const label = el("div", { className: "sender" }); + label.textContent = name; + label.style.color = `hsl(${hashHue(name)} 65% 68%)`; + bubble.append(label); + } if (m.kind === "media") { if (m.mime && m.mime.startsWith("image/")) { const img = el("img", { className: "media-img", alt: m.name || "image" }); diff --git a/public/js/store.js b/public/js/store.js index e83cb4a..a3c502d 100644 --- a/public/js/store.js +++ b/public/js/store.js @@ -1,7 +1,7 @@ // IndexedDB persistence: the Signal protocol store + app data (contacts, chats). // Private keys live ONLY here, in the browser. They are never sent to the server. -const DB_VERSION = 1; +const DB_VERSION = 2; // v2 adds the "groups" object store // The store is namespaced PER ACCOUNT so multiple accounts can coexist on one // browser without their Signal identities colliding. Namespace by username @@ -31,6 +31,8 @@ function openDB() { if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta"); // Known contacts, keyed by userId. if (!db.objectStoreNames.contains("contacts")) db.createObjectStore("contacts", { keyPath: "userId" }); + // Known groups, keyed by groupId. + if (!db.objectStoreNames.contains("groups")) db.createObjectStore("groups", { keyPath: "groupId" }); // Message history, keyed by autoincrement, indexed by conversation. if (!db.objectStoreNames.contains("messages")) { const s = db.createObjectStore("messages", { keyPath: "id", autoIncrement: true }); @@ -89,6 +91,22 @@ export const contacts = { }, }; +// ---- groups ---- +export const groups = { + async put(group) { + return reqP((await tx("groups", "readwrite")).put(group)); + }, + async get(groupId) { + return reqP((await tx("groups")).get(groupId)); + }, + async all() { + return reqP((await tx("groups")).getAll()); + }, + async del(groupId) { + return reqP((await tx("groups", "readwrite")).delete(groupId)); + }, +}; + // ---- messages ---- export const messages = { async add(msg) { diff --git a/public/sw.js b/public/sw.js index 3834c9b..1fa6a60 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,7 +1,7 @@ // Service worker: offline app shell + Web Push wake-up. // It never touches message plaintext — decryption happens only in the page. -const CACHE = "cipher-v4"; +const CACHE = "cipher-v5"; const SHELL = [ "/", "/index.html", diff --git a/src/index.js b/src/index.js index b141221..144f587 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import keys from "./routes/keys.js"; import messages from "./routes/messages.js"; import media from "./routes/media.js"; import backup from "./routes/backup.js"; +import groups from "./routes/groups.js"; export { Mailbox } from "./do/mailbox.js"; @@ -24,6 +25,7 @@ app.route("/api", keys); app.route("/api", messages); app.route("/api", media); app.route("/api", backup); +app.route("/api", groups); // Everything else falls through to static assets (the PWA), handled by the // ASSETS binding via not_found_handling: single-page-application. diff --git a/src/routes/groups.js b/src/routes/groups.js new file mode 100644 index 0000000..2e72765 --- /dev/null +++ b/src/routes/groups.js @@ -0,0 +1,105 @@ +// Group metadata. The server knows who is in a group and its name (like +// WhatsApp) but never sees message content — messages are E2E fan-out. + +import { Hono } from "hono"; +import { requireAuth } from "../lib/auth.js"; +import { uuid, now } from "../lib/util.js"; + +const app = new Hono(); + +// Load a group's members joined with their public profiles. Returns null if the +// caller is not a member (groups are only visible to members). +async function loadGroup(db, groupId, callerId) { + const g = await db.prepare("SELECT id, name, created_by, created_at FROM groups WHERE id = ?") + .bind(groupId) + .first(); + if (!g) return null; + const members = await db + .prepare( + `SELECT u.id AS userId, u.username, u.display_name AS displayName + FROM group_members gm JOIN users u ON u.id = gm.user_id + WHERE gm.group_id = ? ORDER BY gm.added_at ASC` + ) + .bind(groupId) + .all(); + const list = members.results || []; + if (!list.some((m) => m.userId === callerId)) return null; + return { groupId: g.id, name: g.name, createdBy: g.created_by, members: list }; +} + +// POST /api/groups body: { name, memberUserIds: [...] } +app.post("/groups", requireAuth(), async (c) => { + const me = c.get("userId"); + const body = await c.req.json().catch(() => null); + const name = String(body?.name || "").trim().slice(0, 60); + if (!name) return c.json({ error: "group name required" }, 400); + const memberIds = Array.from(new Set([me, ...(body?.memberUserIds || [])])); + if (memberIds.length < 2) return c.json({ error: "add at least one other member" }, 400); + + const groupId = uuid(); + const ts = now(); + const stmts = [ + c.env.DB.prepare("INSERT INTO groups (id, name, created_by, created_at) VALUES (?,?,?,?)") + .bind(groupId, name, me, ts), + ]; + for (const uid of memberIds) { + stmts.push( + c.env.DB.prepare( + "INSERT OR IGNORE INTO group_members (group_id, user_id, added_at) VALUES (?,?,?)" + ).bind(groupId, uid, ts) + ); + } + await c.env.DB.batch(stmts); + const group = await loadGroup(c.env.DB, groupId, me); + return c.json(group); +}); + +// GET /api/groups — every group the caller belongs to +app.get("/groups", requireAuth(), async (c) => { + const me = c.get("userId"); + const ids = await c.env.DB.prepare("SELECT group_id FROM group_members WHERE user_id = ?") + .bind(me) + .all(); + const groups = []; + for (const row of ids.results || []) { + const g = await loadGroup(c.env.DB, row.group_id, me); + if (g) groups.push(g); + } + return c.json({ groups }); +}); + +// GET /api/groups/:id +app.get("/groups/:id", requireAuth(), async (c) => { + const me = c.get("userId"); + const g = await loadGroup(c.env.DB, c.req.param("id"), me); + if (!g) return c.json({ error: "not found" }, 404); + return c.json(g); +}); + +// POST /api/groups/:id/members body: { userId } +app.post("/groups/:id/members", requireAuth(), async (c) => { + const me = c.get("userId"); + const groupId = c.req.param("id"); + const g = await loadGroup(c.env.DB, groupId, me); + if (!g) return c.json({ error: "not found" }, 404); + const body = await c.req.json().catch(() => null); + if (!body?.userId) return c.json({ error: "userId required" }, 400); + await c.env.DB.prepare( + "INSERT OR IGNORE INTO group_members (group_id, user_id, added_at) VALUES (?,?,?)" + ) + .bind(groupId, body.userId, now()) + .run(); + return c.json(await loadGroup(c.env.DB, groupId, me)); +}); + +// POST /api/groups/:id/leave +app.post("/groups/:id/leave", requireAuth(), async (c) => { + const me = c.get("userId"); + const groupId = c.req.param("id"); + await c.env.DB.prepare("DELETE FROM group_members WHERE group_id = ? AND user_id = ?") + .bind(groupId, me) + .run(); + return c.json({ ok: true }); +}); + +export default app;