cipher/public/js/app.js
maverick 3d9f712867 Rebrand to Radical Party identity
Match theradicalparty.com brand pulled from sibling apps:
- signature red #FF2D55 (+ hot pink #FF6B82) on warm near-black #0B0B09, cream #F0EDE6
- Syne (display/brand/headings) + IBM Plex Mono (recovery/linking codes) via Google Fonts
- warm red/pink/gold aurora backdrop; red accent glows, focus rings, bubbles throughout
- auth wordmark in Syne + 'A Radical Party Project' tag + 'Vibe, Vote, Veto' tagline
- red lock icon, updated manifest name/description/theme, theme-color #0B0B09
- SW cache v6->v7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 16:08:59 +10:00

1059 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 } 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();
maybeReplenishPreKeys();
registerServiceWorker();
}
// ---------- 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 {}
}
// 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;
$(".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 })
)
);
if (chat.type === "group") {
head.append(el("button", { className: "head-action", title: "Group info", textContent: "ⓘ", onclick: () => showGroupInfo(chat) }));
} else {
head.append(
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 history = await msgStore.forConvo(chatConvo(chat));
for (const m of history) renderMessage(m, chat);
scrollThread();
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 leave = el("button", { type: "button", className: "acct-action danger", textContent: "Leave group" });
const { close } = makeOverlay(
el("h2", { className: "modal-title", textContent: chat.name }),
el("div", { className: "modal-note", textContent: `${chat.group.members.length} members` }),
...rows, leave
);
leave.onclick = async () => {
try { await api.leaveGroup(chat.id); } catch {}
await groups.del(chat.id);
close();
state.activeChat = null;
showEmpty();
await refreshChats();
toast("Left " + chat.name, "info");
};
}
// ---- sending ----
async function ensureRecipientDevices(contact) {
const bundle = await api.bundle(contact.userId);
contact.deviceIds = bundle.bundles.map((b) => b.deviceId);
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);
}
}
async function sendText(chat, text) {
await deliver(chat, { k: "text", text });
await persistAndRender(chatConvo(chat), chat.id, { dir: "out", kind: "text", text, senderId: state.me.userId, ts: Date.now() });
}
async function sendMedia(chat, file) {
const buf = await file.arrayBuffer();
const enc = await C.encryptMedia(buf);
const { key } = await api.uploadMedia(enc.ciphertext);
await deliver(chat, { k: "media", key, keyB64: enc.keyB64, ivB64: enc.ivB64, name: file.name, type: file.type, size: file.size });
await persistAndRender(chatConvo(chat), chat.id, {
dir: "out", kind: "media", name: file.name, mime: file.type,
localUrl: URL.createObjectURL(file), senderId: state.me.userId, ts: Date.now(),
});
}
// ---------- 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;
}
const msg = data.k === "media"
? { dir: "in", kind: "media", name: data.name, mime: data.type, mediaRef: { key: data.key, keyB64: data.keyB64, ivB64: data.ivB64 }, senderId: envelope.fromUserId, ts: envelope.sentAt || Date.now() }
: { dir: "in", kind: "text", text: data.text, senderId: envelope.fromUserId, ts: envelope.sentAt || Date.now() };
if (data.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);
}
}
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;
const out = m.dir === "out";
const bubble = el("div", { className: "bubble " + (out ? "out" : "in") });
// sender label for incoming group messages
if (!out && chat && chat.type === "group") {
const name = senderName(m.senderId, chat.group);
const label = el("div", { className: "sender" });
label.textContent = name;
label.style.color = `hsl(${hashHue(name)} 65% 68%)`;
bubble.append(label);
}
if (m.kind === "media") {
if (m.mime && m.mime.startsWith("image/")) {
const img = el("img", { className: "media-img", alt: m.name || "image" });
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 }));
}
bubble.append(el("div", { className: "time", textContent: fmtTime(m.ts) }));
thread.append(bubble);
}
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" });
}
// ---------- 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();