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;