aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 08:41:13 -0700
committerAdam Soltys <[email protected]>2026-10-18 08:41:13 -0700
commit98d90827cbf581c4322232063b4f785f97bcb45b (patch)
tree21e6cfcd45c5182172ea0e7ceff1c9b105ddff61
parenta9cdc8a6fd7abd38a185f9d8828531374307cf41 (diff)
downloadcoinosv3-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
-rw-r--r--src/app.js4
-rw-r--r--src/calls.js304
-rw-r--r--src/features/messages.js216
-rw-r--r--src/i18n.js26
-rw-r--r--src/style.css29
-rw-r--r--tools/call-test.js160
6 files changed, 732 insertions, 7 deletions
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: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>',
+ video: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>',
+ mic: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
+ micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>',
+ camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>',
+ flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>',
+ hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>',
+ min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>',
+ };
+ 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; }
diff --git a/tools/call-test.js b/tools/call-test.js
new file mode 100644
index 0000000..96271fb
--- /dev/null
+++ b/tools/call-test.js
@@ -0,0 +1,160 @@
+// 1:1 calls (NIP-AC): two wallets, each in its own browser tab, a relay
+// between them. The caller's 📞 / 🎥 in their conversation rings the other
+// over whatever page they're on; Answer connects them — each hears and sees
+// the other — mute and camera-off reach the other side, hang-up ends it on
+// both. A call can be declined, and a stranger (neither followed nor written
+// to) doesn't ring at all. Everything on the wire is an ephemeral gift wrap.
+// Run: bun tools/call-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { getPublicKey } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const PORT = 5305;
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+
+// a small relay: keeps what isn't ephemeral, sends each event to the subscriptions it matches
+const stored = [], subs = new Set(), kindsSeen = [], wrapIds = new Set();
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
+ && (!f.since || e.created_at >= f.since)
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+const server = Bun.serve({ port: PORT,
+ fetch: async (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ if (path === '/live/turn') return Response.json({ iceServers: [], ttl: 600 }); // two tabs on one machine: no TURN needed
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+ },
+ websocket: {
+ open(ws) { ws.data = { subs: new Map() }; },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') {
+ const e = m[1]; kindsSeen.push(e.kind); if (e.kind === 21059) wrapIds.add(e.id);
+ ws.send(JSON.stringify(['OK', e.id, true, '']));
+ if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e);
+ for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match({ ...f, since: undefined }, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e]));
+ } else if (m[0] === 'REQ') {
+ const s = { ws, id: m[1], filters: m.slice(2) };
+ ws.data.subs.set(m[1], s); subs.add(s);
+ for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') { const s = ws.data.subs.get(m[1]); if (s) subs.delete(s); }
+ },
+ close(ws) { for (const s of ws.data.subs.values()) subs.delete(s); },
+ },
+});
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
+// our DM relays lead to the test's relay
+const toLocal = (port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io|nos\.lol/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+async function wallet(name) {
+ const page = await (await browser.createBrowserContext()).newPage(); // a browser of its own: its own storage
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(toLocal, PORT);
+ page.errs = []; page.on('pageerror', (e) => page.errs.push(String(e)));
+ const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x);
+ const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive');
+ return { page, mn, pk: getPublicKey(privateKeyFromSeedWords(mn)), base: cacheKeyFor(mn + '\n'), click, waitText, name };
+}
+const waitFor = async (page, fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+const follow = (w, pks, profiles) => w.page.evaluate(([k, pks, profiles]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify(profiles));
+}, [w.base, pks, profiles]);
+// the call's pictures and sound actually moving: frames decoded, bytes in
+const media = (page) => page.evaluate(async () => {
+ const v = document.querySelector('video.call-remote'), a = document.querySelector('audio.call-audio');
+ const s = a && a.srcObject;
+ return { video: !!v && v.videoWidth > 0 && !v.paused, audio: !!s && s.getAudioTracks().some((t) => t.readyState === 'live'), local: !!document.querySelector('video.call-local')?.videoWidth };
+});
+
+try {
+ const A = await wallet('alice'), B = await wallet('bob'), C = await wallet('carol');
+ const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() }, [C.pk]: { name: 'Carol Stranger', t: Date.now() } };
+ await follow(A, [B.pk], profiles); await follow(B, [A.pk], profiles); await follow(C, [B.pk], profiles);
+ for (const w of [A, B, C]) { await w.page.reload({ waitUntil: 'domcontentloaded' }); await w.waitText('receive'); }
+ const openDm = async (w, peer) => {
+ await w.page.goto(`http://localhost:${PORT}/` + npubEncode(peer), { waitUntil: 'domcontentloaded' });
+ await w.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await w.page.evaluate(() => { const b = [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)); b.click(); });
+ await w.page.waitForSelector('.chat-head .call-start', { timeout: 10000 });
+ };
+
+ console.log('[a video call]');
+ await openDm(A, B.pk);
+ check('the conversation has voice and video call buttons', await A.page.evaluate(() => document.querySelectorAll('.chat-head .call-start').length === 2));
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
+ check('the caller sees it calling, their own camera on', await waitFor(A.page, () => /calling/i.test(document.querySelector('.call-overlay .call-status')?.textContent || '') && document.querySelector('video.call-local')?.videoWidth > 0, 10000));
+ check('the callee, on their wallet page, is rung: who, and a video call', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText) && /video call/i.test(document.querySelector('.call-overlay').innerText), 15000));
+ check('everything went as ephemeral gift wraps', kindsSeen.length > 0 && !kindsSeen.some((k) => k >= 25050 && k <= 25055) && kindsSeen.includes(21059));
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ check('answered: both connected, the clock running', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000) && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000));
+ await sleep(1500);
+ const ma = await media(A.page), mb = await media(B.page);
+ check('each sees the other\'s picture and gets their sound', ma.video && ma.audio && mb.video && mb.audio, JSON.stringify({ ma, mb }));
+ await B.page.evaluate(() => document.querySelector('.call-bar .call-btn[aria-label="Mute"]').click());
+ check('mute turns the microphone off', await waitFor(B.page, () => !!document.querySelector('.call-btn.on[aria-label="Unmute"]'), 3000));
+ await A.page.evaluate(() => document.querySelector('.call-btn[aria-label="Minimize"]').click());
+ check('minimized: a bar across the top, the page usable under it', await waitFor(A.page, () => !!document.querySelector('.call-min-bar') && !document.querySelector('.call-bar'), 3000));
+ await A.page.evaluate(() => document.querySelector('.call-min-bar').click());
+ await waitFor(A.page, () => !!document.querySelector('.call-hang'), 3000);
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ check('hanging up ends it here', await waitFor(A.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 3000));
+ check('...and there', await waitFor(B.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000));
+ check('...the cameras let go', await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000) && await waitFor(B.page, () => !document.querySelector('.call-overlay'), 6000));
+
+ console.log('\n[declined]');
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ check('a voice call rings', await waitFor(B.page, () => /voice call/i.test(document.querySelector('.call-overlay')?.innerText || ''), 15000));
+ await B.page.evaluate(() => document.querySelector('.call-decline').click());
+ check('declined: the caller is told', await waitFor(A.page, () => /declined/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000));
+ await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000);
+
+ console.log('\n[a remote signer\'s way: the candidates inside the offer and answer]');
+ for (const w of [A, B]) await w.page.evaluate(() => localStorage.setItem('coinos-call-trickle', '0'));
+ const before = wrapIds.size;
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
+ await waitFor(B.page, () => !!document.querySelector('.call-accept'), 20000);
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ check('it connects', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000));
+ await sleep(1500);
+ const m2 = await media(A.page);
+ check('...picture and sound', m2.video && m2.audio, JSON.stringify(m2));
+ const sent = wrapIds.size - before;
+ check('...with a handful of messages, not one per candidate (offer, answer, its copy for my other devices)', sent <= 3, sent + ' wraps');
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000);
+ for (const w of [A, B]) await w.page.evaluate(() => localStorage.removeItem('coinos-call-trickle'));
+
+ console.log('\n[a stranger]');
+ await openDm(C, B.pk);
+ await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000);
+ await sleep(5000);
+ check('someone Bob neither follows nor wrote to doesn\'t ring him', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await C.page.evaluate(() => document.querySelector('.call-hang')?.click());
+ for (const w of [A, B, C]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ calls: rung, answered, seen and heard, ended' : '\n❌ failed');
+process.exit(ok ? 0 : 1);