A group call is N-1 ordinary 1:1 calls, one to each other member, each riding the pairwise session that member already has — a transport a human already authenticated by comparing the safety code. No mixer, no SFU, no point at which two people's media meets anywhere but on a device. Call control is separate from call media, because the two reach different sets of people. Who opened a call, who joined and who left travels as group frames signed with the sender's group identity key, so it reaches members currently reachable only through a relay — and a relaying member can drop one but cannot write one. Media flows only where a direct link exists, so a member without one shows as connecting rather than being omitted. Frames carry a per-sender sequence checked before the action, so a captured leave cannot end a later call, and simultaneous calls converge on the lower random call id. One capture is shared across every leg rather than one getUserMedia per member, and legs answer without prompting: the flag permitting that is set only locally, only while this user is in the call, and cleared when they leave. UI: a gallery that sizes itself from the space it has, a spotlight view, an active-speaker indicator read from the waveform, and the call surface in the same visual language as the 1:1 one. Also in this commit, the v6.5.0 language-suggestion work that had not been pushed yet; its notes are in the changelog. And two fixes: the safety-code input asks for digits rather than text, and starting a new chat from inside a group no longer creates it behind the group where it cannot be seen — which had made it impossible to connect to anyone new, or to add anyone to a group, while a group was open. Claude-Session: https://claude.ai/code/session_01XSxAkET3hQTkYDQfbjCQwZ
98 lines
4.3 KiB
JavaScript
98 lines
4.3 KiB
JavaScript
// One leg of a group call must never sit there ringing.
|
|
//
|
|
// A group call is N-1 ordinary 1:1 calls, and each of them is answered without
|
|
// prompting because the user already consented once, by joining. That answer is
|
|
// decided when the offer LANDS — so an offer that arrives before this session
|
|
// has been told it is a call leg falls through the check and rings instead.
|
|
//
|
|
// The window is real. A member who joins can place their offers immediately,
|
|
// and those offers can overtake the group frame announcing that they joined. The
|
|
// symptom was asymmetric and confusing: one member's tile read "connecting" for
|
|
// the whole call while the other member's browser was quietly ringing for a call
|
|
// they had already agreed to be in.
|
|
//
|
|
// The ordering that opens the window is fixed elsewhere (the app announces
|
|
// before it connects). This covers the safety net underneath it: being told,
|
|
// late, that a session is a call leg has to pick up whatever is already ringing.
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
globalThis.window = { EnhancedSecureCryptoUtils: { secureLog: { log() {} } } };
|
|
|
|
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
|
|
|
|
/** A manager with only the call-state machinery a leg touches. */
|
|
function callLeg(phase, { pending = null } = {}) {
|
|
const leg = Object.create(EnhancedSecureWebRTCManager.prototype);
|
|
leg.callState = {
|
|
active: phase !== 'idle', phase, withVideo: false, micEnabled: true,
|
|
cameraEnabled: false, remoteHasVideo: false, callId: 'c1', quality: null,
|
|
groupCallId: null, error: null,
|
|
};
|
|
leg._callGroupContext = null;
|
|
leg._callStateListeners = new Set();
|
|
leg._pendingCallOffer = pending;
|
|
leg.onCallStateChanged = null;
|
|
leg.accepted = 0;
|
|
leg.acceptCall = async function () { this.accepted += 1; };
|
|
leg._startAdaptation = () => {};
|
|
leg._stopAdaptation = () => {};
|
|
leg._secureLog = () => {};
|
|
return leg;
|
|
}
|
|
|
|
const offer = { sdp: 'v=0', callId: 'c1', withVideo: false };
|
|
|
|
// ── an offer that was already ringing is answered when the leg is claimed ────
|
|
{
|
|
const leg = callLeg('incoming', { pending: offer });
|
|
leg.setCallGroupContext('group-call-1');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
assert.equal(leg.accepted, 1, 'a ringing group leg must be answered as soon as it is claimed');
|
|
assert.equal(leg.getCallState().groupCallId, 'group-call-1',
|
|
'and the state must say which group call it belongs to, so the 1:1 UI stays out of the way');
|
|
}
|
|
|
|
// ── an idle session is left alone: there is nothing to answer ────────────────
|
|
{
|
|
const leg = callLeg('idle');
|
|
leg.setCallGroupContext('group-call-1');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
assert.equal(leg.accepted, 0, 'claiming an idle session must not invent a call');
|
|
}
|
|
|
|
// ── a call already up is not answered a second time ─────────────────────────
|
|
{
|
|
const leg = callLeg('active', { pending: offer });
|
|
leg.setCallGroupContext('group-call-1');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
assert.equal(leg.accepted, 0, 'a live call must not be re-answered');
|
|
}
|
|
|
|
// ── THE guard: releasing a leg must never auto-answer anything ───────────────
|
|
//
|
|
// This is the part that would be a security bug rather than a display one. The
|
|
// flag is what allows a microphone to open without asking, so clearing it — which
|
|
// is what leaving a call does — must not be a path to opening one.
|
|
{
|
|
const leg = callLeg('incoming', { pending: offer });
|
|
leg._callGroupContext = 'group-call-1';
|
|
leg.setCallGroupContext(null);
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
assert.equal(leg.accepted, 0, 'clearing the group context must never answer a call');
|
|
assert.equal(leg.getCallState().groupCallId, null);
|
|
}
|
|
|
|
// ── and a session that is no longer a leg rings normally again ──────────────
|
|
{
|
|
const leg = callLeg('idle');
|
|
leg.setCallGroupContext('group-call-1');
|
|
leg.setCallGroupContext(null);
|
|
assert.equal(leg._callGroupContext, null,
|
|
'a cleared context must not linger — it is what lets a call open a microphone unasked');
|
|
}
|
|
|
|
console.log('group-call-autoanswer: ok');
|