Files
securebit-chat/src/components/ui/GroupCallUI.jsx
T
lockbitchat 113bb107d3
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
feat(groups): audio and video calls in group chats; release v6.6.6
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
2026-09-01 01:04:11 -04:00

576 lines
33 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}