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
+40
View File
@@ -10,6 +10,7 @@
// the code reaching the store fails here rather than on a user's screen.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const { GroupSession, GROUP_FRAMES, groupFrameType, decodeEnvelope } =
await import('../src/group/GroupSession.js');
@@ -199,4 +200,43 @@ async function formPair(groupName) {
'a failed group must not be confirmable');
}
// ---------------------------------------------------------------------------
// a group must not be able to trap the user inside it
// ---------------------------------------------------------------------------
//
// One conversation owns the column at a time, and a group owns it
// unconditionally: the 1:1 side of the screen is rendered only when no group is
// active. So every action that is supposed to bring a CHAT to the front has to
// clear the active group first, or it silently does nothing.
//
// "New chat" did not, and the consequence was not cosmetic. A group is built out
// of chats that have already been verified, so adding a member means first
// opening a chat with them — and from inside a group, the button that opens one
// created a session that was never rendered: no invitation to copy, nothing to
// verify. The admin was told there was nobody left to add and had no way to
// change that.
//
// app.jsx cannot be imported here (it is a .jsx module that expects a browser),
// so this reads the wiring. Crude, but it is the invariant that broke.
{
const app = readFileSync(new URL('../src/app.jsx', import.meta.url), 'utf8');
const newChat = app.slice(app.indexOf('const handleNewChat'), app.indexOf('const handleRenameSession'));
assert.match(newChat, /SET_ACTIVE_GROUP, id: null/,
'starting a new chat must clear the active group, or the new chat is never shown');
assert.ok(
newChat.indexOf('SET_ACTIVE_GROUP') < newChat.indexOf('createSessionRef'),
'the group must be cleared before the session is created, so the new chat lands in front',
);
// The sibling path has always done it; keep them together.
const sidebar = app.slice(app.indexOf('React.createElement(SessionsSidebar'), app.indexOf('onSelectGroup:'));
assert.match(sidebar, /onSelect: \(id\) => \{ groupsDispatch\(\{ type: GA\.SET_ACTIVE_GROUP, id: null \}\)/,
'selecting a chat must clear the active group too');
// And the group view really is exclusive, which is why the above matters.
assert.match(app, /activeGroup && React\.createElement\('main', \{\s*key: 'group-main'/);
assert.match(app, /!activeGroup && React\.createElement\('main', \{\s*key: 'main'/);
}
console.log('group-app-integration.test.mjs: all assertions passed');