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
+107
View File
@@ -0,0 +1,107 @@
// The geometry of a gallery view.
//
// Separated from the component because it is arithmetic, not rendering: it has
// no React in it, it is the part that was actually wrong when tiles came out
// tiny on a desktop, and it is the part worth pinning down in a test that does
// not need a browser to run.
/** Gap between tiles, and the shape each one holds. */
export const TILE_GAP = 10;
export const TILE_ASPECT = 16 / 9;
/** Below this a tile stops shrinking and the grid scrolls instead. */
export const MIN_TILE_W = 150;
/**
* How big each tile should be, given the space there actually is.
*
* The first version of this picked a column count from the number of people and
* let CSS divide the width. That is wrong in both directions and it showed:
* capped, three people on a wide monitor got three small tiles adrift in empty
* space; uncapped, they got three letterbox strips. Neither is what a call
* looks like, because neither is looking at the window.
*
* So do what a gallery view does: try every column count, work out how large a
* tile could be at that count — bounded by the width a column gets AND by the
* height its row gets, since a tile has a fixed shape and the smaller of the two
* wins — and keep whichever count makes the tiles biggest. Two people on a wide
* screen come out side by side and enormous; six come out three by two; the same
* six on a phone come out two by three, because there the height is what is
* plentiful. Nothing is special-cased per device.
*
* Pure and exported so the arithmetic can be tested without a browser.
*
* @returns {{cols: number, rows: number, tileW: number, tileH: number}}
*/
export function gridLayout(count, width, height, {
gap = TILE_GAP, aspect = TILE_ASPECT, minWidth = MIN_TILE_W,
} = {}) {
if (count <= 0) return { cols: 0, rows: 0, tileW: 0, tileH: 0 };
if (!(width > 0) || !(height > 0)) {
// Not measured yet — first paint, or a container with no layout. Fall
// back to a shape that is reasonable rather than to zero-sized tiles.
const cols = Math.min(count, count <= 2 ? count : Math.ceil(Math.sqrt(count)));
return { cols, rows: Math.ceil(count / cols), tileW: 0, tileH: 0 };
}
let best = { cols: 1, rows: count, tileW: 0, tileH: 0 };
for (let cols = 1; cols <= count; cols++) {
const rows = Math.ceil(count / cols);
const perColumn = (width - gap * (cols - 1)) / cols;
const perRow = (height - gap * (rows - 1)) / rows;
if (perColumn <= 0 || perRow <= 0) continue;
// A tile keeps its aspect ratio, so it is as large as the tighter of the
// two constraints allows — never stretched to fill one of them.
const tileW = Math.min(perColumn, perRow * aspect);
if (tileW > best.tileW) best = { cols, rows, tileW, tileH: tileW / aspect };
}
if (best.tileW < minWidth) {
// Too many people for the space. Stop shrinking and let the grid scroll:
// tiles small enough to be unreadable are worse than a scrollbar.
best.tileW = minWidth;
best.tileH = minWidth / aspect;
}
return best;
}
/**
* The spotlight arrangement: one large tile, the rest in a strip beneath it.
*
* A gallery is the right default — in a call of three or four everyone is worth
* the same amount of screen. It stops being right the moment someone is
* presenting, or the moment there are enough people that equal shares means
* nobody is legible. So a tile can be pinned, and the stage splits: the pinned
* person takes what is left after a strip of thumbnails, and the strip scrolls
* sideways rather than shrinking further.
*
* The strip's height is a fraction of the stage, floored and capped, because a
* proportion alone gives useless thumbnails on a phone and absurd ones on a
* 4K monitor.
*
* @returns {{stageH: number, main: {w: number, h: number}, stripH: number, thumbW: number}}
*/
export function spotlightLayout(othersCount, width, height, {
gap = TILE_GAP, aspect = TILE_ASPECT, minThumb = 92, maxThumb = 168,
} = {}) {
if (!(width > 0) || !(height > 0)) {
return { main: { w: 0, h: 0 }, stripH: 0, thumbW: 0 };
}
if (othersCount <= 0) {
const w = Math.min(width, height * aspect);
return { main: { w, h: w / aspect }, stripH: 0, thumbW: 0 };
}
const thumbW = Math.max(minThumb, Math.min(maxThumb, Math.round(height * 0.2 * aspect)));
const stripH = thumbW / aspect;
const mainH = height - stripH - gap;
const mainW = Math.min(width, mainH * aspect);
// Refuse to split a stage that cannot carry the split. The test is not
// "does it fit" but "is it worth it": a main tile barely larger than the
// thumbnails below it spotlights nobody, it just takes a gallery and makes
// one cell slightly bigger. Below that the caller falls back to the gallery,
// and is told so by a main tile of zero rather than a useless one.
if (mainH <= 0 || mainW < thumbW * 2) {
return { main: { w: 0, h: 0 }, stripH: 0, thumbW: 0 };
}
return { main: { w: mainW, h: mainW / aspect }, stripH, thumbW };
}