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: '', minimize: '', expand: '', users: '', micOn: '', micOff: '', camOn: '', camOff: '', grid: '', flip: '', phone: '', phoneHangup: '', }; const svg = (inner, size, sw) => h('span', { style: { display: 'grid', placeItems: 'center', width: size + 'px', height: size + 'px' }, dangerouslySetInnerHTML: { __html: `${inner}` }, }); 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