| 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 | |