Receipts: messages now carry a shared client id (mid); recipients send delivered/read receipts over the E2E relay, rendered as ✓/✓✓ ticks (blue when read). Groups aggregate — ticks turn on once every member acks. Disappearing messages: per-chat timer (Off..1 week) stored locally and synced to peers via an encrypted config control message; messages carry a ttl and a client-side sweeper deletes them from both sides on schedule. Group invite links: new group_invites table + /groups/:id/invite (mint/ revoke) and /groups/join routes. Group info gains "Add people" and "Invite via link" (shareable URL + QR); opening a #join=<token> link joins the group and re-syncs every member's roster over the relay. store.js v3 adds a mid index + getByMid/put/sweepExpired; SW cache v9. Verified: 12 live server tests (invite/join/add/revoke/leave) + 11 store unit tests (receipts, sweep, meta) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1432 lines
55 KiB
JavaScript
1432 lines
55 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, meta } from "./store.js";
|
||
import * as accounts from "./accounts.js";
|
||
import qrcode from "./vendor/qrcode.js";
|
||
import { CallManager } from "./call.js";
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const el = (tag, props = {}, ...children) => {
|
||
const n = Object.assign(document.createElement(tag), props);
|
||
for (const c of children) n.append(c);
|
||
return n;
|
||
};
|
||
|
||
const state = {
|
||
me: null, // { userId, deviceId, username, displayName }
|
||
token: null,
|
||
socket: null,
|
||
activeChat: null, // { type:"dm"|"group", id, name, contact?, group? }
|
||
};
|
||
|
||
// ---------- boot ----------
|
||
async function boot() {
|
||
const saved = accounts.getActive();
|
||
if (saved?.token) {
|
||
await activateAccount(saved);
|
||
} else {
|
||
renderAuth();
|
||
}
|
||
}
|
||
|
||
// Switch the whole app to a given account: point the encrypted store at that
|
||
// account's namespace, set auth, and (re)enter the app.
|
||
async function activateAccount(acct) {
|
||
if (state.socket) { state.socket.close(); state.socket = null; }
|
||
state.me = acct;
|
||
state.token = acct.token;
|
||
state.activeChat = null;
|
||
useAccount(acct.username); // per-account IndexedDB namespace
|
||
setToken(acct.token);
|
||
accounts.setActive(acct.username);
|
||
await enterApp();
|
||
}
|
||
|
||
// ---------- auth ----------
|
||
function renderAuth() {
|
||
const root = $("#app");
|
||
root.innerHTML = "";
|
||
const form = el("form", { className: "auth-card" });
|
||
const title = el("h1", { textContent: "Cipher" });
|
||
const brandTag = el("div", { className: "brand-tag", textContent: "A Radical Party Project" });
|
||
const tag = el("p", { className: "tagline", textContent: "Radically private. End-to-end encrypted. Vibe, Vote, Veto." });
|
||
const err = el("div", { className: "auth-err" });
|
||
|
||
const username = el("input", { placeholder: "username", autocomplete: "username", required: true });
|
||
const displayName = el("input", { placeholder: "display name (register only)", autocomplete: "name" });
|
||
const password = el("input", { type: "password", placeholder: "password", autocomplete: "current-password", required: true });
|
||
|
||
const loginBtn = el("button", { type: "submit", className: "primary", textContent: "Log in" });
|
||
const registerBtn = el("button", { type: "button", className: "ghost", textContent: "Create account" });
|
||
|
||
const divider = el("div", { className: "auth-divider", textContent: "new device?" });
|
||
const restoreBtn = el("button", { type: "button", className: "link-btn", textContent: "Restore with recovery code", onclick: (e) => { e.preventDefault(); startRecoveryRestore(); } });
|
||
const qrLinkBtn = el("button", { type: "button", className: "link-btn", textContent: "Link from another device", onclick: (e) => { e.preventDefault(); startQrLink(); } });
|
||
|
||
form.append(title, brandTag, tag, err, username, displayName, password, loginBtn, registerBtn, divider, restoreBtn, qrLinkBtn);
|
||
root.append(form);
|
||
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
err.textContent = "";
|
||
const uname = username.value.trim().toLowerCase();
|
||
try {
|
||
loginBtn.disabled = true;
|
||
// Point the store at this account BEFORE anything touches it, so the keys
|
||
// this browser generated at registration are found under the right namespace.
|
||
useAccount(uname);
|
||
const res = await api.login({ username: uname, password: password.value });
|
||
const identity = await C.store.getIdentityKeyPair();
|
||
if (!identity) {
|
||
err.textContent =
|
||
"No keys for @" + uname + " on this device. Log in on the device where you created the account (multi-device linking is coming).";
|
||
loginBtn.disabled = false;
|
||
return;
|
||
}
|
||
await onAuthenticated(res);
|
||
} catch (ex) {
|
||
err.textContent = ex.message;
|
||
} finally {
|
||
loginBtn.disabled = false;
|
||
}
|
||
};
|
||
|
||
registerBtn.onclick = async () => {
|
||
err.textContent = "";
|
||
const uname = username.value.trim().toLowerCase();
|
||
if (!uname || password.value.length < 8) {
|
||
err.textContent = "username + 8-char password required";
|
||
return;
|
||
}
|
||
if (accounts.list().some((a) => a.username === uname)) {
|
||
err.textContent = "@" + uname + " already signed in on this browser — use Log in / Switch";
|
||
return;
|
||
}
|
||
try {
|
||
registerBtn.disabled = true;
|
||
registerBtn.textContent = "Generating keys…";
|
||
useAccount(uname); // namespace the store before generating/persisting keys
|
||
const device = await C.generateRegistration();
|
||
const res = await api.register({
|
||
username: uname,
|
||
displayName: displayName.value || uname,
|
||
password: password.value,
|
||
device,
|
||
});
|
||
await onAuthenticated(res);
|
||
} catch (ex) {
|
||
err.textContent = ex.message;
|
||
} finally {
|
||
registerBtn.disabled = false;
|
||
registerBtn.textContent = "Create account";
|
||
}
|
||
};
|
||
}
|
||
|
||
async function onAuthenticated(res) {
|
||
const acct = {
|
||
userId: res.userId,
|
||
deviceId: res.deviceId,
|
||
username: res.username,
|
||
displayName: res.displayName || res.username,
|
||
token: res.token,
|
||
};
|
||
accounts.save(acct);
|
||
accounts.setActive(acct.username);
|
||
await activateAccount(acct);
|
||
}
|
||
|
||
// ---------- main app ----------
|
||
async function enterApp() {
|
||
renderShell();
|
||
await syncGroups();
|
||
await refreshChats();
|
||
connectSocket();
|
||
initCalls();
|
||
startSweeper();
|
||
maybeReplenishPreKeys();
|
||
registerServiceWorker();
|
||
await maybeHandleJoin();
|
||
}
|
||
|
||
// Disappearing-message durations offered in the timer menu.
|
||
const DISAPPEAR_OPTIONS = [
|
||
{ label: "Off", secs: 0 },
|
||
{ label: "30 seconds", secs: 30 },
|
||
{ label: "5 minutes", secs: 300 },
|
||
{ label: "1 hour", secs: 3600 },
|
||
{ label: "8 hours", secs: 28800 },
|
||
{ label: "1 day", secs: 86400 },
|
||
{ label: "1 week", secs: 604800 },
|
||
];
|
||
function disappearLabel(secs) {
|
||
return DISAPPEAR_OPTIONS.find((o) => o.secs === secs)?.label || `${secs}s`;
|
||
}
|
||
|
||
// ---------- calls ----------
|
||
function initCalls() {
|
||
if (state.calls) return;
|
||
state.calls = new CallManager({
|
||
sendSignal: (userId, obj) => {
|
||
// call signaling rides the same E2E relay as messages (not persisted)
|
||
deliverToUser(userId, null, JSON.stringify(obj)).catch((e) => console.error("call signal failed", e));
|
||
},
|
||
onState: renderCallUI,
|
||
});
|
||
}
|
||
|
||
function startCallWith(chat, video) {
|
||
if (chat.type !== "dm") { toast("Group calls aren't supported yet"); return; }
|
||
try {
|
||
state.calls.startCall(chat.contact.userId, chat.name, video, crypto.randomUUID());
|
||
} catch (ex) {
|
||
toast(ex.message);
|
||
}
|
||
}
|
||
|
||
// Deterministic vibrant colour from a string — used for avatars.
|
||
function hashHue(str) {
|
||
let h = 0;
|
||
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||
return h % 360;
|
||
}
|
||
function avatarEl(name, { group = false, size = "" } = {}) {
|
||
const initial = (name || "?").trim()[0]?.toUpperCase() || "?";
|
||
const hue = hashHue(name || "?");
|
||
const a = el("div", { className: "avatar" + (size ? " " + size : "") + (group ? " group" : "") });
|
||
a.style.background = `linear-gradient(135deg, hsl(${hue} 70% 52%), hsl(${(hue + 40) % 360} 70% 42%))`;
|
||
a.textContent = group ? "" : initial;
|
||
if (group) a.append(el("span", { className: "group-glyph", textContent: "⛊" }));
|
||
return a;
|
||
}
|
||
|
||
function renderShell() {
|
||
const root = $("#app");
|
||
root.innerHTML = "";
|
||
const shell = el("div", { className: "shell" });
|
||
|
||
// Sidebar
|
||
const sidebar = el("aside", { className: "sidebar" });
|
||
const header = el("div", { className: "side-header", title: "Accounts & settings", onclick: openAccountMenu });
|
||
header.append(
|
||
avatarEl(state.me.displayName || state.me.username),
|
||
el("div", { className: "side-id" },
|
||
el("div", { className: "me" },
|
||
document.createTextNode(state.me.displayName || state.me.username),
|
||
el("span", { className: "caret", textContent: "▾" })
|
||
),
|
||
el("div", { className: "handle", textContent: "@" + state.me.username })
|
||
)
|
||
);
|
||
const actions = el("div", { className: "side-actions" },
|
||
el("button", { className: "pill", onclick: startNewChat }, el("span", { textContent: "+" }), document.createTextNode("Chat")),
|
||
el("button", { className: "pill", onclick: startNewGroup }, el("span", { textContent: "⛊" }), document.createTextNode("Group"))
|
||
);
|
||
const list = el("div", { className: "chat-list", id: "chatList" });
|
||
sidebar.append(header, actions, list);
|
||
|
||
// Main pane
|
||
const main = el("section", { className: "main", id: "main" });
|
||
main.append(emptyState());
|
||
|
||
shell.append(sidebar, main);
|
||
root.append(shell);
|
||
}
|
||
|
||
function emptyState() {
|
||
return el("div", { className: "empty" },
|
||
el("div", { className: "empty-badge", textContent: "🔒" }),
|
||
el("div", { className: "empty-title", textContent: "Your messages are end-to-end encrypted" }),
|
||
el("div", { className: "empty-sub", textContent: "Pick a conversation, or start a new chat or group." })
|
||
);
|
||
}
|
||
|
||
// Pull the latest group list from the server into the local store.
|
||
async function syncGroups() {
|
||
try {
|
||
const { groups: gs } = await api.listGroups();
|
||
for (const g of gs) await groups.put(g);
|
||
} catch {}
|
||
}
|
||
|
||
// If the app was opened via an invite link (#join=<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();
|