cipher/public/js/api.js
maverick c48f9f4006 Encrypted group chats + major UI redesign
Groups (E2E via pairwise fan-out — sender encrypts each message separately to
every member with existing Signal sessions; server holds only name+membership):
- migration 0003: groups + group_members; routes/groups.js (create/list/get/add/leave)
- client: unified DM+group chat model, group create modal w/ member chips,
  fan-out deliver, groupId payload routing on receive, lazy group fetch,
  sender names in group bubbles, group info + leave

UI overhaul — glassmorphism redesign:
- animated aurora backdrop, blurred glass panels, violet→cyan gradient brand
- color-hashed gradient avatars (per name), group avatars
- message bubbles with rise animation + tails, gradient outgoing bubbles
- polished auth card w/ gradient border, pill actions, modal pop-in, toast styling
- responsive: full-width thread on mobile with back button

Verified live: 3-user group message fans out and both members decrypt.
SW cache v4->v5.

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

127 lines
4.2 KiB
JavaScript

// Thin client for the Cipher Worker API + the mailbox WebSocket.
let token = null;
export function setToken(t) {
token = t;
}
async function req(method, path, body, raw = false) {
const headers = {};
if (token) headers["Authorization"] = "Bearer " + token;
const opts = { method, headers };
if (body !== undefined) {
if (raw) {
opts.body = body; // ArrayBuffer / Blob
} else {
headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
}
const res = await fetch(path, opts);
if (!res.ok) {
let msg = res.statusText;
try {
msg = (await res.json()).error || msg;
} catch {}
throw new Error(msg);
}
const ct = res.headers.get("Content-Type") || "";
return ct.includes("application/json") ? res.json() : res;
}
export const api = {
register: (payload) => req("POST", "/api/auth/register", payload),
login: (payload) => req("POST", "/api/auth/login", payload),
lookup: (username) => req("GET", "/api/users/" + encodeURIComponent(username)),
bundle: (userId) => req("GET", "/api/keys/bundle/" + userId),
keyCount: () => req("GET", "/api/keys/count"),
addPreKeys: (oneTimePreKeys) => req("POST", "/api/keys/prekeys", { oneTimePreKeys }),
resetKeys: (signedPreKey, oneTimePreKeys) =>
req("POST", "/api/keys/reset", { signedPreKey, oneTimePreKeys }),
send: (toUserId, messages) => req("POST", "/api/messages/send", { toUserId, messages }),
// recovery backup (server stores/serves only ciphertext)
putBackup: (payload) => req("POST", "/api/backup", { payload }),
getBackup: () => req("GET", "/api/backup"),
backupExists: () => req("GET", "/api/backup/exists"),
// QR provisioning relay
provisionNew: (ephemeralPub) => req("POST", "/api/provision/new", { ephemeralPub }),
provisionGet: (id) => req("GET", "/api/provision/" + id),
provisionComplete: (id, payload) => req("POST", "/api/provision/" + id + "/complete", { payload }),
// groups (metadata only; messages are E2E fan-out)
createGroup: (name, memberUserIds) => req("POST", "/api/groups", { name, memberUserIds }),
listGroups: () => req("GET", "/api/groups"),
getGroup: (id) => req("GET", "/api/groups/" + id),
addGroupMember: (id, userId) => req("POST", "/api/groups/" + id + "/members", { userId }),
leaveGroup: (id) => req("POST", "/api/groups/" + id + "/leave", {}),
uploadMedia: async (ciphertext) => {
const res = await req("POST", "/api/media", ciphertext, true);
return res.json ? res.json() : res;
},
fetchMedia: async (key) => {
const res = await req("GET", "/api/media/" + key);
return res.arrayBuffer();
},
};
// ---- WebSocket with auto-reconnect + ack ----
export class MailboxSocket {
constructor(tok, onEnvelope) {
this.tok = tok;
this.onEnvelope = onEnvelope;
this.ws = null;
this.backoff = 1000;
this.closed = false;
this.pending = []; // envelope ids awaiting ack batch
}
connect() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
this.ws = new WebSocket(`${proto}//${location.host}/api/ws?token=${encodeURIComponent(this.tok)}`);
this.ws.onopen = () => {
this.backoff = 1000;
this.ws.send(JSON.stringify({ t: "pull" }));
};
this.ws.onmessage = async (ev) => {
let msg;
try {
msg = JSON.parse(ev.data);
} catch {
return;
}
if (msg.t === "message" && msg.envelope) {
try {
await this.onEnvelope(msg.envelope);
this.ack(msg.envelope.id);
} catch (e) {
console.error("envelope handling failed", e);
}
}
};
this.ws.onclose = () => {
if (this.closed) return;
setTimeout(() => this.connect(), this.backoff);
this.backoff = Math.min(this.backoff * 2, 30000);
};
this.ws.onerror = () => this.ws && this.ws.close();
}
ack(id) {
// Batch acks briefly to reduce chatter.
this.pending.push(id);
clearTimeout(this._ackTimer);
this._ackTimer = setTimeout(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.pending.length) {
this.ws.send(JSON.stringify({ t: "ack", ids: this.pending }));
this.pending = [];
}
}, 150);
}
close() {
this.closed = true;
if (this.ws) this.ws.close();
}
}