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>
1059 lines
41 KiB
JavaScript
1059 lines
41 KiB
JavaScript
// 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();
|