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",