// report.js: "Report a problem" (spec §15.26, 2026-10-03) — the sheet, // what kind of client is reporting, and the offer on the holding screen // for ten minutes after leaving a call. // // The room (room.js) opens the sheet from its More menu, from the // Devices panel beside "Copy call diagnostics", and from the words that // say a call is over; it hands over the report token from its `hello` // and the call's diagnostics with the people named by short peer id. // Leaving the call navigates to the holding screen, and the page that // held both is gone, so room.js leaves them here, in this tab's // sessionStorage, for as long as the server will still take a report // (hub.ReportAfterLeave); the holding screen offers the sheet with them. // // What is sent is what the sheet says: the note, if any, and the // diagnostics — connection states and counts, no audio, no video, no // names. Nothing is sent until Send is pressed. // // House rules as room.js's: no library, no inline script, nothing built // with innerHTML. (() => { 'use strict'; // ---- the rules, lifted by name in test/browser/room-quality.test.mjs ---- // reportClient is protocol.ReportClient for this browser: enums, and // versions as digits and dots — never the user agent string itself. // `ua` is navigator.userAgent; `hints` what navigator.userAgentData // says ({platform, platformVersion}), where it says anything — Chromium // freezes the operating system's version in the user agent string, and // only the hints carry the real one; `version` is the server's build // (rastrillo.BuildVersion); `touch` is navigator.maxTouchPoints, the // one way to tell an iPad asking for the desktop site from a Mac. // Anything the server would refuse is left out instead. function reportClient(ua, hints, version, touch) { const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,23}$/; const NUMERIC = /^[0-9]{1,4}([._][0-9]{1,6}){0,3}$/; const c = { app: 'web' }; if (typeof version === 'string' && VERSION.test(version)) c.version = version; const s = String(ua || ''); const dotted = (m) => (m ? m.slice(1).filter((x) => x !== undefined && x !== '').join('.') : ''); let m = null; let browser = 'other'; let browserVersion = ''; if ((m = s.match(/\bEdg(?:e|A|iOS)?\/(\d{1,4})(?:\.(\d{1,6}))?/))) { browser = 'edge'; } else if ((m = s.match(/\b(?:Firefox|FxiOS)\/(\d{1,4})(?:\.(\d{1,6}))?/))) { browser = 'firefox'; } else if (/\b(?:OPR|OPiOS|SamsungBrowser|YaBrowser|Vivaldi)\//.test(s)) { m = null; } else if ((m = s.match(/\b(?:HeadlessChrome|Chrome|CriOS|Chromium)\/(\d{1,4})(?:\.(\d{1,6}))?/))) { browser = 'chrome'; } else if ((m = s.match(/\bVersion\/(\d{1,4})(?:\.(\d{1,6}))?.*\bSafari\//))) { browser = 'safari'; } browserVersion = dotted(m); c.browser = browser; if (NUMERIC.test(browserVersion)) c.browserVersion = browserVersion; let os = 'other'; let osVersion = ''; const ios = s.match(/\bOS (\d{1,4})_(\d{1,6})(?:_(\d{1,6}))?/); if (/\biPad\b/.test(s) || (/\bMacintosh\b/.test(s) && touch > 1)) { os = 'ipados'; osVersion = dotted(ios); } else if (/\b(?:iPhone|iPod)\b/.test(s)) { os = 'ios'; osVersion = dotted(ios); } else if (/\bAndroid\b/.test(s)) { os = 'android'; osVersion = dotted(s.match(/\bAndroid (\d{1,4})(?:\.(\d{1,6}))?(?:\.(\d{1,6}))?/)); } else if (/\bCrOS\b/.test(s)) { os = 'chromeos'; } else if (/\bMacintosh\b|\bMac OS X\b/.test(s)) { // The user agent string's macOS version has said 10_15_7 since // 2020 whatever the machine runs: not a version, so not sent. os = 'macos'; } else if (/\bWindows\b/.test(s)) { os = 'windows'; } else if (/\bLinux\b|\bX11\b/.test(s)) { os = 'linux'; } const pv = hints && typeof hints.platformVersion === 'string' ? hints.platformVersion : ''; if (pv && ['macos', 'windows', 'chromeos', 'android', 'linux'].includes(os)) { const parts = pv.split('.').slice(0, 3).join('.'); if (NUMERIC.test(parts)) osVersion = parts; } c.os = os; if (NUMERIC.test(osVersion)) c.osVersion = osVersion; return c; } // problemAnswer is what the sheet says for the server's answer to a // problem report: the reference to quote, or the sentence for why not. function problemAnswer(status, data) { if (status === 200 && data && typeof data.ref === 'string' && /^[0-9A-Z]{8}$/.test(data.ref)) { return { ref: data.ref, text: '' }; } if (status === 429) return { ref: '', text: 'Too many reports from this call — try again in a minute.' }; if (status === 404) return { ref: '', text: 'This call can no longer be reported.' }; return { ref: '', text: 'The report could not be sent.' }; } // problemBody is protocol.ProblemReport as JSON, at most `max` bytes: // the note to its thousand characters, and the diagnostics cut short // from the end — the state of the call comes first and the oldest of // the screen's log last — with a line saying so, if the whole would not // fit the server's 64 KB. function problemBody(note, diagnostics, client, max) { const size = (x) => (typeof TextEncoder === 'function' ? new TextEncoder().encode(x).length : x.length * 3); const n = Array.from(String(note || '')).slice(0, 1000).join(''); let d = String(diagnostics || ''); let body = JSON.stringify({ note: n, diagnostics: d, client }); while (size(body) > max && d.length > 0) { d = d.slice(0, Math.floor(d.length * 0.9)); body = JSON.stringify({ note: n, diagnostics: d + '\n[cut short to fit]', client }); } return body; } // ---- this browser ---- const LAST_KEY = 'calls.lastCall'; // hub.ReportAfterLeave: how long the server takes a report for after // the participation ends. const AFTER_LEAVE = 10 * 60 * 1000; const NOTE_MAX = 1000; const BODY_MAX = 64 * 1024 - 512; const $ = (id) => document.getElementById(id); const sheet = $('report-sheet'); // The operating system's real version, where the browser will say it. // Asked once, at load, and used by every report after it answers. let hints = null; try { const uad = navigator.userAgentData; if (uad) { hints = { platform: uad.platform || '' }; if (typeof uad.getHighEntropyValues === 'function') { uad.getHighEntropyValues(['platformVersion']).then((h) => { hints = { platform: h.platform || hints.platform, platformVersion: h.platformVersion || '' }; }, () => {}); } } } catch { hints = null; } function client() { const version = (sheet && sheet.dataset.callsVersion) || ''; return reportClient(navigator.userAgent, hints, version, navigator.maxTouchPoints || 0); } // remember is room.js's, at leave: what the holding screen needs to // offer the sheet for the next ten minutes. function remember(rec) { try { sessionStorage.setItem(LAST_KEY, JSON.stringify({ sqid: rec.sqid, token: rec.token, at: Date.now(), diagnostics: rec.diagnostics, client: rec.client, })); } catch { // Private browsing, or full: the holding screen simply has no offer. } } function recall(sqid) { let rec = null; try { rec = JSON.parse(sessionStorage.getItem(LAST_KEY) || 'null'); } catch { rec = null; } if (!rec || typeof rec.token !== 'string' || typeof rec.at !== 'number') return null; if (Date.now() - rec.at >= AFTER_LEAVE) { try { sessionStorage.removeItem(LAST_KEY); } catch { /* nothing to forget */ } return null; } return rec.sqid === sqid ? rec : null; } // ---- the sheet ---- const form = $('report-form'); const noteEl = $('report-note'); const hint = $('report-note-hint'); const ask = $('report-ask'); const error = $('report-error'); const send = $('report-send'); const cancel = $('report-cancel'); const done = $('report-done'); const doneTitle = $('report-done-title'); const refEl = $('report-ref'); const copy = $('report-copy'); const copied = $('report-copied'); const closeButton = $('report-close'); let current = null; // the open sheet's options let round = 0; // which opening a POST belongs to function hintText() { const left = NOTE_MAX - (noteEl ? noteEl.value.length : 0); return 'Optional. ' + left.toLocaleString('en-GB') + ' of 1,000 characters left.'; } function show(which) { ask.hidden = which !== 'ask'; done.hidden = which !== 'done'; } function close() { if (!sheet) return; if (typeof sheet.close === 'function' && sheet.open) sheet.close(); else sheet.removeAttribute('open'); } function open(opts) { if (!sheet || !form) return; round += 1; current = opts; noteEl.value = ''; hint.textContent = hintText(); error.hidden = true; error.textContent = ''; send.disabled = false; send.textContent = 'Send'; copied.textContent = ''; show('ask'); if (typeof sheet.showModal === 'function') { if (!sheet.open) sheet.showModal(); } else { sheet.setAttribute('open', ''); } noteEl.focus(); } async function submit() { const opts = current; if (!opts) return; const mine = round; send.disabled = true; send.textContent = 'Sending…'; error.hidden = true; const token = typeof opts.token === 'function' ? opts.token() : opts.token; let answer; if (!token) { answer = problemAnswer(404, null); } else { const body = problemBody(noteEl.value, opts.diagnostics(), opts.client(), BODY_MAX); try { const res = await fetch('/m/' + encodeURIComponent(opts.sqid) + '/problem', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-Calls-Report': token }, body, }); let data = null; try { data = await res.json(); } catch { data = null; } answer = problemAnswer(res.status, data); } catch { answer = problemAnswer(0, null); } } // Closed, or opened again, while that was in flight: the answer // belongs to a sheet nobody is looking at. if (mine !== round || !sheet.hasAttribute('open')) return; send.disabled = false; send.textContent = 'Send'; if (answer.ref) { refEl.textContent = answer.ref; show('done'); doneTitle.focus(); return; } error.textContent = answer.text; error.hidden = false; } if (sheet && form) { hint.textContent = hintText(); noteEl.addEventListener('input', () => { hint.textContent = hintText(); }); form.addEventListener('submit', (ev) => { ev.preventDefault(); submit().catch(() => { error.textContent = problemAnswer(0, null).text; error.hidden = false; send.disabled = false; send.textContent = 'Send'; }); }); cancel.addEventListener('click', close); closeButton.addEventListener('click', close); copy.addEventListener('click', () => { const ref = refEl.textContent || ''; const ok = () => { copied.textContent = 'Reference copied.'; }; const failed = () => { copied.textContent = 'Select the reference to copy it.'; }; try { navigator.clipboard.writeText(ref).then(ok, failed); } catch { failed(); } }); // The sheet's keys are its own. Escape closes it and nothing behind // it (the call window closes its popovers on Escape too), and Space // on the sheet is never the room's mute. sheet.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { ev.preventDefault(); close(); } ev.stopPropagation(); }); // The keyboard goes back to what opened the sheet — or, when that was // in a panel that has since closed, to the button the panel hung from. sheet.addEventListener('close', () => { round += 1; const back = current && current.returnFocus; current = null; if (back && back.isConnected && !back.hidden && typeof back.focus === 'function') back.focus(); }); } // ---- the holding screen, after leaving ---- const last = $('report-last'); const lastOpen = $('report-last-open'); const devices = $('devices'); if (last && lastOpen && devices) { const rec = recall(devices.dataset.sqid || ''); if (rec && sheet) { last.hidden = false; lastOpen.addEventListener('click', () => { open({ sqid: rec.sqid, token: () => (Date.now() - rec.at < AFTER_LEAVE ? rec.token : ''), diagnostics: () => String(rec.diagnostics || ''), client: () => (rec.client && rec.client.app === 'web' ? rec.client : client()), returnFocus: lastOpen, }); }); // Gone when the server would no longer take it. setTimeout(() => { if (document.activeElement === lastOpen) lastOpen.blur(); last.hidden = true; }, Math.max(0, AFTER_LEAVE - (Date.now() - rec.at))); } } window.CallsReport = { client, open, remember }; })();