cipher/public/js/app.js
maverick fc3f79379e Fix New chat button: replace window.prompt with in-app modal
- window.prompt/alert are suppressed in Brave and installed PWAs, so the
  button appeared to do nothing. Replace with a proper modal (inline error,
  Enter/Escape, click-outside to close).
- Mobile nav: sidebar was fully hidden (button unreachable); now sidebar shows
  by default and a back button toggles between list and thread.
- Bump SW cache v1->v2 so the new shell actually ships past the cache.

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

448 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// Cipher client orchestration: auth, contacts, chat, media. Ties crypto + api + UI.
import { api, setToken, MailboxSocket } from "./api.js";
import * as C from "./crypto.js";
import { 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);
}
}
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 refreshContacts();
close();
await openChat(contact);
} catch (ex) {
setError(ex.message === "not found" ? "No user found: @" + uname : ex.message);
}
},
});
}
// 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();
};
}
// ---------- chat view ----------
async function openChat(contact) {
state.activeContact = contact;
$(".shell")?.classList.add("chat-open");
const main = $("#main");
main.innerHTML = "";
const back = el("button", {
className: "back-btn",
textContent: "",
title: "Back",
onclick: () => {
state.activeContact = null;
$(".shell")?.classList.remove("chat-open");
},
});
const head = el("div", { className: "chat-head" });
head.append(
back,
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();