diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 08:41:13 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 08:41:13 -0700 |
| commit | 98d90827cbf581c4322232063b4f785f97bcb45b (patch) | |
| tree | 21e6cfcd45c5182172ea0e7ceff1c9b105ddff61 /src/calls.js | |
| parent | a9cdc8a6fd7abd38a185f9d8828531374307cf41 (diff) | |
| download | coinosv3-98d90827cbf581c4322232063b4f785f97bcb45b.tar.gz coinosv3-98d90827cbf581c4322232063b4f785f97bcb45b.zip | |
Voice and video calls in DMs (NIP-AC, as Amethyst, nospeak and Noscall speak it): 📞 and 🎥 in a conversation's header; a call from someone you follow or have written to rings over any page (tone, buzz, a notification when the app is out of sight) with Answer and Decline; mute, camera off, switch camera, minimize to a bar, hang up. WebRTC device to device (DTLS-SRTP), set up by signed messages in ephemeral gift wraps to the DM inboxes; TURN from coinos's live server (Cloudflare) for the connections that can't go direct. A remote signer puts the candidates inside the offer and answer instead of signing one each.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'src/calls.js')
| -rw-r--r-- | src/calls.js | 304 |
1 files changed, 304 insertions, 0 deletions
diff --git a/src/calls.js b/src/calls.js new file mode 100644 index 0000000..2447a2f --- /dev/null +++ b/src/calls.js @@ -0,0 +1,304 @@ +// 1:1 voice and video calls (NIP-AC, the draft Amethyst, nospeak and Noscall +// speak): WebRTC between the two devices, its setup carried over nostr. +// +// Every setup message (offer, answer, ICE candidate, hang-up, reject, +// renegotiate) is an event signed by its sender and wrapped for its one +// recipient in an ephemeral gift wrap (kind 21059: NIP-44 to them, under a +// throwaway key, no seal). Relays see only who it's for. The sound and +// picture go device to device, encrypted by WebRTC itself (DTLS-SRTP); the +// fingerprints that pin that encryption ride in the signed offer and answer, +// so nobody between can stand in for either side. When the two can't reach +// each other directly, TURN relays the (still encrypted) packets. +// +// No DOM here: the app hands in how to sign, send, fetch ICE servers, open +// the camera, and who may ring; it gets back the call's state to draw. +import { finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure'; +import * as nip44 from 'nostr-tools/nip44'; + +export const CALL_WRAP = 21059; +export const CALL = { OFFER: 25050, ANSWER: 25051, ICE: 25052, HANGUP: 25053, REJECT: 25054, RENEGOTIATE: 25055 }; +const KINDS = new Set(Object.values(CALL)); +const ALT = { + [CALL.OFFER]: 'WebRTC call offer', [CALL.ANSWER]: 'WebRTC call answer', [CALL.ICE]: 'WebRTC ICE candidate', + [CALL.HANGUP]: 'WebRTC call hangup', [CALL.REJECT]: 'WebRTC call rejection', [CALL.RENEGOTIATE]: 'WebRTC call renegotiation', +}; +export const STALE_S = 20; // older setup messages are a relay replaying the past +const RING_MS = 60_000; +const ENDED_MS = 2500; // how long "Call ended" stays up + +const now = () => Math.floor(Date.now() / 1000); +const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1]; +export const callIdOf = (ev) => tagOf(ev, 'call-id'); +export const uuid = () => (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() + : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => ((Math.random() * 16) | (c === 'x' ? 0 : 8)).toString(16))); + +// The unsigned setup message. +export function callEvent(kind, to, callId, content = '', extra = []) { + return { kind, created_at: now(), content, tags: [['p', to], ['call-id', callId], ['alt', ALT[kind]], ...extra] }; +} +// A signed one, wrapped for one recipient. +export function wrapCall(signed, receiverPk) { + const eph = generateSecretKey(); + return finalizeEvent({ + kind: CALL_WRAP, created_at: now(), tags: [['p', receiverPk]], + content: nip44.encrypt(JSON.stringify(signed), nip44.getConversationKey(eph, receiverPk)), + }, eph); +} +// A wrap to us opened: the setup message inside, its signature good, or null +// (not a call — Concord's presence beats ride the same kind — or not ours). +// `decryptFrom(peerPk, ciphertext)`: our key's NIP-44 decryption. +export async function openCall(wrap, decryptFrom) { + if (!wrap || wrap.kind !== CALL_WRAP) return null; + let inner; + try { inner = JSON.parse(await decryptFrom(wrap.pubkey, wrap.content)); } catch { return null; } + if (!inner || !KINDS.has(inner.kind) || !callIdOf(inner) || !verifyEvent(inner)) return null; + return inner; +} +// ICE candidates as the spec writes them: JSON of the three fields. +export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdpMid: c.sdpMid, sdpMLineIndex: c.sdpMLineIndex }); + +// The calls of one identity. env: +// me() our pubkey +// sign(template) → signed event (our key or signer) +// send(peerPk, signed) wrap and publish to them (the app picks the relays) +// iceServers() → RTCIceServer[] (TURN from coinos, STUN at least) +// media(type, facing) → MediaStream: microphone, and camera for 'video' +// mayRing(pk) whether this person may ring us (followed, or a conversation) +// changed() the call's state moved: draw it +// trickle() false for a signer that's slow or asks each time (a +// bunker, Amber): the candidates go inside the offer or +// answer instead of one signed message each +// RTCPeerConnection (tests may pass their own) +export function createCalls(env) { + const PC = env.RTCPeerConnection || globalThis.RTCPeerConnection; + let call = null; // { id, peer, type, dir, state, reason, pc, local, remote, offer, remoteSet, ice: [], at, connectedAt, muted, camOff, facing } + const seen = new Set(); + const early = new Map(); // peer -> candidates that came before their call did + let endTimer = null, ringTimer = null; + const changed = () => { try { env.changed(); } catch {} }; + + async function sendFor(c, kind, to, content = '', extra = []) { + const signed = await env.sign(callEvent(kind, to, c.id, content, extra)); + await env.send(to, signed); + } + + function stopMedia(c) { + for (const s of [c.local]) if (s) for (const t of s.getTracks()) { try { t.stop(); } catch {} } + try { c.pc && c.pc.close(); } catch {} + } + function end(reason, c = call) { + if (!c || c !== call || c.state === 'ended') return; + clearTimeout(ringTimer); + stopMedia(c); + c.state = 'ended'; c.reason = reason; c.endedAt = Date.now(); + changed(); + clearTimeout(endTimer); + endTimer = setTimeout(() => { if (call === c) { call = null; changed(); } }, ENDED_MS); + } + + async function connection(c) { + const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle' }); + c.pc = pc; + c.remote = new MediaStream(); + pc.ontrack = (e) => { for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t); changed(); }; + c.trickle = env.trickle ? env.trickle() !== false : true; + pc.onicecandidate = (e) => { if (c.trickle && e.candidate && call === c && c.state !== 'ended') sendFor(c, CALL.ICE, c.peer, candidateJson(e.candidate)).catch(() => {}); }; + pc.onconnectionstatechange = () => { + if (call !== c) return; + const s = pc.connectionState; + if (s === 'connected' && c.state !== 'connected') { c.state = 'connected'; c.connectedAt = Date.now(); clearTimeout(ringTimer); changed(); } + else if (s === 'failed') end('failed', c); + }; + for (const t of c.local.getTracks()) pc.addTrack(t, c.local); + return pc; + } + // without trickle: the description once its candidates are gathered (a few seconds at most) + async function described(c) { + const pc = c.pc; + if (!c.trickle && pc.iceGatheringState !== 'complete') { + await new Promise((resolve) => { + const done = () => { if (pc.iceGatheringState === 'complete') resolve(); }; + pc.addEventListener('icegatheringstatechange', done); + setTimeout(resolve, 4000); + }); + } + return pc.localDescription.sdp; + } + async function addCandidates(c) { + const list = [...(early.get(c.peer) || []), ...c.ice]; + early.delete(c.peer); c.ice = []; + for (const cand of list) { try { await c.pc.addIceCandidate(cand); } catch {} } + } + + // ---- what the person does + async function start(peer, type = 'voice') { + if (call && call.state !== 'ended') throw new Error('busy'); + clearTimeout(endTimer); + const c = call = { id: uuid(), peer, type, dir: 'out', state: 'starting', ice: [], at: Date.now(), muted: false, camOff: false, facing: 'user' }; + changed(); + try { + c.local = await env.media(type, c.facing); + if (call !== c) { stopMedia(c); return c; } + const pc = await connection(c); + await pc.setLocalDescription(await pc.createOffer()); + c.state = 'offering'; + changed(); + await sendFor(c, CALL.OFFER, peer, await described(c), [['call-type', type]]); + ringTimer = setTimeout(() => { if (call === c && c.state === 'offering') { sendFor(c, CALL.HANGUP, peer, 'timeout').catch(() => {}); end('no-answer', c); } }, RING_MS); + } catch (e) { + end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c); + } + return c; + } + async function accept() { + const c = call; + if (!c || c.state !== 'incoming') return; + clearTimeout(ringTimer); + c.state = 'connecting'; + changed(); + try { + c.local = await env.media(c.type, c.facing); + if (call !== c) { stopMedia(c); return; } + const pc = await connection(c); + await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); + c.remoteSet = true; + await addCandidates(c); + await pc.setLocalDescription(await pc.createAnswer()); + const signed = await env.sign(callEvent(CALL.ANSWER, c.peer, c.id, await described(c))); + await env.send(c.peer, signed); + // our other devices stop ringing: answered here + env.send(env.me(), await env.sign(callEvent(CALL.ANSWER, env.me(), c.id, ''))).catch(() => {}); + } catch (e) { + sendFor(c, CALL.HANGUP, c.peer, 'error').catch(() => {}); + end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c); + } + } + async function reject() { + const c = call; + if (!c || c.state !== 'incoming') return; + sendFor(c, CALL.REJECT, c.peer).catch(() => {}); + env.sign(callEvent(CALL.REJECT, env.me(), c.id)).then((s) => env.send(env.me(), s)).catch(() => {}); + end('declined', c); + } + function hangup() { + const c = call; + if (!c || c.state === 'ended') return; + if (c.state === 'incoming') { reject(); return; } + sendFor(c, CALL.HANGUP, c.peer).catch(() => {}); + end('hung-up', c); + } + function setMuted(on) { + if (!call || !call.local) return; + call.muted = on; + for (const t of call.local.getAudioTracks()) t.enabled = !on; + changed(); + } + function setCamOff(on) { + if (!call || !call.local) return; + call.camOff = on; + for (const t of call.local.getVideoTracks()) t.enabled = !on; + changed(); + } + // the other camera: the sender's track swapped, no new offer needed + async function flip() { + const c = call; + if (!c || !c.local || !c.local.getVideoTracks().length) return; + const facing = c.facing === 'user' ? 'environment' : 'user'; + try { + const fresh = await env.media('video-only', facing); + const track = fresh.getVideoTracks()[0]; + const sender = c.pc && c.pc.getSenders().find((s) => s.track && s.track.kind === 'video'); + if (sender) await sender.replaceTrack(track); + for (const old of c.local.getVideoTracks()) { c.local.removeTrack(old); old.stop(); } + track.enabled = !c.camOff; + c.local.addTrack(track); + c.facing = facing; + changed(); + } catch {} + } + + // ---- what arrives + async function handle(ev) { + if (!ev || seen.has(ev.id)) return; + seen.add(ev.id); + if (seen.size > 5000) seen.clear(); + if (now() - ev.created_at > STALE_S) return; + const me = env.me(); + const id = callIdOf(ev), from = ev.pubkey, self = from === me; + const c = call; + switch (ev.kind) { + case CALL.OFFER: { + if (self) return; + if (c && c.state !== 'ended') { + if (c.id !== id) { const s = await env.sign(callEvent(CALL.REJECT, from, id, 'busy')); env.send(from, s).catch(() => {}); } + return; + } + if (!(await env.mayRing(from))) return; + clearTimeout(endTimer); + const type = tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice'; + const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), muted: false, camOff: false, facing: 'user' }; + ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, RING_MS); + changed(); + return; + } + case CALL.ANSWER: { + if (!c || c.id !== id) return; + if (self) { if (c.state === 'incoming') end('answered-elsewhere', c); return; } // another of our devices took it + if (from !== c.peer) return; + if (c.state === 'offering') { + c.state = 'connecting'; changed(); + try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); c.remoteSet = true; await addCandidates(c); } + catch { end('error', c); } + } else if (c.renegotiating) { // their answer to our renegotiation + c.renegotiating = false; + try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); } catch {} + } + return; + } + case CALL.ICE: { + if (self) return; + let cand; try { cand = JSON.parse(ev.content); } catch { return; } + if (c && c.id === id && from === c.peer) { + if (c.pc && c.remoteSet) { try { await c.pc.addIceCandidate(cand); } catch {} } + else c.ice.push(cand); + } else { + // before the offer that opens its call: kept for when it does + const list = early.get(from) || []; list.push(cand); early.set(from, list.slice(-50)); + } + return; + } + case CALL.HANGUP: { + if (self || !c || c.id !== id || from !== c.peer) return; + end(c.state === 'incoming' ? 'missed' : 'peer-hung-up', c); + return; + } + case CALL.REJECT: { + if (!c || c.id !== id) return; + if (self) { if (c.state === 'incoming') end('declined-elsewhere', c); return; } + if (from !== c.peer) return; + end(ev.content === 'busy' ? 'busy' : 'peer-declined', c); + return; + } + case CALL.RENEGOTIATE: { + if (self || !c || c.id !== id || from !== c.peer || !c.pc) return; + try { + if (c.pc.signalingState === 'have-local-offer') { + if (me > from) return; // ours wins (the higher key), theirs is dropped + await c.pc.setLocalDescription({ type: 'rollback' }); + } + await c.pc.setRemoteDescription({ type: 'offer', sdp: ev.content }); + await c.pc.setLocalDescription(await c.pc.createAnswer()); + await sendFor(c, CALL.ANSWER, c.peer, c.pc.localDescription.sdp); + } catch {} + return; + } + } + } + + return { + get call() { return call; }, + start, accept, reject, hangup, setMuted, setCamOff, flip, handle, + // a sign-out or account switch: whatever was going on ends here + stop() { if (call && call.state !== 'ended') hangup(); call = null; early.clear(); }, + }; +} |
