// 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 { meta, contacts, messages as msgStore } from "./store.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, activeContact: null, // contact object }; // ---------- boot ---------- async function boot() { const saved = await meta.get("account"); if (saved?.token) { state.me = saved; state.token = saved.token; setToken(saved.token); await enterApp(); } else { renderAuth(); } } // ---------- auth ---------- function renderAuth() { const root = $("#app"); root.innerHTML = ""; const form = el("form", { className: "auth-card" }); const title = el("h1", { textContent: "Cipher" }); const tag = el("p", { className: "tagline", textContent: "end-to-end encrypted messaging" }); 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" }); form.append(title, tag, err, username, displayName, password, loginBtn, registerBtn); root.append(form); form.onsubmit = async (e) => { e.preventDefault(); err.textContent = ""; try { loginBtn.disabled = true; const res = await api.login({ username: username.value, password: password.value }); await onAuthenticated(res); } catch (ex) { err.textContent = ex.message; } finally { loginBtn.disabled = false; } }; registerBtn.onclick = async () => { err.textContent = ""; if (!username.value || password.value.length < 8) { err.textContent = "username + 8-char password required"; return; } try { registerBtn.disabled = true; registerBtn.textContent = "Generating keys…"; const device = await C.generateRegistration(); const res = await api.register({ username: username.value, displayName: displayName.value || username.value, 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) { state.me = res; state.token = res.token; setToken(res.token); await meta.set("account", res); await enterApp(); } // ---------- main app ---------- async function enterApp() { renderShell(); await refreshContacts(); connectSocket(); maybeReplenishPreKeys(); registerServiceWorker(); } 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" }); header.append( el("div", { className: "me", textContent: state.me.displayName || state.me.username }), el("div", { className: "handle", textContent: "@" + state.me.username }) ); const newChat = el("button", { className: "new-chat", textContent: "+ New chat", onclick: startNewChat }); const list = el("div", { className: "chat-list", id: "chatList" }); sidebar.append(header, newChat, list); // Main pane const main = el("section", { className: "main", id: "main" }); main.append(el("div", { className: "empty", textContent: "Select a chat or start a new one" })); shell.append(sidebar, main); root.append(shell); } async function refreshContacts() { const list = $("#chatList"); if (!list) return; list.innerHTML = ""; const all = await contacts.all(); for (const c of all) { const item = el("div", { className: "chat-item", onclick: () => openChat(c) }); item.append( el("div", { className: "avatar", textContent: (c.displayName || c.username)[0].toUpperCase() }), el("div", { className: "chat-meta" }, el("div", { className: "chat-name", textContent: c.displayName || c.username }), el("div", { className: "chat-sub", textContent: "@" + c.username }) ) ); list.append(item); } } async function startNewChat() { const username = prompt("Username to message:"); if (!username) return; try { const user = await api.lookup(username.trim().toLowerCase()); const contact = { userId: user.userId, username: user.username, displayName: user.displayName, deviceIds: [], }; await contacts.put(contact); await refreshContacts(); await openChat(contact); } catch (ex) { alert("Could not find user: " + ex.message); } } // ---------- chat view ---------- async function openChat(contact) { state.activeContact = contact; const main = $("#main"); main.innerHTML = ""; const head = el("div", { className: "chat-head" }); head.append( el("div", { className: "avatar", textContent: (contact.displayName || contact.username)[0].toUpperCase() }), el("div", {}, el("div", { className: "chat-name", textContent: contact.displayName || contact.username }), el("div", { className: "chat-sub", textContent: "🔒 end-to-end encrypted" }) ) ); const thread = el("div", { className: "thread", id: "thread" }); const composer = el("form", { className: "composer" }); const input = el("input", { placeholder: "Type a message", autocomplete: "off" }); const attach = el("label", { className: "attach", textContent: "📎" }); const fileInput = el("input", { type: "file", style: "display:none" }); attach.append(fileInput); const sendBtn = el("button", { type: "submit", className: "send", textContent: "Send" }); composer.append(attach, input, sendBtn); main.append(head, thread, composer); // load history const convo = state.me.userId + ":" + contact.userId; const history = await msgStore.forConvo(convo); for (const m of history) renderMessage(m); scrollThread(); composer.onsubmit = async (e) => { e.preventDefault(); const text = input.value.trim(); if (!text) return; input.value = ""; await sendText(contact, text); }; fileInput.onchange = async () => { const file = fileInput.files[0]; if (file) await sendMedia(contact, file); fileInput.value = ""; }; } function convoKey(otherUserId) { return state.me.userId + ":" + otherUserId; } 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; } async function sendText(contact, text) { // structured payload lets us carry media refs in the same E2E channel const payload = JSON.stringify({ k: "text", text }); await deliver(contact, payload); await persistAndRender(contact.userId, { dir: "out", kind: "text", text, ts: Date.now() }); } async function sendMedia(contact, file) { const buf = await file.arrayBuffer(); const enc = await C.encryptMedia(buf); const { key } = await api.uploadMedia(enc.ciphertext); const payload = JSON.stringify({ k: "media", key, keyB64: enc.keyB64, ivB64: enc.ivB64, name: file.name, type: file.type, size: file.size, }); await deliver(contact, payload); await persistAndRender(contact.userId, { dir: "out", kind: "media", name: file.name, mime: file.type, localUrl: URL.createObjectURL(file), ts: Date.now(), }); } async function deliver(contact, plaintextPayload) { let deviceIds = contact.deviceIds; if (!deviceIds || !deviceIds.length) deviceIds = await ensureRecipientDevices(contact); const messages = await C.encryptForUser(contact.userId, deviceIds, plaintextPayload); await api.send(contact.userId, messages); } // ---------- receiving ---------- function connectSocket() { if (state.socket) state.socket.close(); state.socket = new MailboxSocket(state.token, onEnvelope); state.socket.connect(); } async function onEnvelope(envelope) { // Look up / create the sender contact. let contact = await contactByUserId(envelope.fromUserId); const plaintext = await C.decryptEnvelope(envelope, envelope.fromUserId); let data; try { data = JSON.parse(plaintext); } catch { data = { k: "text", text: plaintext }; } if (!contact) { // Unknown sender — create a stub contact so the message has a home. contact = { userId: envelope.fromUserId, username: envelope.fromUserId.slice(0, 8), displayName: "New contact", deviceIds: [] }; await contacts.put(contact); await refreshContacts(); } if (data.k === "media") { await persistAndRender(envelope.fromUserId, { dir: "in", kind: "media", name: data.name, mime: data.type, mediaRef: { key: data.key, keyB64: data.keyB64, ivB64: data.ivB64 }, ts: envelope.sentAt || Date.now(), }); } else { await persistAndRender(envelope.fromUserId, { dir: "in", kind: "text", text: data.text, ts: envelope.sentAt || Date.now() }); } } async function contactByUserId(userId) { return contacts.get(userId); } // ---------- persistence + render ---------- async function persistAndRender(otherUserId, msg) { const record = { ...msg, convo: convoKey(otherUserId), otherUserId }; await msgStore.add(record); if (state.activeContact && state.activeContact.userId === otherUserId) { renderMessage(record); scrollThread(); } } function renderMessage(m) { const thread = $("#thread"); if (!thread) return; const bubble = el("div", { className: "bubble " + (m.dir === "out" ? "out" : "in") }); if (m.kind === "media") { if (m.mime && m.mime.startsWith("image/")) { const img = el("img", { className: "media-img", alt: m.name || "image" }); if (m.localUrl) img.src = m.localUrl; else if (m.mediaRef) loadMediaInto(img, m.mediaRef); bubble.append(img); } else { const link = el("a", { className: "media-file", textContent: "📄 " + (m.name || "file") }); if (m.mediaRef) makeDownloadable(link, m.mediaRef, m.name); bubble.append(link); } } else { bubble.append(el("div", { className: "text", textContent: m.text })); } bubble.append(el("div", { className: "time", textContent: fmtTime(m.ts) })); thread.append(bubble); } async function loadMediaInto(img, ref) { try { const ct = await api.fetchMedia(ref.key); const plain = await C.decryptMedia(ct, ref.keyB64, ref.ivB64); img.src = URL.createObjectURL(new Blob([plain])); } catch (e) { img.alt = "failed to load media"; } } async function makeDownloadable(link, ref, name) { link.onclick = async (e) => { e.preventDefault(); const ct = await api.fetchMedia(ref.key); const plain = await C.decryptMedia(ct, ref.keyB64, ref.ivB64); const url = URL.createObjectURL(new Blob([plain])); const a = el("a", { href: url, download: name || "file" }); a.click(); }; } function scrollThread() { const t = $("#thread"); if (t) t.scrollTop = t.scrollHeight; } function fmtTime(ts) { const d = new Date(ts); return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } // ---------- prekey replenishment ---------- async function maybeReplenishPreKeys() { try { const { count } = await api.keyCount(); if (count < 20) { const otks = await C.generateMoreOneTimePreKeys(100); await api.addPreKeys(otks); } } catch {} } function registerServiceWorker() { if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => {}); } } boot();