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
+5
View File
@@ -87,6 +87,11 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
React.useEffect(() => { if (phase === 'idle') setMinimized(false); }, [phase]);
if (!active || phase === 'idle' || phase === 'ended') return null;
// One leg of a group call runs on this same session. The group's own surface
// renders it as a tile alongside the others; a second full-screen 1:1 overlay
// on top of that would be the same audio shown twice, with two hang-up
// buttons that mean different things.
if (call.groupCallId) return null;
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
const ringing = phase === 'outgoing' || phase === 'connecting';
+575
View File
@@ -0,0 +1,575 @@
import { t } from '../../i18n/index.js';
import { LEG_STATE } from '../../group/groupCallMedia.js';
import { gridLayout, spotlightLayout, TILE_GAP } from './callLayout.js';
// Group call surfaces: the ringing prompt, the tile grid, the control bar and
// the minimized dock.
//
// It is deliberately the same visual language as the 1:1 call (CallUI.jsx) —
// same icons, same control discs, same encrypted badge — because it is the same
// thing happening more than once, and a second design would suggest otherwise.
// What it adds is the part a group call has and a 1:1 call does not: a tile per
// member, each carrying that member's own connection state, because in a mesh
// call the connection is per pair and "the call is fine" is not a thing anybody
// can say on behalf of everyone else.
//
// Purely presentational. All media and all signalling live in GroupCallMedia
// and GroupSession; this reads a snapshot and calls back.
const h = (...args) => React.createElement(...args);
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
const ICON = {
lock: '<path d="M7 11V7a5 5 0 0 1 10 0v4"/><rect x="4.5" y="11" width="15" height="9" rx="2.2"/>',
minimize: '<path d="M9 4v4a1 1 0 0 1-1 1H4M15 4v4a1 1 0 0 0 1 1h4M9 20v-4a1 1 0 0 0-1-1H4M15 20v-4a1 1 0 0 1 1-1h4"/>',
expand: '<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>',
users: '<path d="M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19"/><circle cx="10" cy="8" r="3.2"/><path d="M20 19v-1.5a3.5 3.5 0 0 0-2.6-3.4"/><path d="M15.5 5.3a3.2 3.2 0 0 1 0 5.4"/>',
micOn: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/>',
micOff: '<path d="M9 9v-1a3 3 0 0 1 5.1-2.1M15 11v3a3 3 0 0 1-4.6 2.5"/><path d="M5 11a7 7 0 0 0 10.3 6.2M19 11a7 7 0 0 1-.4 2.3"/><path d="M12 18v3"/><path d="M3 3l18 18"/>',
camOn: '<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2.5"/>',
camOff: '<path d="M16 16H3a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h2l2-2M11 6h2l7-3v14M2 2l20 20"/>',
grid: '<rect x="3" y="3" width="7.5" height="7.5" rx="1.6"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.6"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.6"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.6"/>',
flip: '<path d="M3 7h3l2-2h8l2 2h3v12H3z"/><path d="M9.5 13a2.5 2.5 0 0 1 5 0M14.5 13l-1.3-1.3M14.5 13l1.3-1.3"/>',
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>',
phoneHangup: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z" transform="rotate(135 12 12)"/>',
};
const svg = (inner, size, sw) => h('span', {
style: { display: 'grid', placeItems: 'center', width: size + 'px', height: size + 'px' },
dangerouslySetInnerHTML: { __html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>` },
});
const ctrlBase = {
width: '56px', height: '56px', borderRadius: '50%', display: 'grid', placeItems: 'center',
border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.05)',
color: '#cfcfd4', cursor: 'pointer', transition: 'all .15s',
};
const dangerCtrl = { ...ctrlBase, background: '#e5484d', color: '#fff', border: '1px solid transparent' };
const endBtn = {
width: '56px', height: '56px', borderRadius: '50%', display: 'grid', placeItems: 'center',
border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer',
boxShadow: '0 8px 24px rgba(229,72,77,0.35)', transition: 'transform .15s',
};
const minimizeBtn = {
width: '36px', height: '36px', borderRadius: '9px', display: 'grid', placeItems: 'center',
border: '1px solid rgba(255,255,255,0.15)', background: 'rgba(0,0,0,0.35)',
color: '#fff', cursor: 'pointer', transition: 'all .15s',
};
const QUALITY = {
excellent: { bars: 4, color: '#3ecf8e' },
good: { bars: 3, color: '#3ecf8e' },
fair: { bars: 2, color: '#e3c84e' },
poor: { bars: 1, color: '#e5727a' },
};
function qualityBars(quality) {
const q = QUALITY[quality];
if (!q) return null;
return h('span', { key: 'q', style: { display: 'inline-flex', alignItems: 'flex-end', gap: '2px', height: '12px' } },
[0, 1, 2, 3].map((i) => h('span', {
key: i,
style: {
width: '2.5px', height: (4 + i * 2.6) + 'px', borderRadius: '1px',
background: i < q.bars ? q.color : 'rgba(255,255,255,0.18)',
},
})));
}
function initials(name) {
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
return ((words[0]?.[0] || '') + (words[1]?.[0] || words[0]?.[1] || '')).toUpperCase() || '#';
}
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
/**
* The size of an element, kept current as it changes.
*
* The layout above needs real numbers, and the only honest source of those is
* the element itself: the window's size says nothing about a panel inside a
* sidebar layout, and a call that is resized — a window dragged wider, a phone
* turned — has to re-solve rather than keep the size it was born with.
*/
function useMeasuredSize() {
const [size, setSize] = React.useState({ w: 0, h: 0 });
// A CALLBACK ref, not an object one, and this is the whole point of the hook.
//
// With `useRef` the effect's only dependency is the ref object, which never
// changes — so the effect runs exactly once, on mount. The element it wants
// to measure is inside a branch that mount does not always render: whoever
// JOINS a call sees the "somebody is calling" prompt first, and there is no
// stage in it. Their effect ran against `null`, never ran again, and the
// size stayed 0×0 for the rest of the call while the person who STARTED the
// call — mounted straight into the grid — measured fine. One user saw a
// normal call and the other saw tiles collapsed to the width of their own
// labels, from the same code.
//
// A callback ref is state: React calls it when the node attaches and again
// when it detaches, so the effect re-runs the moment there is something to
// measure. It also covers the same trip through the minimized dock, which
// unmounts the stage and brings it back.
const [node, setNode] = React.useState(null);
React.useLayoutEffect(() => {
if (!node) return undefined;
const apply = () => setSize((prev) => (
prev.w === node.clientWidth && prev.h === node.clientHeight
? prev // same numbers, same object: no re-render
: { w: node.clientWidth, h: node.clientHeight }
));
apply(); // before paint, so the first frame is already laid out
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}
const observer = new ResizeObserver(apply);
observer.observe(node);
return () => observer.disconnect();
}, [node]);
return [setNode, size];
}
/**
* One member's tile.
*
* A tile always says what THIS leg is doing, never what the call is doing. In a
* mesh call one member can be connected while another is still being dialled,
* and a single shared status line would have to lie about one of them.
*/
function Tile({ peer, stream, self, localStream, cameraEnabled, speaking, tileW, tileH, onSelect, pinned, compact }) {
const videoRef = React.useRef(null);
const source = self ? localStream : stream;
const showVideo = self ? cameraEnabled : peer.hasVideo;
// Video only. Every tile's <video> is MUTED and the call's audio is played by
// GroupCallMedia from elements that are not in this tree — a tile unmounts
// whenever the user opens another chat, and a call must not go silent
// because somebody looked at a different window.
React.useEffect(() => {
const el = videoRef.current;
if (!el || !source) return;
if (el.srcObject !== source) { el.muted = true; el.srcObject = source; }
const played = el.play && el.play();
if (played && played.catch) played.catch(() => {});
});
// Nothing to say about our own tile: its name already reads "You", and the
// status slot repeating it put the same word at both ends of the label row.
const statusWord = self
? null
: peer.state === LEG_STATE.ACTIVE ? null
: peer.state === LEG_STATE.UNREACHABLE ? t('groupCall.waitingLink')
: peer.state === LEG_STATE.FAILED ? t('groupCall.legFailed')
: t('groupCall.connecting');
const avatarPx = Math.max(44, Math.min(148, Math.round((tileH || 180) * 0.42)));
// The speaking ring is drawn as a border on the tile itself rather than as an
// overlay, so it cannot be covered by the video and does not change layout
// when it appears — a tile that resized every time somebody spoke would make
// the whole grid twitch.
return h('div', {
// A tile is a control as well as a picture: clicking it spotlights that
// person. Given a role and a key handler rather than wrapped in a button,
// so a <video> is not nested inside interactive content.
role: onSelect ? 'button' : undefined,
tabIndex: onSelect ? 0 : undefined,
onClick: onSelect,
onKeyDown: onSelect ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(); } } : undefined,
title: pinned ? t('groupCall.unpin', { name: peer.name })
: onSelect ? t('groupCall.pin', { name: peer.name })
: (speaking ? t('groupCall.speaking', { name: peer.name }) : peer.name),
style: {
cursor: onSelect ? 'pointer' : 'default',
position: 'relative', borderRadius: '14px', overflow: 'hidden', background: '#141417',
border: speaking ? '2px solid #3ecf8e' : '2px solid rgba(255,255,255,0.08)',
boxShadow: speaking ? '0 0 0 3px rgba(62,207,142,0.16)' : 'none',
transition: 'border-color .12s ease, box-shadow .12s ease',
// Sized by the grid, which is the only thing that knows how much
// room there is. aspectRatio stays as the fallback for the first
// paint, before the container has been measured.
// Measured: an exact size. Unmeasured: a shape that is still a tile.
// The floor matters more than it looks — without a minimum a flex item
// can shrink to its own text, and one bad containing block turned the
// whole call into a cluster of label-sized rectangles. Measurement now
// makes the layout GOOD; it is no longer what makes it work at all.
flex: tileW ? '0 0 auto' : '1 1 260px',
minWidth: tileW ? undefined : '200px',
width: tileW ? tileW + 'px' : 'auto',
height: tileH ? tileH + 'px' : 'auto',
aspectRatio: tileH ? undefined : '16 / 9',
display: 'grid', placeItems: 'center',
},
}, [
showVideo
? h('video', {
key: 'v', ref: videoRef, autoPlay: true, muted: true, playsInline: true,
style: {
position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover',
transform: self ? 'scaleX(-1)' : 'none', background: '#0f0f11',
},
})
: h('div', {
key: 'av',
style: {
// Proportional to the tile: a fixed disc looks lost in a
// large tile and crowds a small one.
width: avatarPx + 'px', height: avatarPx + 'px',
borderRadius: '50%', display: 'grid', placeItems: 'center',
background: 'radial-gradient(circle at 35% 30%, #2a2a30, #161618)',
border: speaking ? '1.5px solid rgba(62,207,142,0.65)' : '1px solid rgba(255,255,255,0.1)',
color: speaking ? '#3ecf8e' : '#cfcfd4',
fontFamily: MONO, fontSize: Math.round(avatarPx * 0.3) + 'px', fontWeight: 700,
transition: 'color .12s ease, border-color .12s ease',
},
}, initials(peer.name)),
h('div', {
key: 'label',
style: {
position: 'absolute', insetInline: 0, bottom: 0, display: 'flex', alignItems: 'center',
gap: compact ? '4px' : '7px', padding: compact ? '4px 6px' : '8px 10px',
background: 'linear-gradient(0deg, rgba(0,0,0,0.7), transparent)',
},
}, [
h('span', {
key: 'n',
style: {
flex: 1, minWidth: 0, fontSize: compact ? '10.5px' : '12.5px', fontWeight: 700, color: '#f4f4f6',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
},
}, peer.name),
speaking && h('span', {
key: 'sp', style: { flex: 'none', color: '#3ecf8e', display: 'grid', placeItems: 'center' },
}, svg(ICON.micOn, compact ? 11 : 13, 2)),
(statusWord && !compact) && h('span', {
key: 's',
style: {
flex: 'none', fontFamily: MONO, fontSize: '10.5px',
color: peer.state === LEG_STATE.FAILED ? '#e5727a' : '#9a9aa2',
},
}, statusWord),
(!self && !compact) && qualityBars(peer.quality),
]),
]);
}
/**
* The whole group-call surface.
*
* Three states, in the order they happen: a prompt when somebody else opened a
* call we have not joined, the grid while we are in it, and a dock when the user
* wants the transcript back without hanging up.
*/
export function GroupCallUI({
call, media, groupName, localStream, getRemoteStream,
onJoin, onDismiss, onLeave, onToggleMic, onToggleCamera, onFlipCamera,
}) {
// EVERY hook runs before EVERY early return below, without exception.
// Measuring the stage was added underneath one of them, so the first render
// that reached the grid ran more hooks than the render before it and React
// refused it outright (#310, "rendered more hooks than during the previous
// render") — which took the whole call down at the moment it started. The
// early returns are what make this file easy to get wrong; keeping the hooks
// in one block at the top is what makes it hard.
const [minimized, setMinimized] = React.useState(false);
const [seconds, setSeconds] = React.useState(0);
const [pinned, setPinned] = React.useState(null);
const [stageRef, stage] = useMeasuredSize();
const joined = !!call?.joined;
React.useEffect(() => {
if (!joined) { setSeconds(0); return undefined; }
const started = Date.now();
const iv = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000);
return () => clearInterval(iv);
}, [joined, call?.callId]);
React.useEffect(() => { if (!call) setMinimized(false); }, [!call]);
// A pin is dropped when the call ends, and when the person it points at
// leaves — otherwise the stage would spotlight somebody who is not in the
// call any more and the rest of the grid would look mysteriously short.
const present = React.useMemo(
() => new Set(['self', ...(media?.peers || []).map((p) => p.fp)]),
[media],
);
React.useEffect(() => {
if (pinned && !present.has(pinned)) setPinned(null);
}, [pinned, present]);
if (!call) return null;
const encBadge = h('span', {
key: 'enc',
style: { display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '11.5px', fontWeight: 600, color: '#3ecf8e' },
}, [svg(ICON.lock, 12, 2), t('call.encryptedShort')]);
// ── somebody is calling the group and we have not joined ─────────────────
if (!joined) {
return h('div', {
style: {
position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column',
background: 'radial-gradient(680px 460px at 50% 36%, rgba(240,137,42,0.08), transparent 70%), #0d0d0f',
animation: 'sbExpand .2s ease',
},
}, [
h('div', { key: 'top', style: { flex: 'none', padding: '16px 18px' } },
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: '#3ecf8e' } },
[svg(ICON.lock, 13, 2), t('call.encrypted')])),
h('div', { key: 'mid', style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '0 24px', textAlign: 'center' } }, [
h('div', {
key: 'av',
style: {
position: 'relative', width: '112px', height: '112px', marginBottom: '26px',
display: 'grid', placeItems: 'center',
},
}, [
h('span', { key: 'p1', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(240,137,42,0.5)', animation: 'sbCallPulse 2s ease-out infinite' } }),
h('span', { key: 'p2', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(240,137,42,0.4)', animation: 'sbCallPulse 2s ease-out infinite', animationDelay: '1s' } }),
h('div', {
key: 'c',
style: {
width: '96px', height: '96px', borderRadius: '50%', display: 'grid', placeItems: 'center',
background: 'radial-gradient(circle at 35% 30%, #2a2a30, #161618)',
border: '1px solid rgba(255,255,255,0.1)', color: '#8a8a92',
},
}, svg(ICON.users, 42, 1.6)),
]),
h('div', { key: 'n', style: { fontSize: '22px', fontWeight: 800, letterSpacing: '-0.4px', color: '#f4f4f6' } }, groupName),
h('div', { key: 's', style: { fontFamily: MONO, fontSize: '13.5px', color: '#9a9aa2', marginTop: '8px' } },
call.withVideo
? t('groupCall.startedVideo', { name: call.startedByName })
: t('groupCall.startedVoice', { name: call.startedByName })),
h('div', { key: 'p', style: { fontSize: '12.5px', color: '#6b6b73', marginTop: '6px' } },
t('groupCall.inCall', { count: call.participants.length })),
]),
h('div', { key: 'ctrls', style: { flex: 'none', display: 'flex', justifyContent: 'center', gap: '48px', padding: '24px 24px 40px' } }, [
h('div', { key: 'dec', style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' } }, [
h('button', { key: 'b', onClick: onDismiss, title: t('groupCall.dismiss'), style: { ...endBtn, width: '62px', height: '62px' } }, svg(ICON.phoneHangup, 24, 1.9)),
h('span', { key: 'l', style: { fontFamily: MONO, fontSize: '10.5px', color: '#8a8a92' } }, t('groupCall.dismiss')),
]),
h('div', { key: 'acc', style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' } }, [
h('button', {
key: 'b', onClick: onJoin, title: t('groupCall.join'),
style: {
width: '62px', height: '62px', borderRadius: '50%', display: 'grid', placeItems: 'center',
border: 'none', background: '#3ecf8e', color: '#06231a', cursor: 'pointer',
boxShadow: '0 8px 24px rgba(62,207,142,0.35)',
},
}, svg(ICON.phone, 24, 1.9)),
h('span', { key: 'l', style: { fontFamily: MONO, fontSize: '10.5px', color: '#8a8a92' } }, t('groupCall.join')),
]),
]),
]);
}
const peers = media?.peers || [];
const selfTile = {
fp: 'self', name: t('groupCall.you'), state: LEG_STATE.ACTIVE,
hasVideo: media?.cameraEnabled, quality: null, speaking: media?.selfSpeaking === true,
};
// ── minimized dock ───────────────────────────────────────────────────────
if (minimized) {
// Collapsed, there are no tiles to carry the indicator, so the one line
// the dock has says who is talking instead of how long the call has run.
const speakers = peers.filter((p) => p.speaking).map((p) => p.name);
const talking = speakers.length === 1 ? speakers[0]
: speakers.length > 1 ? speakers.slice(0, 2).join(', ')
: (media?.selfSpeaking ? t('groupCall.you') : null);
return h('div', {
style: {
position: 'absolute', bottom: '18px', insetInlineEnd: '18px', zIndex: 40, width: '244px',
borderRadius: '14px', overflow: 'hidden', background: '#161618',
border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 18px 44px rgba(0,0,0,0.55)',
animation: 'sbExpand .18s ease',
},
}, [
h('div', { key: 'bar', style: { display: 'flex', alignItems: 'center', gap: '11px', padding: '11px 12px' } }, [
h('span', {
key: 'ic',
style: {
flex: 'none', width: '34px', height: '34px', borderRadius: '9px', display: 'grid',
placeItems: 'center', background: 'rgba(62,207,142,0.1)',
border: '1px solid rgba(62,207,142,0.25)', color: '#3ecf8e',
},
}, svg(ICON.users, 16, 1.9)),
h('div', { key: 'tx', style: { flex: 1, minWidth: 0 } }, [
h('div', { key: 'n', style: { fontSize: '13px', fontWeight: 700, color: '#f4f4f6', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, groupName),
h('div', {
key: 's',
style: {
fontFamily: MONO, fontSize: '11px', color: talking ? '#3ecf8e' : '#9a9aa2',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
},
}, talking
? t('groupCall.speaking', { name: talking })
: `${t('groupCall.inCall', { count: call.participants.length })} · ${fmt(seconds)}`),
]),
h('button', { key: 'exp', onClick: () => setMinimized(false), title: t('call.expand'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: 'rgba(255,255,255,0.05)', color: '#cfcfd4', cursor: 'pointer' } }, svg(ICON.expand, 15, 2)),
h('button', { key: 'end', onClick: onLeave, title: t('groupCall.leave'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer' } }, svg(ICON.phoneHangup, 15, 2)),
]),
]);
}
// ── the call itself ──────────────────────────────────────────────────────
const tiles = [selfTile, ...peers];
const tileFor = (id) => tiles.find((tile) => tile.fp === id) || null;
const spotlight = pinned ? tileFor(pinned) : null;
const others = spotlight ? tiles.filter((tile) => tile.fp !== spotlight.fp) : [];
const spot = spotlight ? spotlightLayout(others.length, stage.w, stage.h) : null;
// A stage too short to split honestly falls back to the gallery rather than
// rendering a main tile with no height.
const spotlit = spotlight && spot && spot.main.w > 0;
const layout = gridLayout(tiles.length, stage.w, stage.h);
const renderTile = (tile, extra = {}) => h(Tile, {
key: tile.fp,
peer: tile,
self: tile.fp === 'self',
speaking: tile.speaking === true,
localStream,
cameraEnabled: media?.cameraEnabled,
stream: tile.fp === 'self' ? null : getRemoteStream(tile.fp),
...extra,
});
return h('div', {
style: {
position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column',
background: '#0a0a0c', animation: 'sbExpand .2s ease',
},
}, [
h('div', {
key: 'top',
style: {
flex: 'none', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
gap: '14px', padding: '16px 18px 10px',
},
}, [
h('div', { key: 'l', style: { minWidth: 0 } }, [
h('div', { key: 'n', style: { fontSize: '17px', fontWeight: 800, letterSpacing: '-0.3px', color: '#fff', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, groupName),
h('div', { key: 's', style: { display: 'inline-flex', alignItems: 'center', gap: '9px', marginTop: '4px' } }, [
h('span', { key: 'd', style: { fontFamily: MONO, fontSize: '12.5px', color: '#e8e8eb' } }, fmt(seconds)),
encBadge,
h('span', { key: 'c', style: { fontSize: '11.5px', color: '#8a8a92' } },
t('groupCall.inCall', { count: call.participants.length })),
]),
]),
h('div', { key: 'r', style: { flex: 'none', display: 'flex', alignItems: 'center', gap: '8px' } }, [
spotlit && h('button', {
key: 'grid', onClick: () => setPinned(null), title: t('groupCall.showEveryone'),
style: {
height: '36px', padding: '0 12px', borderRadius: '9px', display: 'inline-flex',
alignItems: 'center', gap: '7px', border: '1px solid rgba(255,255,255,0.15)',
background: 'rgba(0,0,0,0.35)', color: '#fff', cursor: 'pointer',
fontFamily: 'inherit', fontSize: '12.5px', fontWeight: 600,
},
}, [svg(ICON.grid, 14, 2), t('groupCall.showEveryone')]),
h('button', { key: 'min', onClick: () => setMinimized(true), title: t('call.minimize'), style: minimizeBtn }, svg(ICON.minimize, 16, 2)),
]),
]),
media?.error && h('div', {
key: 'err',
style: {
flex: 'none', margin: '0 18px 10px', padding: '9px 12px', borderRadius: '10px',
background: 'rgba(229,114,122,0.1)', border: '1px solid rgba(229,114,122,0.28)',
color: '#e5727a', fontSize: '12.5px', lineHeight: 1.5,
},
}, t(`groupCall.err.${media.error}`)),
// The stage is what gets measured, so it carries no padding of its own —
// padding would be counted as usable room and every tile would come out
// slightly too large for the space that is really there.
h('div', {
key: 'stage',
ref: stageRef,
className: 'msc-scroll',
style: {
flex: 1, minHeight: 0, minWidth: 0, overflow: 'auto',
// Breathing room as MARGIN, not padding: clientWidth counts
// padding as usable space and every tile would come out that
// much too wide for the room actually available.
margin: '0 14px 6px',
// Flex, not grid. A grid with `justify-content: center` sizes its
// column to the content, so the row's `width: 100%` had nothing
// definite to resolve against and collapsed to the widest label —
// which is exactly what the broken screenshot showed. A flex
// container keeps a definite content box either way, so a row
// asking for the full width gets the full width.
// `safe center` centres it without the clipping plain centring
// causes once the content overflows: the first row stays
// reachable and the stage scrolls as normal.
display: 'flex', alignItems: 'safe center', justifyContent: 'safe center',
},
}, spotlit
// ── spotlight: one person large, the rest in a strip underneath ──
? h('div', {
style: {
display: 'flex', flexDirection: 'column', gap: TILE_GAP + 'px',
width: '100%', height: stage.h + 'px', minHeight: 0,
},
}, [
h('div', {
key: 'main',
style: { flex: 'none', display: 'flex', justifyContent: 'center', minHeight: 0 },
}, renderTile(spotlight, {
tileW: spot.main.w, tileH: spot.main.h,
pinned: true, onSelect: () => setPinned(null),
})),
others.length > 0 && h('div', {
key: 'strip',
className: 'msc-scroll',
style: {
flex: 'none', height: spot.stripH + 'px', display: 'flex',
gap: TILE_GAP + 'px', justifyContent: others.length > 4 ? 'flex-start' : 'center',
// The strip scrolls sideways rather than shrinking: past a
// handful of people, thumbnails that keep dividing stop
// being recognisable, which is the whole point of them.
overflowX: 'auto', overflowY: 'hidden',
},
}, others.map((tile) => renderTile(tile, {
tileW: spot.thumbW, tileH: spot.stripH, compact: true,
onSelect: () => setPinned(tile.fp),
}))),
])
// ── gallery: everyone the same size ─────────────────────────────
: h('div', {
// Wrapping flex rather than a grid, for one reason: a call of three
// lays out two over one, and in a grid that lone tile is stuck in the
// first column with a hole beside it. Fixing the row width to exactly
// `cols` tiles makes the wrap land where the layout said it should,
// and a short last row centres itself.
style: {
display: 'flex', flexWrap: 'wrap', gap: TILE_GAP + 'px',
justifyContent: 'center', alignContent: 'center',
width: layout.tileW
? (layout.cols * layout.tileW + (layout.cols - 1) * TILE_GAP) + 'px'
: '100%',
},
}, tiles.map((tile) => renderTile(tile, {
tileW: layout.tileW, tileH: layout.tileH,
onSelect: () => setPinned(tile.fp),
})))),
h('div', {
key: 'ctrls',
style: {
flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '16px',
padding: '16px 20px calc(22px + var(--sb-safe-bottom, env(safe-area-inset-bottom, 0px)))',
background: 'linear-gradient(0deg, rgba(0,0,0,0.6), transparent)',
},
}, [
h('button', { key: 'mic', onClick: onToggleMic, title: t('call.mute'), style: media?.micEnabled ? ctrlBase : dangerCtrl },
svg(media?.micEnabled ? ICON.micOn : ICON.micOff, 21, 1.9)),
h('button', { key: 'cam', onClick: onToggleCamera, title: t('call.camera'), style: media?.cameraEnabled ? ctrlBase : dangerCtrl },
svg(media?.cameraEnabled ? ICON.camOn : ICON.camOff, 21, 1.8)),
media?.cameraEnabled && h('button', { key: 'flip', onClick: onFlipCamera, title: t('call.flipCamera'), style: ctrlBase }, svg(ICON.flip, 21, 1.8)),
h('button', { key: 'end', onClick: onLeave, title: t('groupCall.leave'), style: endBtn }, svg(ICON.phoneHangup, 22, 1.9)),
]),
]);
}
if (typeof window !== 'undefined') {
window.GroupCallUI = GroupCallUI;
}
+69 -9
View File
@@ -37,6 +37,9 @@ const ICON = {
x: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
plus: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
relay: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h4l3-7 4 14 3-7h2"/></svg>',
phone: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>',
exit: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 20H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4"/><path d="m15 16 4-4-4-4"/><path d="M19 12H10"/></svg>',
video: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2.5"/></svg>',
};
const svg = (markup, extra = {}) => h('span', {
@@ -584,6 +587,7 @@ function Bubble({ msg }) {
export function GroupChatView({
group, input, setInput, onSend, onLeave, onRemoveMember, onAddMembers, isAdmin, scrollRef,
onStartCall, callActive, callOverlay,
}) {
const ready = group.phase === GROUP_PHASE.READY && group.sasConfirmed;
// Only members we are RELAYING to. A member who is offline is not relayed —
@@ -599,14 +603,40 @@ export function GroupChatView({
};
return h('div', {
style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, background: C.bg },
style: {
position: 'relative', display: 'flex', flexDirection: 'column',
height: '100%', minHeight: 0, background: C.bg,
},
}, [
// Responsive rules the inline styles cannot express. The group header had
// none, so on a phone the drawer hamburger — which is position:fixed at the
// top-left corner — sat directly on top of the group's avatar and name. The
// 1:1 header has always reserved that space; this is the same reservation,
// plus the safe-area strip an installed web view draws under.
h('style', {
key: 'head-css',
dangerouslySetInnerHTML: {
__html: '@media (max-width:1023px){'
+ '.sb-group-header{padding-inline-start:58px !important;gap:9px !important;}'
+ '}'
+ '@media (max-width:600px){'
// Icon-only actions: three labelled buttons and a group name do not
// fit a narrow screen, and the name is the part that must not be cut.
+ '.sb-group-header .sb-gh-label{display:none !important;}'
+ '.sb-group-header .sb-gh-btn{padding:8px 10px !important;}'
+ '}',
},
}),
// header
h('div', {
key: 'head',
className: 'sb-group-header',
style: {
flex: 'none', display: 'flex', alignItems: 'center', gap: '12px', padding: '0 16px',
height: '64px', borderBottom: `1px solid ${C.line}`,
flex: 'none', display: 'flex', alignItems: 'center', gap: '12px',
padding: 'var(--sb-safe-top, 0px) 16px 0',
minHeight: 'calc(64px + var(--sb-safe-top, 0px))',
borderBottom: `1px solid ${C.line}`,
},
}, [
h('span', {
@@ -634,14 +664,40 @@ export function GroupChatView({
: '',
]),
]),
// Calls are offered only once the group is usable: a call before the
// safety code is confirmed would be media on links nobody has
// authenticated as belonging to this group.
(ready && onStartCall) && h('button', {
key: 'callvoice', className: 'sb-gh-btn', onClick: () => onStartCall(false), disabled: callActive,
title: t('groupCall.startVoice'), 'aria-label': t('groupCall.startVoice'),
style: {
...btn(false), flex: 'none', padding: '8px 10px', opacity: callActive ? 0.4 : 1,
cursor: callActive ? 'default' : 'pointer',
},
}, svg(ICON.phone, { key: 'i' })),
(ready && onStartCall) && h('button', {
key: 'callvideo', className: 'sb-gh-btn', onClick: () => onStartCall(true), disabled: callActive,
title: t('groupCall.startVideo'), 'aria-label': t('groupCall.startVideo'),
style: {
...btn(false), flex: 'none', padding: '8px 10px', opacity: callActive ? 0.4 : 1,
cursor: callActive ? 'default' : 'pointer',
},
}, svg(ICON.video, { key: 'i' })),
(isAdmin && onAddMembers && group.members.length < GROUP_LIMITS.MAX_MEMBERS) && h('button', {
key: 'add', onClick: onAddMembers, title: t('group.inviteMore'),
style: { ...btn(false), padding: '8px 12px', fontSize: '12.5px' },
}, [svg(ICON.plus, { key: 'i' }), t('group.add')]),
key: 'add', className: 'sb-gh-btn', onClick: onAddMembers, title: t('group.inviteMore'),
style: { ...btn(false), flex: 'none', padding: '8px 12px', fontSize: '12.5px' },
}, [
svg(ICON.plus, { key: 'i' }),
h('span', { key: 'l', className: 'sb-gh-label' }, t('group.add')),
]),
h('button', {
key: 'leave', onClick: onLeave, title: t('group.leaveThis'),
style: { ...btn(false), padding: '8px 12px', fontSize: '12.5px', color: C.bad, borderColor: 'rgba(229,114,122,0.3)' },
}, t('group.leave')),
key: 'leave', className: 'sb-gh-btn', onClick: onLeave, title: t('group.leaveThis'),
'aria-label': t('group.leaveThis'),
style: { ...btn(false), flex: 'none', padding: '8px 12px', fontSize: '12.5px', color: C.bad, borderColor: 'rgba(229,114,122,0.3)' },
}, [
svg(ICON.exit, { key: 'i' }),
h('span', { key: 'l', className: 'sb-gh-label' }, t('group.leave')),
]),
]),
h(MemberStrip, { key: 'strip', group, onRemove: onRemoveMember, isAdmin }),
@@ -702,5 +758,9 @@ export function GroupChatView({
},
}, svg(ICON.send)),
]),
// The group call covers the transcript when it is running and renders
// nothing when it is not — the same shape as the 1:1 call overlay.
callOverlay,
]);
}
+9 -3
View File
@@ -546,12 +546,18 @@ const EnhancedMinimalHeader = ({
// animated together so the bar *materialises* on scroll rather than fading in.
const GLASS = 'blur(20px) saturate(180%)';
const MATERIALISE = 'background .25s ease, backdrop-filter .25s ease, -webkit-backdrop-filter .25s ease, border-color .25s ease';
const overlay = { position: 'fixed', top: 0, left: 0, right: 0 };
// Installed on iOS the web view starts at the physical top of the screen, so a
// bar pinned to top:0 sits under the clock and the notch. --sb-safe-top is that
// strip (src/styles/pwa.css) and 0 in a browser tab, so it can be added
// unconditionally: the bar grows by the inset and its contents clear the status
// bar, while the translucent background still reaches the top edge.
const safeTop = { paddingTop: 'var(--sb-safe-top, 0px)', paddingBottom: 'var(--sb-bar-extra, 0px)' };
const overlay = { position: 'fixed', top: 0, left: 0, right: 0, ...safeTop };
const headerStyle = onLanding
? (scrolled
? { ...overlay, background: 'rgba(15,15,17,0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(255,255,255,0.06)', transition: MATERIALISE }
: { ...overlay, background: 'transparent', backdropFilter: 'blur(0px) saturate(100%)', WebkitBackdropFilter: 'blur(0px) saturate(100%)', borderBottom: '1px solid transparent', transition: MATERIALISE })
: { background: 'rgba(18,18,20,0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(255,255,255,0.06)' };
: { ...safeTop, background: 'rgba(18,18,20,0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(255,255,255,0.06)' };
return React.createElement('header', {
className: onLanding ? 'header-minimal z-50' : 'header-minimal sticky top-0 z-50',
@@ -565,7 +571,7 @@ const EnhancedMinimalHeader = ({
React.createElement('div', {
key: 'content',
className: 'flex items-center justify-between',
style: { height: '64px', gap: '16px' }
style: { height: 'var(--sb-bar-h, 64px)', gap: '16px' }
}, [
// Left: logo + wordmark
React.createElement('div', { key: 'left', style: { display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0 } }, [
+140
View File
@@ -0,0 +1,140 @@
// An offer to switch language — never a redirect.
//
// The tempting version of this feature is to read navigator.language on load and send
// the visitor to the matching locale. It breaks the site for search engines in a way
// that is invisible from a browser: Googlebot crawls with a single language (en) from a
// single place, so a language redirect answers every one of the thirteen locale URLs
// with the English page. The twelve translations then have no indexable document of
// their own, the hreflang cluster they are declared in contradicts what the crawler
// actually receives, and the work of translating the site earns nothing. It is also
// wrong for people: a link shared into a German group chat has to open in German for
// everyone who follows it, including the person whose laptop is set to English.
//
// So the URL stays authoritative and this bar carries the whole feature. It appears only
// when the page being read is not the language the visitor probably wants, it says so in
// that language, and it offers a normal link. Dismissing it is remembered for good.
//
// Landing only, like the switcher: following the link is a navigation, and a navigation
// during a session drops the peer connection.
import {
LOCALE_META,
dismissLocaleSuggestion,
localeHref,
localeSuggestionDismissed,
rememberLocale,
storedLocale,
suggestedLocale,
t,
} from '../../i18n/index.js';
import { prefersReducedMotion } from '../../ui/motion.js';
const LanguageSuggestion = () => {
// Resolved once, at mount: the answer cannot change without a navigation, and
// re-deriving it on every render would mean a storage read per render.
const [target] = React.useState(() => {
if (typeof window === 'undefined' || !window.location) return null;
if (localeSuggestionDismissed()) return null;
return suggestedLocale({
pathname: window.location.pathname || '/',
languages: window.navigator?.languages || [],
stored: storedLocale(),
});
});
const [gone, setGone] = React.useState(false);
// Mounted invisible, then raised on the next frame, so the bar arrives rather than
// being part of the first paint — the page's own content should land first.
const [shown, setShown] = React.useState(false);
React.useEffect(() => {
if (!target) return undefined;
const id = setTimeout(() => setShown(true), 600);
return () => clearTimeout(id);
}, [target]);
if (!target || gone) return null;
const meta = LOCALE_META[target] || {};
const dir = meta.dir === 'rtl' ? 'rtl' : 'ltr';
const pathname = typeof window !== 'undefined' ? window.location.pathname : '/';
const href = localeHref(target, pathname);
const still = prefersReducedMotion();
const close = () => {
dismissLocaleSuggestion();
setGone(true);
};
// The copy is in the language being offered — the point of the bar is to be readable
// by someone who cannot read the page it sits on. `dir` and `lang` come with it, so
// Arabic in this bar is laid out as Arabic even on the English page.
const line = React.createElement('div', {
key: 'line',
lang: meta.htmlLang || target,
dir,
style: { fontSize: '13px', lineHeight: 1.45, color: '#cfcfd4', textAlign: 'start' },
}, t('language.suggest.text', null, target));
const link = React.createElement('a', {
key: 'cta',
href,
hrefLang: meta.htmlLang || target,
lang: meta.htmlLang || target,
dir,
// Following the link is an explicit choice, so it is remembered — the bar has
// then done its job and will not be offered again.
onClick: () => { rememberLocale(target); dismissLocaleSuggestion(); },
style: {
display: 'inline-flex', alignItems: 'center', gap: '6px',
padding: '7px 12px', borderRadius: '9px',
border: '1px solid rgba(240,137,42,0.30)', background: 'rgba(240,137,42,0.12)',
color: '#f0892a', fontSize: '12.5px', fontWeight: 600,
textDecoration: 'none', whiteSpace: 'nowrap',
},
}, t('language.suggest.cta', null, target));
const dismiss = React.createElement('button', {
key: 'dismiss',
type: 'button',
onClick: close,
// Labelled in the offered language too: this button is for the same reader.
'aria-label': t('language.suggest.dismiss', null, target),
style: {
padding: '7px 10px', borderRadius: '9px',
border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)',
color: '#8a8a92', font: 'inherit', fontSize: '12.5px', fontWeight: 500,
cursor: 'pointer', whiteSpace: 'nowrap',
},
}, t('language.suggest.dismiss', null, target));
return React.createElement('div', {
// A region rather than a dialog: it interrupts nothing and takes no focus.
role: 'region',
'aria-label': t('language.label'),
style: {
// Bottom inline-start, because the install prompt owns the opposite corner.
position: 'fixed', bottom: '24px', insetInlineStart: '24px', zIndex: 50,
maxWidth: 'min(340px, calc(100vw - 48px))',
display: 'flex', flexDirection: 'column', gap: '11px',
padding: '14px 16px', borderRadius: '14px',
border: '1px solid rgba(255,255,255,0.08)', background: '#161618',
boxShadow: '0 16px 40px rgba(0,0,0,0.5)',
opacity: shown ? 1 : 0,
transform: shown || still ? 'none' : 'translateY(10px)',
transition: still ? 'opacity .2s linear' : 'opacity .3s ease, transform .3s cubic-bezier(.2,.7,.3,1)',
// Invisible and untouchable until raised, so it cannot swallow a tap during
// the fade.
pointerEvents: shown ? 'auto' : 'none',
},
}, [
line,
React.createElement('div', {
key: 'actions',
style: { display: 'flex', alignItems: 'center', gap: '8px' },
}, [link, dismiss]),
]);
};
window.LanguageSuggestion = LanguageSuggestion;
export { LanguageSuggestion };
+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 };
}