aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/calls.js66
-rw-r--r--src/features/messages.js15
2 files changed, 68 insertions, 13 deletions
diff --git a/src/calls.js b/src/calls.js
index 66d6f16..6f18aec 100644
--- a/src/calls.js
+++ b/src/calls.js
@@ -71,6 +71,8 @@ export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdp
// 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
+// warm(pk) a call with them may be coming: look up what sending to
+// them takes (their relays, TURN) ahead of it
// RTCPeerConnection (tests may pass their own)
export function createCalls(env) {
const PC = env.RTCPeerConnection || globalThis.RTCPeerConnection;
@@ -103,8 +105,10 @@ export function createCalls(env) {
endTimer = setTimeout(() => { if (call === c) { call = null; changed(); } }, ENDED_MS);
}
+ // (iceCandidatePoolSize: the routes — TURN's included, which take a round
+ // trip to allocate — are gathered from the start, not from the answer on)
async function connection(c) {
- const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle' });
+ const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle', iceCandidatePoolSize: 2 });
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(); };
@@ -121,17 +125,47 @@ export function createCalls(env) {
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);
+ if (c.local) 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)
+ // While a call rings: its connection made and the offer set, the routes
+ // gathering — everything but the microphone and camera, which wait for
+ // Answer. Answering then only adds them and replies (it used to do all of
+ // this after the tap, seconds of "Connecting…").
+ async function prepare(c) {
+ const pc = await connection(c);
+ if (call !== c || c.state === 'ended') { try { pc.close(); } catch {} return null; }
+ await pc.setRemoteDescription({ type: 'offer', sdp: c.offer });
+ c.remoteSet = true;
+ await addCandidates(c);
+ return pc;
+ }
+ // our tracks into the transceivers the offer made
+ function attachLocal(c) {
+ const trs = c.pc.getTransceivers();
+ for (const track of c.local.getTracks()) {
+ const tr = trs.find((x) => !x.sender.track && x.receiver.track && x.receiver.track.kind === track.kind && !x.stopped);
+ if (tr) {
+ tr.sender.replaceTrack(track);
+ tr.direction = 'sendrecv';
+ try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {}
+ } else c.pc.addTrack(track, c.local);
+ }
+ }
+ // Without trickle: the description once it holds routes enough — one
+ // through the router (srflx) and one through TURN (relay) — or after a
+ // second and a half. Waiting for gathering to finish took up to 4 s (TURN
+ // over TCP and TLS is slow to give up), all of it after the tap.
async function described(c) {
const pc = c.pc;
- if (!c.trickle && pc.iceGatheringState !== 'complete') {
+ const enough = () => pc.iceGatheringState === 'complete'
+ || (/ typ srflx/.test(pc.localDescription.sdp) && / typ relay/.test(pc.localDescription.sdp));
+ if (!c.trickle && !enough()) {
await new Promise((resolve) => {
- const done = () => { if (pc.iceGatheringState === 'complete') resolve(); };
- pc.addEventListener('icegatheringstatechange', done);
- setTimeout(resolve, 4000);
+ const check = () => { if (enough()) resolve(); };
+ pc.addEventListener('icegatheringstatechange', check);
+ pc.addEventListener('icecandidate', check);
+ setTimeout(resolve, 1500);
});
}
return pc.localDescription.sdp;
@@ -169,12 +203,18 @@ export function createCalls(env) {
c.state = 'connecting';
changed();
try {
- c.local = await env.media(c.type, c.facing);
+ const [local, ready] = await Promise.all([env.media(c.type, c.facing), (c.prep || Promise.resolve(null)).catch(() => null)]);
+ c.local = local;
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);
+ let pc = ready;
+ if (pc && pc.signalingState === 'have-remote-offer') attachLocal(c);
+ else { // nothing made while it rang (or it failed): all of it now
+ try { pc && pc.close(); } catch {}
+ 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);
@@ -261,6 +301,8 @@ export function createCalls(env) {
const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), offerAt: ev.created_at * 1000, muted: false, camOff: false, facing: 'user' };
ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, Math.max(5000, RING_MS - (Date.now() - ev.created_at * 1000)));
changed();
+ try { if (env.warm) env.warm(from); } catch {}
+ n.prep = prepare(n).catch(() => null);
return;
}
case CALL.ANSWER: {
diff --git a/src/features/messages.js b/src/features/messages.js
index 54c9cfe..e239aee 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -9120,6 +9120,7 @@ export function messagesFeature(ctx) {
mayRing: async (pk) => mayRing(pk),
changed: () => { callRing(); render(); },
ended: (rec) => { logCall(rec).catch(() => {}); },
+ warm: (pk) => callWarm(pk),
// 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; },
@@ -9238,6 +9239,17 @@ export function messagesFeature(ctx) {
}
} catch {}
}
+ // A call with them may be coming (it rings, or their conversation is open):
+ // their relays looked up, TURN fetched — the seconds the first send or
+ // answer used to spend on it. Not through Amber: a prompt to open a chat.
+ const warmed = new Set();
+ function callWarm(pk) {
+ if (!pk || warmed.has(pk)) return;
+ warmed.add(pk);
+ inboxFor(pk).catch(() => {});
+ const login = hook('nostrLoginIdentity');
+ if (!(login && login.signer && login.signer.interactive)) callIce().catch(() => {});
+ }
function startCall(peer, type) {
if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; }
const cur = callsNow().call;
@@ -16467,6 +16479,7 @@ export function messagesFeature(ctx) {
const peer = ui.msgPeer;
if (!peer) { ui.msgView = 'home'; return homeView(); }
const thread = threads.get(peer) || new Map();
+ if (!isMe(peer)) setTimeout(() => callWarm(peer), 1500); // a call may be next
const msgs = foldCallLogs([...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at));
// a first message may go by White Noise: have their KeyPackage looked up before Send
wnKnow(peer);
@@ -17312,7 +17325,7 @@ export function messagesFeature(ctx) {
clearTimeout(drainTimer);
pendingWraps.clear();
dmStarted = false;
- if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; callRing(); }
+ if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; warmed.clear(); callRing(); }
listsSynced = false;
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; }
clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = [];