// call-window.js: the call window's controls (2026-09-28) — when they // show, the popovers over the picture, and a phone's controls sheet. // // Presentation only, like call-layout.js: room.js owns every stream and // every control's meaning, and nothing here is sent anywhere. // // When the controls show is the Mac's ControlsVisibility (CallChrome.swift), // one small state machine with the time passed in, so // test/browser/room-window.test.mjs can drive it without a browser and // hold its constants equal to the Swift ones. The pointer moving shows // them, and 3 s without it hides them; leaving the window hides them // 1.5 s later (never later than they were due anyway). A tap on the // picture toggles them. They stay while something holds them: the // pointer over the bar, a popover (More among them), the chat or the // phone's sheet open, keyboard focus in them, the call over // (its way out is in the notices, and the bar with it), and — because a // page cannot tell whether a screen reader is running — always, for // anyone who has asked for reduced motion. Tab brings them back. Space // works with them hidden. Hidden, they are only transparent: a screen // reader still has them, and a press on one that is not showing (a // finger cannot hover first) shows them and does nothing else. (() => { 'use strict'; // ---- the rule, lifted by name in test/browser/room-window.test.mjs ---- // A pointer keeps the controls up for 3 s after it last moved // (ControlsVisibility.pointerDelay); a touch screen 3 s after the last // tap (touchDelay); leaving the window hides them 1.5 s later // (pointerExitDelay). The fades are calls.css's: 0.35 s going, 0.2 s // coming back. const CONTROLS_POINTER_DELAY = 3; const CONTROLS_TOUCH_DELAY = 3; const CONTROLS_EXIT_DELAY = 1.5; // appleTouch: an iPhone or iPad, whatever browser — every one there is // Safari's engine, with Safari's rule that a tab in the background or // a locked screen loses its camera, microphone and sound. An iPad asks // for desktop sites as a Mac, so a "Mac" with a touch screen is one. // Pure, and lifted by name in test/browser/room-window.test.mjs. function appleTouch(nav) { if (!nav) return false; const platform = String(nav.platform || ''); const agent = String(nav.userAgent || ''); if (/^(iPhone|iPad|iPod)/.test(platform) || /\b(iPhone|iPad|iPod)\b/.test(agent)) return true; return platform === 'MacIntel' && (nav.maxTouchPoints || 0) > 1; } function controlsDelay(input) { return input === 'touch' ? CONTROLS_TOUCH_DELAY : CONTROLS_POINTER_DELAY; } // controlsStart: the controls start visible, so a call opens with them // in view, and fade on the usual schedule. function controlsStart(input, now) { return { input, visible: true, holds: {}, hideAt: now + controlsDelay(input), pointerInside: false }; } function controlsHeld(s) { for (const k in s.holds) if (s.holds[k]) return true; return false; } // controlsStep is one input to the machine, and the next state: // move the pointer moved over the window: show, start the wait // exit it left the window: hide EXIT_DELAY later, or when due // tap a tap on the picture: toggle, unless held // activity a key such as Tab, a click: show, start the wait // hold {hold, on}: something that keeps them up started/stopped // tick the clock: hide once hideAt has passed function controlsStep(s, ev, now) { const n = { input: s.input, visible: s.visible, holds: Object.assign({}, s.holds), hideAt: s.hideAt, pointerInside: s.pointerInside }; const reveal = () => { n.visible = true; n.hideAt = controlsHeld(n) ? null : now + controlsDelay(n.input); }; const hide = () => { n.visible = false; n.hideAt = null; }; switch (ev.type) { case 'move': n.pointerInside = true; reveal(); break; case 'exit': { n.pointerInside = false; delete n.holds.pointerOverControls; if (!n.visible || controlsHeld(n)) break; const linger = now + CONTROLS_EXIT_DELAY; n.hideAt = Math.min(n.hideAt == null ? linger : n.hideAt, linger); break; } case 'tap': if (n.visible && !controlsHeld(n)) hide(); else reveal(); break; case 'activity': reveal(); break; case 'hold': if (ev.on) { n.holds[ev.hold] = true; n.visible = true; n.hideAt = null; } else { if (!n.holds[ev.hold]) return n; delete n.holds[ev.hold]; if (!controlsHeld(n) && n.visible) n.hideAt = now + controlsDelay(n.input); } break; case 'tick': if (!controlsHeld(n) && n.hideAt != null && now >= n.hideAt) hide(); break; default: break; } return n; } // ---- the page ---- const $ = (id) => document.getElementById(id); const room = $('room'); const win = room ? room.closest('.calls-window') : null; const bar = $('controls'); const top = $('call-top'); if (!win || !room || !bar || !top) return; const sheet = $('call-sheet'); const moreControls = $('more-controls'); const leave = $('leave'); const chatOverlay = $('chat-overlay'); const chatToggle = $('toggle-chat'); const tileMenu = $('tile-menu'); const phoneQuery = matchMedia('(max-width: 599px), (max-height: 499px) and (pointer: coarse)'); const motionQuery = matchMedia('(prefers-reduced-motion: reduce)'); const phone = () => phoneQuery.matches; const now = () => performance.now() / 1000; // The bar's popovers. `own` is whether this file opens and closes it; // the device panel is room.js's (it fills the selects on opening), so // it is opened and closed through its own button. const popovers = [ { toggle: $('layout-toggle'), panel: $('layout-panel'), own: true }, { toggle: $('devices-toggle'), panel: $('room-devices'), own: false }, { toggle: $('call-more-toggle'), panel: $('call-more'), own: true }, ].filter((p) => p.toggle && p.panel); let s = controlsStart('pointer', now()); // A fixture's pin (window.callsChrome.pin), as the Mac's // ControlsVisibilityModel has: shown or hidden whatever happens. let pinned = null; let timer = 0; function visible() { return pinned === null ? s.visible : pinned; } function render() { const want = visible() ? 'shown' : 'hidden'; if (win.dataset.chrome !== want) win.dataset.chrome = want; win.toggleAttribute('data-pointer-inside', !visible() && s.pointerInside && pinned === null); } function schedule() { clearTimeout(timer); if (s.hideAt == null) return; timer = setTimeout(() => step({ type: 'tick' }), Math.max(0, (s.hideAt - now()) * 1000) + 20); } function step(ev) { s = controlsStep(s, ev, now()); render(); schedule(); } function hold(name, on) { if (!!s.holds[name] === !!on) return; step({ type: 'hold', hold: name, on: !!on }); } // Over the bar, the pointer holds them (the Mac's bar and traffic // lights). The title takes no pointer: it is only words. const overChrome = (el) => !!el && (bar.contains(el) || top.contains(el)); const inPopover = (el) => !!el && popovers.some((p) => p.panel.contains(el)); win.addEventListener('pointermove', (ev) => { if (ev.pointerType === 'touch') return; step({ type: 'move' }); hold('pointerOverControls', overChrome(ev.target) || inPopover(ev.target)); }); win.addEventListener('pointerleave', (ev) => { if (ev.pointerType === 'touch') return; step({ type: 'exit' }); }); // A finger has no hover: a tap on the picture toggles the controls, // and a tap on a control that is not showing shows the controls and // does nothing else — its click is swallowed. A mouse always moves // first, so its controls are up by the time it presses. const INTERACTIVE = 'button, a, summary, input, select, textarea, label, .calls-popover, .calls-chat-overlay, ' + '.calls-sheet, .calls-notices > *, .calls-tile-tools, .calls-tile-menu'; let swallow = false; win.addEventListener('pointerdown', (ev) => { const target = ev.target instanceof Element ? ev.target : null; if (!target) return; if (ev.pointerType !== 'touch') { step({ type: 'activity' }); return; } if (!visible() && target.closest('.calls-fades')) { swallow = true; step({ type: 'activity' }); return; } step({ type: target.closest(INTERACTIVE) ? 'activity' : 'tap' }); }, true); win.addEventListener('click', (ev) => { if (!swallow) return; swallow = false; ev.preventDefault(); ev.stopPropagation(); }, true); // The keyboard: Tab is navigation starting, so it brings them back; // focus that the keyboard put in the bar, the title, a popover or a // tile's arrangement buttons holds them. A mouse click focuses a button too, but not :focus-visible, // and a clicked button should not keep the bar up for ever. function focusCheck() { const a = document.activeElement; let keyboard = false; if (a && a !== document.body && (overChrome(a) || inPopover(a) || a.closest('.calls-tile-tools, .calls-tile-menu'))) { try { keyboard = a.matches(':focus-visible'); } catch { keyboard = true; } } hold('keyboardFocus', keyboard); } document.addEventListener('focusin', focusCheck); document.addEventListener('focusout', () => setTimeout(focusCheck, 0)); // ---- popovers and the phone's sheet ---- function placePopover(p) { if (p.panel.hidden) return; if (phone()) { p.panel.style.removeProperty('--calls-pop-x'); return; } const w = win.getBoundingClientRect(); const b = p.toggle.getBoundingClientRect(); const width = p.panel.offsetWidth; const x = Math.min(Math.max(b.left + b.width / 2 - w.left - width / 2, 12), w.width - width - 12); p.panel.style.setProperty('--calls-pop-x', Math.round(Math.max(12, x)) + 'px'); } function setPopover(p, open) { if (p.panel.hidden === !open) return; if (p.own) { p.panel.hidden = !open; p.toggle.setAttribute('aria-expanded', String(open)); } else if (!p.toggle.disabled) { p.toggle.click(); } else { p.panel.hidden = !open; p.toggle.setAttribute('aria-expanded', String(open)); } } function setSheet(open) { if (!sheet || !moreControls) return; if (sheet.hasAttribute('data-open') === open) return; sheet.toggleAttribute('data-open', open); moreControls.setAttribute('aria-expanded', String(open)); } for (const p of popovers) { if (p.own) { p.toggle.addEventListener('click', () => { const open = p.panel.hidden; setPopover(p, open); if (open) { const first = p.panel.querySelector('[aria-pressed="true"], a, button, select, input'); if (first) first.focus(); } }); } // Whoever opened it, one popover at a time, placed over its button, // and on a phone in place of the sheet. new MutationObserver(() => { if (!p.panel.hidden) { for (const other of popovers) if (other !== p) setPopover(other, false); if (phone()) setSheet(false); placePopover(p); } menusCheck(); }).observe(p.panel, { attributes: true, attributeFilter: ['hidden'] }); } if (moreControls && sheet) { moreControls.addEventListener('click', () => { const open = !sheet.hasAttribute('data-open'); if (open) for (const p of popovers) setPopover(p, false); setSheet(open); }); } // Chat from the sheet: the sheet goes, and the chat is what is left. chatToggle?.addEventListener('click', () => { if (phone()) setSheet(false); }); phoneQuery.addEventListener?.('change', () => { setSheet(false); for (const p of popovers) placePopover(p); }); window.addEventListener('resize', () => { for (const p of popovers) placePopover(p); }); // A press anywhere else closes what is open: a popover, the sheet. Chat is a panel, not a popover, and stays. document.addEventListener('pointerdown', (ev) => { const t = ev.target instanceof Node ? ev.target : null; if (!t) return; for (const p of popovers) { if (!p.panel.hidden && !p.panel.contains(t) && !p.toggle.contains(t)) setPopover(p, false); } if (sheet && sheet.hasAttribute('data-open') && !sheet.contains(t) && !(moreControls && moreControls.contains(t)) && !popovers.some((p) => p.panel.contains(t))) setSheet(false); }, true); // Escape closes the innermost open thing and puts the keyboard back on // what opened it. document.addEventListener('keydown', (ev) => { if (ev.key === 'Tab') step({ type: 'activity' }); if (ev.key !== 'Escape' || ev.defaultPrevented) return; const open = popovers.find((p) => !p.panel.hidden); if (open) { ev.preventDefault(); setPopover(open, false); open.toggle.focus(); return; } if (sheet && sheet.hasAttribute('data-open')) { ev.preventDefault(); setSheet(false); moreControls?.focus(); } }); // What holds the controls up besides the pointer and the keyboard. function menusCheck() { const open = popovers.some((p) => !p.panel.hidden) || (chatOverlay && !chatOverlay.hidden) || (tileMenu && !tileMenu.hidden) || (sheet && sheet.hasAttribute('data-open')); hold('menuOpen', open); } const watch = (el, attrs) => { if (el) new MutationObserver(menusCheck).observe(el, { attributes: true, attributeFilter: attrs }); }; watch(chatOverlay, ['hidden']); watch(tileMenu, ['hidden']); watch(sheet, ['data-open']); // The call is over: Leave is disabled and the way out is in the notices. if (leave) { const alertCheck = () => hold('alert', leave.disabled); new MutationObserver(alertCheck).observe(leave, { attributes: true, attributeFilter: ['disabled'] }); alertCheck(); } const motionCheck = () => hold('reducedMotion', motionQuery.matches); motionQuery.addEventListener?.('change', motionCheck); motionCheck(); menusCheck(); // ---- the relay notice ---- // There is no title pill (2026-10-02): how the call is going is the // status notice room.js keeps (#status) — Connecting, Waiting to be let // in, The room is full, Reconnecting, the call over with its way out — // and a pill saying it again in fewer words was clutter. // §5.5's relay notice: shown in the first room a tab's session opens, // until it is put away; not again in that session. const floor = $('floor-notice'); if (floor) { const key = 'calls.floorSeen'; let seen = false; try { seen = sessionStorage.getItem(key) === '1'; } catch { /* private mode: show it */ } if (!seen) { floor.hidden = false; try { sessionStorage.setItem(key, '1'); } catch { /* this page only */ } } $('floor-dismiss')?.addEventListener('click', () => { floor.hidden = true; }); } // iPhone and iPad: Safari (and every browser there, which is Safari // underneath) stops the camera and microphone and lets the call's sound // lapse once the tab is in the background or the screen locks — a call // that "keeps cutting in and out" (Till, 2026-10-03). Said once a // session, put away like the relay notice. An iPad asks for the desktop // site and calls itself a Mac, so a Mac with a touch screen is one. const keepOpen = $('keep-open-notice'); if (keepOpen && appleTouch(navigator)) { const key = 'calls.keepOpenSeen'; let seen = false; try { seen = sessionStorage.getItem(key) === '1'; } catch { /* private mode: show it */ } if (!seen) { keepOpen.hidden = false; try { sessionStorage.setItem(key, '1'); } catch { /* this page only */ } } $('keep-open-dismiss')?.addEventListener('click', () => { keepOpen.hidden = true; }); } // The notices sit under the title strip — none for a title Calls wrote, // which is not shown — and it keeps its place while faded, so they do // not jump as the controls come and go. const titleHeight = () => win.style.setProperty('--calls-title-h', Math.ceil(top.offsetHeight) + 'px'); if (window.ResizeObserver) new ResizeObserver(titleHeight).observe(top); titleHeight(); render(); schedule(); // For the gate and for a look from the console, and for snapshots: // pin(true|false) holds the controls shown or hidden, pin(null) lets // them go again. window.callsChrome = { state: () => ({ visible: visible(), holds: Object.keys(s.holds).filter((k) => s.holds[k]), hideIn: s.hideAt == null ? null : Math.max(0, s.hideAt - now()), pointerInside: s.pointerInside, pinned, }), pin(v) { pinned = v === true || v === false ? v : null; render(); }, }; })();