@sphoro/bugcatch-web-sdk 0.16.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.
- package/README.md +92 -5
- package/dist/bugcatch-call.es.js +586 -0
- package/dist/bugcatch-call.es.js.map +1 -0
- package/dist/bugcatch-call.umd.js +2 -0
- package/dist/bugcatch-call.umd.js.map +1 -0
- package/dist/bugcatch-core.es.js +3214 -0
- package/dist/bugcatch-core.es.js.map +1 -0
- package/dist/bugcatch-panel.es.js +2402 -0
- package/dist/bugcatch-panel.es.js.map +1 -0
- package/dist/bugcatch-panel.umd.js +1240 -0
- package/dist/bugcatch-panel.umd.js.map +1 -0
- package/dist/bugcatch.es.js +2 -5522
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.standalone.umd.js +1536 -0
- package/dist/bugcatch.standalone.umd.js.map +1 -0
- package/dist/bugcatch.umd.js +89 -1183
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/agent-issues.js +30 -0
- package/src/announcements.js +147 -0
- package/src/busy.js +18 -0
- package/src/call.js +7 -0
- package/src/capture.js +9 -0
- package/src/chunks.js +56 -0
- package/src/gesture.js +33 -0
- package/src/icons.js +18 -0
- package/src/identity.js +14 -9
- package/src/incoming-call.js +8 -30
- package/src/index.d.ts +50 -0
- package/src/index.js +170 -37
- package/src/lazy.js +23 -0
- package/src/notice.js +68 -0
- package/src/panel/call.js +242 -0
- package/src/panel/compose.js +416 -0
- package/src/panel/composer.js +235 -0
- package/src/panel/icons.js +34 -0
- package/src/panel/index.js +251 -0
- package/src/panel/known.js +130 -0
- package/src/panel/messages.js +176 -0
- package/src/panel/notices.js +34 -0
- package/src/panel/picks.js +61 -0
- package/src/panel/refs.js +321 -0
- package/src/panel/render.js +124 -0
- package/src/panel/signin.js +228 -0
- package/src/panel/styles.js +960 -0
- package/src/panel/thread.js +384 -0
- package/src/session.js +8 -3
- package/src/umd/call.js +6 -0
- package/src/umd/lazy.js +98 -0
- package/src/umd/panel.js +6 -0
- package/src/updates.js +20 -7
- package/src/widget.js +243 -3164
|
@@ -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
|
+
}
|