@sphoro/bugcatch-web-sdk 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +104 -6
  2. package/dist/bugcatch-call.es.js +587 -0
  3. package/dist/bugcatch-call.es.js.map +1 -0
  4. package/dist/bugcatch-call.umd.js +2 -0
  5. package/dist/bugcatch-call.umd.js.map +1 -0
  6. package/dist/bugcatch-core.es.js +3292 -0
  7. package/dist/bugcatch-core.es.js.map +1 -0
  8. package/dist/bugcatch-panel.es.js +2531 -0
  9. package/dist/bugcatch-panel.es.js.map +1 -0
  10. package/dist/bugcatch-panel.umd.js +1311 -0
  11. package/dist/bugcatch-panel.umd.js.map +1 -0
  12. package/dist/bugcatch.es.js +2 -5625
  13. package/dist/bugcatch.es.js.map +1 -1
  14. package/dist/bugcatch.standalone.umd.js +1619 -0
  15. package/dist/bugcatch.standalone.umd.js.map +1 -0
  16. package/dist/bugcatch.umd.js +195 -1318
  17. package/dist/bugcatch.umd.js.map +1 -1
  18. package/package.json +6 -6
  19. package/src/accent.js +38 -0
  20. package/src/announcements.js +147 -0
  21. package/src/busy.js +18 -0
  22. package/src/call.js +7 -0
  23. package/src/capture.js +9 -0
  24. package/src/chunks.js +56 -0
  25. package/src/gesture.js +33 -0
  26. package/src/icons.js +18 -0
  27. package/src/identity.js +13 -8
  28. package/src/incoming-call.js +25 -45
  29. package/src/index.d.ts +93 -2
  30. package/src/index.js +164 -42
  31. package/src/lazy.js +23 -0
  32. package/src/notice.js +68 -0
  33. package/src/panel/call.js +242 -0
  34. package/src/panel/compose.js +416 -0
  35. package/src/panel/composer.js +235 -0
  36. package/src/panel/icons.js +36 -0
  37. package/src/panel/index.js +265 -0
  38. package/src/panel/known.js +130 -0
  39. package/src/panel/messages.js +176 -0
  40. package/src/panel/notices.js +34 -0
  41. package/src/panel/picks.js +65 -0
  42. package/src/panel/refs.js +321 -0
  43. package/src/panel/render.js +132 -0
  44. package/src/panel/signin.js +228 -0
  45. package/src/panel/styles.js +1016 -0
  46. package/src/panel/thread.js +494 -0
  47. package/src/session.js +8 -3
  48. package/src/umd/call.js +6 -0
  49. package/src/umd/lazy.js +98 -0
  50. package/src/umd/panel.js +6 -0
  51. package/src/updates.js +97 -34
  52. package/src/widget.js +385 -3376
