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
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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 } }, [
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user