@sphoro/bugcatch-web-sdk 0.17.0 → 0.18.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 (51) hide show
  1. package/README.md +69 -4
  2. package/dist/bugcatch-call.es.js +586 -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 +3214 -0
  7. package/dist/bugcatch-core.es.js.map +1 -0
  8. package/dist/bugcatch-panel.es.js +2402 -0
  9. package/dist/bugcatch-panel.es.js.map +1 -0
  10. package/dist/bugcatch-panel.umd.js +1240 -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 +1536 -0
  15. package/dist/bugcatch.standalone.umd.js.map +1 -0
  16. package/dist/bugcatch.umd.js +89 -1224
  17. package/dist/bugcatch.umd.js.map +1 -1
  18. package/package.json +6 -6
  19. package/src/announcements.js +147 -0
  20. package/src/busy.js +18 -0
  21. package/src/call.js +7 -0
  22. package/src/capture.js +9 -0
  23. package/src/chunks.js +56 -0
  24. package/src/gesture.js +33 -0
  25. package/src/icons.js +18 -0
  26. package/src/identity.js +13 -8
  27. package/src/incoming-call.js +8 -30
  28. package/src/index.d.ts +50 -0
  29. package/src/index.js +138 -35
  30. package/src/lazy.js +23 -0
  31. package/src/notice.js +68 -0
  32. package/src/panel/call.js +242 -0
  33. package/src/panel/compose.js +416 -0
  34. package/src/panel/composer.js +235 -0
  35. package/src/panel/icons.js +34 -0
  36. package/src/panel/index.js +251 -0
  37. package/src/panel/known.js +130 -0
  38. package/src/panel/messages.js +176 -0
  39. package/src/panel/notices.js +34 -0
  40. package/src/panel/picks.js +61 -0
  41. package/src/panel/refs.js +321 -0
  42. package/src/panel/render.js +124 -0
  43. package/src/panel/signin.js +228 -0
  44. package/src/panel/styles.js +960 -0
  45. package/src/panel/thread.js +384 -0
  46. package/src/session.js +8 -3
  47. package/src/umd/call.js +6 -0
  48. package/src/umd/lazy.js +98 -0
  49. package/src/umd/panel.js +6 -0
  50. package/src/updates.js +20 -7
  51. package/src/widget.js +250 -3260
