Add WebRTC E2E voice/video calling (1:1)

- call.js: CallManager — RTCPeerConnection lifecycle, offer/answer/ICE/bye state
  machine, mute/camera toggles, hangup. STUN (Google + Cloudflare) for NAT.
- Signaling (SDP + ICE) rides the existing E2E message relay as {k:"call"}
  payloads — never seen by the server in clear. Media is P2P, encrypted by
  WebRTC's mandatory DTLS-SRTP, so audio/video is end-to-end and never hits R2.
- app.js: call buttons (📞/🎥) in DM headers, incoming/outgoing/connected call
  overlay with video tiles + PiP, mute/camera/hangup, ring pulse, duration timer.
- Verified signaling state machine (mocked WebRTC): both sides reach 'connected'.
  Group calls not supported yet (needs SFU). SW cache v5->v6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
maverick 2026-07-22 13:21:52 +10:00
parent c48f9f4006
commit 67367d5e83
4 changed files with 336 additions and 1 deletions

View file

@ -209,6 +209,28 @@
.chip-x { cursor: pointer; font-weight: 700; opacity: .8; } .chip-x { cursor: pointer; font-weight: 700; opacity: .8; }
.chip-x:hover { opacity: 1; } .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 ---------- */
#toasts { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 200; display: flex; flex-direction: column; gap: 8px; align-items: center; } #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; } .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; }

View file

@ -5,6 +5,7 @@ import * as C from "./crypto.js";
import { useAccount, contacts, groups, messages as msgStore } from "./store.js"; import { useAccount, contacts, groups, messages as msgStore } from "./store.js";
import * as accounts from "./accounts.js"; import * as accounts from "./accounts.js";
import qrcode from "./vendor/qrcode.js"; import qrcode from "./vendor/qrcode.js";
import { CallManager } from "./call.js";
const $ = (sel) => document.querySelector(sel); const $ = (sel) => document.querySelector(sel);
const el = (tag, props = {}, ...children) => { const el = (tag, props = {}, ...children) => {
@ -142,10 +143,32 @@ async function enterApp() {
await syncGroups(); await syncGroups();
await refreshChats(); await refreshChats();
connectSocket(); connectSocket();
initCalls();
maybeReplenishPreKeys(); maybeReplenishPreKeys();
registerServiceWorker(); 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. // Deterministic vibrant colour from a string — used for avatars.
function hashHue(str) { function hashHue(str) {
let h = 0; let h = 0;
@ -649,6 +672,71 @@ function toast(message, kind = "error") {
setTimeout(() => t.remove(), 4000); 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 ---------- // ---------- chat view ----------
// ---- conversation helpers (unify DMs + groups) ---- // ---- conversation helpers (unify DMs + groups) ----
function chatConvo(chat) { function chatConvo(chat) {
@ -685,6 +773,11 @@ async function openChat(chat) {
); );
if (chat.type === "group") { if (chat.type === "group") {
head.append(el("button", { className: "head-action", title: "Group info", textContent: "ⓘ", onclick: () => showGroupInfo(chat) })); 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" }); const thread = el("div", { className: "thread", id: "thread" });
@ -834,6 +927,16 @@ async function onEnvelope(envelope) {
let data; let data;
try { data = JSON.parse(plaintext); } catch { data = { k: "text", text: plaintext }; } 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" 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: "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() }; : { dir: "in", kind: "text", text: data.text, senderId: envelope.fromUserId, ts: envelope.sentAt || Date.now() };

209
public/js/call.js Normal file
View file

@ -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();
}
}

View file

@ -1,7 +1,7 @@
// Service worker: offline app shell + Web Push wake-up. // Service worker: offline app shell + Web Push wake-up.
// It never touches message plaintext — decryption happens only in the page. // It never touches message plaintext — decryption happens only in the page.
const CACHE = "cipher-v5"; const CACHE = "cipher-v6";
const SHELL = [ const SHELL = [
"/", "/",
"/index.html", "/index.html",
@ -11,6 +11,7 @@ const SHELL = [
"/js/crypto.js", "/js/crypto.js",
"/js/store.js", "/js/store.js",
"/js/accounts.js", "/js/accounts.js",
"/js/call.js",
"/js/vendor/libsignal.js", "/js/vendor/libsignal.js",
"/js/vendor/qrcode.js", "/js/vendor/qrcode.js",
"/icons/icon.svg", "/icons/icon.svg",