Add read receipts, disappearing messages, and group invite links

Receipts: messages now carry a shared client id (mid); recipients send
delivered/read receipts over the E2E relay, rendered as ✓/✓✓ ticks (blue
when read). Groups aggregate — ticks turn on once every member acks.

Disappearing messages: per-chat timer (Off..1 week) stored locally and
synced to peers via an encrypted config control message; messages carry a
ttl and a client-side sweeper deletes them from both sides on schedule.

Group invite links: new group_invites table + /groups/:id/invite (mint/
revoke) and /groups/join routes. Group info gains "Add people" and
"Invite via link" (shareable URL + QR); opening a #join=<token> link joins
the group and re-syncs every member's roster over the relay.

store.js v3 adds a mid index + getByMid/put/sweepExpired; SW cache v9.
Verified: 12 live server tests (invite/join/add/revoke/leave) + 11
store unit tests (receipts, sweep, meta) all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
King Omar 2026-07-22 18:32:24 +10:00
parent 3d9f712867
commit 726d9d53ba
7 changed files with 490 additions and 20 deletions

View file

@ -0,0 +1,11 @@
-- Shareable group invite links. A token is a bearer capability: anyone with an
-- account who has the link can join the group. Revoke by deleting the row(s).
-- The server still only ever holds group METADATA — message content stays E2E.
CREATE TABLE group_invites (
token TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_group_invites_group ON group_invites(group_id);

View file

@ -175,7 +175,11 @@
.bubble.in { background: var(--glass-2); border: 1px solid var(--stroke); align-self: flex-start; border-bottom-left-radius: 5px; }
.bubble.out { background: var(--out-grad); align-self: flex-end; border-bottom-right-radius: 5px; box-shadow: 0 4px 16px rgba(255,45,85,.3); }
.bubble .sender { font-size: 12.5px; font-weight: 700; margin-bottom: 2px; }
.bubble .time { font-size: 10.5px; color: rgba(255,255,255,.55); text-align: right; margin-top: 3px; }
.bubble .time { font-size: 10.5px; color: rgba(255,255,255,.55); text-align: right; margin-top: 3px; display: flex; gap: 4px; justify-content: flex-end; align-items: center; }
.bubble .ticks { font-size: 11px; letter-spacing: -2px; color: rgba(255,255,255,.6); }
.bubble .ticks.read { color: #7DE2FF; text-shadow: 0 0 6px rgba(125,226,255,.5); }
.sys-note { align-self: center; max-width: 82%; text-align: center; font-size: 12px; color: var(--muted); background: rgba(255,255,255,.05); border: 1px solid var(--stroke); padding: 5px 12px; border-radius: 14px; margin: 6px 0; animation: rise .22s ease; }
.head-action.timer.active { background: rgba(255,45,85,.22); color: var(--accent-2); box-shadow: inset 0 0 0 1px var(--accent); }
.media-img { max-width: 260px; border-radius: 12px; display: block; }
.media-file { color: var(--text); text-decoration: none; font-weight: 500; }

View file

@ -56,6 +56,9 @@ export const api = {
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", {}),
groupInvite: (id) => req("POST", "/api/groups/" + id + "/invite", {}),
revokeGroupInvite: (id) => req("DELETE", "/api/groups/" + id + "/invite"),
joinGroup: (token) => req("POST", "/api/groups/join", { token }),
uploadMedia: async (ciphertext) => {
const res = await req("POST", "/api/media", ciphertext, true);
return res.json ? res.json() : res;

View file

@ -2,7 +2,7 @@
import { api, setToken, MailboxSocket } from "./api.js";
import * as C from "./crypto.js";
import { useAccount, contacts, groups, messages as msgStore } from "./store.js";
import { useAccount, contacts, groups, messages as msgStore, meta } from "./store.js";
import * as accounts from "./accounts.js";
import qrcode from "./vendor/qrcode.js";
import { CallManager } from "./call.js";
@ -145,8 +145,24 @@ async function enterApp() {
await refreshChats();
connectSocket();
initCalls();
startSweeper();
maybeReplenishPreKeys();
registerServiceWorker();
await maybeHandleJoin();
}
// Disappearing-message durations offered in the timer menu.
const DISAPPEAR_OPTIONS = [
{ label: "Off", secs: 0 },
{ label: "30 seconds", secs: 30 },
{ label: "5 minutes", secs: 300 },
{ label: "1 hour", secs: 3600 },
{ label: "8 hours", secs: 28800 },
{ label: "1 day", secs: 86400 },
{ label: "1 week", secs: 604800 },
];
function disappearLabel(secs) {
return DISAPPEAR_OPTIONS.find((o) => o.secs === secs)?.label || `${secs}s`;
}
// ---------- calls ----------
@ -235,6 +251,55 @@ async function syncGroups() {
} catch {}
}
// If the app was opened via an invite link (#join=<token> or ?join=<token>),
// join that group now that we're authenticated.
function getJoinToken() {
const h = location.hash.match(/join=([A-Za-z0-9_-]+)/);
if (h) return h[1];
return new URL(location.href).searchParams.get("join");
}
async function maybeHandleJoin() {
const token = getJoinToken();
if (!token) return;
history.replaceState(null, "", location.pathname); // consume the token from the URL
try {
const g = await api.joinGroup(token);
await groups.put(g);
await refreshChats();
await announceGroupUpdate(g); // tell existing members to pull the new roster
await openChat({ type: "group", id: g.groupId, name: g.name, sub: `${g.members.length} members`, group: g });
toast("Joined " + g.name, "info");
} catch (ex) {
toast(ex.message === "invalid or expired invite" ? "That invite link is no longer valid" : "Couldn't join group: " + ex.message);
}
}
// Tell every other member of a group to re-pull its roster (membership changed).
async function announceGroupUpdate(group) {
const others = (group.members || []).filter((m) => m.userId !== state.me.userId);
await Promise.all(
others.map((m) =>
deliverToUser(m.userId, null, JSON.stringify({ k: "group-update", groupId: group.groupId })).catch(() => {})
)
);
}
// Re-fetch a group's metadata from the server and refresh anything showing it.
async function resyncGroup(groupId) {
try {
const g = await api.getGroup(groupId);
await groups.put(g);
if (state.activeChat?.type === "group" && state.activeChat.id === groupId) {
state.activeChat.group = g;
state.activeChat.name = g.name;
}
await refreshChats();
return g;
} catch {
return null;
}
}
// Unified chat list: DMs + groups, most-recent first.
async function refreshChats() {
const list = $("#chatList");
@ -751,6 +816,7 @@ function senderName(userId, group) {
async function openChat(chat) {
state.activeChat = chat;
chat.disappearing = (await meta.get("disap:" + chatConvo(chat))) || 0;
$(".shell")?.classList.add("chat-open");
await refreshChats(); // reflect active highlight
const main = $("#main");
@ -772,10 +838,15 @@ async function openChat(chat) {
el("div", { className: "chat-sub", textContent: sub })
)
);
const timerBtn = el("button", {
className: "head-action timer" + (chat.disappearing > 0 ? " active" : ""),
title: "Disappearing messages", textContent: "⏲", onclick: () => openDisappearingMenu(chat),
});
if (chat.type === "group") {
head.append(el("button", { className: "head-action", title: "Group info", textContent: "ⓘ", onclick: () => showGroupInfo(chat) }));
head.append(timerBtn, el("button", { className: "head-action", title: "Group info", textContent: "ⓘ", onclick: () => showGroupInfo(chat) }));
} else {
head.append(
timerBtn,
el("button", { className: "head-action", title: "Voice call", textContent: "📞", onclick: () => startCallWith(chat, false) }),
el("button", { className: "head-action", title: "Video call", textContent: "🎥", onclick: () => startCallWith(chat, true) })
);
@ -793,9 +864,11 @@ async function openChat(chat) {
main.append(head, thread, composer);
const history = await msgStore.forConvo(chatConvo(chat));
const nowTs = Date.now();
const history = (await msgStore.forConvo(chatConvo(chat))).filter((m) => !(m.expiresAt && m.expiresAt <= nowTs));
for (const m of history) renderMessage(m, chat);
scrollThread();
sendReadReceipts(chat, history); // we're looking at it — mark incoming as read
composer.onsubmit = async (e) => {
e.preventDefault();
@ -835,14 +908,21 @@ function showGroupInfo(chat) {
)
)
);
const addBtn = el("button", { type: "button", className: "acct-action", textContent: " Add people" });
const inviteBtn = el("button", { type: "button", className: "acct-action", textContent: "🔗 Invite via link" });
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
...rows, addBtn, inviteBtn, leave
);
addBtn.onclick = () => { close(); addGroupPeople(chat); };
inviteBtn.onclick = () => { close(); showGroupInvite(chat); };
leave.onclick = async () => {
try { await api.leaveGroup(chat.id); } catch {}
// let the rest of the group drop us from their roster
announceGroupUpdate(chat.group).catch(() => {});
await groups.del(chat.id);
close();
state.activeChat = null;
@ -852,6 +932,87 @@ function showGroupInfo(chat) {
};
}
// Add one or more members by username, then re-sync everyone's roster.
function addGroupPeople(chat) {
openModal({
title: "Add people",
placeholder: "Enter a username",
action: "Add",
onSubmit: async (value, setError, closeModal) => {
const uname = value.trim().toLowerCase();
if (!uname) return setError("Enter a username");
if (chat.group.members.some((m) => m.username === uname)) return setError("@" + uname + " is already in the group");
let user;
try { user = await api.lookup(uname); }
catch { return setError("No user found: @" + uname); }
try {
const updated = await api.addGroupMember(chat.id, user.userId);
await groups.put(updated);
state.activeChat && state.activeChat.id === chat.id && (state.activeChat.group = updated);
chat.group = updated;
await announceGroupUpdate(updated); // includes the new member, who now learns of the group
await refreshChats();
closeModal();
toast("Added @" + uname, "info");
showGroupInfo(chat);
} catch (ex) {
setError(ex.message);
}
},
});
}
// Show a shareable invite link + QR for the group.
async function showGroupInvite(chat) {
const status = el("div", { className: "modal-note", textContent: "Creating invite link…" });
const linkBox = el("div", { className: "code-box small", style: "display:none" });
const qrHolder = el("div", {});
const copy = el("button", { type: "button", className: "primary", textContent: "Copy link" });
const share = el("button", { type: "button", className: "ghost", textContent: "Share" });
const revoke = el("button", { type: "button", className: "acct-action danger", textContent: "Reset link", style: "display:none" });
const { close } = makeOverlay(
el("h2", { className: "modal-title", textContent: "Invite to " + chat.name }),
el("p", { className: "modal-note", textContent: "Anyone with a Cipher account who opens this link can join the group. Reset it to revoke the old link." }),
qrHolder, status, linkBox,
el("div", { className: "modal-actions" }, share, copy),
revoke
);
let link = "";
const render = (token) => {
link = `${location.origin}/#join=${token}`;
status.style.display = "none";
linkBox.style.display = "block";
linkBox.textContent = link;
qrHolder.innerHTML = "";
qrHolder.append(qrImg(link));
revoke.style.display = "block";
};
try {
const { token } = await api.groupInvite(chat.id);
render(token);
} catch (ex) {
status.textContent = "Couldn't create invite: " + ex.message;
return;
}
copy.onclick = () => { navigator.clipboard?.writeText(link); copy.textContent = "Copied ✓"; };
share.onclick = () => {
if (navigator.share) navigator.share({ title: "Join " + chat.name + " on Cipher", url: link }).catch(() => {});
else { navigator.clipboard?.writeText(link); copy.textContent = "Copied ✓"; }
};
revoke.onclick = async () => {
revoke.disabled = true;
try {
await api.revokeGroupInvite(chat.id);
const { token } = await api.groupInvite(chat.id);
render(token);
copy.textContent = "Copy link";
toast("Invite link reset", "info");
} catch (ex) { toast(ex.message); }
finally { revoke.disabled = false; }
};
}
// ---- sending ----
async function ensureRecipientDevices(contact) {
const bundle = await api.bundle(contact.userId);
@ -893,19 +1054,44 @@ async function deliver(chat, payloadObj) {
}
}
// How many peers a message is sent to (for group receipt aggregation).
function recipientCount(chat) {
return chat.type === "group"
? chat.group.members.filter((m) => m.userId !== state.me.userId).length
: 1;
}
// Extra fields tracking outgoing delivery/read state + disappearing timer.
function outMeta(chat, ttl, ts) {
return {
status: "sent",
recipients: recipientCount(chat),
deliveredBy: [],
readBy: [],
...(ttl > 0 ? { expiresAt: ts + ttl * 1000 } : {}),
};
}
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() });
const mid = crypto.randomUUID();
const ttl = chat.disappearing || 0;
const ts = Date.now();
await deliver(chat, { k: "text", text, mid, ttl });
await persistAndRender(chatConvo(chat), chat.id, {
dir: "out", kind: "text", text, mid, senderId: state.me.userId, ts, ...outMeta(chat, ttl, ts),
});
}
async function sendMedia(chat, file) {
const mid = crypto.randomUUID();
const ttl = chat.disappearing || 0;
const ts = Date.now();
const buf = await file.arrayBuffer();
const enc = await C.encryptMedia(buf);
const { key } = await api.uploadMedia(enc.ciphertext);
await deliver(chat, { 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, mid, ttl });
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(),
dir: "out", kind: "media", name: file.name, mime: file.type, mid,
localUrl: URL.createObjectURL(file), senderId: state.me.userId, ts, ...outMeta(chat, ttl, ts),
});
}
@ -938,9 +1124,30 @@ async function onEnvelope(envelope) {
return;
}
// Delivery/read receipt — update the tick on the sent message, never rendered.
if (data.k === "receipt") {
await applyReceipt(envelope.fromUserId, data);
return;
}
// Group roster changed (member added / someone joined via link) — re-pull it.
if (data.k === "group-update") {
await resyncGroup(data.groupId);
return;
}
// Disappearing-timer change from the other side — apply + note it inline.
if (data.k === "config") {
await applyConfig(envelope.fromUserId, data);
return;
}
const ts = envelope.sentAt || Date.now();
const base = { dir: "in", senderId: envelope.fromUserId, ts, mid: data.mid };
if (data.ttl > 0) base.expiresAt = ts + data.ttl * 1000;
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() };
? { ...base, kind: "media", name: data.name, mime: data.type, mediaRef: { key: data.key, keyB64: data.keyB64, ivB64: data.ivB64 } }
: { ...base, kind: "text", text: data.text };
if (data.groupId) {
// group message — make sure we know the group, then file it there
@ -961,6 +1168,81 @@ async function onEnvelope(envelope) {
}
await persistAndRender(convoKeyDM(envelope.fromUserId), envelope.fromUserId, msg);
}
// Acknowledge back to the sender: delivered always, read if they're looking at it.
if (data.mid) {
const groupId = data.groupId || null;
sendReceipt(envelope.fromUserId, [data.mid], "delivered", groupId);
const convo = groupId ? "g:" + groupId : convoKeyDM(envelope.fromUserId);
if (state.activeChat && chatConvo(state.activeChat) === convo && document.hasFocus()) {
sendReceipt(envelope.fromUserId, [data.mid], "read", groupId);
}
}
}
// ---------- receipts ----------
function sendReceipt(toUserId, mids, sub, groupId) {
if (!mids.length) return;
deliverToUser(toUserId, null, JSON.stringify({ k: "receipt", sub, mids, groupId })).catch(() => {});
}
// Send read receipts for every incoming message currently in view.
async function sendReadReceipts(chat, history) {
const groupId = chat.type === "group" ? chat.id : null;
const bySender = new Map();
for (const m of history) {
if (m.dir !== "in" || !m.mid || !m.senderId) continue;
if (!bySender.has(m.senderId)) bySender.set(m.senderId, []);
bySender.get(m.senderId).push(m.mid);
}
for (const [sender, mids] of bySender) sendReceipt(sender, mids, "read", groupId);
}
// Apply an inbound receipt to the matching sent message and refresh its tick.
async function applyReceipt(fromUserId, data) {
const mids = data.mids || (data.mid ? [data.mid] : []);
const rank = { sent: 0, delivered: 1, read: 2 };
for (const mid of mids) {
const rec = await msgStore.getByMid(mid);
if (!rec || rec.dir !== "out") continue;
if (rec.recipients > 1 || (rec.convo || "").startsWith("g:")) {
// Group: aggregate — read implies delivered.
const add = (field) => {
const s = new Set(rec[field] || []);
s.add(fromUserId);
rec[field] = [...s];
};
add("deliveredBy");
if (data.sub === "read") add("readBy");
} else if ((rank[data.sub] ?? 0) > (rank[rec.status] ?? 0)) {
rec.status = data.sub;
}
await msgStore.put(rec);
updateTick(rec);
}
}
// ---------- disappearing config ----------
async function applyConfig(fromUserId, data) {
const groupId = data.groupId || null;
const convo = groupId ? "g:" + groupId : convoKeyDM(fromUserId);
const secs = data.disappearing || 0;
await meta.set("disap:" + convo, secs);
if (state.activeChat && chatConvo(state.activeChat) === convo) state.activeChat.disappearing = secs;
const who = await displayNameFor(fromUserId, groupId);
const text = secs > 0
? `${who} set disappearing messages to ${disappearLabel(secs)}`
: `${who} turned off disappearing messages`;
await persistAndRender(convo, groupId || fromUserId, { dir: "in", kind: "system", text, mid: crypto.randomUUID(), ts: Date.now() });
}
async function displayNameFor(userId, groupId) {
if (groupId) {
const g = await groups.get(groupId);
return senderName(userId, g);
}
const c = await contacts.get(userId);
return c ? c.displayName || c.username : userId.slice(0, 6);
}
function convoKeyDM(otherUserId) {
@ -980,8 +1262,18 @@ async function persistAndRender(convo, chatId, msg) {
function renderMessage(m, chat) {
const thread = $("#thread");
if (!thread) return;
// System notices (disappearing timer changes) render as a centered pill.
if (m.kind === "system") {
const pill = el("div", { className: "sys-note", textContent: m.text });
if (m.mid) pill.dataset.mid = m.mid;
thread.append(pill);
return;
}
const out = m.dir === "out";
const bubble = el("div", { className: "bubble " + (out ? "out" : "in") });
if (m.mid) bubble.dataset.mid = m.mid;
// sender label for incoming group messages
if (!out && chat && chat.type === "group") {
const name = senderName(m.senderId, chat.group);
@ -1004,10 +1296,37 @@ function renderMessage(m, chat) {
} else {
bubble.append(el("div", { className: "text", textContent: m.text }));
}
bubble.append(el("div", { className: "time", textContent: fmtTime(m.ts) }));
const metaRow = el("div", { className: "time", textContent: fmtTime(m.ts) });
if (out) {
const ticks = el("span", { className: "ticks" });
applyTickGlyph(ticks, tickState(m, chat));
metaRow.append(ticks);
}
bubble.append(metaRow);
thread.append(bubble);
}
// Delivery/read tick state for an outgoing message.
function tickState(rec, chat) {
if (chat && chat.type === "group") {
const recips = rec.recipients || 0;
if (recips > 0 && (rec.readBy || []).length >= recips) return "read";
if (recips > 0 && (rec.deliveredBy || []).length >= recips) return "delivered";
return "sent";
}
return rec.status || "sent";
}
function applyTickGlyph(node, st) {
node.classList.toggle("read", st === "read");
node.textContent = st === "sent" ? "✓" : "✓✓";
}
// Re-render the tick on a message that's currently on screen.
function updateTick(rec) {
if (!state.activeChat || rec.convo !== chatConvo(state.activeChat)) return;
const node = document.querySelector(`#thread .bubble[data-mid="${rec.mid}"] .ticks`);
if (node) applyTickGlyph(node, tickState(rec, state.activeChat));
}
async function loadMediaInto(img, ref) {
try {
const ct = await api.fetchMedia(ref.key);
@ -1039,6 +1358,60 @@ function fmtTime(ts) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
// ---------- disappearing messages ----------
function openDisappearingMenu(chat) {
const rows = DISAPPEAR_OPTIONS.map((opt) =>
el("button", {
type: "button",
className: "acct-action" + ((chat.disappearing || 0) === opt.secs ? " active" : ""),
textContent: opt.label,
onclick: async () => {
close();
try { await setDisappearing(chat, opt.secs); }
catch (ex) { toast(ex.message); }
},
})
);
const { close } = makeOverlay(
el("h2", { className: "modal-title", textContent: "Disappearing messages" }),
el("p", { className: "modal-note", textContent: "New messages in this chat vanish from every device after the timer. It applies to everyone in the conversation." }),
...rows
);
}
async function setDisappearing(chat, secs) {
const convo = chatConvo(chat);
await meta.set("disap:" + convo, secs);
chat.disappearing = secs;
await deliver(chat, { k: "config", disappearing: secs });
const text = secs > 0
? `You set disappearing messages to ${disappearLabel(secs)}`
: "You turned off disappearing messages";
await persistAndRender(convo, chat.id, { dir: "out", kind: "system", text, mid: crypto.randomUUID(), ts: Date.now() });
// reflect the timer indicator in the header
const btn = document.querySelector("#main .head-action.timer");
if (btn) btn.classList.toggle("active", secs > 0);
}
// Periodically delete messages whose disappearing timer elapsed.
function startSweeper() {
if (state.sweeper) clearInterval(state.sweeper);
const tick = async () => {
try {
const removed = await msgStore.sweepExpired(Date.now());
if (!removed.length) return;
if (state.activeChat) {
const convo = chatConvo(state.activeChat);
for (const r of removed) {
if (r.convo === convo) document.querySelector(`#thread [data-mid="${r.mid}"]`)?.remove();
}
}
} catch {}
};
state.sweeper = setInterval(tick, 10000);
tick();
}
// ---------- prekey replenishment ----------
async function maybeReplenishPreKeys() {
try {

View file

@ -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 = 2; // v2 adds the "groups" object store
const DB_VERSION = 3; // v2 adds "groups"; v3 adds a "mid" index on messages (receipts + disappearing)
// The store is namespaced PER ACCOUNT so multiple accounts can coexist on one
// browser without their Signal identities colliding. Namespace by username
@ -27,17 +27,23 @@ function openDB() {
const db = req.result;
// Signal protocol material (identity keypair, prekeys, sessions, peer identities).
if (!db.objectStoreNames.contains("signal")) db.createObjectStore("signal");
// App metadata (my account, settings).
// App metadata (my account, settings, per-chat disappearing timers).
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.
// Message history, keyed by autoincrement, indexed by conversation + message id.
let msgs;
if (!db.objectStoreNames.contains("messages")) {
const s = db.createObjectStore("messages", { keyPath: "id", autoIncrement: true });
s.createIndex("convo", "convo");
msgs = db.createObjectStore("messages", { keyPath: "id", autoIncrement: true });
msgs.createIndex("convo", "convo");
} else {
msgs = req.transaction.objectStore("messages");
}
// "mid" is the client-generated message id shared by both parties, used to
// match receipts back to the sent message and to expire disappearing messages.
if (!msgs.indexNames.contains("mid")) msgs.createIndex("mid", "mid");
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
@ -112,10 +118,31 @@ export const messages = {
async add(msg) {
return reqP((await tx("messages", "readwrite")).add(msg));
},
// Overwrite an existing record (must carry its autoincrement `id`).
async put(msg) {
return reqP((await tx("messages", "readwrite")).put(msg));
},
async forConvo(convo) {
const idx = (await tx("messages")).index("convo");
return reqP(idx.getAll(convo));
},
// Look a message up by its shared client id (for applying receipts).
async getByMid(mid) {
if (!mid) return undefined;
const idx = (await tx("messages")).index("mid");
return reqP(idx.get(mid));
},
// Delete every message whose disappearing timer has elapsed; returns them so
// the UI can drop their bubbles.
async sweepExpired(nowTs) {
const all = await reqP((await tx("messages")).getAll());
const expired = all.filter((m) => m.expiresAt && m.expiresAt <= nowTs);
if (expired.length) {
const s = await tx("messages", "readwrite");
for (const m of expired) s.delete(m.id);
}
return expired;
},
};
// ---- Signal protocol store ----

View file

@ -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-v7";
const CACHE = "cipher-v9";
const SHELL = [
"/",
"/index.html",

View file

@ -3,7 +3,7 @@
import { Hono } from "hono";
import { requireAuth } from "../lib/auth.js";
import { uuid, now } from "../lib/util.js";
import { uuid, now, randomToken } from "../lib/util.js";
const app = new Hono();
@ -92,6 +92,58 @@ app.post("/groups/:id/members", requireAuth(), async (c) => {
return c.json(await loadGroup(c.env.DB, groupId, me));
});
// POST /api/groups/:id/invite — create (or return the existing) shareable invite
// token. Only members can mint one. The token is the whole capability.
app.post("/groups/:id/invite", 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);
let row = await c.env.DB.prepare("SELECT token FROM group_invites WHERE group_id = ? LIMIT 1")
.bind(groupId)
.first();
if (!row) {
const token = randomToken(18);
await c.env.DB.prepare(
"INSERT INTO group_invites (token, group_id, created_by, created_at) VALUES (?,?,?,?)"
)
.bind(token, groupId, me, now())
.run();
row = { token };
}
return c.json({ token: row.token });
});
// DELETE /api/groups/:id/invite — revoke every invite token for the group.
app.delete("/groups/:id/invite", 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);
await c.env.DB.prepare("DELETE FROM group_invites WHERE group_id = ?").bind(groupId).run();
return c.json({ ok: true });
});
// POST /api/groups/join body: { token } — join a group via an invite link.
app.post("/groups/join", requireAuth(), async (c) => {
const me = c.get("userId");
const body = await c.req.json().catch(() => null);
const token = String(body?.token || "").trim();
if (!token) return c.json({ error: "invite token required" }, 400);
const inv = await c.env.DB.prepare("SELECT group_id FROM group_invites WHERE token = ?")
.bind(token)
.first();
if (!inv) return c.json({ error: "invalid or expired invite" }, 404);
await c.env.DB.prepare(
"INSERT OR IGNORE INTO group_members (group_id, user_id, added_at) VALUES (?,?,?)"
)
.bind(inv.group_id, me, now())
.run();
const g = await loadGroup(c.env.DB, inv.group_id, me);
if (!g) return c.json({ error: "could not join" }, 500);
return c.json(g);
});
// POST /api/groups/:id/leave
app.post("/groups/:id/leave", requireAuth(), async (c) => {
const me = c.get("userId");