@@ -0,0 +1,242 @@
1
+ // Talking to the team instead of typing: the offer on the form, the screen
2
+ // that replaces the form while the team is rung, and the bar that sits over a
3
+ // report's thread during a call about it.
4
+ //
5
+ // Only what the reporter sees. Placing the call, the microphone and the
6
+ // connection are the client's (startCall, startLiveCall in index.js), and their
7
+ // code is fetched only when a call is actually placed.
8
+
9
+ import * as ICONS from './icons.js';
10
+ import { humanDuration } from '../capture.js';
11
+ import { escapeHtml } from './render.js';
12
+
13
+ export function mountCallOffer(ctx) {
14
+ const {
15
+ client, scrim, say, callBtn, close,
16
+ } = ctx;
17
+
18
+ // Asked once, when the dialog opens, and the button stays hidden unless the
19
+ // answer is yes. Doing it here rather than at init keeps one request off the
20
+ // page load of every site this SDK is on, for a button most of them will
21
+ // never show.
22
+ //
23
+ // The wording is fixed because there is one kind of call: it rings a room and
24
+ // may go unanswered. A button that said "talk to us" would have somebody
25
+ // expecting an instant answer sit through twenty seconds of ringing.
26
+ let callOffered = false;
27
+ function offerCall() {
28
+ client.callKind()
29
+ .then((kind) => {
30
+ if (!kind) return;
31
+ callOffered = true;
32
+ // Offered, so fetch what placing it needs now: the press should ring,
33
+ // not wait for code.
34
+ ctx.preloadCall();
35
+ // Never over a panel that is still asking who this is: the call would
36
+ // file a report, and on a project that requires sign-in that is the one
37
+ // thing this panel is holding back.
38
+ callBtn.hidden = ctx.mustSignIn();
39
+ })
40
+ .catch(() => { /* not available is the safe answer to every failure */ });
41
+ }
42
+ offerCall();
43
+
44
+ callBtn.addEventListener('click', async () => {
45
+ ctx.sending = true;
46
+ say('');
47
+ ctx.refreshSendState();
48
+ callBtn.disabled = true;
49
+ callBtn.innerHTML = '<span class="spinner"></span>Ringing…';
50
+
51
+ try {
52
+ // No id: this files the report from what the page was doing and rings the
53
+ // team about it, in one round trip.
54
+ const handle = await client.startCall();
55
+ showCall(handle);
56
+ } catch (err) {
57
+ ctx.sending = false;
58
+ callBtn.disabled = false;
59
+ callBtn.innerHTML = `${ICONS.phone}Talk to the team`;
60
+ ctx.refreshSendState();
61
+ // A report filed with no call is still a report. Saying "the call
62
+ // failed" and nothing else would leave the reporter thinking their
63
+ // context went nowhere, and pressing Send again would file it twice.
64
+ if (err.filed) ctx.succeed(err.filed, { callFailed: err.reason || 'The call could not be started.' });
65
+ else {
66
+ say(err.message.replace('[BugCatch] ', ''));
67
+ // The same follow-up the Send button does, because a call can fail for
68
+ // the same reason a report can: this browser was signed out while the
69
+ // panel sat open. Without it the message says sign in while the row
70
+ // under it still names who signed in, which reads as a broken widget.
71
+ ctx.recheckSignIn();
72
+ }
73
+ }
74
+ });
75
+
76
+ /**
77
+ * The call, once the team is being rung.
78
+ *
79
+ * Replaces the panel for the same reason the history view does: it is the
80
+ * same dialog answering a different question, and the reporter got here from
81
+ * one launcher. There is no way back to the form — the report has already
82
+ * been filed, and a Cancel that implied otherwise would be a lie.
83
+ */
84
+ function showCall(handle) {
85
+ const panel = scrim.querySelector('.panel');
86
+ const ref = handle?.bugId ? String(handle.bugId).slice(0, 8) : null;
87
+
88
+ panel.innerHTML = `
89
+ <div class="call">
90
+ <div class="call-head">
91
+ <span class="call-dot" aria-hidden="true"></span>
92
+ <strong class="call-state">Ringing…</strong>
93
+ </div>
94
+ <p class="call-note">
95
+ We're ringing the team now. Someone will pick up if they're free — your report${
96
+ ref ? ` <code>${escapeHtml(ref)}</code>` : ''} is already filed either way, so nothing is lost if nobody answers.
97
+ </p>
98
+ <div class="foot">
99
+ <span class="spacer"></span>
100
+ <button class="primary" type="button" data-act="hangup">Cancel</button>
101
+ </div>
102
+ </div>`;
103
+
104
+ const stateEl = panel.querySelector('.call-state');
105
+ const dot = panel.querySelector('.call-dot');
106
+
107
+ // A call spends its first twenty seconds ringing, and can end by nobody
108
+ // picking up — which is not a failure and must not read as one.
109
+ const WORDING = {
110
+ ringing: 'Ringing the team…',
111
+ connecting: 'Someone answered — connecting…',
112
+ connected: 'Connected',
113
+ missed: 'Nobody was free',
114
+ ended: 'Call ended',
115
+ failed: 'The call dropped',
116
+ };
117
+
118
+ // `missed` is over too, and is the ordinary way a live call ends when the
119
+ // team is busy. Left out of this list it would leave the dialog showing
120
+ // "Nobody was free" above a Cancel button for a call that has already
121
+ // stopped.
122
+ const OVER = ['ended', 'failed', 'missed'];
123
+
124
+ const offState = client.on('call', ({ state }) => {
125
+ stateEl.textContent = WORDING[state] || state;
126
+ dot.classList.toggle('off', OVER.includes(state));
127
+ if (OVER.includes(state)) {
128
+ panel.querySelector('[data-act="hangup"]').textContent = 'Close';
129
+ if (state === 'missed') {
130
+ // Said plainly, and said as the non-event it is. The report is filed
131
+ // and the thread already says the call went unanswered, so the one
132
+ // thing left to tell them is that they have not lost anything.
133
+ const note = panel.querySelector('.call-note');
134
+ if (note) {
135
+ note.textContent = 'Nobody was free to pick up just now. Your report is filed '
136
+ + 'and the team will reply in the chat.';
137
+ }
138
+ }
139
+ }
140
+ });
141
+
142
+ panel.querySelector('[data-act="hangup"]').addEventListener('click', () => {
143
+ offState();
144
+ client.endCall();
145
+ close();
146
+ });
147
+ }
148
+
149
+ /**
150
+ * The call, on top of the report it is about.
151
+ *
152
+ * A bar rather than a screen of its own, unlike the one the composer's button
153
+ * opens — and for a reason that only applies here. A call has nothing to show
154
+ * while it runs, because nobody is listening to it but the person on the
155
+ * other end, so what is worth looking at is the report both people are
156
+ * talking about.
157
+ */
158
+ function showLiveCall(body, thread, handle) {
159
+ const bar = document.createElement('div');
160
+ bar.className = 'livecall';
161
+ bar.innerHTML = `
162
+ <span class="call-dot" aria-hidden="true"></span>
163
+ <span class="who" role="status" aria-live="polite">Calling the team…</span>
164
+ <span class="secs" hidden></span>
165
+ ${handle?.recorded ? '<span class="call-kept">This call is recorded.</span>' : ''}
166
+ <button class="act" type="button" data-act="mute" hidden>Mute</button>
167
+ <button class="act danger" type="button" data-act="hangup">Cancel</button>`;
168
+ body.insertBefore(bar, thread);
169
+
170
+ const who = bar.querySelector('.who');
171
+ const secs = bar.querySelector('.secs');
172
+ const dot = bar.querySelector('.call-dot');
173
+ const muteBtn = bar.querySelector('[data-act="mute"]');
174
+ const hangBtn = bar.querySelector('[data-act="hangup"]');
175
+
176
+ let ticker = null;
177
+ const stopTicking = () => { if (ticker) { clearInterval(ticker); ticker = null; } };
178
+
179
+ /** Left up rather than removed, so somebody who looked away can see how it
180
+ * ended — and dismissed by hand, or by the next call replacing it. */
181
+ const settle = (text) => {
182
+ stopTicking();
183
+ dot.classList.add('off');
184
+ bar.classList.add('over');
185
+ who.textContent = text;
186
+ secs.hidden = true;
187
+ muteBtn.hidden = true;
188
+ hangBtn.textContent = 'Dismiss';
189
+ hangBtn.classList.remove('danger');
190
+ };
191
+
192
+ const off = client.on('call', ({ state }) => {
193
+ if (state === 'ringing') who.textContent = 'Calling the team…';
194
+
195
+ if (state === 'connecting') {
196
+ who.textContent = 'Someone answered — connecting…';
197
+ }
198
+
199
+ if (state === 'connected') {
200
+ who.textContent = "You're on a call with the team";
201
+ muteBtn.hidden = false;
202
+ secs.hidden = false;
203
+ const from = Date.now();
204
+ stopTicking();
205
+ // Started on connect rather than on answer: the seconds a person is
206
+ // charged for looking at are the ones they could hear.
207
+ ticker = setInterval(() => {
208
+ secs.textContent = humanDuration(Math.floor((Date.now() - from) / 1000));
209
+ }, 1000);
210
+ secs.textContent = '0s';
211
+ }
212
+
213
+ if (state === 'missed') {
214
+ settle('Nobody was free. Send a message instead and the team will pick it up.');
215
+ }
216
+ if (state === 'ended') settle('Call ended.');
217
+ if (state === 'failed') settle('The call dropped.');
218
+ });
219
+
220
+ muteBtn.addEventListener('click', () => {
221
+ const muted = handle.setMuted(!handle.isMuted());
222
+ muteBtn.textContent = muted ? 'Unmute' : 'Mute';
223
+ // Announced rather than only shown: somebody who muted by accident finds
224
+ // out when the other person stops replying, which is a long way in.
225
+ who.textContent = muted ? "You're muted" : "You're on a call with the team";
226
+ });
227
+
228
+ hangBtn.addEventListener('click', () => {
229
+ off();
230
+ stopTicking();
231
+ client.endCall();
232
+ bar.remove();
233
+ // The note about the call lands in the thread a moment after it ends, and
234
+ // the reporter is looking straight at where it goes.
235
+ ctx.refreshThread(ctx.openReportId());
236
+ });
237
+ }
238
+
239
+ ctx.offerCall = offerCall;
240
+ ctx.callOffered = () => callOffered;
241
+ ctx.showLiveCall = showLiveCall;
242
+ }
@@ -0,0 +1,416 @@
1
+ // The report form: what went wrong, how much it is in the way, anything that
2
+ // shows it — and the Send that files it.
3
+ //
4
+ // Also the draft this device keeps of it, because a form that loses a
5
+ // half-written report to a reload is a form people stop starting.
6
+
7
+ import * as ICONS from './icons.js';
8
+ import {
9
+ captureScreenshot, startRecording, screenshotSupported, captureSupported,
10
+ humanSize, humanDuration, normalizeMime, REPORT_MIMES,
11
+ } from '../capture.js';
12
+ import { draftKey, draftOperation, readDraft } from '../drafts.js';
13
+ import { escapeHtml } from './render.js';
14
+
15
+ const PRIORITIES = [
16
+ { value: 'LOW', label: 'Low' },
17
+ { value: 'MEDIUM', label: 'Medium' },
18
+ { value: 'HIGH', label: 'High' },
19
+ { value: 'CRITICAL', label: 'Critical' },
20
+ ];
21
+
22
+ const DEFAULT_PRIORITY = 'MEDIUM';
23
+
24
+ /** The form's markup, for the panel's body. */
25
+ export function composeHtml(cfg) {
26
+ const canCapture = cfg.attachments !== false;
27
+ const canShoot = canCapture && screenshotSupported();
28
+ const canRecord = canCapture && captureSupported();
29
+ return `
30
+ <div class="compose">
31
+ <div class="field">
32
+ <label for="bc-title">What went wrong?</label>
33
+ <input id="bc-title" type="text" placeholder="e.g. The checkout button does nothing" autocomplete="off"
34
+ aria-describedby="bc-title-err" />
35
+ <p class="field-err" id="bc-title-err" hidden></p>
36
+ </div>
37
+ <div class="field">
38
+ <label for="bc-desc">Anything else that helps <span class="opt">(optional)</span></label>
39
+ <textarea id="bc-desc" placeholder="What you were doing, what you expected, what happened instead."></textarea>
40
+ </div>
41
+ <!-- Drawn only when this project asks for an identifier and the report
42
+ does not already carry one. Between the description and the
43
+ severity because that is where it belongs in the reading order:
44
+ it is a question about what they just typed. -->
45
+ <div class="refs" hidden></div>
46
+ <div class="field">
47
+ <label id="bc-prio-l">How much is it getting in your way?</label>
48
+ <div class="seg" role="group" aria-labelledby="bc-prio-l">
49
+ ${PRIORITIES.map((p) => `
50
+ <button type="button" data-prio="${p.value}" aria-pressed="${p.value === DEFAULT_PRIORITY}">${p.label}</button>
51
+ `).join('')}
52
+ </div>
53
+ </div>
54
+ ${canCapture ? `
55
+ <div class="field">
56
+ <div class="field-head">
57
+ <label id="bc-att-l">Show us <span class="opt">(optional)</span></label>
58
+ <span class="count" aria-live="polite"></span>
59
+ </div>
60
+ <div class="tools" role="group" aria-labelledby="bc-att-l">
61
+ ${canShoot ? `<button class="tool" type="button" data-act="shot">${ICONS.camera}Screenshot</button>` : ''}
62
+ ${canRecord ? `<button class="tool" type="button" data-act="rec">${ICONS.video}Record screen</button>` : ''}
63
+ <button class="tool" type="button" data-act="file">${ICONS.paperclip}Upload file</button>
64
+ </div>
65
+ <div class="thumbs"></div>
66
+ <input type="file" accept="${REPORT_MIMES.join(',')}" multiple hidden />
67
+ </div>` : ''}
68
+ </div>`;
69
+ }
70
+
71
+ export function mountCompose(ctx) {
72
+ const {
73
+ client, cfg, root, host, dock, scrim, $, say, titleInput, descInput, compose, sendBtn, note,
74
+ } = ctx;
75
+ const thumbs = $('.thumbs');
76
+ const filePicker = scrim.querySelector('input[type=file]');
77
+
78
+ // Uploaded as they are added rather than on send: the upload is the slow
79
+ // part, and doing it while the reporter is still typing means Send is
80
+ // instant and a failure is visible next to the thing that failed.
81
+ const items = [];
82
+ let priority = DEFAULT_PRIORITY;
83
+ let recorder = null;
84
+
85
+ /* ---------- the draft this device keeps ---------- */
86
+
87
+ const draftStatus = document.createElement('div');
88
+ draftStatus.setAttribute('role', 'status');
89
+ draftStatus.className = 'note';
90
+ compose.prepend(draftStatus);
91
+ const savedDraftKey = draftKey(cfg, client.signedInAs()?.email);
92
+ let draftLoaded = cfg.drafts === false;
93
+ let draftSubmitted = false;
94
+ let draftWrites = Promise.resolve();
95
+ let draftTimer;
96
+ function persistDraft() {
97
+ clearTimeout(draftTimer);
98
+ if (!draftLoaded || draftSubmitted || cfg.drafts === false) return draftWrites;
99
+ const value = titleInput.value || descInput.value || items.length ? {
100
+ title: titleInput.value, description: descInput.value, priority,
101
+ items: items.map(({ blob, mime, kind }) => ({ blob, mime, kind })),
102
+ } : null;
103
+ draftWrites = draftWrites.catch(() => {}).then(() => draftOperation(savedDraftKey, value))
104
+ .then(() => { draftStatus.textContent = value ? 'Draft saved on this device' : ''; })
105
+ .catch(() => { draftStatus.textContent = 'Draft could not be saved. Keep this tab open.'; });
106
+ return draftWrites;
107
+ }
108
+ function scheduleDraft() { clearTimeout(draftTimer); draftTimer = setTimeout(persistDraft, 250); }
109
+ compose.addEventListener('input', scheduleDraft);
110
+ compose.addEventListener('change', scheduleDraft);
111
+ const onPageHide = () => { void persistDraft(); };
112
+ window.addEventListener('pagehide', onPageHide);
113
+ function reconnectUploads() { items.filter((it) => it.status === 'failed').forEach(upload); }
114
+ window.addEventListener('online', reconnectUploads);
115
+ if (cfg.drafts !== false) {
116
+ // Disable composition briefly so a late storage read cannot replace new text.
117
+ compose.inert = true;
118
+ readDraft(savedDraftKey).then((draft) => {
119
+ if (ctx.closed || !draft) return;
120
+ draftStatus.innerHTML = '<span>You have an unfinished report on this device.</span> <button type="button" data-resume>Resume report</button> <button type="button" data-discard>Discard</button>';
121
+ draftStatus.querySelector('[data-resume]').onclick = () => {
122
+ titleInput.value = draft.title || '';
123
+ descInput.value = draft.description || '';
124
+ priority = PRIORITIES.some((p) => p.value === draft.priority) ? draft.priority : 'MEDIUM';
125
+ scrim.querySelectorAll('[data-prio]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.prio === priority)));
126
+ for (const item of (draft.items || []).slice(0, cfg.maxAttachments ?? 5)) addItem(item);
127
+ draftLoaded = true;
128
+ void persistDraft();
129
+ };
130
+ draftStatus.querySelector('[data-discard]').onclick = () => {
131
+ draftLoaded = true;
132
+ void persistDraft();
133
+ };
134
+ }).catch(() => { draftStatus.textContent = 'Draft storage is unavailable. Keep this tab open.'; })
135
+ .finally(() => { compose.inert = false; if (!draftStatus.querySelector('[data-resume]')) draftLoaded = true; });
136
+ }
137
+
138
+ // Saved on the way out, before anything else is taken down.
139
+ ctx.onDestroy(() => {
140
+ void persistDraft();
141
+ clearTimeout(draftTimer);
142
+ window.removeEventListener('pagehide', onPageHide);
143
+ window.removeEventListener('online', reconnectUploads);
144
+ recorder?.cancel();
145
+ items.forEach((it) => it.previewUrl && URL.revokeObjectURL(it.previewUrl));
146
+ root.querySelector('.recbar')?.remove();
147
+ });
148
+
149
+ /* ---------- severity ---------- */
150
+
151
+ scrim.querySelectorAll('[data-prio]').forEach((btn) => {
152
+ btn.addEventListener('click', () => {
153
+ priority = btn.dataset.prio;
154
+ scheduleDraft();
155
+ scrim.querySelectorAll('[data-prio]').forEach((b) => {
156
+ b.setAttribute('aria-pressed', String(b === btn));
157
+ });
158
+ });
159
+ });
160
+
161
+ /* ---------- attachments ---------- */
162
+
163
+ // The one required field says what is missing right under itself, rather
164
+ // than in a banner the reporter has to connect back to it.
165
+ const titleErr = $('#bc-title-err');
166
+ function flagTitle(text) {
167
+ titleErr.hidden = !text;
168
+ titleErr.innerHTML = text ? `${ICONS.alert}<span>${escapeHtml(text)}</span>` : '';
169
+ titleInput.classList.toggle('invalid', Boolean(text));
170
+ titleInput.setAttribute('aria-invalid', String(Boolean(text)));
171
+ }
172
+ titleInput.addEventListener('input', () => { if (titleInput.value.trim()) flagTitle(''); });
173
+
174
+ function refreshSendState() {
175
+ const busy = items.some((it) => it.status === 'uploading');
176
+ sendBtn.disabled = ctx.sending || busy || items.some((it) => it.status === 'failed');
177
+ note.textContent = busy ? 'Waiting for the upload to finish…' : items.some((it) => it.status === 'failed') ? 'Retry or remove failed attachments before sending.' : '';
178
+ const max = cfg.maxAttachments ?? 5;
179
+ scrim.querySelectorAll('.tool').forEach((t) => {
180
+ t.disabled = ctx.sending || items.length >= max;
181
+ });
182
+ const count = $('.field-head .count');
183
+ if (count) count.textContent = items.length ? `${items.length} of ${max}` : '';
184
+ }
185
+ ctx.refreshSendState = refreshSendState;
186
+
187
+ function drawThumb(item) {
188
+ const el = item.el;
189
+ const label = item.kind === 'VIDEO' ? 'Video' : 'Shot';
190
+ const preview = item.kind === 'VIDEO'
191
+ ? `<video src="${item.previewUrl}" muted playsinline preload="metadata"></video>`
192
+ : `<img src="${item.previewUrl}" alt="" />`;
193
+
194
+ let veil = '';
195
+ if (item.status === 'uploading') {
196
+ veil = `<div class="veil"><div class="spinner"></div><div class="bar"><i style="width:${Math.round(item.progress * 100)}%"></i></div></div>`;
197
+ } else if (item.status === 'failed') {
198
+ veil = `<div class="veil err"><button type="button" data-act="retry" aria-label="Retry upload">${ICONS.retry}</button>Failed</div>`;
199
+ }
200
+
201
+ el.innerHTML = `
202
+ ${preview}
203
+ <span class="kind">${label} · ${humanSize(item.size)}</span>
204
+ <button class="drop" type="button" data-act="drop" aria-label="Remove ${label.toLowerCase()}">${ICONS.close}</button>
205
+ ${veil}`;
206
+
207
+ el.querySelector('[data-act="drop"]').addEventListener('click', () => removeItem(item));
208
+ el.querySelector('[data-act="retry"]')?.addEventListener('click', () => upload(item));
209
+ }
210
+
211
+ function removeItem(item) {
212
+ const i = items.indexOf(item);
213
+ if (i >= 0) items.splice(i, 1);
214
+ if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
215
+ item.el.remove();
216
+ void persistDraft();
217
+ refreshSendState();
218
+ }
219
+
220
+ async function upload(item) {
221
+ item.status = 'uploading';
222
+ item.progress = 0;
223
+ drawThumb(item);
224
+ refreshSendState();
225
+ try {
226
+ const { storageKey, mime } = await client.uploadAttachment(item.blob, item.mime, (p) => {
227
+ item.progress = p;
228
+ // Only the width changes while uploading — repainting the whole tile
229
+ // would restart the entrance animation on every progress event.
230
+ const fill = item.el.querySelector('.bar i');
231
+ if (fill) fill.style.width = `${Math.round(p * 100)}%`;
232
+ });
233
+ item.storageKey = storageKey;
234
+ item.mime = mime;
235
+ item.status = 'ready';
236
+ } catch (err) {
237
+ item.status = 'failed';
238
+ say(err.message.replace('[BugCatch] ', ''));
239
+ }
240
+ drawThumb(item);
241
+ refreshSendState();
242
+ }
243
+
244
+ function addItem({ blob, mime, kind }) {
245
+ const max = cfg.maxAttachmentBytes ?? 25 * 1024 * 1024;
246
+ if (blob.size > max) {
247
+ say(`That file is ${humanSize(blob.size)} — the limit is ${humanSize(max)}.`);
248
+ return;
249
+ }
250
+ // Held to the report set, not the full upload set. A report's attachments
251
+ // are typed SCREENSHOT or VIDEO, so a document accepted here would be filed
252
+ // as a screenshot and render as a broken image. Documents and voice notes
253
+ // go on a message instead, where they keep their own type.
254
+ const normalized = normalizeMime(mime || blob.type, REPORT_MIMES);
255
+ if (!normalized) {
256
+ say('Only PNG, JPEG, WebP, WebM and MP4 files can be attached to a report.');
257
+ return;
258
+ }
259
+ const item = {
260
+ blob, kind, mime: normalized, size: blob.size,
261
+ previewUrl: URL.createObjectURL(blob),
262
+ status: 'uploading', progress: 0, storageKey: null,
263
+ el: document.createElement('div'),
264
+ };
265
+ item.el.className = 'thumb';
266
+ items.push(item);
267
+ thumbs.appendChild(item.el);
268
+ say('');
269
+ void persistDraft();
270
+ upload(item);
271
+ }
272
+
273
+ /* Hide our own UI so the capture shows the page, not this dialog. */
274
+ async function withWidgetHidden(fn) {
275
+ host.style.opacity = '0';
276
+ host.style.pointerEvents = 'none';
277
+ // One frame so the browser has actually painted the hidden state before
278
+ // the picker — and the frame we grab — is taken.
279
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
280
+ try {
281
+ return await fn();
282
+ } finally {
283
+ host.style.opacity = '';
284
+ host.style.pointerEvents = '';
285
+ }
286
+ }
287
+
288
+ scrim.querySelector('[data-act="shot"]')?.addEventListener('click', async () => {
289
+ try {
290
+ const shot = await withWidgetHidden(captureScreenshot);
291
+ addItem(shot);
292
+ } catch (err) {
293
+ // NotAllowedError is the user dismissing the picker — not a failure.
294
+ if (err?.name !== 'NotAllowedError') say(`Screenshot failed: ${err.message}`);
295
+ }
296
+ });
297
+
298
+ scrim.querySelector('[data-act="file"]')?.addEventListener('click', () => filePicker.click());
299
+ filePicker?.addEventListener('change', () => {
300
+ const room = (cfg.maxAttachments ?? 5) - items.length;
301
+ [...filePicker.files].slice(0, Math.max(room, 0)).forEach((file) => {
302
+ addItem({
303
+ blob: file,
304
+ mime: file.type,
305
+ kind: file.type.startsWith('video/') ? 'VIDEO' : 'SCREENSHOT',
306
+ });
307
+ });
308
+ filePicker.value = '';
309
+ });
310
+
311
+ /* ---------- recording ---------- */
312
+
313
+ scrim.querySelector('[data-act="rec"]')?.addEventListener('click', async () => {
314
+ const maxMs = cfg.maxRecordingMs ?? 120_000;
315
+ let bar;
316
+ try {
317
+ recorder = await startRecording({
318
+ maxMs,
319
+ onTick: (s) => { if (bar) bar.querySelector('.time').textContent = humanDuration(s); },
320
+ onAutoStop: () => { if (recorder) stopRecording({ keep: true }); },
321
+ });
322
+ } catch (err) {
323
+ if (err?.name !== 'NotAllowedError') say(`Recording failed: ${err.message}`);
324
+ return;
325
+ }
326
+
327
+ // Step out of the way for the duration: a recording of this dialog sitting
328
+ // over the page is not what anyone means by "record the bug".
329
+ scrim.style.display = 'none';
330
+ dock.hidden = true;
331
+
332
+ bar = document.createElement('div');
333
+ bar.className = 'recbar';
334
+ bar.innerHTML = `
335
+ <span class="dot"></span>
336
+ <span class="time">0:00</span>
337
+ <span class="hint">Recording — up to ${humanDuration(Math.round(maxMs / 1000))}</span>
338
+ <button class="stop" type="button">Stop</button>
339
+ <button class="ghost" type="button">Discard</button>`;
340
+ bar.querySelector('.stop').addEventListener('click', () => stopRecording({ keep: true }));
341
+ bar.querySelector('.ghost').addEventListener('click', () => stopRecording({ keep: false }));
342
+ root.appendChild(bar);
343
+ bar.querySelector('.stop').focus();
344
+ });
345
+
346
+ async function stopRecording({ keep }) {
347
+ const rec = recorder;
348
+ if (!rec) return;
349
+ recorder = null;
350
+ root.querySelector('.recbar')?.remove();
351
+ scrim.style.display = '';
352
+ dock.hidden = false;
353
+
354
+ if (!keep) { rec.cancel(); titleInput.focus(); return; }
355
+ try {
356
+ const clip = await rec.stop();
357
+ if (clip.blob.size) addItem(clip);
358
+ else say('That recording came back empty — nothing was captured.');
359
+ } catch (err) {
360
+ say(`Recording failed: ${err.message}`);
361
+ }
362
+ titleInput.focus();
363
+ }
364
+ ctx.isRecording = () => Boolean(recorder);
365
+ ctx.stopRecording = stopRecording;
366
+
367
+ /* ---------- send ---------- */
368
+
369
+ sendBtn.addEventListener('click', async () => {
370
+ const title = titleInput.value;
371
+ if (!title.trim()) {
372
+ flagTitle('Add a few words about what went wrong — a short sentence is plenty.');
373
+ titleInput.focus();
374
+ return;
375
+ }
376
+
377
+ // Ask, if this project asks and nothing they wrote answered it — the
378
+ // first press opens the question rather than sending. See refs.js.
379
+ if (ctx.holdForReference()) return;
380
+
381
+ ctx.sending = true;
382
+ say('');
383
+ refreshSendState();
384
+ const original = sendBtn.innerHTML;
385
+ sendBtn.innerHTML = '<span class="spinner" aria-hidden="true"></span>Sending…';
386
+
387
+ try {
388
+ const res = await client.report({
389
+ title,
390
+ description: descInput.value,
391
+ priority,
392
+ references: ctx.answeredReference(),
393
+ issue: ctx.pickedIssue()?.key || null,
394
+ attachments: items
395
+ .filter((it) => it.status === 'ready')
396
+ .map((it) => ({ type: it.kind, storageKey: it.storageKey, mime: it.mime })),
397
+ });
398
+ if (res?.cancelled) { ctx.sending = false; sendBtn.innerHTML = original; refreshSendState(); return; }
399
+ draftSubmitted = true;
400
+ clearTimeout(draftTimer);
401
+ await draftWrites;
402
+ if (cfg.drafts !== false) await draftOperation(savedDraftKey, null).catch(() => {});
403
+ ctx.succeed(res);
404
+ } catch (err) {
405
+ ctx.sending = false;
406
+ sendBtn.innerHTML = original;
407
+ say(err.message.replace('[BugCatch] ', ''));
408
+ refreshSendState();
409
+ // One reason a send fails is that this browser was signed out while the
410
+ // panel sat open — the same address signed in elsewhere. The message
411
+ // above already says so; this puts the panel back in step with it
412
+ // instead of leaving a Send button that will fail the same way again.
413
+ ctx.recheckSignIn();
414
+ }
415
+ });
416
+ }