oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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