oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1// report.js: "Report a problem" (spec §15.26, 2026-10-03) — the sheet,
2// what kind of client is reporting, and the offer on the holding screen
3// for ten minutes after leaving a call.
4//
5// The room (room.js) opens the sheet from its More menu, from the
6// Devices panel beside "Copy call diagnostics", and from the words that
7// say a call is over; it hands over the report token from its `hello`
8// and the call's diagnostics with the people named by short peer id.
9// Leaving the call navigates to the holding screen, and the page that
10// held both is gone, so room.js leaves them here, in this tab's
11// sessionStorage, for as long as the server will still take a report
12// (hub.ReportAfterLeave); the holding screen offers the sheet with them.
13//
14// What is sent is what the sheet says: the note, if any, and the
15// diagnostics — connection states and counts, no audio, no video, no
16// names. Nothing is sent until Send is pressed.
17//
18// House rules as room.js's: no library, no inline script, nothing built
19// with innerHTML.
20(() => {
21 'use strict';
22
23 // ---- the rules, lifted by name in test/browser/room-quality.test.mjs ----
24
25 // reportClient is protocol.ReportClient for this browser: enums, and
26 // versions as digits and dots — never the user agent string itself.
27 // `ua` is navigator.userAgent; `hints` what navigator.userAgentData
28 // says ({platform, platformVersion}), where it says anything — Chromium
29 // freezes the operating system's version in the user agent string, and
30 // only the hints carry the real one; `version` is the server's build
31 // (rastrillo.BuildVersion); `touch` is navigator.maxTouchPoints, the
32 // one way to tell an iPad asking for the desktop site from a Mac.
33 // Anything the server would refuse is left out instead.
34 function reportClient(ua, hints, version, touch) {
35 const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,23}$/;
36 const NUMERIC = /^[0-9]{1,4}([._][0-9]{1,6}){0,3}$/;
37 const c = { app: 'web' };
38 if (typeof version === 'string' && VERSION.test(version)) c.version = version;
39 const s = String(ua || '');
40 const dotted = (m) => (m ? m.slice(1).filter((x) => x !== undefined && x !== '').join('.') : '');
41 let m = null;
42 let browser = 'other';
43 let browserVersion = '';
44 if ((m = s.match(/\bEdg(?:e|A|iOS)?\/(\d{1,4})(?:\.(\d{1,6}))?/))) {
45 browser = 'edge';
46 } else if ((m = s.match(/\b(?:Firefox|FxiOS)\/(\d{1,4})(?:\.(\d{1,6}))?/))) {
47 browser = 'firefox';
48 } else if (/\b(?:OPR|OPiOS|SamsungBrowser|YaBrowser|Vivaldi)\//.test(s)) {
49 m = null;
50 } else if ((m = s.match(/\b(?:HeadlessChrome|Chrome|CriOS|Chromium)\/(\d{1,4})(?:\.(\d{1,6}))?/))) {
51 browser = 'chrome';
52 } else if ((m = s.match(/\bVersion\/(\d{1,4})(?:\.(\d{1,6}))?.*\bSafari\//))) {
53 browser = 'safari';
54 }
55 browserVersion = dotted(m);
56 c.browser = browser;
57 if (NUMERIC.test(browserVersion)) c.browserVersion = browserVersion;
58
59 let os = 'other';
60 let osVersion = '';
61 const ios = s.match(/\bOS (\d{1,4})_(\d{1,6})(?:_(\d{1,6}))?/);
62 if (/\biPad\b/.test(s) || (/\bMacintosh\b/.test(s) && touch > 1)) {
63 os = 'ipados';
64 osVersion = dotted(ios);
65 } else if (/\b(?:iPhone|iPod)\b/.test(s)) {
66 os = 'ios';
67 osVersion = dotted(ios);
68 } else if (/\bAndroid\b/.test(s)) {
69 os = 'android';
70 osVersion = dotted(s.match(/\bAndroid (\d{1,4})(?:\.(\d{1,6}))?(?:\.(\d{1,6}))?/));
71 } else if (/\bCrOS\b/.test(s)) {
72 os = 'chromeos';
73 } else if (/\bMacintosh\b|\bMac OS X\b/.test(s)) {
74 // The user agent string's macOS version has said 10_15_7 since
75 // 2020 whatever the machine runs: not a version, so not sent.
76 os = 'macos';
77 } else if (/\bWindows\b/.test(s)) {
78 os = 'windows';
79 } else if (/\bLinux\b|\bX11\b/.test(s)) {
80 os = 'linux';
81 }
82 const pv = hints && typeof hints.platformVersion === 'string' ? hints.platformVersion : '';
83 if (pv && ['macos', 'windows', 'chromeos', 'android', 'linux'].includes(os)) {
84 const parts = pv.split('.').slice(0, 3).join('.');
85 if (NUMERIC.test(parts)) osVersion = parts;
86 }
87 c.os = os;
88 if (NUMERIC.test(osVersion)) c.osVersion = osVersion;
89 return c;
90 }
91
92 // problemAnswer is what the sheet says for the server's answer to a
93 // problem report: the reference to quote, or the sentence for why not.
94 function problemAnswer(status, data) {
95 if (status === 200 && data && typeof data.ref === 'string' && /^[0-9A-Z]{8}$/.test(data.ref)) {
96 return { ref: data.ref, text: '' };
97 }
98 if (status === 429) return { ref: '', text: 'Too many reports from this call — try again in a minute.' };
99 if (status === 404) return { ref: '', text: 'This call can no longer be reported.' };
100 return { ref: '', text: 'The report could not be sent.' };
101 }
102
103 // problemBody is protocol.ProblemReport as JSON, at most `max` bytes:
104 // the note to its thousand characters, and the diagnostics cut short
105 // from the end — the state of the call comes first and the oldest of
106 // the screen's log last — with a line saying so, if the whole would not
107 // fit the server's 64 KB.
108 function problemBody(note, diagnostics, client, max) {
109 const size = (x) => (typeof TextEncoder === 'function' ? new TextEncoder().encode(x).length : x.length * 3);
110 const n = Array.from(String(note || '')).slice(0, 1000).join('');
111 let d = String(diagnostics || '');
112 let body = JSON.stringify({ note: n, diagnostics: d, client });
113 while (size(body) > max && d.length > 0) {
114 d = d.slice(0, Math.floor(d.length * 0.9));
115 body = JSON.stringify({ note: n, diagnostics: d + '\n[cut short to fit]', client });
116 }
117 return body;
118 }
119
120 // ---- this browser ----
121
122 const LAST_KEY = 'calls.lastCall';
123 // hub.ReportAfterLeave: how long the server takes a report for after
124 // the participation ends.
125 const AFTER_LEAVE = 10 * 60 * 1000;
126 const NOTE_MAX = 1000;
127 const BODY_MAX = 64 * 1024 - 512;
128
129 const $ = (id) => document.getElementById(id);
130 const sheet = $('report-sheet');
131
132 // The operating system's real version, where the browser will say it.
133 // Asked once, at load, and used by every report after it answers.
134 let hints = null;
135 try {
136 const uad = navigator.userAgentData;
137 if (uad) {
138 hints = { platform: uad.platform || '' };
139 if (typeof uad.getHighEntropyValues === 'function') {
140 uad.getHighEntropyValues(['platformVersion']).then((h) => {
141 hints = { platform: h.platform || hints.platform, platformVersion: h.platformVersion || '' };
142 }, () => {});
143 }
144 }
145 } catch {
146 hints = null;
147 }
148
149 function client() {
150 const version = (sheet && sheet.dataset.callsVersion) || '';
151 return reportClient(navigator.userAgent, hints, version, navigator.maxTouchPoints || 0);
152 }
153
154 // remember is room.js's, at leave: what the holding screen needs to
155 // offer the sheet for the next ten minutes.
156 function remember(rec) {
157 try {
158 sessionStorage.setItem(LAST_KEY, JSON.stringify({
159 sqid: rec.sqid, token: rec.token, at: Date.now(), diagnostics: rec.diagnostics, client: rec.client,
160 }));
161 } catch {
162 // Private browsing, or full: the holding screen simply has no offer.
163 }
164 }
165
166 function recall(sqid) {
167 let rec = null;
168 try {
169 rec = JSON.parse(sessionStorage.getItem(LAST_KEY) || 'null');
170 } catch {
171 rec = null;
172 }
173 if (!rec || typeof rec.token !== 'string' || typeof rec.at !== 'number') return null;
174 if (Date.now() - rec.at >= AFTER_LEAVE) {
175 try { sessionStorage.removeItem(LAST_KEY); } catch { /* nothing to forget */ }
176 return null;
177 }
178 return rec.sqid === sqid ? rec : null;
179 }
180
181 // ---- the sheet ----
182
183 const form = $('report-form');
184 const noteEl = $('report-note');
185 const hint = $('report-note-hint');
186 const ask = $('report-ask');
187 const error = $('report-error');
188 const send = $('report-send');
189 const cancel = $('report-cancel');
190 const done = $('report-done');
191 const doneTitle = $('report-done-title');
192 const refEl = $('report-ref');
193 const copy = $('report-copy');
194 const copied = $('report-copied');
195 const closeButton = $('report-close');
196
197 let current = null; // the open sheet's options
198 let round = 0; // which opening a POST belongs to
199
200 function hintText() {
201 const left = NOTE_MAX - (noteEl ? noteEl.value.length : 0);
202 return 'Optional. ' + left.toLocaleString('en-GB') + ' of 1,000 characters left.';
203 }
204
205 function show(which) {
206 ask.hidden = which !== 'ask';
207 done.hidden = which !== 'done';
208 }
209
210 function close() {
211 if (!sheet) return;
212 if (typeof sheet.close === 'function' && sheet.open) sheet.close();
213 else sheet.removeAttribute('open');
214 }
215
216 function open(opts) {
217 if (!sheet || !form) return;
218 round += 1;
219 current = opts;
220 noteEl.value = '';
221 hint.textContent = hintText();
222 error.hidden = true;
223 error.textContent = '';
224 send.disabled = false;
225 send.textContent = 'Send';
226 copied.textContent = '';
227 show('ask');
228 if (typeof sheet.showModal === 'function') {
229 if (!sheet.open) sheet.showModal();
230 } else {
231 sheet.setAttribute('open', '');
232 }
233 noteEl.focus();
234 }
235
236 async function submit() {
237 const opts = current;
238 if (!opts) return;
239 const mine = round;
240 send.disabled = true;
241 send.textContent = 'Sending…';
242 error.hidden = true;
243 const token = typeof opts.token === 'function' ? opts.token() : opts.token;
244 let answer;
245 if (!token) {
246 answer = problemAnswer(404, null);
247 } else {
248 const body = problemBody(noteEl.value, opts.diagnostics(), opts.client(), BODY_MAX);
249 try {
250 const res = await fetch('/m/' + encodeURIComponent(opts.sqid) + '/problem', {
251 method: 'POST',
252 credentials: 'same-origin',
253 headers: { 'Content-Type': 'application/json', 'X-Calls-Report': token },
254 body,
255 });
256 let data = null;
257 try {
258 data = await res.json();
259 } catch {
260 data = null;
261 }
262 answer = problemAnswer(res.status, data);
263 } catch {
264 answer = problemAnswer(0, null);
265 }
266 }
267 // Closed, or opened again, while that was in flight: the answer
268 // belongs to a sheet nobody is looking at.
269 if (mine !== round || !sheet.hasAttribute('open')) return;
270 send.disabled = false;
271 send.textContent = 'Send';
272 if (answer.ref) {
273 refEl.textContent = answer.ref;
274 show('done');
275 doneTitle.focus();
276 return;
277 }
278 error.textContent = answer.text;
279 error.hidden = false;
280 }
281
282 if (sheet && form) {
283 hint.textContent = hintText();
284 noteEl.addEventListener('input', () => {
285 hint.textContent = hintText();
286 });
287 form.addEventListener('submit', (ev) => {
288 ev.preventDefault();
289 submit().catch(() => {
290 error.textContent = problemAnswer(0, null).text;
291 error.hidden = false;
292 send.disabled = false;
293 send.textContent = 'Send';
294 });
295 });
296 cancel.addEventListener('click', close);
297 closeButton.addEventListener('click', close);
298 copy.addEventListener('click', () => {
299 const ref = refEl.textContent || '';
300 const ok = () => { copied.textContent = 'Reference copied.'; };
301 const failed = () => { copied.textContent = 'Select the reference to copy it.'; };
302 try {
303 navigator.clipboard.writeText(ref).then(ok, failed);
304 } catch {
305 failed();
306 }
307 });
308 // The sheet's keys are its own. Escape closes it and nothing behind
309 // it (the call window closes its popovers on Escape too), and Space
310 // on the sheet is never the room's mute.
311 sheet.addEventListener('keydown', (ev) => {
312 if (ev.key === 'Escape') {
313 ev.preventDefault();
314 close();
315 }
316 ev.stopPropagation();
317 });
318 // The keyboard goes back to what opened the sheet — or, when that was
319 // in a panel that has since closed, to the button the panel hung from.
320 sheet.addEventListener('close', () => {
321 round += 1;
322 const back = current && current.returnFocus;
323 current = null;
324 if (back && back.isConnected && !back.hidden && typeof back.focus === 'function') back.focus();
325 });
326 }
327
328 // ---- the holding screen, after leaving ----
329
330 const last = $('report-last');
331 const lastOpen = $('report-last-open');
332 const devices = $('devices');
333 if (last && lastOpen && devices) {
334 const rec = recall(devices.dataset.sqid || '');
335 if (rec && sheet) {
336 last.hidden = false;
337 lastOpen.addEventListener('click', () => {
338 open({
339 sqid: rec.sqid,
340 token: () => (Date.now() - rec.at < AFTER_LEAVE ? rec.token : ''),
341 diagnostics: () => String(rec.diagnostics || ''),
342 client: () => (rec.client && rec.client.app === 'web' ? rec.client : client()),
343 returnFocus: lastOpen,
344 });
345 });
346 // Gone when the server would no longer take it.
347 setTimeout(() => {
348 if (document.activeElement === lastOpen) lastOpen.blur();
349 last.hidden = true;
350 }, Math.max(0, AFTER_LEAVE - (Date.now() - rec.at)));
351 }
352 }
353
354 window.CallsReport = { client, open, remember };
355})();
356