feat(groups): audio and video calls in group chats; release v6.6.6
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

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
This commit is contained in:
lockbitchat
2026-09-01 01:04:11 -04:00
parent 5e32f547b9
commit 113bb107d3
62 changed files with 8412 additions and 1039 deletions
+308
View File
@@ -75,6 +75,11 @@ import {
verifyMeshDescriptor,
signLinkProbe,
verifyLinkProbe,
CALL_ACTIONS,
newCallId,
assertCallId,
signGroupCall,
verifyGroupCall,
randomBytes,
canonicalFingerprints,
assertGroupId,
@@ -106,6 +111,9 @@ export const GROUP_FRAMES = Object.freeze({
MESH_ABORT: 'g_mabort',
// "The pairwise chat this arrived on is me, member <fp>."
PROBE: 'g_probe',
// Call control: who opened a call, who is in it, who has left. Media never
// travels here — see the call section below.
CALL: 'g_call',
});
/** The outer wrapper every group frame travels inside. See encodeEnvelope. */
@@ -325,6 +333,23 @@ export class GroupSession {
this._probed = new Set();
/** The coalescing timer for _meshMaintain, or null when none is armed. */
this._meshPass = null;
/**
* The call this group is currently holding, or null.
*
* { callId, startedBy, withVideo, startedAt, participants: Set<fp>, joined }
*
* `joined` is about US specifically: a call can be running with three
* people in it while we have not picked up, and the difference decides
* whether this device is capturing a microphone. It is never inferred
* from the participant set, because a member could otherwise put us in a
* call by naming us in one.
*/
this.call = null;
/** Our own counter over call frames. Monotonic; never reset within an epoch. */
this.callSeq = 0;
/** fp -> highest call sequence seen, so a captured frame cannot be replayed. */
this._callSeen = new Map();
}
// -----------------------------------------------------------------------
@@ -372,6 +397,9 @@ export class GroupSession {
this._meshFailures.clear();
this._probed.clear();
this.call = null;
this._callSeen.clear();
this.members.clear();
this.sessionToFp.clear();
@@ -419,9 +447,28 @@ export class GroupSession {
}
_emitMembers() {
this._pruneCall();
this._emit('members', { members: this._memberSnapshot(), epoch: this.epoch });
}
/**
* Drop anyone from the current call who is no longer a member.
*
* Membership can change under a call — the admin removes somebody, a roster
* for a new epoch arrives — and a participant list that outlives the roster
* would show a person in the call who is not in the group, which is exactly
* the kind of stale claim a group must not make about who can hear it.
*/
_pruneCall() {
if (!this.call) return;
let changed = false;
for (const fp of [...this.call.participants]) {
if (!this.members.has(fp)) { this.call.participants.delete(fp); changed = true; }
}
if (this.call.participants.size === 0) { this.call = null; changed = true; }
if (changed) this._emit('call', { call: this.getCallSnapshot() });
}
// -----------------------------------------------------------------------
// routing
// -----------------------------------------------------------------------
@@ -1616,6 +1663,264 @@ export class GroupSession {
});
}
// -----------------------------------------------------------------------
// calls
// -----------------------------------------------------------------------
//
// WHAT TRAVELS HERE AND WHAT DOES NOT
// -----------------------------------
// Only the roster of a call: somebody opened one, somebody joined it,
// somebody left. No SDP, no ICE, no audio, no video. Media is carried by the
// pairwise sessions themselves — each member places an ordinary encrypted
// call to each other member over the link they already share, so a group
// call is N-1 of the 1:1 calls this app already makes, on transports that
// were already SAS-verified. There is no mixer, no conference server and no
// point at which two people's audio meets anywhere but on a device.
//
// That is why call control is separate from the media path. Control has to
// reach every member, including one who is currently reachable only through
// a relay; media can only flow where a direct link exists. Splitting them
// means a member with no direct link still SEES the call and can be dialled
// into it as the mesh completes, instead of silently missing it.
//
// WHY THESE FRAMES ARE SIGNED
// ---------------------------
// A relaying member carries call control for pairs that cannot reach each
// other. Unsigned, that member could add somebody to a call they never
// joined, or drop somebody who is in one, and nobody could tell it had
// happened. Signed with the group identity key, a relay can still refuse to
// carry a frame — the same availability cost relaying always has — but it
// cannot write one.
/** What the app renders. Null when there is no call. */
getCallSnapshot() {
if (!this.call) return null;
const starter = this.members.get(this.call.startedBy);
return {
callId: this.call.callId,
startedBy: this.call.startedBy,
startedByName: this.call.startedBy === this.selfFp
? 'You'
: (starter?.name || 'A member'),
withVideo: this.call.withVideo,
startedAt: this.call.startedAt,
joined: this.call.joined,
participants: [...this.call.participants].map((fp) => {
const member = this.members.get(fp);
return {
fp,
name: fp === this.selfFp ? 'You' : (member?.name || 'A member'),
self: fp === this.selfFp,
sessionId: member?.sessionId || null,
state: member?.state || MEMBER_STATE.LOST,
};
}).sort((a, b) => (a.fp < b.fp ? -1 : a.fp > b.fp ? 1 : 0)),
};
}
_emitCall() {
this._emit('call', { call: this.getCallSnapshot() });
}
_requireReady() {
if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) {
throw new GroupSessionError('the group code has not been confirmed', 'not_ready');
}
}
/** Sign and fan out one call-control frame. */
async _sendCallFrame(action, callId, withVideo) {
const seq = ++this.callSeq;
const sig = await signGroupCall(this.subtle, this.identity.keyPair.privateKey, {
groupId: this.groupId, epoch: this.epoch, callId, action,
fp: this.selfFp, seq, withVideo,
});
return this._broadcast({
type: GROUP_FRAMES.CALL,
gid: this.groupId,
epoch: this.epoch,
callId,
action,
fp: this.selfFp,
seq,
v: withVideo === true,
ts: Date.now(),
sig: toB64(sig),
});
}
/**
* Open a call and put ourselves in it.
*
* Refused while one is already running: joining the call that exists is what
* the user means, and a second concurrent call would split the group into two
* rooms that cannot hear each other.
*/
async startCall({ withVideo = false, prepare = null } = {}) {
this._requireReady();
if (this.call) throw new GroupSessionError('a call is already running in this group', 'call_in_progress');
const callId = newCallId();
this.call = {
callId,
startedBy: this.selfFp,
withVideo: withVideo === true,
startedAt: Date.now(),
participants: new Set([this.selfFp]),
joined: true,
};
this._emitCall();
// Media needs a direct link, so a call is the moment it is most worth
// having one. The mesh would get there on its own; this stops the first
// seconds of the call being spent waiting for a maintenance pass.
this._scheduleMeshMaintain();
try {
// `prepare` is where the caller opens its microphone, and it runs
// BEFORE the group is told anything. Announcing first would ring
// everybody else's device for a call this one turns out not to be
// able to make — a denied permission, no microphone, another
// application holding it. Failing here costs nobody but the person
// who pressed the button.
if (typeof prepare === 'function') await prepare(this.getCallSnapshot());
const { unreachable } = await this._sendCallFrame(CALL_ACTIONS.START, callId, this.call.withVideo);
return { callId, unreachable };
} catch (error) {
this.call = null;
this._emitCall();
throw error;
}
}
/** Join the call that is already running. */
async joinCall() {
this._requireReady();
if (!this.call) throw new GroupSessionError('there is no call to join', 'no_call');
if (this.call.joined) return { callId: this.call.callId, unreachable: [] };
this.call.joined = true;
this.call.participants.add(this.selfFp);
this._emitCall();
this._scheduleMeshMaintain();
const { unreachable } = await this._sendCallFrame(CALL_ACTIONS.JOIN, this.call.callId, this.call.withVideo);
return { callId: this.call.callId, unreachable };
}
/**
* Leave the call.
*
* Leaving is always local first: the frame is best effort, because a member
* who cannot be reached must not be able to keep us in a call by being
* unreachable.
*/
async leaveCall() {
if (!this.call) return;
const callId = this.call.callId;
const withVideo = this.call.withVideo;
this.call.joined = false;
this.call.participants.delete(this.selfFp);
if (this.call.participants.size === 0) this.call = null;
this._emitCall();
try {
await this._sendCallFrame(CALL_ACTIONS.LEAVE, callId, withVideo);
} catch (_) { /* leaving is best effort */ }
}
/**
* A member is gone (left the call, left the group, or was removed).
*
* A call with NOBODY in it is over. A call with only us in it is not: that
* is exactly the state every call is in for the seconds between opening it
* and the first person joining, and ending it there would hang up on
* somebody who is on their way in. Leaving is the user's decision, and
* leaveCall is the only thing that makes it.
*/
_dropFromCall(fp) {
if (!this.call || !this.call.participants.has(fp)) return;
this.call.participants.delete(fp);
if (this.call.participants.size === 0) this.call = null;
this._emitCall();
}
async _onCall(frame) {
const epoch = assertEpoch(frame.epoch);
const seq = assertEpoch(frame.seq);
const senderFp = assertFingerprint(String(frame.fp || ''));
const callId = assertCallId(String(frame.callId || ''));
const action = String(frame.action || '');
const withVideo = frame.v === true;
if (senderFp === this.selfFp) return;
const member = this.members.get(senderFp);
if (!member || !member.publicKey) throw new GroupSessionError('call frame from a non-member', 'not_a_member');
if (epoch !== this.epoch) throw new GroupSessionError('call frame from another epoch', 'stale_epoch');
// Replay window first, so a captured frame is dropped before its action
// is considered at all. Equal counts as a replay: a sender never reuses
// a sequence number, and fan-out duplicates of the same frame are
// exactly what this absorbs.
const seen = this._callSeen.get(senderFp);
if (seen !== undefined && seq <= seen) return;
const ok = await verifyGroupCall(this.subtle, member.publicKey, {
groupId: this.groupId, epoch, callId, action, fp: senderFp, seq, withVideo,
}, fromB64(String(frame.sig || ''), { max: GROUP_LIMITS.MAX_SIG_BYTES }));
if (!ok) throw new GroupSessionError('call frame signature did not verify', 'bad_signature');
this._callSeen.set(senderFp, seq);
// Nothing about a call may be acted on before the group itself is
// usable: a call that arrives mid-ceremony would be a ringing phone for
// a group nobody has authenticated yet.
if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) return;
switch (action) {
case CALL_ACTIONS.START: {
if (this.call && this.call.callId !== callId) {
// Two calls opened at once. The lower id wins for everyone,
// because every member compares the same two values and gets
// the same answer — so the group converges on one room
// instead of splitting into two that cannot hear each other.
if (callId >= this.call.callId) return;
// We are being moved off a call we may be in. Say so, so the
// media layer tears the old one down before building the new.
this.call = null;
}
if (!this.call) {
this.call = {
callId,
startedBy: senderFp,
withVideo,
startedAt: Date.now(),
participants: new Set([senderFp]),
joined: false,
};
} else {
this.call.participants.add(senderFp);
}
this._emitCall();
this._scheduleMeshMaintain();
return;
}
case CALL_ACTIONS.JOIN: {
if (!this.call || this.call.callId !== callId) return;
if (this.call.participants.has(senderFp)) return;
this.call.participants.add(senderFp);
// A member joining with video turns the call into one that has
// video in it; nobody's own camera is turned on by this.
if (withVideo) this.call.withVideo = true;
this._emitCall();
this._scheduleMeshMaintain();
return;
}
case CALL_ACTIONS.LEAVE: {
if (!this.call || this.call.callId !== callId) return;
this._dropFromCall(senderFp);
return;
}
default:
return;
}
}
// -----------------------------------------------------------------------
// inbound dispatch
// -----------------------------------------------------------------------
@@ -1658,6 +1963,8 @@ export class GroupSession {
return this._onMeshAnswer(frame);
case GROUP_FRAMES.MESH_ABORT:
return this._onMeshAbort(frame);
case GROUP_FRAMES.CALL:
return this._onCall(frame);
case GROUP_FRAMES.PROBE:
// A probe is a claim about the link it arrived on, so it is only
// meaningful on a direct one. Relayed, it says nothing.
@@ -1695,6 +2002,7 @@ export class GroupSession {
const member = this.members.get(fp);
if (!member || fp === this.selfFp) return;
this._emit('left', { fp, name: member.name });
this._dropFromCall(fp);
// The admin leaving ends the group for everyone else. Nobody else can
// sign a roster, so there is no next epoch and no safety code to compare