| 1 | // call-window.js: the call window's controls (2026-09-28) — when they |
| 2 | // show, the popovers over the picture, and a phone's controls sheet. |
| 3 | // |
| 4 | // Presentation only, like call-layout.js: room.js owns every stream and |
| 5 | // every control's meaning, and nothing here is sent anywhere. |
| 6 | // |
| 7 | // When the controls show is the Mac's ControlsVisibility (CallChrome.swift), |
| 8 | // one small state machine with the time passed in, so |
| 9 | // test/browser/room-window.test.mjs can drive it without a browser and |
| 10 | // hold its constants equal to the Swift ones. The pointer moving shows |
| 11 | // them, and 3 s without it hides them; leaving the window hides them |
| 12 | // 1.5 s later (never later than they were due anyway). A tap on the |
| 13 | // picture toggles them. They stay while something holds them: the |
| 14 | // pointer over the bar, a popover (More among them), the chat or the |
| 15 | // phone's sheet open, keyboard focus in them, the call over |
| 16 | // (its way out is in the notices, and the bar with it), and — because a |
| 17 | // page cannot tell whether a screen reader is running — always, for |
| 18 | // anyone who has asked for reduced motion. Tab brings them back. Space |
| 19 | // works with them hidden. Hidden, they are only transparent: a screen |
| 20 | // reader still has them, and a press on one that is not showing (a |
| 21 | // finger cannot hover first) shows them and does nothing else. |
| 22 | (() => { |
| 23 | 'use strict'; |
| 24 | |
| 25 | // ---- the rule, lifted by name in test/browser/room-window.test.mjs ---- |
| 26 | |
| 27 | // A pointer keeps the controls up for 3 s after it last moved |
| 28 | // (ControlsVisibility.pointerDelay); a touch screen 3 s after the last |
| 29 | // tap (touchDelay); leaving the window hides them 1.5 s later |
| 30 | // (pointerExitDelay). The fades are calls.css's: 0.35 s going, 0.2 s |
| 31 | // coming back. |
| 32 | const CONTROLS_POINTER_DELAY = 3; |
| 33 | const CONTROLS_TOUCH_DELAY = 3; |
| 34 | const CONTROLS_EXIT_DELAY = 1.5; |
| 35 | |
| 36 | // appleTouch: an iPhone or iPad, whatever browser — every one there is |
| 37 | // Safari's engine, with Safari's rule that a tab in the background or |
| 38 | // a locked screen loses its camera, microphone and sound. An iPad asks |
| 39 | // for desktop sites as a Mac, so a "Mac" with a touch screen is one. |
| 40 | // Pure, and lifted by name in test/browser/room-window.test.mjs. |
| 41 | function appleTouch(nav) { |
| 42 | if (!nav) return false; |
| 43 | const platform = String(nav.platform || ''); |
| 44 | const agent = String(nav.userAgent || ''); |
| 45 | if (/^(iPhone|iPad|iPod)/.test(platform) || /\b(iPhone|iPad|iPod)\b/.test(agent)) return true; |
| 46 | return platform === 'MacIntel' && (nav.maxTouchPoints || 0) > 1; |
| 47 | } |
| 48 | |
| 49 | function controlsDelay(input) { |
| 50 | return input === 'touch' ? CONTROLS_TOUCH_DELAY : CONTROLS_POINTER_DELAY; |
| 51 | } |
| 52 | |
| 53 | // controlsStart: the controls start visible, so a call opens with them |
| 54 | // in view, and fade on the usual schedule. |
| 55 | function controlsStart(input, now) { |
| 56 | return { input, visible: true, holds: {}, hideAt: now + controlsDelay(input), pointerInside: false }; |
| 57 | } |
| 58 | |
| 59 | function controlsHeld(s) { |
| 60 | for (const k in s.holds) if (s.holds[k]) return true; |
| 61 | return false; |
| 62 | } |
| 63 | |
| 64 | // controlsStep is one input to the machine, and the next state: |
| 65 | // move the pointer moved over the window: show, start the wait |
| 66 | // exit it left the window: hide EXIT_DELAY later, or when due |
| 67 | // tap a tap on the picture: toggle, unless held |
| 68 | // activity a key such as Tab, a click: show, start the wait |
| 69 | // hold {hold, on}: something that keeps them up started/stopped |
| 70 | // tick the clock: hide once hideAt has passed |
| 71 | function controlsStep(s, ev, now) { |
| 72 | const n = { input: s.input, visible: s.visible, holds: Object.assign({}, s.holds), hideAt: s.hideAt, pointerInside: s.pointerInside }; |
| 73 | const reveal = () => { |
| 74 | n.visible = true; |
| 75 | n.hideAt = controlsHeld(n) ? null : now + controlsDelay(n.input); |
| 76 | }; |
| 77 | const hide = () => { |
| 78 | n.visible = false; |
| 79 | n.hideAt = null; |
| 80 | }; |
| 81 | switch (ev.type) { |
| 82 | case 'move': |
| 83 | n.pointerInside = true; |
| 84 | reveal(); |
| 85 | break; |
| 86 | case 'exit': { |
| 87 | n.pointerInside = false; |
| 88 | delete n.holds.pointerOverControls; |
| 89 | if (!n.visible || controlsHeld(n)) break; |
| 90 | const linger = now + CONTROLS_EXIT_DELAY; |
| 91 | n.hideAt = Math.min(n.hideAt == null ? linger : n.hideAt, linger); |
| 92 | break; |
| 93 | } |
| 94 | case 'tap': |
| 95 | if (n.visible && !controlsHeld(n)) hide(); |
| 96 | else reveal(); |
| 97 | break; |
| 98 | case 'activity': |
| 99 | reveal(); |
| 100 | break; |
| 101 | case 'hold': |
| 102 | if (ev.on) { |
| 103 | n.holds[ev.hold] = true; |
| 104 | n.visible = true; |
| 105 | n.hideAt = null; |
| 106 | } else { |
| 107 | if (!n.holds[ev.hold]) return n; |
| 108 | delete n.holds[ev.hold]; |
| 109 | if (!controlsHeld(n) && n.visible) n.hideAt = now + controlsDelay(n.input); |
| 110 | } |
| 111 | break; |
| 112 | case 'tick': |
| 113 | if (!controlsHeld(n) && n.hideAt != null && now >= n.hideAt) hide(); |
| 114 | break; |
| 115 | default: |
| 116 | break; |
| 117 | } |
| 118 | return n; |
| 119 | } |
| 120 | |
| 121 | // ---- the page ---- |
| 122 | |
| 123 | const $ = (id) => document.getElementById(id); |
| 124 | const room = $('room'); |
| 125 | const win = room ? room.closest('.calls-window') : null; |
| 126 | const bar = $('controls'); |
| 127 | const top = $('call-top'); |
| 128 | if (!win || !room || !bar || !top) return; |
| 129 | const sheet = $('call-sheet'); |
| 130 | const moreControls = $('more-controls'); |
| 131 | const leave = $('leave'); |
| 132 | const chatOverlay = $('chat-overlay'); |
| 133 | const chatToggle = $('toggle-chat'); |
| 134 | const tileMenu = $('tile-menu'); |
| 135 | const phoneQuery = matchMedia('(max-width: 599px), (max-height: 499px) and (pointer: coarse)'); |
| 136 | const motionQuery = matchMedia('(prefers-reduced-motion: reduce)'); |
| 137 | const phone = () => phoneQuery.matches; |
| 138 | const now = () => performance.now() / 1000; |
| 139 | |
| 140 | // The bar's popovers. `own` is whether this file opens and closes it; |
| 141 | // the device panel is room.js's (it fills the selects on opening), so |
| 142 | // it is opened and closed through its own button. |
| 143 | const popovers = [ |
| 144 | { toggle: $('layout-toggle'), panel: $('layout-panel'), own: true }, |
| 145 | { toggle: $('devices-toggle'), panel: $('room-devices'), own: false }, |
| 146 | { toggle: $('call-more-toggle'), panel: $('call-more'), own: true }, |
| 147 | ].filter((p) => p.toggle && p.panel); |
| 148 | |
| 149 | let s = controlsStart('pointer', now()); |
| 150 | // A fixture's pin (window.callsChrome.pin), as the Mac's |
| 151 | // ControlsVisibilityModel has: shown or hidden whatever happens. |
| 152 | let pinned = null; |
| 153 | let timer = 0; |
| 154 | |
| 155 | function visible() { |
| 156 | return pinned === null ? s.visible : pinned; |
| 157 | } |
| 158 | |
| 159 | function render() { |
| 160 | const want = visible() ? 'shown' : 'hidden'; |
| 161 | if (win.dataset.chrome !== want) win.dataset.chrome = want; |
| 162 | win.toggleAttribute('data-pointer-inside', !visible() && s.pointerInside && pinned === null); |
| 163 | } |
| 164 | |
| 165 | function schedule() { |
| 166 | clearTimeout(timer); |
| 167 | if (s.hideAt == null) return; |
| 168 | timer = setTimeout(() => step({ type: 'tick' }), Math.max(0, (s.hideAt - now()) * 1000) + 20); |
| 169 | } |
| 170 | |
| 171 | function step(ev) { |
| 172 | s = controlsStep(s, ev, now()); |
| 173 | render(); |
| 174 | schedule(); |
| 175 | } |
| 176 | |
| 177 | function hold(name, on) { |
| 178 | if (!!s.holds[name] === !!on) return; |
| 179 | step({ type: 'hold', hold: name, on: !!on }); |
| 180 | } |
| 181 | |
| 182 | // Over the bar, the pointer holds them (the Mac's bar and traffic |
| 183 | // lights). The title takes no pointer: it is only words. |
| 184 | const overChrome = (el) => !!el && (bar.contains(el) || top.contains(el)); |
| 185 | const inPopover = (el) => !!el && popovers.some((p) => p.panel.contains(el)); |
| 186 | |
| 187 | win.addEventListener('pointermove', (ev) => { |
| 188 | if (ev.pointerType === 'touch') return; |
| 189 | step({ type: 'move' }); |
| 190 | hold('pointerOverControls', overChrome(ev.target) || inPopover(ev.target)); |
| 191 | }); |
| 192 | win.addEventListener('pointerleave', (ev) => { |
| 193 | if (ev.pointerType === 'touch') return; |
| 194 | step({ type: 'exit' }); |
| 195 | }); |
| 196 | |
| 197 | // A finger has no hover: a tap on the picture toggles the controls, |
| 198 | // and a tap on a control that is not showing shows the controls and |
| 199 | // does nothing else — its click is swallowed. A mouse always moves |
| 200 | // first, so its controls are up by the time it presses. |
| 201 | const INTERACTIVE = 'button, a, summary, input, select, textarea, label, .calls-popover, .calls-chat-overlay, ' + |
| 202 | '.calls-sheet, .calls-notices > *, .calls-tile-tools, .calls-tile-menu'; |
| 203 | let swallow = false; |
| 204 | win.addEventListener('pointerdown', (ev) => { |
| 205 | const target = ev.target instanceof Element ? ev.target : null; |
| 206 | if (!target) return; |
| 207 | if (ev.pointerType !== 'touch') { |
| 208 | step({ type: 'activity' }); |
| 209 | return; |
| 210 | } |
| 211 | if (!visible() && target.closest('.calls-fades')) { |
| 212 | swallow = true; |
| 213 | step({ type: 'activity' }); |
| 214 | return; |
| 215 | } |
| 216 | step({ type: target.closest(INTERACTIVE) ? 'activity' : 'tap' }); |
| 217 | }, true); |
| 218 | win.addEventListener('click', (ev) => { |
| 219 | if (!swallow) return; |
| 220 | swallow = false; |
| 221 | ev.preventDefault(); |
| 222 | ev.stopPropagation(); |
| 223 | }, true); |
| 224 | |
| 225 | // The keyboard: Tab is navigation starting, so it brings them back; |
| 226 | // focus that the keyboard put in the bar, the title, a popover or a |
| 227 | // tile's arrangement buttons holds them. A mouse click focuses a button too, but not :focus-visible, |
| 228 | // and a clicked button should not keep the bar up for ever. |
| 229 | function focusCheck() { |
| 230 | const a = document.activeElement; |
| 231 | let keyboard = false; |
| 232 | if (a && a !== document.body && (overChrome(a) || inPopover(a) || a.closest('.calls-tile-tools, .calls-tile-menu'))) { |
| 233 | try { keyboard = a.matches(':focus-visible'); } catch { keyboard = true; } |
| 234 | } |
| 235 | hold('keyboardFocus', keyboard); |
| 236 | } |
| 237 | document.addEventListener('focusin', focusCheck); |
| 238 | document.addEventListener('focusout', () => setTimeout(focusCheck, 0)); |
| 239 | |
| 240 | // ---- popovers and the phone's sheet ---- |
| 241 | |
| 242 | function placePopover(p) { |
| 243 | if (p.panel.hidden) return; |
| 244 | if (phone()) { |
| 245 | p.panel.style.removeProperty('--calls-pop-x'); |
| 246 | return; |
| 247 | } |
| 248 | const w = win.getBoundingClientRect(); |
| 249 | const b = p.toggle.getBoundingClientRect(); |
| 250 | const width = p.panel.offsetWidth; |
| 251 | const x = Math.min(Math.max(b.left + b.width / 2 - w.left - width / 2, 12), w.width - width - 12); |
| 252 | p.panel.style.setProperty('--calls-pop-x', Math.round(Math.max(12, x)) + 'px'); |
| 253 | } |
| 254 | |
| 255 | function setPopover(p, open) { |
| 256 | if (p.panel.hidden === !open) return; |
| 257 | if (p.own) { |
| 258 | p.panel.hidden = !open; |
| 259 | p.toggle.setAttribute('aria-expanded', String(open)); |
| 260 | } else if (!p.toggle.disabled) { |
| 261 | p.toggle.click(); |
| 262 | } else { |
| 263 | p.panel.hidden = !open; |
| 264 | p.toggle.setAttribute('aria-expanded', String(open)); |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | function setSheet(open) { |
| 269 | if (!sheet || !moreControls) return; |
| 270 | if (sheet.hasAttribute('data-open') === open) return; |
| 271 | sheet.toggleAttribute('data-open', open); |
| 272 | moreControls.setAttribute('aria-expanded', String(open)); |
| 273 | } |
| 274 | |
| 275 | for (const p of popovers) { |
| 276 | if (p.own) { |
| 277 | p.toggle.addEventListener('click', () => { |
| 278 | const open = p.panel.hidden; |
| 279 | setPopover(p, open); |
| 280 | if (open) { |
| 281 | const first = p.panel.querySelector('[aria-pressed="true"], a, button, select, input'); |
| 282 | if (first) first.focus(); |
| 283 | } |
| 284 | }); |
| 285 | } |
| 286 | // Whoever opened it, one popover at a time, placed over its button, |
| 287 | // and on a phone in place of the sheet. |
| 288 | new MutationObserver(() => { |
| 289 | if (!p.panel.hidden) { |
| 290 | for (const other of popovers) if (other !== p) setPopover(other, false); |
| 291 | if (phone()) setSheet(false); |
| 292 | placePopover(p); |
| 293 | } |
| 294 | menusCheck(); |
| 295 | }).observe(p.panel, { attributes: true, attributeFilter: ['hidden'] }); |
| 296 | } |
| 297 | |
| 298 | if (moreControls && sheet) { |
| 299 | moreControls.addEventListener('click', () => { |
| 300 | const open = !sheet.hasAttribute('data-open'); |
| 301 | if (open) for (const p of popovers) setPopover(p, false); |
| 302 | setSheet(open); |
| 303 | }); |
| 304 | } |
| 305 | // Chat from the sheet: the sheet goes, and the chat is what is left. |
| 306 | chatToggle?.addEventListener('click', () => { if (phone()) setSheet(false); }); |
| 307 | phoneQuery.addEventListener?.('change', () => { |
| 308 | setSheet(false); |
| 309 | for (const p of popovers) placePopover(p); |
| 310 | }); |
| 311 | window.addEventListener('resize', () => { for (const p of popovers) placePopover(p); }); |
| 312 | |
| 313 | // A press anywhere else closes what is open: a popover, the sheet. Chat is a panel, not a popover, and stays. |
| 314 | document.addEventListener('pointerdown', (ev) => { |
| 315 | const t = ev.target instanceof Node ? ev.target : null; |
| 316 | if (!t) return; |
| 317 | for (const p of popovers) { |
| 318 | if (!p.panel.hidden && !p.panel.contains(t) && !p.toggle.contains(t)) setPopover(p, false); |
| 319 | } |
| 320 | if (sheet && sheet.hasAttribute('data-open') && !sheet.contains(t) && !(moreControls && moreControls.contains(t)) && |
| 321 | !popovers.some((p) => p.panel.contains(t))) setSheet(false); |
| 322 | }, true); |
| 323 | |
| 324 | // Escape closes the innermost open thing and puts the keyboard back on |
| 325 | // what opened it. |
| 326 | document.addEventListener('keydown', (ev) => { |
| 327 | if (ev.key === 'Tab') step({ type: 'activity' }); |
| 328 | if (ev.key !== 'Escape' || ev.defaultPrevented) return; |
| 329 | const open = popovers.find((p) => !p.panel.hidden); |
| 330 | if (open) { |
| 331 | ev.preventDefault(); |
| 332 | setPopover(open, false); |
| 333 | open.toggle.focus(); |
| 334 | return; |
| 335 | } |
| 336 | if (sheet && sheet.hasAttribute('data-open')) { |
| 337 | ev.preventDefault(); |
| 338 | setSheet(false); |
| 339 | moreControls?.focus(); |
| 340 | } |
| 341 | }); |
| 342 | |
| 343 | // What holds the controls up besides the pointer and the keyboard. |
| 344 | function menusCheck() { |
| 345 | const open = popovers.some((p) => !p.panel.hidden) || (chatOverlay && !chatOverlay.hidden) || |
| 346 | (tileMenu && !tileMenu.hidden) || |
| 347 | (sheet && sheet.hasAttribute('data-open')); |
| 348 | hold('menuOpen', open); |
| 349 | } |
| 350 | const watch = (el, attrs) => { if (el) new MutationObserver(menusCheck).observe(el, { attributes: true, attributeFilter: attrs }); }; |
| 351 | watch(chatOverlay, ['hidden']); |
| 352 | watch(tileMenu, ['hidden']); |
| 353 | watch(sheet, ['data-open']); |
| 354 | // The call is over: Leave is disabled and the way out is in the notices. |
| 355 | if (leave) { |
| 356 | const alertCheck = () => hold('alert', leave.disabled); |
| 357 | new MutationObserver(alertCheck).observe(leave, { attributes: true, attributeFilter: ['disabled'] }); |
| 358 | alertCheck(); |
| 359 | } |
| 360 | const motionCheck = () => hold('reducedMotion', motionQuery.matches); |
| 361 | motionQuery.addEventListener?.('change', motionCheck); |
| 362 | motionCheck(); |
| 363 | menusCheck(); |
| 364 | |
| 365 | // ---- the relay notice ---- |
| 366 | |
| 367 | // There is no title pill (2026-10-02): how the call is going is the |
| 368 | // status notice room.js keeps (#status) — Connecting, Waiting to be let |
| 369 | // in, The room is full, Reconnecting, the call over with its way out — |
| 370 | // and a pill saying it again in fewer words was clutter. |
| 371 | |
| 372 | // §5.5's relay notice: shown in the first room a tab's session opens, |
| 373 | // until it is put away; not again in that session. |
| 374 | const floor = $('floor-notice'); |
| 375 | if (floor) { |
| 376 | const key = 'calls.floorSeen'; |
| 377 | let seen = false; |
| 378 | try { seen = sessionStorage.getItem(key) === '1'; } catch { /* private mode: show it */ } |
| 379 | if (!seen) { |
| 380 | floor.hidden = false; |
| 381 | try { sessionStorage.setItem(key, '1'); } catch { /* this page only */ } |
| 382 | } |
| 383 | $('floor-dismiss')?.addEventListener('click', () => { floor.hidden = true; }); |
| 384 | } |
| 385 | |
| 386 | // iPhone and iPad: Safari (and every browser there, which is Safari |
| 387 | // underneath) stops the camera and microphone and lets the call's sound |
| 388 | // lapse once the tab is in the background or the screen locks — a call |
| 389 | // that "keeps cutting in and out" (Till, 2026-10-03). Said once a |
| 390 | // session, put away like the relay notice. An iPad asks for the desktop |
| 391 | // site and calls itself a Mac, so a Mac with a touch screen is one. |
| 392 | const keepOpen = $('keep-open-notice'); |
| 393 | if (keepOpen && appleTouch(navigator)) { |
| 394 | const key = 'calls.keepOpenSeen'; |
| 395 | let seen = false; |
| 396 | try { seen = sessionStorage.getItem(key) === '1'; } catch { /* private mode: show it */ } |
| 397 | if (!seen) { |
| 398 | keepOpen.hidden = false; |
| 399 | try { sessionStorage.setItem(key, '1'); } catch { /* this page only */ } |
| 400 | } |
| 401 | $('keep-open-dismiss')?.addEventListener('click', () => { keepOpen.hidden = true; }); |
| 402 | } |
| 403 | |
| 404 | // The notices sit under the title strip — none for a title Calls wrote, |
| 405 | // which is not shown — and it keeps its place while faded, so they do |
| 406 | // not jump as the controls come and go. |
| 407 | const titleHeight = () => win.style.setProperty('--calls-title-h', Math.ceil(top.offsetHeight) + 'px'); |
| 408 | if (window.ResizeObserver) new ResizeObserver(titleHeight).observe(top); |
| 409 | titleHeight(); |
| 410 | |
| 411 | render(); |
| 412 | schedule(); |
| 413 | |
| 414 | // For the gate and for a look from the console, and for snapshots: |
| 415 | // pin(true|false) holds the controls shown or hidden, pin(null) lets |
| 416 | // them go again. |
| 417 | window.callsChrome = { |
| 418 | state: () => ({ |
| 419 | visible: visible(), |
| 420 | holds: Object.keys(s.holds).filter((k) => s.holds[k]), |
| 421 | hideIn: s.hideAt == null ? null : Math.max(0, s.hideAt - now()), |
| 422 | pointerInside: s.pointerInside, |
| 423 | pinned, |
| 424 | }), |
| 425 | pin(v) { |
| 426 | pinned = v === true || v === false ? v : null; |
| 427 | render(); |
| 428 | }, |
| 429 | }; |
| 430 | })(); |
| 431 | |