From 726d9d53ba87dd9a6a231742cc70a149597026b8 Mon Sep 17 00:00:00 2001 From: King Omar Date: Wed, 22 Jul 2026 18:32:24 +1000 Subject: [PATCH] Add read receipts, disappearing messages, and group invite links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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) --- migrations/0004_group_invites.sql | 11 + public/index.html | 6 +- public/js/api.js | 3 + public/js/app.js | 397 +++++++++++++++++++++++++++++- public/js/store.js | 37 ++- public/sw.js | 2 +- src/routes/groups.js | 54 +++- 7 files changed, 490 insertions(+), 20 deletions(-) create mode 100644 migrations/0004_group_invites.sql diff --git a/migrations/0004_group_invites.sql b/migrations/0004_group_invites.sql new file mode 100644 index 0000000..dede31f --- /dev/null +++ b/migrations/0004_group_invites.sql @@ -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); diff --git a/public/index.html b/public/index.html index cb25905..3748202 100644 --- a/public/index.html +++ b/public/index.html @@ -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; } diff --git a/public/js/api.js b/public/js/api.js index 28bb686..c170b3c 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -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; diff --git a/public/js/app.js b/public/js/app.js index 139fa61..7fffb37 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, 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= or ?join=), +// 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 { diff --git a/public/js/store.js b/public/js/store.js index a3c502d..59765ca 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 = 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 ---- diff --git a/public/sw.js b/public/sw.js index 71674e2..1343e93 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-v7"; +const CACHE = "cipher-v9"; const SHELL = [ "/", "/index.html", diff --git a/src/routes/groups.js b/src/routes/groups.js index 2e72765..92b0b63 100644 --- a/src/routes/groups.js +++ b/src/routes/groups.js @@ -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");