// Cipher client orchestration: auth, contacts, chat, media. Ties crypto + api + UI. import { api, setToken, MailboxSocket } from "./api.js"; import * as C from "./crypto.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"; const $ = (sel) => document.querySelector(sel); const el = (tag, props = {}, ...children) => { const n = Object.assign(document.createElement(tag), props); for (const c of children) n.append(c); return n; }; const state = { me: null, // { userId, deviceId, username, displayName } token: null, socket: null, activeChat: null, // { type:"dm"|"group", id, name, contact?, group? } }; // ---------- boot ---------- async function boot() { const saved = accounts.getActive(); if (saved?.token) { await activateAccount(saved); } else { renderAuth(); } } // Switch the whole app to a given account: point the encrypted store at that // account's namespace, set auth, and (re)enter the app. async function activateAccount(acct) { if (state.socket) { state.socket.close(); state.socket = null; } state.me = acct; state.token = acct.token; state.activeChat = null; useAccount(acct.username); // per-account IndexedDB namespace setToken(acct.token); accounts.setActive(acct.username); await enterApp(); } // ---------- auth ---------- function renderAuth() { const root = $("#app"); root.innerHTML = ""; const form = el("form", { className: "auth-card" }); const title = el("h1", { textContent: "Cipher" }); const brandTag = el("div", { className: "brand-tag", textContent: "A Radical Party Project" }); const tag = el("p", { className: "tagline", textContent: "Radically private. End-to-end encrypted. Vibe, Vote, Veto." }); const err = el("div", { className: "auth-err" }); const username = el("input", { placeholder: "username", autocomplete: "username", required: true }); const displayName = el("input", { placeholder: "display name (register only)", autocomplete: "name" }); const password = el("input", { type: "password", placeholder: "password", autocomplete: "current-password", required: true }); const loginBtn = el("button", { type: "submit", className: "primary", textContent: "Log in" }); const registerBtn = el("button", { type: "button", className: "ghost", textContent: "Create account" }); const divider = el("div", { className: "auth-divider", textContent: "new device?" }); const restoreBtn = el("button", { type: "button", className: "link-btn", textContent: "Restore with recovery code", onclick: (e) => { e.preventDefault(); startRecoveryRestore(); } }); const qrLinkBtn = el("button", { type: "button", className: "link-btn", textContent: "Link from another device", onclick: (e) => { e.preventDefault(); startQrLink(); } }); form.append(title, brandTag, tag, err, username, displayName, password, loginBtn, registerBtn, divider, restoreBtn, qrLinkBtn); root.append(form); form.onsubmit = async (e) => { e.preventDefault(); err.textContent = ""; const uname = username.value.trim().toLowerCase(); try { loginBtn.disabled = true; // Point the store at this account BEFORE anything touches it, so the keys // this browser generated at registration are found under the right namespace. useAccount(uname); const res = await api.login({ username: uname, password: password.value }); const identity = await C.store.getIdentityKeyPair(); if (!identity) { err.textContent = "No keys for @" + uname + " on this device. Log in on the device where you created the account (multi-device linking is coming)."; loginBtn.disabled = false; return; } await onAuthenticated(res); } catch (ex) { err.textContent = ex.message; } finally { loginBtn.disabled = false; } }; registerBtn.onclick = async () => { err.textContent = ""; const uname = username.value.trim().toLowerCase(); if (!uname || password.value.length < 8) { err.textContent = "username + 8-char password required"; return; } if (accounts.list().some((a) => a.username === uname)) { err.textContent = "@" + uname + " already signed in on this browser — use Log in / Switch"; return; } try { registerBtn.disabled = true; registerBtn.textContent = "Generating keys…"; useAccount(uname); // namespace the store before generating/persisting keys const device = await C.generateRegistration(); const res = await api.register({ username: uname, displayName: displayName.value || uname, password: password.value, device, }); await onAuthenticated(res); } catch (ex) { err.textContent = ex.message; } finally { registerBtn.disabled = false; registerBtn.textContent = "Create account"; } }; } async function onAuthenticated(res) { const acct = { userId: res.userId, deviceId: res.deviceId, username: res.username, displayName: res.displayName || res.username, token: res.token, }; accounts.save(acct); accounts.setActive(acct.username); await activateAccount(acct); } // ---------- main app ---------- async function enterApp() { renderShell(); await syncGroups(); 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 ---------- function initCalls() { if (state.calls) return; state.calls = new CallManager({ sendSignal: (userId, obj) => { // call signaling rides the same E2E relay as messages (not persisted) deliverToUser(userId, null, JSON.stringify(obj)).catch((e) => console.error("call signal failed", e)); }, onState: renderCallUI, }); } function startCallWith(chat, video) { if (chat.type !== "dm") { toast("Group calls aren't supported yet"); return; } try { state.calls.startCall(chat.contact.userId, chat.name, video, crypto.randomUUID()); } catch (ex) { toast(ex.message); } } // 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 = ""; const shell = el("div", { className: "shell" }); // Sidebar const sidebar = el("aside", { className: "sidebar" }); const header = el("div", { className: "side-header", title: "Accounts & settings", onclick: openAccountMenu }); header.append( 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 list = el("div", { className: "chat-list", id: "chatList" }); sidebar.append(header, actions, list); // Main pane const main = el("section", { className: "main", id: "main" }); main.append(emptyState()); shell.append(sidebar, main); root.append(shell); } 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 {} } // 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"); 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 = ""; 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( avatarEl(chat.name, { group: chat.type === "group" }), el("div", { className: "chat-meta" }, el("div", { className: "chat-name", textContent: chat.name }), el("div", { className: "chat-sub", textContent: chat.sub }) ) ); list.append(item); } } function startNewChat() { openModal({ title: "New chat", placeholder: "Enter a username", action: "Start chat", onSubmit: async (value, setError, close) => { const uname = value.trim().toLowerCase(); if (!uname) return setError("Enter a username"); 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: [] }; await contacts.put(contact); await refreshChats(); close(); 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); } }, }); } // ---------- 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" }); const err = el("div", { className: "modal-err" }); const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" }); const submit = el("button", { type: "button", className: "primary", textContent: action }); const card = el("div", { className: "modal-card" }, el("h2", { className: "modal-title", textContent: title }), input, err, el("div", { className: "modal-actions" }, cancel, submit) ); const overlay = el("div", { className: "modal-overlay" }, card); document.body.append(overlay); setTimeout(() => input.focus(), 0); const close = () => overlay.remove(); const setError = (m) => { err.textContent = m; }; const go = async () => { submit.disabled = true; setError(""); try { await onSubmit(input.value, setError, close); } finally { submit.disabled = false; } }; cancel.onclick = close; submit.onclick = go; overlay.onclick = (e) => { if (e.target === overlay) close(); }; input.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); go(); } if (e.key === "Escape") close(); }; } // ---------- overlay + toast helpers ---------- function makeOverlay(...children) { const card = el("div", { className: "modal-card" }, ...children); const overlay = el("div", { className: "modal-overlay" }, card); overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; document.body.append(overlay); return { overlay, card, close: () => overlay.remove() }; } function encodeLink(obj) { return btoa(JSON.stringify(obj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function decodeLink(s) { const b = s.trim().replace(/-/g, "+").replace(/_/g, "/"); return JSON.parse(atob(b)); } function qrImg(text) { const qr = qrcode(0, "M"); qr.addData(text); qr.make(); const img = el("img", { className: "qr", alt: "linking QR code" }); img.src = qr.createDataURL(6, 12); return img; } // ---------- recovery code: set up backup ---------- async function setupRecovery() { let code; try { const blob = await C.exportIdentityBlob(state.me); code = C.generateRecoveryCode(); const payload = await C.encryptWithCode(blob, code); await api.putBackup(payload); } catch (ex) { toast("Couldn't set up recovery: " + ex.message); return; } const codeBox = el("div", { className: "code-box", textContent: code }); const copy = el("button", { type: "button", className: "primary", textContent: "Copy code", onclick: () => { navigator.clipboard?.writeText(code); copy.textContent = "Copied ✓"; }, }); const done = el("button", { type: "button", className: "ghost", textContent: "I've saved it" }); const { close } = makeOverlay( el("h2", { className: "modal-title", textContent: "Your recovery code" }), el("p", { className: "modal-note", textContent: "Save this somewhere safe. It's the ONLY way to restore your account on a new device if you lose this one. We can't recover it for you." }), codeBox, el("div", { className: "modal-actions" }, copy, done) ); done.onclick = close; } // ---------- recovery code: restore on a new device ---------- function startRecoveryRestore() { const uname = el("input", { className: "modal-input", placeholder: "username", autocomplete: "username" }); const pass = el("input", { type: "password", className: "modal-input", placeholder: "password", autocomplete: "current-password" }); const code = el("input", { className: "modal-input", placeholder: "recovery code (XXXX-XXXX-…)" }); const err = el("div", { className: "modal-err" }); const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" }); const go = el("button", { type: "button", className: "primary", textContent: "Restore" }); const { close } = makeOverlay( el("h2", { className: "modal-title", textContent: "Restore with recovery code" }), el("p", { className: "modal-note", textContent: "Enter your login and the recovery code you saved when you set up this account." }), uname, pass, code, err, el("div", { className: "modal-actions" }, cancel, go) ); cancel.onclick = close; go.onclick = async () => { err.textContent = ""; const u = uname.value.trim().toLowerCase(); if (!u || !pass.value || !code.value) { err.textContent = "All fields required"; return; } go.disabled = true; go.textContent = "Restoring…"; try { useAccount(u); const res = await api.login({ username: u, password: pass.value }); setToken(res.token); const { payload } = await api.getBackup(); let blob; try { blob = await C.decryptWithCode(payload, code.value); } catch { throw new Error("Wrong recovery code"); } const fresh = await C.importIdentityBlob(blob); await api.resetKeys(fresh.signedPreKey, fresh.oneTimePreKeys); close(); await onAuthenticated(res); toast("Account restored on this device", "info"); } catch (ex) { err.textContent = ex.message === "no backup" ? "No recovery backup found for that account" : ex.message; go.disabled = false; go.textContent = "Restore"; } }; } // ---------- QR linking: authorize a new device (from the signed-in device) ---------- function linkDeviceAuthorizer() { const paste = el("textarea", { className: "modal-input", placeholder: "Paste the linking code from your new device", rows: 3 }); const err = el("div", { className: "modal-err" }); const status = el("div", { className: "modal-note" }); const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" }); const approve = el("button", { type: "button", className: "primary", textContent: "Approve link" }); const scanBtn = el("button", { type: "button", className: "ghost", textContent: "📷 Scan QR" }); const scanArea = el("div", {}); const { close } = makeOverlay( el("h2", { className: "modal-title", textContent: "Link a device" }), el("p", { className: "modal-note", textContent: "On your new device choose “Link from another device”, then scan its QR here or paste its code." }), scanBtn, scanArea, paste, status, err, el("div", { className: "modal-actions" }, cancel, approve) ); cancel.onclick = close; const doApprove = async (linkText) => { err.textContent = ""; let link; try { link = decodeLink(linkText); } catch { err.textContent = "Invalid linking code"; return; } if (!link.p || !link.k) { err.textContent = "Invalid linking code"; return; } approve.disabled = true; status.textContent = "Approving…"; try { const blob = { ...(await C.exportIdentityBlob(state.me)), token: state.me.token }; const payload = await C.encryptToEphemeral(blob, link.k); await api.provisionComplete(link.p, payload); close(); toast("Device linked ✓", "info"); } catch (ex) { err.textContent = ex.message; approve.disabled = false; status.textContent = ""; } }; approve.onclick = () => doApprove(paste.value); scanBtn.onclick = async () => { if (!("BarcodeDetector" in window)) { err.textContent = "Camera scanning not supported here — paste the code instead"; return; } try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }); const video = el("video", { className: "qr-video", autoplay: true, playsInline: true, muted: true }); video.srcObject = stream; scanArea.innerHTML = ""; scanArea.append(video); const detector = new BarcodeDetector({ formats: ["qr_code"] }); const tick = async () => { if (!video.isConnected) { stream.getTracks().forEach((t) => t.stop()); return; } try { const codes = await detector.detect(video); if (codes.length) { stream.getTracks().forEach((t) => t.stop()); scanArea.innerHTML = ""; await doApprove(codes[0].rawValue); return; } } catch {} requestAnimationFrame(tick); }; requestAnimationFrame(tick); } catch { err.textContent = "Couldn't open camera — paste the code instead"; } }; } // ---------- QR linking: get linked (on the new device) ---------- async function startQrLink() { let eph, provisionId; const status = el("div", { className: "modal-note", textContent: "Generating link…" }); const codeText = el("div", { className: "code-box small" }); const copy = el("button", { type: "button", className: "ghost", textContent: "Copy code" }); const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" }); const qrHolder = el("div", {}); const { overlay, close } = makeOverlay( el("h2", { className: "modal-title", textContent: "Link from another device" }), el("p", { className: "modal-note", textContent: "On a device already signed in, choose “Link a device” and scan this QR (or paste the code)." }), qrHolder, status, codeText, el("div", { className: "modal-actions" }, copy, cancel) ); let cancelled = false; cancel.onclick = () => { cancelled = true; close(); }; try { eph = await C.generateEphemeralKeyPair(); const res = await api.provisionNew(eph.pubB64); provisionId = res.provisionId; } catch (ex) { status.textContent = "Couldn't start linking: " + ex.message; return; } const linkCode = encodeLink({ p: provisionId, k: eph.pubB64 }); qrHolder.append(qrImg(linkCode)); codeText.textContent = linkCode; status.textContent = "Waiting for approval…"; copy.onclick = () => { navigator.clipboard?.writeText(linkCode); copy.textContent = "Copied ✓"; }; // poll for the encrypted blob for (let i = 0; i < 150 && !cancelled; i++) { await new Promise((r) => setTimeout(r, 1200)); let got; try { got = await api.provisionGet(provisionId); } catch { continue; } if (got?.payload) { status.textContent = "Approved — restoring…"; try { const blob = await C.decryptFromEphemeral(got.payload, eph.keyPair); useAccount(blob.username); setToken(blob.token); const fresh = await C.importIdentityBlob(blob); await api.resetKeys(fresh.signedPreKey, fresh.oneTimePreKeys); close(); await onAuthenticated({ userId: blob.userId, deviceId: blob.deviceId, username: blob.username, displayName: blob.displayName, token: blob.token, }); toast("Device linked ✓", "info"); } catch (ex) { status.textContent = "Link failed: " + ex.message; } return; } } if (!cancelled) status.textContent = "Link timed out — try again"; } // ---------- account menu (switch / add / log out) ---------- function openAccountMenu() { const all = accounts.list(); const rows = []; for (const a of all) { const active = a.username === state.me.username; rows.push( el("button", { type: "button", className: "acct-row" + (active ? " active" : ""), textContent: (active ? "● " : "○ ") + "@" + a.username, onclick: async () => { overlay.remove(); if (!active) await activateAccount(a); }, }) ); } const recoveryBtn = el("button", { type: "button", className: "acct-action", textContent: "🔑 Set up recovery code", onclick: () => { overlay.remove(); setupRecovery(); }, }); const linkBtn = el("button", { type: "button", className: "acct-action", textContent: "🔗 Link a device", onclick: () => { overlay.remove(); linkDeviceAuthorizer(); }, }); const addBtn = el("button", { type: "button", className: "acct-action", textContent: "+ Add another account", onclick: () => { overlay.remove(); renderAuth(); }, }); const logoutBtn = el("button", { type: "button", className: "acct-action danger", textContent: "Log out @" + state.me.username, onclick: async () => { overlay.remove(); accounts.remove(state.me.username); if (state.socket) { state.socket.close(); state.socket = null; } const remaining = accounts.list(); if (remaining.length) await activateAccount(remaining[0]); else renderAuth(); }, }); const card = el("div", { className: "modal-card" }, el("h2", { className: "modal-title", textContent: "Accounts" }), ...rows, recoveryBtn, linkBtn, addBtn, logoutBtn ); const overlay = el("div", { className: "modal-overlay" }, card); overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; document.body.append(overlay); } // ---------- toast (surface errors instead of failing silently) ---------- function toast(message, kind = "error") { const t = el("div", { className: "toast " + kind, textContent: message }); let host = $("#toasts"); if (!host) { host = el("div", { id: "toasts" }); document.body.append(host); } host.append(t); setTimeout(() => t.remove(), 4000); } // ---------- call UI ---------- let callTimer = null; function fmtDuration(ms) { const s = Math.max(0, Math.floor(ms / 1000)); const m = Math.floor(s / 60); return `${m < 10 ? "0" : ""}${m}:${s % 60 < 10 ? "0" : ""}${s % 60}`; } function renderCallUI(cs) { let ov = document.getElementById("callOverlay"); if (cs.phase === "idle" || cs.phase === "ended") { if (ov) ov.remove(); if (callTimer) { clearInterval(callTimer); callTimer = null; } return; } if (!ov) { const remoteVid = el("video", { id: "remoteVid", className: "remote-vid", autoplay: true, playsInline: true }); const localVid = el("video", { id: "localVid", className: "local-vid", autoplay: true, playsInline: true, muted: true }); const avatarWrap = el("div", { id: "callAvatar", className: "call-avatar-wrap" }); const status = el("div", { id: "callStatus", className: "call-status" }); const controls = el("div", { id: "callControls", className: "call-controls" }); ov = el("div", { id: "callOverlay", className: "call-overlay" }, remoteVid, avatarWrap, localVid, status, controls); document.body.append(ov); } const remoteVid = ov.querySelector("#remoteVid"); const localVid = ov.querySelector("#localVid"); const avatarWrap = ov.querySelector("#callAvatar"); const status = ov.querySelector("#callStatus"); const controls = ov.querySelector("#callControls"); const isVideo = cs.callType === "video"; const remoteVideoLive = isVideo && cs.remoteStream; if (cs.remoteStream && remoteVid.srcObject !== cs.remoteStream) remoteVid.srcObject = cs.remoteStream; if (cs.localStream && localVid.srcObject !== cs.localStream) localVid.srcObject = cs.localStream; remoteVid.style.display = remoteVideoLive ? "block" : "none"; localVid.style.display = isVideo && cs.localStream && !cs.cameraOff ? "block" : "none"; avatarWrap.innerHTML = ""; if (!remoteVideoLive) { const a = avatarEl(cs.peerName || "?"); a.classList.add("call-avatar"); avatarWrap.append(a, el("div", { className: "call-peer", textContent: cs.peerName || "" })); } if (callTimer) { clearInterval(callTimer); callTimer = null; } if (cs.phase === "outgoing") status.textContent = "Ringing…"; else if (cs.phase === "incoming") status.textContent = `Incoming ${cs.callType} call`; else if (cs.phase === "connected") { const upd = () => { status.textContent = "🔒 " + fmtDuration(Date.now() - cs.startedAt); }; upd(); callTimer = setInterval(upd, 1000); } controls.innerHTML = ""; if (cs.phase === "incoming") { controls.append( el("button", { className: "call-btn hangup", textContent: "✕", title: "Decline", onclick: () => state.calls.decline() }), el("button", { className: "call-btn accept", textContent: "✓", title: "Accept", onclick: async () => { try { await state.calls.accept(); } catch (e) { toast(e.message); } } }) ); } else { controls.append(el("button", { className: "call-btn" + (cs.muted ? " active" : ""), textContent: cs.muted ? "🔇" : "🎙️", title: "Mute", onclick: () => state.calls.toggleMute() })); if (isVideo) controls.append(el("button", { className: "call-btn" + (cs.cameraOff ? " active" : ""), textContent: "🎥", title: "Camera", onclick: () => state.calls.toggleCamera() })); controls.append(el("button", { className: "call-btn hangup", textContent: "📞", title: "Hang up", onclick: () => state.calls.hangup() })); } } // ---------- chat view ---------- // ---- 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; chat.disappearing = (await meta.get("disap:" + chatConvo(chat))) || 0; $(".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.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, 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 }) ) ); 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(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) }) ); } const thread = el("div", { className: "thread", id: "thread" }); const composer = el("form", { className: "composer" }); const attach = el("label", { className: "attach", title: "Attach", textContent: "+" }); const fileInput = el("input", { type: "file", style: "display:none" }); attach.append(fileInput); 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); 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(); const text = input.value.trim(); if (!text) return; input.value = ""; try { await sendText(chat, text); } catch (ex) { console.error("send failed", ex); toast("Couldn't send: " + ex.message); input.value = text; } }; fileInput.onchange = async () => { const file = fileInput.files[0]; if (file) { try { await sendMedia(chat, file); } catch (ex) { console.error("media send failed", ex); toast("Couldn't send file: " + ex.message); } } fileInput.value = ""; }; } 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 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, 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; showEmpty(); await refreshChats(); toast("Left " + chat.name, "info"); }; } // 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); contact.deviceIds = bundle.bundles.map((b) => b.deviceId); await contacts.put(contact); await C.ensureSessions(bundle); return contact.deviceIds; } // 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; } // 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); } } // 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) { 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, mid, ttl }); await persistAndRender(chatConvo(chat), chat.id, { dir: "out", kind: "media", name: file.name, mime: file.type, mid, localUrl: URL.createObjectURL(file), senderId: state.me.userId, ts, ...outMeta(chat, ttl, ts), }); } // ---------- receiving ---------- function connectSocket() { if (state.socket) state.socket.close(); state.socket = new MailboxSocket(state.token, onEnvelope); state.socket.connect(); } async function onEnvelope(envelope) { 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 unacked to retry after the session heals } let data; try { data = JSON.parse(plaintext); } catch { data = { k: "text", text: plaintext }; } // Call signaling (SDP/ICE) — route to the call manager, never render as a message. if (data.k === "call") { if (data.sub === "offer") { const c = await contacts.get(envelope.fromUserId); data.peerName = c ? c.displayName || c.username : envelope.fromUserId.slice(0, 6); } try { await state.calls?.onSignal(envelope.fromUserId, data); } catch (e) { console.error("call signal", e); } 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" ? { ...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 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 { // 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); } // 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) { return state.me.userId + ":" + otherUserId; } // ---------- persistence + render ---------- async function persistAndRender(convo, chatId, msg) { const record = { ...msg, convo }; await msgStore.add(record); if (state.activeChat && state.activeChat.id === chatId) { renderMessage(record, state.activeChat); scrollThread(); } } 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); 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" }); if (m.localUrl) img.src = m.localUrl; else if (m.mediaRef) loadMediaInto(img, m.mediaRef); bubble.append(img); } else { const link = el("a", { className: "media-file", textContent: "📄 " + (m.name || "file") }); if (m.mediaRef) makeDownloadable(link, m.mediaRef, m.name); bubble.append(link); } } else { bubble.append(el("div", { className: "text", textContent: m.text })); } 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); const plain = await C.decryptMedia(ct, ref.keyB64, ref.ivB64); img.src = URL.createObjectURL(new Blob([plain])); } catch (e) { img.alt = "failed to load media"; } } async function makeDownloadable(link, ref, name) { link.onclick = async (e) => { e.preventDefault(); const ct = await api.fetchMedia(ref.key); const plain = await C.decryptMedia(ct, ref.keyB64, ref.ivB64); const url = URL.createObjectURL(new Blob([plain])); const a = el("a", { href: url, download: name || "file" }); a.click(); }; } function scrollThread() { const t = $("#thread"); if (t) t.scrollTop = t.scrollHeight; } function fmtTime(ts) { const d = new Date(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 { const { count } = await api.keyCount(); if (count < 20) { const otks = await C.generateMoreOneTimePreKeys(100); await api.addPreKeys(otks); } } catch {} } function registerServiceWorker() { if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => {}); } } boot();