diff --git a/public/index.html b/public/index.html
index 84ade36..7347c77 100644
--- a/public/index.html
+++ b/public/index.html
@@ -209,6 +209,28 @@
.chip-x { cursor: pointer; font-weight: 700; opacity: .8; }
.chip-x:hover { opacity: 1; }
+ /* ---------- calls ---------- */
+ .call-overlay {
+ position: fixed; inset: 0; z-index: 150; display: flex; flex-direction: column;
+ align-items: center; justify-content: center; gap: 22px;
+ background: radial-gradient(120% 120% at 50% 0%, #1a1030, #08080f);
+ animation: fade .25s ease;
+ }
+ .remote-vid { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: #000; }
+ .local-vid { position: absolute; right: 18px; bottom: 108px; width: 132px; height: 180px; object-fit: cover; border-radius: 16px; border: 2px solid rgba(255,255,255,.25); box-shadow: var(--shadow); z-index: 2; background: #111; }
+ .call-avatar-wrap { z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 16px; }
+ .call-avatar { width: 120px !important; height: 120px !important; border-radius: 40px !important; font-size: 46px !important; box-shadow: 0 0 0 0 rgba(124,92,255,.5); animation: pulse 2s ease-in-out infinite; }
+ @keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(124,92,255,.45); } 50% { box-shadow: 0 0 0 22px rgba(124,92,255,0); } }
+ .call-peer { font-size: 22px; font-weight: 700; }
+ .call-status { z-index: 2; color: var(--muted); font-size: 15px; font-weight: 500; }
+ .call-controls { z-index: 2; display: flex; gap: 18px; margin-bottom: 6px; }
+ .call-btn { width: 62px; height: 62px; border-radius: 50%; background: rgba(255,255,255,.12); backdrop-filter: blur(10px); color: #fff; font-size: 24px; display: grid; place-items: center; border: 1px solid var(--stroke-2); }
+ .call-btn:hover { background: rgba(255,255,255,.2); }
+ .call-btn.active { background: #fff; color: #111; }
+ .call-btn.hangup { background: linear-gradient(135deg, #ff5470, #c81e3a); transform: rotate(133deg); }
+ .call-btn.hangup:hover { box-shadow: 0 8px 24px rgba(200,30,58,.5); }
+ .call-btn.accept { background: linear-gradient(135deg, #22d37a, #12995a); }
+
/* ---------- toasts ---------- */
#toasts { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 200; display: flex; flex-direction: column; gap: 8px; align-items: center; }
.toast { padding: 12px 20px; border-radius: 14px; font-size: 14px; box-shadow: var(--shadow); max-width: 90vw; backdrop-filter: blur(20px); border: 1px solid var(--stroke-2); animation: rise .25s ease; }
diff --git a/public/js/app.js b/public/js/app.js
index 525dd82..d650066 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -5,6 +5,7 @@ import * as C from "./crypto.js";
import { useAccount, contacts, groups, messages as msgStore } 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) => {
@@ -142,10 +143,32 @@ async function enterApp() {
await syncGroups();
await refreshChats();
connectSocket();
+ initCalls();
maybeReplenishPreKeys();
registerServiceWorker();
}
+// ---------- 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;
@@ -649,6 +672,71 @@ function toast(message, kind = "error") {
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) {
@@ -685,6 +773,11 @@ async function openChat(chat) {
);
if (chat.type === "group") {
head.append(el("button", { className: "head-action", title: "Group info", textContent: "β", onclick: () => showGroupInfo(chat) }));
+ } else {
+ head.append(
+ 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" });
@@ -834,6 +927,16 @@ async function onEnvelope(envelope) {
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;
+ }
+
const msg = data.k === "media"
? { dir: "in", kind: "media", name: data.name, mime: data.type, mediaRef: { key: data.key, keyB64: data.keyB64, ivB64: data.ivB64 }, senderId: envelope.fromUserId, ts: envelope.sentAt || Date.now() }
: { dir: "in", kind: "text", text: data.text, senderId: envelope.fromUserId, ts: envelope.sentAt || Date.now() };
diff --git a/public/js/call.js b/public/js/call.js
new file mode 100644
index 0000000..2d80bab
--- /dev/null
+++ b/public/js/call.js
@@ -0,0 +1,209 @@
+// 1:1 encrypted voice/video calls.
+//
+// Signaling (SDP offer/answer + ICE candidates) travels E2E-encrypted through
+// the normal message relay as {k:"call"} payloads β the server never sees it in
+// the clear. The media itself is peer-to-peer and encrypted by WebRTC's
+// mandatory DTLS-SRTP, so audio/video is end-to-end too and never touches the
+// server (STUN is only used to discover network paths).
+
+const ICE_SERVERS = [
+ { urls: "stun:stun.l.google.com:19302" },
+ { urls: "stun:stun.cloudflare.com:3478" },
+];
+
+export class CallManager {
+ // sendSignal(peerUserId, obj) -> encrypts & relays a {k:"call"} payload.
+ // onState(state) -> drives the UI. randomId() -> string (no Math.random in some ctxs).
+ constructor({ sendSignal, onState }) {
+ this.sendSignal = sendSignal;
+ this.onState = onState;
+ this.reset();
+ }
+
+ reset() {
+ this.pc = null;
+ this.localStream = null;
+ this.remoteStream = null;
+ this.peerUserId = null;
+ this.peerName = null;
+ this.callId = null;
+ this.callType = null; // "audio" | "video"
+ this.role = null; // "caller" | "callee"
+ this.phase = "idle"; // idle | outgoing | incoming | connected | ended
+ this.pendingOffer = null;
+ this.pendingIce = [];
+ this.muted = false;
+ this.cameraOff = false;
+ this.startedAt = 0;
+ }
+
+ emit() {
+ this.onState({
+ phase: this.phase,
+ peerName: this.peerName,
+ callType: this.callType,
+ localStream: this.localStream,
+ remoteStream: this.remoteStream,
+ muted: this.muted,
+ cameraOff: this.cameraOff,
+ startedAt: this.startedAt,
+ role: this.role,
+ });
+ }
+
+ busy() {
+ return this.phase !== "idle" && this.phase !== "ended";
+ }
+
+ newPc() {
+ const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
+ pc.onicecandidate = (e) => {
+ if (e.candidate) {
+ this.sendSignal(this.peerUserId, { k: "call", callId: this.callId, sub: "ice", candidate: e.candidate });
+ }
+ };
+ pc.ontrack = (e) => {
+ this.remoteStream = e.streams[0];
+ this.emit();
+ };
+ pc.onconnectionstatechange = () => {
+ if (pc.connectionState === "connected" && this.phase !== "connected") {
+ this.phase = "connected";
+ if (!this.startedAt) this.startedAt = Date.now();
+ this.emit();
+ }
+ if (["failed", "disconnected", "closed"].includes(pc.connectionState) && this.phase === "connected") {
+ this.hangup(true);
+ }
+ };
+ return pc;
+ }
+
+ async getMedia(video) {
+ return navigator.mediaDevices.getUserMedia({
+ audio: true,
+ video: video ? { facingMode: "user" } : false,
+ });
+ }
+
+ // ---- outgoing ----
+ async startCall(peerUserId, peerName, video, callId) {
+ if (this.busy()) return;
+ this.peerUserId = peerUserId;
+ this.peerName = peerName;
+ this.callType = video ? "video" : "audio";
+ this.role = "caller";
+ this.callId = callId;
+ this.phase = "outgoing";
+ this.emit();
+ try {
+ this.localStream = await this.getMedia(video);
+ } catch {
+ this.phase = "ended";
+ this.emit();
+ throw new Error("Camera/microphone permission denied");
+ }
+ this.pc = this.newPc();
+ for (const t of this.localStream.getTracks()) this.pc.addTrack(t, this.localStream);
+ const offer = await this.pc.createOffer();
+ await this.pc.setLocalDescription(offer);
+ this.sendSignal(peerUserId, { k: "call", callId: this.callId, sub: "offer", callType: this.callType, sdp: offer, peerName: "caller" });
+ this.emit();
+ }
+
+ // ---- incoming signal handling ----
+ async onSignal(fromUserId, data) {
+ if (data.sub === "offer") {
+ if (this.busy()) {
+ // already on a call β auto-decline
+ this.sendSignal(fromUserId, { k: "call", callId: data.callId, sub: "bye", reason: "busy" });
+ return;
+ }
+ this.peerUserId = fromUserId;
+ this.callId = data.callId;
+ this.callType = data.callType || "audio";
+ this.role = "callee";
+ this.phase = "incoming";
+ this.pendingOffer = data.sdp;
+ this.emit();
+ return;
+ }
+ if (!this.callId || data.callId !== this.callId) return;
+
+ if (data.sub === "answer") {
+ if (this.pc && !this.pc.currentRemoteDescription) {
+ await this.pc.setRemoteDescription(data.sdp);
+ await this.flushIce();
+ }
+ } else if (data.sub === "ice") {
+ if (this.pc && this.pc.remoteDescription) {
+ try { await this.pc.addIceCandidate(data.candidate); } catch {}
+ } else {
+ this.pendingIce.push(data.candidate);
+ }
+ } else if (data.sub === "bye") {
+ this.hangup(true);
+ }
+ }
+
+ async flushIce() {
+ for (const c of this.pendingIce) {
+ try { await this.pc.addIceCandidate(c); } catch {}
+ }
+ this.pendingIce = [];
+ }
+
+ // ---- accept an incoming call ----
+ async accept() {
+ if (this.phase !== "incoming") return;
+ try {
+ this.localStream = await this.getMedia(this.callType === "video");
+ } catch {
+ this.decline();
+ throw new Error("Camera/microphone permission denied");
+ }
+ this.pc = this.newPc();
+ for (const t of this.localStream.getTracks()) this.pc.addTrack(t, this.localStream);
+ await this.pc.setRemoteDescription(this.pendingOffer);
+ await this.flushIce();
+ const answer = await this.pc.createAnswer();
+ await this.pc.setLocalDescription(answer);
+ this.sendSignal(this.peerUserId, { k: "call", callId: this.callId, sub: "answer", sdp: answer });
+ this.phase = "connected";
+ if (!this.startedAt) this.startedAt = Date.now();
+ this.emit();
+ }
+
+ decline() {
+ if (this.peerUserId) this.sendSignal(this.peerUserId, { k: "call", callId: this.callId, sub: "bye", reason: "declined" });
+ this.teardown();
+ }
+
+ hangup(silent) {
+ if (!silent && this.peerUserId) {
+ this.sendSignal(this.peerUserId, { k: "call", callId: this.callId, sub: "bye" });
+ }
+ this.teardown();
+ }
+
+ teardown() {
+ if (this.pc) { try { this.pc.close(); } catch {} }
+ if (this.localStream) this.localStream.getTracks().forEach((t) => t.stop());
+ this.phase = "ended";
+ this.emit();
+ this.reset();
+ this.emit();
+ }
+
+ toggleMute() {
+ this.muted = !this.muted;
+ if (this.localStream) this.localStream.getAudioTracks().forEach((t) => (t.enabled = !this.muted));
+ this.emit();
+ }
+
+ toggleCamera() {
+ this.cameraOff = !this.cameraOff;
+ if (this.localStream) this.localStream.getVideoTracks().forEach((t) => (t.enabled = !this.cameraOff));
+ this.emit();
+ }
+}
diff --git a/public/sw.js b/public/sw.js
index 1fa6a60..4c32a8a 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,7 +1,7 @@
// Service worker: offline app shell + Web Push wake-up.
// It never touches message plaintext β decryption happens only in the page.
-const CACHE = "cipher-v5";
+const CACHE = "cipher-v6";
const SHELL = [
"/",
"/index.html",
@@ -11,6 +11,7 @@ const SHELL = [
"/js/crypto.js",
"/js/store.js",
"/js/accounts.js",
+ "/js/call.js",
"/js/vendor/libsignal.js",
"/js/vendor/qrcode.js",
"/icons/icon.svg",