From 98d90827cbf581c4322232063b4f785f97bcb45b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 18 Oct 2026 08:41:13 -0700 Subject: 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. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg --- src/app.js | 4 +- src/calls.js | 304 +++++++++++++++++++++++++++++++++++++++++++++++ src/features/messages.js | 216 ++++++++++++++++++++++++++++++++- src/i18n.js | 26 ++++ src/style.css | 29 +++++ 5 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 src/calls.js (limited to 'src') diff --git a/src/app.js b/src/app.js index 079cf7b..88dfb76 100644 --- a/src/app.js +++ b/src/app.js @@ -853,7 +853,9 @@ function renderInner() { if (bottomNav) bottomNav.prepend(h('div', { class: 'app-nav-wide app-nav-brand' }, brandLogo())); // the wallet beside the feed and chats on a wide screen (its own page needs no twin) const side = bottomNav && featScreen && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null; - morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : [])]); + // a call, over whatever page is up (the messages feature's) + const callOv = activeAccount() ? featureHook('callOverlay') : null; + morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]); promoteLazySrc(root); // sources on the nodes that actually made it into the page try { document.documentElement.classList.toggle('no-scroll', !!(ui.lightbox || ui.mediaPager)); } catch {} if (fpath) { 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(); }, + }; +} diff --git a/src/features/messages.js b/src/features/messages.js index 33ba903..57acc3b 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -52,6 +52,7 @@ import { animateZap, warmZapSound } from '../zap-animation.js'; import { SIGNER_SILENT } from '../dm.js'; import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js'; import { dlog } from '../debug.js'; +import { createCalls, openCall, wrapCall, CALL_WRAP } from '../calls.js'; import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus, isLiveNow, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js'; import { qrSvg } from '../qr.js'; @@ -3255,6 +3256,14 @@ export function messagesFeature(ctx) { return true; } dmSub(DM_RELAYS); + // calls ringing in (ephemeral wraps: only what comes while we listen) + const callSub = (relays, authed) => { + const f = { kinds: [CALL_WRAP], '#p': pks, since: Math.floor(Date.now() / 1000) - 20 }; + const on = (w) => { onCallWrap(w).catch(() => {}); }; + const u = authed ? subscribeAuthed(relays, f, on, { onauth: relayAuth, onclose: noteSubClosed }) : subscribeOn(relays, f, on, { onclose: noteSubClosed }); + allUnsubs.push(u); dmUnsubs.push(u); + }; + callSub(DM_RELAYS, false); // Senders deliver to the relays our kind-10050 advertises — a list other // clients may have published with relays beyond our defaults. Read those // too, or a compliant sender's DM lands somewhere we never look (a reply @@ -3269,6 +3278,7 @@ export function messagesFeature(ctx) { // in Adam's list, while a cap of three read the first three) .slice(0, 8); ownInbox = extras; + if (extras.length && dmStarted) callSub(extras, true); // inbox relays are often NIP-42 gated: authenticate as the identity if (extras.length && dmStarted) { const u = subscribeAuthed(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { @@ -9024,6 +9034,194 @@ export function messagesFeature(ctx) { h('div', { class: 'small faint' }, t('goLiveHint')))); } + // ---- calls (NIP-AC: src/calls.js) ------------------------------------------ + // A 📞 or 🎥 in a conversation's header calls that person; a call from + // someone you follow (or talk to) rings over whatever page is up. The setup + // goes as ephemeral gift wraps to their DM inboxes and ours; TURN comes from + // coinos's live server (Cloudflare's), for the connections that can't go direct. + const liveBase = () => { + const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname); + return local ? location.origin : APP_BASE; + }; + let calls = null; + const callInbox = new Map(); // peer -> their inbox relays, for this session + async function inboxFor(pk) { + if (callInbox.has(pk)) return callInbox.get(pk); + const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => [])) + .then((l) => [...new Set([...DM_RELAYS, ...(l || []).filter((r) => /^wss:\/\//i.test(r)).slice(0, 4)])]); + callInbox.set(pk, p); + return p; + } + async function signAsMe(tmpl) { + const id = await requireIdentity(); + return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl); + } + let iceCache = null; // { servers, until } + async function callIce() { + if (iceCache && Date.now() < iceCache.until) return iceCache.servers; + const STUN = [{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302'] }]; + try { + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/turn'], ['method', 'GET']] }); + const r = await fetch(liveBase() + '/live/turn', { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) }); + const j = await r.json(); + if (!Array.isArray(j.iceServers) || !j.iceServers.length) return STUN; + iceCache = { servers: j.iceServers, until: Date.now() + Math.max(60, (j.ttl || 600) - 600) * 1000 }; + return j.iceServers; + } catch { return STUN; } + } + function callMedia(type, facing = 'user') { + const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } }; + const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; + if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video }); + return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio }); + } + // who may ring: someone you follow, or someone you've written to + const mayRing = (pk) => !hiddenPk(pk) && (followsNow().set.has(pk) + || [...(threads.get(pk) || new Map()).values()].some((m) => m.mine)); + function callsNow() { + if (calls) return calls; + calls = createCalls({ + me: () => dmSubKeys()[0], + sign: signAsMe, + send: async (pk, signed) => { + const ok = await publishOn(await inboxFor(pk), wrapCall(signed, pk), { onauth: relayAuth }); + if (!ok) throw new Error('not delivered'); + }, + iceServers: callIce, + media: callMedia, + mayRing: async (pk) => mayRing(pk), + changed: () => { callRing(); render(); }, + // a key of our own signs each candidate at once; a remote signer gets + // them all inside the offer or answer instead + trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; }, + }); + return calls; + } + // a wrap addressed to us: opened by whichever of our keys it's for + async function onCallWrap(wrap) { + for (const d of dmDecryptors()) { + const dec = d instanceof Uint8Array ? async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(d, pk)) : (pk, ct) => d.decryptFrom(pk, ct); + const inner = await openCall(wrap, dec).catch(() => null); + if (inner) { profileOf(inner.pubkey); await callsNow().handle(inner); return; } + } + } + function startCall(peer, type) { + if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; } + const cur = callsNow().call; + if (cur && cur.state !== 'ended') { ui.callMin = false; render(); return; } + ui.callMin = false; + callsNow().start(peer, type).catch(() => {}); + } + + // The ring: a tone (two short bursts every few seconds) and the phone's + // buzz for a call coming in, a softer one while ours rings them; and, with + // the app out of sight, a notification. + let ringCtx = null, ringTimer = null, ringFor = null; + function tone(ctx, freqs, at, len, vol) { + for (const f of freqs) { + const o = ctx.createOscillator(), g = ctx.createGain(); + o.frequency.value = f; o.connect(g); g.connect(ctx.destination); + g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(vol, at + 0.02); g.gain.setValueAtTime(vol, at + len - 0.05); g.gain.linearRampToValueAtTime(0, at + len); + o.start(at); o.stop(at + len + 0.05); + } + } + function callRing() { + const c = calls && calls.call; + const want = c && (c.state === 'incoming' || c.state === 'offering') ? c.id + ':' + c.state : null; + if (want === ringFor) return; + ringFor = want; + clearInterval(ringTimer); ringTimer = null; + try { navigator.vibrate && navigator.vibrate(0); } catch {} + if (!want) return; + const incoming = c.state === 'incoming'; + const beat = () => { + try { + ringCtx = ringCtx || new (window.AudioContext || window.webkitAudioContext)(); + if (ringCtx.state === 'suspended') ringCtx.resume().catch(() => {}); + const t0 = ringCtx.currentTime + 0.05; + if (incoming) { tone(ringCtx, [440, 480], t0, 0.4, 0.12); tone(ringCtx, [440, 480], t0 + 0.6, 0.4, 0.12); } + else tone(ringCtx, [440, 480], t0, 1.2, 0.05); // the ringback, heard by the caller + } catch {} + if (incoming) { try { navigator.vibrate && navigator.vibrate([400, 200, 400]); } catch {} } + }; + beat(); + ringTimer = setInterval(beat, incoming ? 3000 : 4000); + if (incoming && typeof document !== 'undefined' && document.hidden) { + try { + if (Notification.permission === 'granted') navigator.serviceWorker?.ready.then((r) => r.showNotification(t('callIncomingFrom', { name: displayName(c.peer) }), { + body: t(c.type === 'video' ? 'callIncomingVideo' : 'callIncomingVoice'), tag: 'call', renotify: true, requireInteraction: true, + })).catch(() => {}); + } catch {} + } + } + + // the call's sound and pictures into their elements, after each paint + function mountCallMedia() { + const c = calls && calls.call; + const put = (sel, stream) => { + const el = document.querySelector(sel); + if (el && stream && el.srcObject !== stream) { el.srcObject = stream; el.play().catch(() => {}); } + }; + if (!c) return; + put('audio.call-audio', c.remote); + put('video.call-remote', c.remote); + put('video.call-local', c.local); + } + const callReason = (r) => t({ 'no-answer': 'callNoAnswer', 'peer-declined': 'callDeclined', busy: 'callBusy', failed: 'callFailed', error: 'callFailed', + 'no-permission': 'callNoPermission', missed: 'callMissed', 'answered-elsewhere': 'callAnsweredElsewhere', 'declined-elsewhere': 'callDeclinedElsewhere' }[r] || 'callEnded'); + const CALL_ICONS = { + phone: '', + video: '', + mic: '', + micOff: '', + camOff: '', + flip: '', + hang: '', + min: '', + }; + const callIcon = (k) => h('span', { style: 'display:flex', html: CALL_ICONS[k] }); + // The call, over any page: who, how it's going, and its buttons; minimized, + // a bar across the top to come back by. + function callOverlay() { + const c = calls && calls.call; + if (!c) return null; + queueMicrotask(mountCallMedia); + if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock + const video = c.type === 'video'; + const remoteVideo = video && c.remote && c.remote.getVideoTracks().length > 0; + const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' }); + const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice') + : c.state === 'starting' || c.state === 'offering' ? t('callCalling') + : c.state === 'connecting' ? t('callConnecting') + : c.state === 'connected' ? clockOf((Date.now() - c.connectedAt) / 1000) + : callReason(c.reason); + if (ui.callMin && c.state !== 'incoming' && c.state !== 'ended') { + return h('div', { class: 'call-overlay call-min', 'data-key': 'call-overlay' }, sound, + h('button', { class: 'call-min-bar', onClick: () => { ui.callMin = false; render(); } }, + h('span', { class: 'go-live-dot' }), h('span', { class: 'call-min-name' }, displayName(c.peer)), h('span', { class: 'call-min-status' }, status))); + } + const btn = (cls, icon, label, onClick, on = false) => h('button', { class: 'call-btn ' + cls + (on ? ' on' : ''), title: label, 'aria-label': label, onClick }, callIcon(icon)); + return h('div', { class: 'call-overlay' + (remoteVideo && c.state === 'connected' ? ' has-video' : ''), 'data-key': 'call-overlay' }, + sound, + video ? h('video', { class: 'call-remote', 'data-key': 'call-remote:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null, + h('div', { class: 'call-who' }, + avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false), + h('div', { class: 'call-name' }, displayName(c.peer)), + h('div', { class: 'call-status' }, status)), + video && c.local ? h('video', { class: 'call-local' + (c.facing === 'user' ? ' mirror' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null, + c.state === 'incoming' + ? h('div', { class: 'call-bar' }, + btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()), + btn('call-accept', video ? 'video' : 'phone', t('callAccept'), () => callsNow().accept())) + : c.state === 'ended' ? null + : h('div', { class: 'call-bar' }, + btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted), + video ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null, + video ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null, + btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }), + btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup()))); + } + // A quote inside a quote: one line of who and what, never a third level // of cards (a bare "view note" link said nothing about where it leads). function quoteMini(ref) { @@ -16180,12 +16378,16 @@ export function messagesFeature(ctx) { h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => openProfile(peer) }, h('div', { class: 'chat-title' }, displayName(peer)), h('div', { class: 'muted small' }, t('msgDmEncrypted'))), - // Only where there is a choice: this person can be reached two ways. - // The pill says which way the next message goes; tapping it pins one. - choice ? h('button', { - class: 'btn-sm route-pill', style: 'margin-left:auto;flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true', - onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); }, - }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null), + h('div', { class: 'row gap6', style: 'margin-left:auto;flex-shrink:0;align-items:center' }, + // a call: voice or video, to this one person + isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'voice', title: t('callVoice'), 'aria-label': t('callVoice'), onClick: () => startCall(peer, 'voice') }, callIcon('phone')), + isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'video', title: t('callVideo'), 'aria-label': t('callVideo'), onClick: () => startCall(peer, 'video') }, callIcon('video')), + // Only where there is a choice: this person can be reached two ways. + // The pill says which way the next message goes; tapping it pins one. + choice ? h('button', { + class: 'btn-sm route-pill', style: 'flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true', + onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); }, + }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null)), h('div', { class: 'chat-log', onScroll: (e) => { @@ -16876,6 +17078,7 @@ export function messagesFeature(ctx) { return def && adhocFeeds.has(def.id) ? feedPath(def) : null; }, avatarNode(pk, cls) { return avatar(pk, cls || 'chat-avatar mini', false); }, + callOverlay() { return callOverlay(); }, init() { const session = ++feedWarmSession; // this wallet's own feed (the feature outlives a sign-out): a feed @@ -16965,6 +17168,7 @@ export function messagesFeature(ctx) { clearTimeout(drainTimer); pendingWraps.clear(); dmStarted = false; + if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; callRing(); } listsSynced = false; if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; } clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = []; diff --git a/src/i18n.js b/src/i18n.js index f14ba82..f8b0cca 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -492,6 +492,32 @@ const STR = { goLiveBehind: 'Your connection is falling behind', goLiveLost: 'Lost the connection to the live server.', goLiveRestarted: 'The stream had to start over.', + callVoice: 'Voice call', + callVideo: 'Video call', + callIncomingVoice: 'Voice call', + callIncomingVideo: 'Video call', + callIncomingFrom: '{name} is calling', + callCalling: 'Calling…', + callConnecting: 'Connecting…', + callAccept: 'Answer', + callDecline: 'Decline', + callHangUp: 'Hang up', + callMute: 'Mute', + callUnmute: 'Unmute', + callCamOff: 'Turn camera off', + callCamOn: 'Turn camera on', + callFlip: 'Switch camera', + callMinimize: 'Minimize', + callEnded: 'Call ended', + callNoAnswer: 'No answer', + callDeclined: 'Declined', + callBusy: 'They’re on another call', + callFailed: 'Couldn’t connect', + callNoPermission: 'The microphone or camera isn’t allowed', + callMissed: 'Missed call', + callAnsweredElsewhere: 'Answered on another device', + callDeclinedElsewhere: 'Declined on another device', + callUnsupported: 'This browser can’t make calls.', feedAttach: 'Attach a picture or video', postLike: 'Like', postBoost: 'Boost', diff --git a/src/style.css b/src/style.css index a0996f9..e1b47c1 100644 --- a/src/style.css +++ b/src/style.css @@ -1912,3 +1912,32 @@ body::after { .media-pager.moving .mp-position { animation: none; opacity: 1; } @keyframes mp-position-out { from { opacity: 1; } to { opacity: 0; } } .media-pager.few .mp-position { visibility: hidden; } + +/* A call (calls.js): over every page, its picture filling it when there is + one; minimized, a bar along the top. */ +.call-overlay { position: fixed; inset: 0; z-index: 1000; background: #101318; color: #fff; + display: flex; flex-direction: column; align-items: center; justify-content: space-between; + padding: max(28px, env(safe-area-inset-top)) 16px max(28px, env(safe-area-inset-bottom)); } +.call-overlay .call-remote { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: #000; display: none; } +.call-overlay.has-video .call-remote { display: block; } +.call-overlay.has-video .call-who { position: relative; text-shadow: 0 1px 3px rgba(0, 0, 0, .7); } +.call-overlay.has-video .call-who .call-avatar { display: none; } +.call-who { display: flex; flex-direction: column; align-items: center; gap: 10px; margin-top: 8vh; z-index: 1; } +.call-who .call-avatar { width: 112px; height: 112px; font-size: 32px; } +.call-name { font-size: 22px; font-weight: 650; } +.call-status { font-size: 15px; opacity: .8; font-variant-numeric: tabular-nums; } +.call-local { position: absolute; right: 16px; bottom: 120px; width: 28vw; max-width: 180px; aspect-ratio: 3 / 4; object-fit: cover; + border-radius: 12px; background: #000; box-shadow: 0 2px 12px rgba(0, 0, 0, .5); z-index: 2; } +.call-local.mirror { transform: scaleX(-1); } +.call-bar { display: flex; gap: 18px; z-index: 2; flex-wrap: wrap; justify-content: center; } +.call-btn { width: 60px; height: 60px; border-radius: 50%; border: 0; display: flex; align-items: center; justify-content: center; + background: rgba(255, 255, 255, .16); color: #fff; cursor: pointer; padding: 0; } +.call-btn:hover { background: rgba(255, 255, 255, .26); } +.call-btn.on { background: #fff; color: #101318; } +.call-btn.call-hang, .call-btn.call-decline { background: #e0245e; } +.call-btn.call-accept { background: #1fa45a; } +.call-overlay.call-min { inset: 0 0 auto 0; padding: 0; background: none; height: auto; justify-content: flex-start; pointer-events: none; } +.call-min-bar { pointer-events: auto; margin: max(8px, env(safe-area-inset-top)) auto 0; display: flex; align-items: center; gap: 10px; + padding: 8px 16px; border-radius: 999px; border: 0; background: #1fa45a; color: #fff; font-weight: 600; cursor: pointer; box-shadow: 0 2px 10px rgba(0, 0, 0, .3); } +.call-min-status { font-variant-numeric: tabular-nums; opacity: .9; } +.call-min .call-audio { display: none; } -- cgit v1.2.3