cipher/public/js/app.js
King Omar e075629d4d Drop "Vibe, Vote, Veto." from the auth screen tagline
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:47:29 +10:00

1432 lines
55 KiB
JavaScript
Raw Permalink 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, 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." });
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=<token> or ?join=<token>),
// join that group now that we're authenticated.
function getJoinToken() {
const h = location.hash.match(/join=([A-Za-z0-9_-]+)/);
if (h) return h[1];
return new URL(location.href).searchParams.get("join");
}
async function maybeHandleJoin() {
const token = getJoinToken();
if (!token) return;
history.replaceState(null, "", location.pathname); // consume the token from the URL
try {
const g = await api.joinGroup(token);
await groups.put(g);
await refreshChats();
await announceGroupUpdate(g); // tell existing members to pull the new roster
await openChat({ type: "group", id: g.groupId, name: g.name, sub: `${g.members.length} members`, group: g });
toast("Joined " + g.name, "info");
} catch (ex) {
toast(ex.message === "invalid or expired invite" ? "That invite link is no longer valid" : "Couldn't join group: " + ex.message);
}
}
// Tell every other member of a group to re-pull its roster (membership changed).
async function announceGroupUpdate(group) {
const others = (group.members || []).filter((m) => m.userId !== state.me.userId);
await Promise.all(
others.map((m) =>
deliverToUser(m.userId, null, JSON.stringify({ k: "group-update", groupId: group.groupId })).catch(() => {})
)
);
}
// Re-fetch a group's metadata from the server and refresh anything showing it.
async function resyncGroup(groupId) {
try {
const g = await api.getGroup(groupId);
await groups.put(g);
if (state.activeChat?.type === "group" && state.activeChat.id === groupId) {
state.activeChat.group = g;
state.activeChat.name = g.name;
}
await refreshChats();
return g;
} catch {
return null;
}
}
// Unified chat list: DMs + groups, most-recent first.
async function refreshChats() {
const list = $("#chatList");
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();