@@ -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
+ }
@@ -0,0 +1,235 @@
1
+ // The reply box under a report's thread.
2
+
3
+ import * as ICONS from './icons.js';
4
+ import {
5
+ startVoiceNote, voiceSupported, humanSize, humanDuration, UPLOAD_MIMES,
6
+ } from '../capture.js';
7
+ import { messageHtml } from './messages.js';
8
+ import { escapeHtml, scrollToLatest } from './render.js';
9
+
10
+ /**
11
+ * The box at the bottom: text, files, and a voice note.
12
+ *
13
+ * Files are uploaded as they are picked, not on send, so the wait happens
14
+ * while the person is still writing rather than after they have pressed the
15
+ * button — and so a failed upload is visible as a failed chip instead of a
16
+ * message that never went.
17
+ */
18
+ export function mountComposer(ctx, body, thread, bugId) {
19
+ const { client, cfg } = ctx;
20
+ const composer = document.createElement('div');
21
+ composer.className = 'composer';
22
+ composer.innerHTML = `
23
+ <div class="staged" hidden></div>
24
+ <div class="row">
25
+ <div class="tools">
26
+ ${cfg.attachments === false ? '' : `
27
+ <button class="tool" type="button" data-act="attach" title="Attach a file" aria-label="Attach a file">${ICONS.paperclip}</button>
28
+ <button class="tool" type="button" data-act="voice" title="Record a voice note" aria-label="Record a voice note">${ICONS.mic}</button>`}
29
+ <button class="tool" type="button" data-act="livecall" title="Call the team" aria-label="Call the team" hidden>${ICONS.phone}</button>
30
+ </div>
31
+ <textarea rows="1" placeholder="Write a reply…" aria-label="Write a reply"></textarea>
32
+ <button class="send" type="button" data-act="send" title="Send" aria-label="Send" disabled>${ICONS.send}</button>
33
+ </div>
34
+ <div class="note" hidden></div>
35
+ <input type="file" hidden multiple accept="${UPLOAD_MIMES.join(',')}">`;
36
+ body.appendChild(composer);
37
+
38
+ const staged = composer.querySelector('.staged');
39
+ const input = composer.querySelector('textarea');
40
+ const sendBtn = composer.querySelector('[data-act="send"]');
41
+ const voiceBtn = composer.querySelector('[data-act="voice"]');
42
+ const liveBtn = composer.querySelector('[data-act="livecall"]');
43
+ const picker = composer.querySelector('input[type=file]');
44
+ const note = composer.querySelector('.note');
45
+
46
+ // Uploaded files waiting to ride on the next message.
47
+ const files = [];
48
+ let recorder = null;
49
+ let sending = false;
50
+
51
+ function say(text, kind = '') {
52
+ note.hidden = !text;
53
+ note.className = `note ${kind}`;
54
+ note.textContent = text || '';
55
+ }
56
+
57
+ function refresh() {
58
+ const ready = files.filter((f) => f.storageKey);
59
+ sendBtn.disabled = sending || (!input.value.trim() && !ready.length);
60
+ staged.hidden = !files.length;
61
+ staged.innerHTML = files.map((f, i) => `
62
+ <span class="chip ${f.storageKey ? '' : 'uploading'}">
63
+ <span class="nm">${escapeHtml(f.name)}</span>
64
+ <span class="sz">${f.storageKey ? humanSize(f.size) : 'sending…'}</span>
65
+ <button type="button" data-drop="${i}" aria-label="Remove ${escapeHtml(f.name)}">${ICONS.close}</button>
66
+ </span>`).join('');
67
+ for (const btn of staged.querySelectorAll('[data-drop]')) {
68
+ btn.addEventListener('click', () => {
69
+ files.splice(Number(btn.dataset.drop), 1);
70
+ refresh();
71
+ });
72
+ }
73
+ }
74
+
75
+ /** Upload one blob and stage it. Its chip is on screen for the whole trip. */
76
+ async function stage(blob, mime, name) {
77
+ const entry = { name, size: blob.size, storageKey: null };
78
+ files.push(entry);
79
+ refresh();
80
+ try {
81
+ const { storageKey, mime: stored } = await client.uploadAttachment(blob, mime);
82
+ entry.storageKey = storageKey;
83
+ entry.mime = stored;
84
+ } catch (err) {
85
+ files.splice(files.indexOf(entry), 1);
86
+ say(err.message || 'That file could not be sent.', 'err');
87
+ }
88
+ refresh();
89
+ }
90
+
91
+ input.addEventListener('input', () => {
92
+ // Grow with the text, up to the CSS cap. A reply about a bug is often
93
+ // three lines, and a one-line box makes people write one line.
94
+ input.style.height = 'auto';
95
+ input.style.height = `${Math.min(input.scrollHeight, 120)}px`;
96
+ refresh();
97
+ });
98
+
99
+ // Enter sends, Shift+Enter breaks the line — the chat convention. Anyone
100
+ // who wants a paragraph gets it; nobody has to hunt for the button.
101
+ input.addEventListener('keydown', (e) => {
102
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
103
+ e.preventDefault();
104
+ send();
105
+ }
106
+ });
107
+
108
+ // Absent when the host set `attachments: false` — which the report form
109
+ // has always honoured and this composer did not, so a host keeping files
110
+ // and microphone audio outside its compliance perimeter still had both
111
+ // offered in the follow-up thread.
112
+ composer.querySelector('[data-act="attach"]')?.addEventListener('click', () => picker.click());
113
+ picker.addEventListener('change', () => {
114
+ say('');
115
+ for (const file of picker.files) {
116
+ if (file.size > client.config.maxAttachmentBytes) {
117
+ say(`${file.name} is larger than ${humanSize(client.config.maxAttachmentBytes)}.`, 'err');
118
+ continue;
119
+ }
120
+ stage(file, file.type, file.name);
121
+ }
122
+ picker.value = '';
123
+ });
124
+
125
+ if (voiceBtn && !voiceSupported()) {
126
+ voiceBtn.disabled = true;
127
+ voiceBtn.title = 'This browser cannot record audio';
128
+ }
129
+
130
+ voiceBtn?.addEventListener('click', async () => {
131
+ if (recorder) {
132
+ const handle = recorder;
133
+ recorder = null;
134
+ voiceBtn.classList.remove('recording');
135
+ voiceBtn.innerHTML = ICONS.mic;
136
+ say('');
137
+ const { blob, mime } = await handle.stop();
138
+ if (blob.size) stage(blob, mime, 'Voice note');
139
+ return;
140
+ }
141
+ try {
142
+ say('');
143
+ recorder = await startVoiceNote({
144
+ maxMs: client.config.maxRecordingMs,
145
+ onTick: (s) => say(`Recording — ${humanDuration(s)}. Tap the mic to stop.`),
146
+ onAutoStop: () => voiceBtn.click(),
147
+ });
148
+ voiceBtn.classList.add('recording');
149
+ voiceBtn.innerHTML = ICONS.stopSquare;
150
+ } catch {
151
+ recorder = null;
152
+ say('The microphone is not available — check the browser permission.', 'err');
153
+ }
154
+ });
155
+
156
+ /* ---------- calling the team ---------- */
157
+
158
+ // Asked once, when a report is opened, and the button stays hidden unless
159
+ // the answer is yes. Most workspaces have live calling off, and a phone
160
+ // icon that apologises on press is worse than no phone icon.
161
+ client.canCallTeam(bugId)
162
+ .then((available) => {
163
+ if (!available) return;
164
+ liveBtn.hidden = false;
165
+ // Offered, so fetch what placing it needs now: the press should ring,
166
+ // not wait for code.
167
+ ctx.preloadCall();
168
+ })
169
+ .catch(() => { /* not available is the safe answer to every failure */ });
170
+
171
+ liveBtn.addEventListener('click', async () => {
172
+ liveBtn.disabled = true;
173
+ say('');
174
+ try {
175
+ const handle = await client.startLiveCall(bugId);
176
+ ctx.showLiveCall(body, thread, handle);
177
+ } catch (err) {
178
+ say(err.message.replace('[BugCatch] ', ''), 'err');
179
+ } finally {
180
+ liveBtn.disabled = false;
181
+ }
182
+ });
183
+
184
+ sendBtn.addEventListener('click', send);
185
+
186
+ // The confirm question's quick answers: the same as typing and sending.
187
+ thread.addEventListener('click', (e) => {
188
+ const quick = e.target.closest?.('[data-quick]');
189
+ if (!quick || sending) return;
190
+ input.value = quick.dataset.quick;
191
+ send();
192
+ });
193
+
194
+ async function send() {
195
+ const text = input.value.trim();
196
+ const ready = files.filter((f) => f.storageKey);
197
+ if (sending || (!text && !ready.length)) return;
198
+
199
+ sending = true;
200
+ refresh();
201
+ say('');
202
+
203
+ // Shown immediately, greyed, and replaced by the refresh below. Waiting
204
+ // for the round trip before anything appears makes a slow network look
205
+ // like a dropped message, which is when people send it twice.
206
+ thread.insertAdjacentHTML('beforeend', messageHtml({
207
+ from: 'reporter',
208
+ body: text,
209
+ at: new Date().toISOString(),
210
+ attachments: ready.map((f) => ({ type: 'FILE', name: f.name, size: f.size })),
211
+ }, 'pending'));
212
+ scrollToLatest(thread, { smooth: true });
213
+
214
+ try {
215
+ await client.replyToReport(bugId, {
216
+ body: text,
217
+ attachments: ready.map((f) => ({
218
+ storageKey: f.storageKey, mime: f.mime, name: f.name, size: f.size,
219
+ })),
220
+ });
221
+ input.value = '';
222
+ input.style.height = 'auto';
223
+ files.length = 0;
224
+ await ctx.refreshThread(bugId);
225
+ } catch (err) {
226
+ thread.querySelector('.msg-row.pending')?.classList.replace('pending', 'failed');
227
+ say(err.message || 'That could not be sent — try again.', 'err');
228
+ } finally {
229
+ sending = false;
230
+ refresh();
231
+ }
232
+ }
233
+
234
+ refresh();
235
+ }