@sphoro/bugcatch-web-sdk 0.13.1 → 0.14.1
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 +48 -78
- package/dist/bugcatch.es.js +1752 -1685
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.umd.js +154 -110
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/drafts.js +32 -0
- package/src/index.d.ts +114 -75
- package/src/index.js +147 -164
- package/src/references.js +172 -0
- package/src/updates.js +45 -74
- package/src/widget.js +320 -105
- package/src/voice.js +0 -372
package/src/widget.js
CHANGED
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
startVoiceNote, voiceSupported,
|
|
12
12
|
humanSize, humanDuration, normalizeMime, UPLOAD_MIMES, REPORT_MIMES,
|
|
13
13
|
} from './capture.js';
|
|
14
|
+
import { draftKey, draftOperation, readDraft } from './drafts.js';
|
|
15
|
+
import { readSession } from './session.js';
|
|
14
16
|
import { sendMessage } from './updates.js';
|
|
17
|
+
import { matchReferences, validateReference } from './references.js';
|
|
15
18
|
|
|
16
19
|
const POSITIONS = {
|
|
17
20
|
'bottom-right': 'bottom:20px;right:20px;',
|
|
@@ -354,6 +357,39 @@ textarea { min-height: 84px; resize: vertical; }
|
|
|
354
357
|
.whoami .err { color: var(--bc-danger); }
|
|
355
358
|
.whoami .code { letter-spacing: .28em; font-variant-numeric: tabular-nums; }
|
|
356
359
|
|
|
360
|
+
/* ---------- which record is this about ----------
|
|
361
|
+
Shaped like .whoami on purpose: both are the widget asking for something
|
|
362
|
+
rather than the reporter telling us something, and a reporter who has learnt
|
|
363
|
+
what the grey block means once should not have to learn it twice. Drawn only
|
|
364
|
+
after Send has been pressed and only when what they wrote did not already
|
|
365
|
+
answer it, so most people never see this at all. */
|
|
366
|
+
.refs {
|
|
367
|
+
margin-bottom: 14px; padding: 10px 12px;
|
|
368
|
+
font-size: 12.5px; line-height: 1.45; color: var(--bc-muted);
|
|
369
|
+
background: var(--bc-subtle); border-radius: 10px;
|
|
370
|
+
}
|
|
371
|
+
.refs .why { margin: 0 0 10px; font-size: 12.5px; line-height: 1.5; color: var(--bc-muted); }
|
|
372
|
+
.refs .row { display: flex; gap: 8px; align-items: stretch; }
|
|
373
|
+
.refs .row input { flex: 1; min-width: 0; }
|
|
374
|
+
.refs select {
|
|
375
|
+
flex: none; max-width: 45%; padding: 10px 10px; font-size: 13px;
|
|
376
|
+
color: var(--bc-fg); background: var(--bc-elevated);
|
|
377
|
+
border: 1px solid var(--bc-border); border-radius: 10px;
|
|
378
|
+
}
|
|
379
|
+
.refs select:focus {
|
|
380
|
+
outline: none; border-color: var(--bc-accent);
|
|
381
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--bc-accent) 18%, transparent);
|
|
382
|
+
}
|
|
383
|
+
.refs .hint { margin: 8px 0 0; font-size: 12px; line-height: 1.45; color: var(--bc-faint); }
|
|
384
|
+
.refs .after { display: flex; gap: 12px; align-items: center; margin-top: 9px; }
|
|
385
|
+
.refs .err { color: var(--bc-danger); }
|
|
386
|
+
.refs .link {
|
|
387
|
+
margin-top: 9px; padding: 0; font-size: 12.5px; font-weight: 600; line-height: 1.45;
|
|
388
|
+
color: var(--bc-accent); background: none; border: none; cursor: pointer;
|
|
389
|
+
}
|
|
390
|
+
.refs .link:hover { text-decoration: underline; }
|
|
391
|
+
.refs .link:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 2px; border-radius: 4px; }
|
|
392
|
+
|
|
357
393
|
/* ---------- severity ---------- */
|
|
358
394
|
.seg { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3px; padding: 3px; background: var(--bc-subtle); border-radius: 10px; }
|
|
359
395
|
.seg button {
|
|
@@ -651,9 +687,9 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
|
|
|
651
687
|
|
|
652
688
|
/* A call in progress, in place of the form.
|
|
653
689
|
---------------------------------------------------------------------------
|
|
654
|
-
Deliberately not styled as a chat.
|
|
655
|
-
|
|
656
|
-
|
|
690
|
+
Deliberately not styled as a chat. Nothing arrives here to read — the
|
|
691
|
+
conversation is happening out loud — so this is a state, a line of
|
|
692
|
+
reassurance about the report, and a way to hang up. */
|
|
657
693
|
.call { padding: 20px 22px 14px; animation: bc-fade-in 240ms ease both; }
|
|
658
694
|
.call-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
659
695
|
.call-head strong { font-size: 14.5px; font-weight: 650; }
|
|
@@ -663,15 +699,14 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
|
|
|
663
699
|
}
|
|
664
700
|
.call-dot.off { background: var(--bc-faint); animation: none; }
|
|
665
701
|
@keyframes bc-call-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
|
666
|
-
/* Said
|
|
667
|
-
|
|
702
|
+
/* Said while it is still ringing, because that is when somebody is deciding
|
|
703
|
+
whether it is worth waiting. */
|
|
668
704
|
.call-note { margin: 0 0 12px; font-size: 12px; color: var(--bc-muted); line-height: 1.5; }
|
|
669
705
|
|
|
670
|
-
/* ----------
|
|
671
|
-
/* A bar rather than its own screen, unlike the
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
so taking it off screen would be taking away the only shared reference. */
|
|
706
|
+
/* ---------- the same call, on top of the report it is about ---------- */
|
|
707
|
+
/* A bar rather than its own screen, unlike the one the composer opens. The
|
|
708
|
+
thread underneath is exactly what both people are talking about, so taking it
|
|
709
|
+
off screen would be taking away the only shared reference. */
|
|
675
710
|
.livecall {
|
|
676
711
|
display: flex; align-items: center; gap: 9px;
|
|
677
712
|
margin: 12px 0 0; padding: 9px 11px;
|
|
@@ -689,15 +724,6 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
|
|
|
689
724
|
.livecall .act.danger { border-color: transparent; background: var(--bc-danger); color: #fff; }
|
|
690
725
|
.livecall.over { color: var(--bc-muted); }
|
|
691
726
|
.call-note code { font-size: 11px; }
|
|
692
|
-
.call-lines {
|
|
693
|
-
max-height: 240px; overflow-y: auto; display: flex; flex-direction: column; gap: 7px;
|
|
694
|
-
padding: 10px 12px; border: 1px solid var(--bc-line); border-radius: 8px;
|
|
695
|
-
background: var(--bc-surface-2, transparent);
|
|
696
|
-
}
|
|
697
|
-
.call-line { font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
|
|
698
|
-
.call-line.agent { color: var(--bc-muted); }
|
|
699
|
-
.call-line.you { color: var(--bc-ink); font-weight: 500; }
|
|
700
|
-
.call-line.you::before { content: 'You: '; color: var(--bc-faint); font-weight: 400; }
|
|
701
727
|
@media (prefers-reduced-motion: reduce) { .call-dot { animation: none; } }
|
|
702
728
|
|
|
703
729
|
.done { padding: 34px 24px 38px; text-align: center; animation: bc-fade-in 240ms ease both; }
|
|
@@ -963,6 +989,11 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
963
989
|
<label for="bc-desc">Any detail that helps <span style="font-weight:450;color:var(--bc-faint)">(optional)</span></label>
|
|
964
990
|
<textarea id="bc-desc" placeholder="I clicked Pay and nothing happened."></textarea>
|
|
965
991
|
</div>
|
|
992
|
+
<!-- Drawn only when this project asks for an identifier and the report
|
|
993
|
+
does not already carry one. Between the description and the
|
|
994
|
+
severity because that is where it belongs in the reading order:
|
|
995
|
+
it is a question about what they just typed. -->
|
|
996
|
+
<div class="refs" hidden></div>
|
|
966
997
|
<div class="field">
|
|
967
998
|
<label id="bc-prio-l">Severity</label>
|
|
968
999
|
<div class="seg" role="group" aria-labelledby="bc-prio-l">
|
|
@@ -994,7 +1025,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
994
1025
|
most cannot. Drawn here rather than as a second launcher because it
|
|
995
1026
|
is the same errand as Send report — the difference is only whether
|
|
996
1027
|
the reporter would rather say it than type it. -->
|
|
997
|
-
<button class="ghost" type="button" data-act="call" hidden>Talk to
|
|
1028
|
+
<button class="ghost" type="button" data-act="call" hidden>Talk to the team</button>
|
|
998
1029
|
<button class="primary" type="button" data-act="send">Send report</button>
|
|
999
1030
|
</div>
|
|
1000
1031
|
</div>`;
|
|
@@ -1009,9 +1040,61 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1009
1040
|
const note = $('.note');
|
|
1010
1041
|
const compose = $('.compose');
|
|
1011
1042
|
const whoami = $('.whoami');
|
|
1043
|
+
const refsBox = $('.refs');
|
|
1012
1044
|
const known = $('.known');
|
|
1013
1045
|
const historyBtn = $('[data-act="history"]');
|
|
1014
1046
|
|
|
1047
|
+
const draftStatus = document.createElement('div');
|
|
1048
|
+
draftStatus.setAttribute('role', 'status');
|
|
1049
|
+
draftStatus.className = 'note';
|
|
1050
|
+
compose.prepend(draftStatus);
|
|
1051
|
+
const savedDraftKey = draftKey(cfg, readSession(cfg.projectId)?.email);
|
|
1052
|
+
let draftLoaded = cfg.drafts === false;
|
|
1053
|
+
let draftSubmitted = false;
|
|
1054
|
+
let draftWrites = Promise.resolve();
|
|
1055
|
+
let draftTimer;
|
|
1056
|
+
function persistDraft() {
|
|
1057
|
+
clearTimeout(draftTimer);
|
|
1058
|
+
if (!draftLoaded || draftSubmitted || cfg.drafts === false) return draftWrites;
|
|
1059
|
+
const value = titleInput.value || $('#bc-desc').value || items.length ? {
|
|
1060
|
+
title: titleInput.value, description: $('#bc-desc').value, priority,
|
|
1061
|
+
items: items.map(({ blob, mime, kind }) => ({ blob, mime, kind })),
|
|
1062
|
+
} : null;
|
|
1063
|
+
draftWrites = draftWrites.catch(() => {}).then(() => draftOperation(savedDraftKey, value))
|
|
1064
|
+
.then(() => { draftStatus.textContent = value ? 'Draft saved on this device' : ''; })
|
|
1065
|
+
.catch(() => { draftStatus.textContent = 'Draft could not be saved. Keep this tab open.'; });
|
|
1066
|
+
return draftWrites;
|
|
1067
|
+
}
|
|
1068
|
+
function scheduleDraft() { clearTimeout(draftTimer); draftTimer = setTimeout(persistDraft, 250); }
|
|
1069
|
+
compose.addEventListener('input', scheduleDraft);
|
|
1070
|
+
compose.addEventListener('change', scheduleDraft);
|
|
1071
|
+
const onPageHide = () => { void persistDraft(); };
|
|
1072
|
+
window.addEventListener('pagehide', onPageHide);
|
|
1073
|
+
function reconnectUploads() { items.filter((it) => it.status === 'failed').forEach(upload); }
|
|
1074
|
+
window.addEventListener('online', reconnectUploads);
|
|
1075
|
+
if (cfg.drafts !== false) {
|
|
1076
|
+
// Disable composition briefly so a late storage read cannot replace new text.
|
|
1077
|
+
compose.inert = true;
|
|
1078
|
+
readDraft(savedDraftKey).then((draft) => {
|
|
1079
|
+
if (closed || !draft) return;
|
|
1080
|
+
draftStatus.innerHTML = 'Unfinished report on this device. <button type="button" data-resume>Resume report</button> <button type="button" data-discard>Discard</button>';
|
|
1081
|
+
draftStatus.querySelector('[data-resume]').onclick = () => {
|
|
1082
|
+
titleInput.value = draft.title || '';
|
|
1083
|
+
$('#bc-desc').value = draft.description || '';
|
|
1084
|
+
priority = PRIORITIES.some((p) => p.value === draft.priority) ? draft.priority : 'MEDIUM';
|
|
1085
|
+
scrim.querySelectorAll('[data-prio]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.prio === priority)));
|
|
1086
|
+
for (const item of (draft.items || []).slice(0, cfg.maxAttachments ?? 5)) addItem(item);
|
|
1087
|
+
draftLoaded = true;
|
|
1088
|
+
void persistDraft();
|
|
1089
|
+
};
|
|
1090
|
+
draftStatus.querySelector('[data-discard]').onclick = () => {
|
|
1091
|
+
draftLoaded = true;
|
|
1092
|
+
void persistDraft();
|
|
1093
|
+
};
|
|
1094
|
+
}).catch(() => { draftStatus.textContent = 'Draft storage is unavailable. Keep this tab open.'; })
|
|
1095
|
+
.finally(() => { compose.inert = false; if (!draftStatus.querySelector('[data-resume]')) draftLoaded = true; });
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1015
1098
|
/* ---------- lifecycle ---------- */
|
|
1016
1099
|
|
|
1017
1100
|
const previouslyFocused = document.activeElement;
|
|
@@ -1024,7 +1107,11 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1024
1107
|
let stopListening = null;
|
|
1025
1108
|
function destroy() {
|
|
1026
1109
|
if (closed) return;
|
|
1110
|
+
void persistDraft();
|
|
1027
1111
|
closed = true;
|
|
1112
|
+
clearTimeout(draftTimer);
|
|
1113
|
+
window.removeEventListener('pagehide', onPageHide);
|
|
1114
|
+
window.removeEventListener('online', reconnectUploads);
|
|
1028
1115
|
recorder?.cancel();
|
|
1029
1116
|
// Stop listening before the DOM goes: a frame arriving after this would
|
|
1030
1117
|
// redraw a panel that is no longer on the page.
|
|
@@ -1078,6 +1165,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1078
1165
|
scrim.querySelectorAll('[data-prio]').forEach((btn) => {
|
|
1079
1166
|
btn.addEventListener('click', () => {
|
|
1080
1167
|
priority = btn.dataset.prio;
|
|
1168
|
+
scheduleDraft();
|
|
1081
1169
|
scrim.querySelectorAll('[data-prio]').forEach((b) => {
|
|
1082
1170
|
b.setAttribute('aria-pressed', String(b === btn));
|
|
1083
1171
|
});
|
|
@@ -1092,8 +1180,8 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1092
1180
|
|
|
1093
1181
|
function refreshSendState() {
|
|
1094
1182
|
const busy = items.some((it) => it.status === 'uploading');
|
|
1095
|
-
sendBtn.disabled = sending || busy;
|
|
1096
|
-
note.textContent = busy ? 'Waiting for the upload to finish…' : '';
|
|
1183
|
+
sendBtn.disabled = sending || busy || items.some((it) => it.status === 'failed');
|
|
1184
|
+
note.textContent = busy ? 'Waiting for the upload to finish…' : items.some((it) => it.status === 'failed') ? 'Retry or remove failed attachments before sending.' : '';
|
|
1097
1185
|
scrim.querySelectorAll('.tool').forEach((t) => {
|
|
1098
1186
|
t.disabled = sending || items.length >= (cfg.maxAttachments ?? 5);
|
|
1099
1187
|
});
|
|
@@ -1128,6 +1216,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1128
1216
|
if (i >= 0) items.splice(i, 1);
|
|
1129
1217
|
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
|
1130
1218
|
item.el.remove();
|
|
1219
|
+
void persistDraft();
|
|
1131
1220
|
refreshSendState();
|
|
1132
1221
|
}
|
|
1133
1222
|
|
|
@@ -1180,6 +1269,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1180
1269
|
items.push(item);
|
|
1181
1270
|
thumbs.appendChild(item.el);
|
|
1182
1271
|
say('');
|
|
1272
|
+
void persistDraft();
|
|
1183
1273
|
upload(item);
|
|
1184
1274
|
}
|
|
1185
1275
|
|
|
@@ -1275,6 +1365,124 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1275
1365
|
titleInput.focus();
|
|
1276
1366
|
}
|
|
1277
1367
|
|
|
1368
|
+
/* ---------- which record is this about ---------- */
|
|
1369
|
+
|
|
1370
|
+
// What this project asks for, and what the reporter has answered.
|
|
1371
|
+
//
|
|
1372
|
+
// The question is asked at Send and not before, which is the whole design of
|
|
1373
|
+
// it. A form that opens with "loan number" above "what went wrong" tells
|
|
1374
|
+
// somebody they have paperwork to do before they may complain, and most of
|
|
1375
|
+
// them will not — while the same question, asked once, after they have said
|
|
1376
|
+
// their piece, and only when what they said did not already contain the
|
|
1377
|
+
// answer, is a reasonable thing to be asked. Most reporters never see it: the
|
|
1378
|
+
// loan number was in the sentence.
|
|
1379
|
+
let refConfig = { policy: 'off', fields: [] };
|
|
1380
|
+
let refChoice = null; // which field they are answering
|
|
1381
|
+
let refValue = '';
|
|
1382
|
+
let refError = '';
|
|
1383
|
+
// Set once they have answered or explicitly skipped, so Send stops asking.
|
|
1384
|
+
let refSettled = false;
|
|
1385
|
+
// Set once we have said "that does not look right" about the current value.
|
|
1386
|
+
// Its own flag rather than reusing refSettled, which was the first attempt
|
|
1387
|
+
// and was a loop: the warning returns early, the next press re-validates the
|
|
1388
|
+
// same unchanged value, warns again, and the report can never be sent. One
|
|
1389
|
+
// warning per value, and then it is their call.
|
|
1390
|
+
let refWarned = false;
|
|
1391
|
+
|
|
1392
|
+
client.referenceFields()
|
|
1393
|
+
.then((cfg) => { refConfig = cfg; })
|
|
1394
|
+
.catch(() => { /* off is the safe answer to every failure */ });
|
|
1395
|
+
|
|
1396
|
+
/** What the report already carries, read out of what they typed. */
|
|
1397
|
+
function referencesInText() {
|
|
1398
|
+
if (refConfig.policy === 'off' || !refConfig.fields.length) return [];
|
|
1399
|
+
return matchReferences(refConfig.fields, [titleInput.value, $('#bc-desc').value]);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/** The answer to send, or an empty list. */
|
|
1403
|
+
function answeredReference() {
|
|
1404
|
+
if (!refChoice || !refValue.trim()) return [];
|
|
1405
|
+
return [{ key: refChoice, value: refValue.trim().slice(0, 120) }];
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function drawRefs() {
|
|
1409
|
+
if (!refChoice) { refsBox.hidden = true; refsBox.innerHTML = ''; return; }
|
|
1410
|
+
const field = refConfig.fields.find((f) => f.key === refChoice) || refConfig.fields[0];
|
|
1411
|
+
const optional = refConfig.policy !== 'required';
|
|
1412
|
+
|
|
1413
|
+
refsBox.hidden = false;
|
|
1414
|
+
refsBox.innerHTML = `
|
|
1415
|
+
<p class="why">${escapeHtml(
|
|
1416
|
+
optional
|
|
1417
|
+
? 'One more thing, so we can find your record — this helps us answer faster.'
|
|
1418
|
+
: 'We need one of these to look this up.',
|
|
1419
|
+
)}</p>
|
|
1420
|
+
<div class="row">
|
|
1421
|
+
${refConfig.fields.length > 1 ? `
|
|
1422
|
+
<select class="ref-kind" aria-label="Which detail you are giving">
|
|
1423
|
+
${refConfig.fields.map((f) => `
|
|
1424
|
+
<option value="${escapeHtml(f.key)}" ${f.key === field.key ? 'selected' : ''}>${escapeHtml(f.label)}</option>
|
|
1425
|
+
`).join('')}
|
|
1426
|
+
</select>` : ''}
|
|
1427
|
+
<input class="ref-value" type="${field.inputMode === 'email' ? 'email' : 'text'}"
|
|
1428
|
+
inputmode="${escapeHtml(field.inputMode === 'numeric' || field.inputMode === 'tel' ? 'numeric' : 'text')}"
|
|
1429
|
+
value="${escapeHtml(refValue)}"
|
|
1430
|
+
placeholder="${escapeHtml(field.placeholder || '')}"
|
|
1431
|
+
aria-label="${escapeHtml(field.label)}" />
|
|
1432
|
+
</div>
|
|
1433
|
+
${field.hint ? `<p class="hint">${escapeHtml(field.hint)}</p>` : ''}
|
|
1434
|
+
${refError ? `<p class="after err">${escapeHtml(refError)}</p>` : ''}
|
|
1435
|
+
${optional ? '<button type="button" class="link" data-act="ref-skip">I don\'t have this</button>' : ''}`;
|
|
1436
|
+
|
|
1437
|
+
const value = refsBox.querySelector('.ref-value');
|
|
1438
|
+
value.addEventListener('input', () => {
|
|
1439
|
+
refValue = value.value;
|
|
1440
|
+
refError = '';
|
|
1441
|
+
// A changed value has not been warned about yet.
|
|
1442
|
+
refWarned = false;
|
|
1443
|
+
});
|
|
1444
|
+
// Enter in the box is the same gesture as pressing Send, which is what
|
|
1445
|
+
// somebody who has just been asked one question expects it to be.
|
|
1446
|
+
value.addEventListener('keydown', (e) => {
|
|
1447
|
+
if (e.key === 'Enter') { e.preventDefault(); sendBtn.click(); }
|
|
1448
|
+
});
|
|
1449
|
+
refsBox.querySelector('.ref-kind')?.addEventListener('change', (e) => {
|
|
1450
|
+
refChoice = e.target.value;
|
|
1451
|
+
refError = '';
|
|
1452
|
+
drawRefs();
|
|
1453
|
+
refsBox.querySelector('.ref-value')?.focus();
|
|
1454
|
+
});
|
|
1455
|
+
refsBox.querySelector('[data-act="ref-skip"]')?.addEventListener('click', () => {
|
|
1456
|
+
// Skipping is answering. Without this the panel asks again on the next
|
|
1457
|
+
// press and the reporter is in a loop with a button that does nothing.
|
|
1458
|
+
refSettled = true;
|
|
1459
|
+
refChoice = null;
|
|
1460
|
+
refValue = '';
|
|
1461
|
+
refError = '';
|
|
1462
|
+
refWarned = false;
|
|
1463
|
+
drawRefs();
|
|
1464
|
+
say('');
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
value.focus();
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Whether Send should stop and ask.
|
|
1472
|
+
*
|
|
1473
|
+
* True at most once per report. The question is skippable on an `optional`
|
|
1474
|
+
* project and not on a `required` one, and on both the answer to "have we
|
|
1475
|
+
* already got one" is read from the text every time — somebody who types the
|
|
1476
|
+
* loan number into the description after being asked has answered the
|
|
1477
|
+
* question, and being asked again would be the widget not listening.
|
|
1478
|
+
*/
|
|
1479
|
+
function needsReference() {
|
|
1480
|
+
if (refConfig.policy === 'off' || !refConfig.fields.length) return false;
|
|
1481
|
+
if (referencesInText().length) return false;
|
|
1482
|
+
if (answeredReference().length) return false;
|
|
1483
|
+
return !refSettled || refConfig.policy === 'required';
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1278
1486
|
/* ---------- send ---------- */
|
|
1279
1487
|
|
|
1280
1488
|
sendBtn.addEventListener('click', async () => {
|
|
@@ -1287,6 +1495,36 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1287
1495
|
return;
|
|
1288
1496
|
}
|
|
1289
1497
|
|
|
1498
|
+
// Ask, if this project asks and nothing they wrote answered it. The first
|
|
1499
|
+
// press opens the question rather than sending; the second sends. That
|
|
1500
|
+
// costs one extra press on projects that want an identifier, and it is the
|
|
1501
|
+
// only shape that does not put paperwork in front of a person who came here
|
|
1502
|
+
// to say something is broken.
|
|
1503
|
+
if (needsReference()) {
|
|
1504
|
+
refChoice = refChoice || refConfig.fields[0].key;
|
|
1505
|
+
drawRefs();
|
|
1506
|
+
say('');
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// They chose a field and typed something that is not that shape. Advisory
|
|
1511
|
+
// on the server — a value that fails is still what they believe identifies
|
|
1512
|
+
// them, and it is kept — but worth one chance to fix a typo here, where the
|
|
1513
|
+
// number is still on screen.
|
|
1514
|
+
if (refChoice && refValue.trim()) {
|
|
1515
|
+
const field = refConfig.fields.find((f) => f.key === refChoice);
|
|
1516
|
+
if (!refWarned && field && !validateReference(field, refValue)) {
|
|
1517
|
+
refError = `That does not look like a ${field.label.toLowerCase()}. Press Send again to use it anyway.`;
|
|
1518
|
+
// Once, and then it is their call. Our idea of the shape of somebody's
|
|
1519
|
+
// account number is not better than theirs, and refusing outright is
|
|
1520
|
+
// how a genuine reference gets lost — so the second press sends it and
|
|
1521
|
+
// the API keeps it, marked as not matching the expected format.
|
|
1522
|
+
refWarned = true;
|
|
1523
|
+
drawRefs();
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1290
1528
|
sending = true;
|
|
1291
1529
|
say('');
|
|
1292
1530
|
refreshSendState();
|
|
@@ -1298,10 +1536,16 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1298
1536
|
title,
|
|
1299
1537
|
description: $('#bc-desc').value,
|
|
1300
1538
|
priority,
|
|
1539
|
+
references: answeredReference(),
|
|
1301
1540
|
attachments: items
|
|
1302
1541
|
.filter((it) => it.status === 'ready')
|
|
1303
1542
|
.map((it) => ({ type: it.kind, storageKey: it.storageKey, mime: it.mime })),
|
|
1304
1543
|
});
|
|
1544
|
+
if (res?.cancelled) { sending = false; sendBtn.innerHTML = original; refreshSendState(); return; }
|
|
1545
|
+
draftSubmitted = true;
|
|
1546
|
+
clearTimeout(draftTimer);
|
|
1547
|
+
await draftWrites;
|
|
1548
|
+
if (cfg.drafts !== false) await draftOperation(savedDraftKey, null).catch(() => {});
|
|
1305
1549
|
succeed(res);
|
|
1306
1550
|
} catch (err) {
|
|
1307
1551
|
sending = false;
|
|
@@ -1439,25 +1683,22 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1439
1683
|
loadKnownIssues();
|
|
1440
1684
|
titleInput.addEventListener('input', () => matchKnown(titleInput.value));
|
|
1441
1685
|
|
|
1442
|
-
/* ---------- talk to
|
|
1686
|
+
/* ---------- talk to the team ---------- */
|
|
1443
1687
|
|
|
1444
1688
|
// Asked once, when the dialog opens, and the button stays hidden unless the
|
|
1445
1689
|
// answer is yes. Doing it here rather than at init keeps one request off the
|
|
1446
1690
|
// page load of every site this SDK is on, for a button most of them will
|
|
1447
1691
|
// never show.
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1452
|
-
|
|
1453
|
-
// instant answer — has been misled by this widget rather than by the product.
|
|
1454
|
-
let callKind = null;
|
|
1692
|
+
//
|
|
1693
|
+
// The wording is fixed because there is one kind of call: it rings a room and
|
|
1694
|
+
// may go unanswered. A button that said "talk to us" would have somebody
|
|
1695
|
+
// expecting an instant answer sit through twenty seconds of ringing.
|
|
1696
|
+
let callOffered = false;
|
|
1455
1697
|
function offerCall() {
|
|
1456
1698
|
client.callKind()
|
|
1457
1699
|
.then((kind) => {
|
|
1458
1700
|
if (!kind) return;
|
|
1459
|
-
|
|
1460
|
-
callBtn.textContent = kind === 'live' ? 'Talk to the team' : 'Talk to us';
|
|
1701
|
+
callOffered = true;
|
|
1461
1702
|
// Never over a panel that is still asking who this is: the call would
|
|
1462
1703
|
// file a report, and on a project that requires sign-in that is the one
|
|
1463
1704
|
// thing this panel is holding back.
|
|
@@ -1498,7 +1739,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1498
1739
|
// The call offer is the same errand as the form, so it goes with it — and
|
|
1499
1740
|
// comes back only if the API had already said this project can take one.
|
|
1500
1741
|
if (shut) callBtn.hidden = true;
|
|
1501
|
-
else if (
|
|
1742
|
+
else if (callOffered) callBtn.hidden = false;
|
|
1502
1743
|
}
|
|
1503
1744
|
|
|
1504
1745
|
function drawWhoami() {
|
|
@@ -1681,23 +1922,22 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1681
1922
|
say('');
|
|
1682
1923
|
refreshSendState();
|
|
1683
1924
|
callBtn.disabled = true;
|
|
1684
|
-
callBtn.innerHTML =
|
|
1685
|
-
callKind === 'live' ? 'Ringing…' : 'Connecting…'}`;
|
|
1925
|
+
callBtn.innerHTML = '<span class="spinner"></span>Ringing…';
|
|
1686
1926
|
|
|
1687
1927
|
try {
|
|
1688
|
-
// No id: this files the report from what the page was doing and
|
|
1689
|
-
// about it, in one round trip.
|
|
1928
|
+
// No id: this files the report from what the page was doing and rings the
|
|
1929
|
+
// team about it, in one round trip.
|
|
1690
1930
|
const handle = await client.startCall();
|
|
1691
1931
|
showCall(handle);
|
|
1692
1932
|
} catch (err) {
|
|
1693
1933
|
sending = false;
|
|
1694
1934
|
callBtn.disabled = false;
|
|
1695
|
-
callBtn.textContent =
|
|
1935
|
+
callBtn.textContent = 'Talk to the team';
|
|
1696
1936
|
refreshSendState();
|
|
1697
1937
|
// A report filed with no call is still a report. Saying "the call
|
|
1698
1938
|
// failed" and nothing else would leave the reporter thinking their
|
|
1699
1939
|
// context went nowhere, and pressing Send again would file it twice.
|
|
1700
|
-
if (err.filed) succeed(err.filed);
|
|
1940
|
+
if (err.filed) succeed(err.filed, { callFailed: err.reason || 'The call could not be started.' });
|
|
1701
1941
|
else {
|
|
1702
1942
|
say(err.message.replace('[BugCatch] ', ''));
|
|
1703
1943
|
// The same follow-up the Send button does, because a call can fail for
|
|
@@ -1710,7 +1950,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1710
1950
|
});
|
|
1711
1951
|
|
|
1712
1952
|
/**
|
|
1713
|
-
* The call, once
|
|
1953
|
+
* The call, once the team is being rung.
|
|
1714
1954
|
*
|
|
1715
1955
|
* Replaces the panel for the same reason the history view does: it is the
|
|
1716
1956
|
* same dialog answering a different question, and the reporter got here from
|
|
@@ -1720,51 +1960,35 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1720
1960
|
function showCall(handle) {
|
|
1721
1961
|
const panel = scrim.querySelector('.panel');
|
|
1722
1962
|
const ref = handle?.bugId ? String(handle.bugId).slice(0, 8) : null;
|
|
1723
|
-
// Read off the handle rather than off `callKind`: the handle is what actually
|
|
1724
|
-
// happened, and it is the only one of the two that cannot be stale.
|
|
1725
|
-
const live = Boolean(handle?.live);
|
|
1726
1963
|
|
|
1727
1964
|
panel.innerHTML = `
|
|
1728
1965
|
<div class="call">
|
|
1729
1966
|
<div class="call-head">
|
|
1730
1967
|
<span class="call-dot" aria-hidden="true"></span>
|
|
1731
|
-
<strong class="call-state"
|
|
1968
|
+
<strong class="call-state">Ringing…</strong>
|
|
1732
1969
|
</div>
|
|
1733
1970
|
<p class="call-note">
|
|
1734
|
-
${
|
|
1735
|
-
? `
|
|
1736
|
-
ref ? ` <code>${escapeHtml(ref)}</code>` : ''} is already filed either way, so nothing is lost if nobody answers.`
|
|
1737
|
-
: `You're talking to our automated assistant, not a person. What you say is
|
|
1738
|
-
added to your report${ref ? ` <code>${escapeHtml(ref)}</code>` : ''} for the team to read.`}
|
|
1971
|
+
We're ringing the team now. Someone will pick up if they're free — your report${
|
|
1972
|
+
ref ? ` <code>${escapeHtml(ref)}</code>` : ''} is already filed either way, so nothing is lost if nobody answers.
|
|
1739
1973
|
</p>
|
|
1740
|
-
<div class="call-lines" role="log" aria-live="polite"></div>
|
|
1741
1974
|
<div class="foot">
|
|
1742
1975
|
<span class="spacer"></span>
|
|
1743
|
-
<button class="primary" type="button" data-act="hangup"
|
|
1976
|
+
<button class="primary" type="button" data-act="hangup">Cancel</button>
|
|
1744
1977
|
</div>
|
|
1745
1978
|
</div>`;
|
|
1746
1979
|
|
|
1747
1980
|
const stateEl = panel.querySelector('.call-state');
|
|
1748
|
-
const linesEl = panel.querySelector('.call-lines');
|
|
1749
1981
|
const dot = panel.querySelector('.call-dot');
|
|
1750
1982
|
|
|
1751
|
-
//
|
|
1752
|
-
//
|
|
1753
|
-
|
|
1754
|
-
// end by nobody picking up, which is not a failure and must not read as one.
|
|
1755
|
-
const WORDING = live ? {
|
|
1983
|
+
// A call spends its first twenty seconds ringing, and can end by nobody
|
|
1984
|
+
// picking up — which is not a failure and must not read as one.
|
|
1985
|
+
const WORDING = {
|
|
1756
1986
|
ringing: 'Ringing the team…',
|
|
1757
1987
|
connecting: 'Someone answered — connecting…',
|
|
1758
1988
|
connected: 'Connected',
|
|
1759
1989
|
missed: 'Nobody was free',
|
|
1760
1990
|
ended: 'Call ended',
|
|
1761
1991
|
failed: 'The call dropped',
|
|
1762
|
-
} : {
|
|
1763
|
-
connecting: 'Connecting…',
|
|
1764
|
-
listening: 'Listening',
|
|
1765
|
-
speaking: 'Assistant is speaking',
|
|
1766
|
-
ended: 'Call ended',
|
|
1767
|
-
failed: 'The call dropped',
|
|
1768
1992
|
};
|
|
1769
1993
|
|
|
1770
1994
|
// `missed` is over too, and is the ordinary way a live call ends when the
|
|
@@ -1791,38 +2015,8 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1791
2015
|
}
|
|
1792
2016
|
});
|
|
1793
2017
|
|
|
1794
|
-
// One element per turn, replaced while a turn is still being recognised —
|
|
1795
|
-
// appending every partial would scroll the reporter's own half-sentence
|
|
1796
|
-
// past them as they said it.
|
|
1797
|
-
let pending = null;
|
|
1798
|
-
const offLines = client.on('call-transcript', ({ role, text, final }) => {
|
|
1799
|
-
if (!text && !final) return;
|
|
1800
|
-
if (role === 'agent') {
|
|
1801
|
-
// The agent arrives as deltas; they belong on one line.
|
|
1802
|
-
if (!pending || pending.dataset.role !== 'agent') {
|
|
1803
|
-
pending = document.createElement('div');
|
|
1804
|
-
pending.className = 'call-line agent';
|
|
1805
|
-
pending.dataset.role = 'agent';
|
|
1806
|
-
linesEl.appendChild(pending);
|
|
1807
|
-
}
|
|
1808
|
-
if (text) pending.textContent += text;
|
|
1809
|
-
if (final) pending = null;
|
|
1810
|
-
} else {
|
|
1811
|
-
if (!pending || pending.dataset.role !== 'reporter') {
|
|
1812
|
-
pending = document.createElement('div');
|
|
1813
|
-
pending.className = 'call-line you';
|
|
1814
|
-
pending.dataset.role = 'reporter';
|
|
1815
|
-
linesEl.appendChild(pending);
|
|
1816
|
-
}
|
|
1817
|
-
pending.textContent = text;
|
|
1818
|
-
if (final) pending = null;
|
|
1819
|
-
}
|
|
1820
|
-
linesEl.scrollTop = linesEl.scrollHeight;
|
|
1821
|
-
});
|
|
1822
|
-
|
|
1823
2018
|
panel.querySelector('[data-act="hangup"]').addEventListener('click', () => {
|
|
1824
2019
|
offState();
|
|
1825
|
-
offLines();
|
|
1826
2020
|
client.endCall();
|
|
1827
2021
|
close();
|
|
1828
2022
|
});
|
|
@@ -1847,17 +2041,24 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1847
2041
|
* for one line and is not enough for two, and a panel that closes over the
|
|
1848
2042
|
* sentence somebody started reading is the reason they open it again.
|
|
1849
2043
|
*/
|
|
1850
|
-
function succeed(res) {
|
|
2044
|
+
function succeed(res, { callFailed = null } = {}) {
|
|
1851
2045
|
const ref = res?.id ? String(res.id).slice(0, 8) : null;
|
|
1852
2046
|
const eta = res?.estimate?.label ? String(res.estimate.label) : null;
|
|
2047
|
+
// A press of Call that filed the report and then could not ring anybody.
|
|
2048
|
+
// Shown as a plain "Thanks" it read as a call that had quietly worked, and
|
|
2049
|
+
// the reporter sat waiting for a phone that was never going to ring.
|
|
2050
|
+
const failed = callFailed
|
|
2051
|
+
? `<p class="eta">${escapeHtml(callFailed)} Your report is filed, and the team will reply in the chat.</p>`
|
|
2052
|
+
: '';
|
|
1853
2053
|
scrim.querySelector('.panel').innerHTML = `
|
|
1854
2054
|
<div class="done">
|
|
1855
2055
|
<div class="tick">${ICONS.check}</div>
|
|
1856
2056
|
<h3>Thanks — that's on its way.</h3>
|
|
1857
2057
|
<p>${ref ? `Filed as <code>${escapeHtml(ref)}</code>.` : 'The team has what they need to reproduce it.'}</p>
|
|
2058
|
+
${failed}
|
|
1858
2059
|
${eta ? `<p class="eta">Reports like this usually take <strong>${escapeHtml(eta)}</strong>. We'll let you know when it moves.</p>` : ''}
|
|
1859
2060
|
</div>`;
|
|
1860
|
-
setTimeout(close, eta ? 4000 : 2000);
|
|
2061
|
+
setTimeout(close, failed ? 6000 : eta ? 4000 : 2000);
|
|
1861
2062
|
}
|
|
1862
2063
|
|
|
1863
2064
|
/**
|
|
@@ -1980,6 +2181,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1980
2181
|
|
|
1981
2182
|
const thread = body.querySelector('.thread');
|
|
1982
2183
|
scrollToLatest(thread);
|
|
2184
|
+
mountVerification(body, report);
|
|
1983
2185
|
if (report.canReply) mountComposer(body, thread, bugId);
|
|
1984
2186
|
}
|
|
1985
2187
|
|
|
@@ -1990,6 +2192,22 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1990
2192
|
* would also throw away a draft, a staged file and the scroll position — for
|
|
1991
2193
|
* someone mid-sentence, a reply arriving would look like the widget crashed.
|
|
1992
2194
|
*/
|
|
2195
|
+
function mountVerification(body, report) {
|
|
2196
|
+
body.querySelector('.fix-verification')?.remove();
|
|
2197
|
+
if (!report.resolvedAt || !['RESOLVED', 'CLOSED'].includes(report.status)) return;
|
|
2198
|
+
const box = document.createElement('div'); box.className = 'fix-verification';
|
|
2199
|
+
if (report.verification) { box.textContent = report.verification === 'CONFIRMED' ? 'You confirmed that this fix works.' : 'Your feedback was recorded.'; }
|
|
2200
|
+
else {
|
|
2201
|
+
box.innerHTML = '<p>Does the fix work for you?</p><textarea maxlength="4000" aria-label="Fix feedback" placeholder="What happens when you try again? (optional)"></textarea><button type="button" data-result="CONFIRMED">It works now</button> <button type="button" data-result="STILL_HAPPENING">Still happening</button><p class="feedback-status" role="status"></p>';
|
|
2202
|
+
box.querySelectorAll('[data-result]').forEach((btn) => { btn.onclick = async () => {
|
|
2203
|
+
box.querySelectorAll('button').forEach((b) => { b.disabled = true; });
|
|
2204
|
+
try { await client.verifyFix(report.id, { result: btn.dataset.result, resolvedAt: report.resolvedAt, note: box.querySelector('textarea').value }); await showReport(report.id); }
|
|
2205
|
+
catch (err) { box.querySelector('.feedback-status').textContent = err.message; box.querySelectorAll('button').forEach((b) => { b.disabled = false; }); }
|
|
2206
|
+
}; });
|
|
2207
|
+
}
|
|
2208
|
+
body.querySelector('.report-head')?.after(box);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
1993
2211
|
async function refreshThread(bugId) {
|
|
1994
2212
|
const body = scrim.querySelector('.history.chatting');
|
|
1995
2213
|
const thread = body?.querySelector('.thread');
|
|
@@ -2001,6 +2219,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2001
2219
|
} catch { /* a failed refresh leaves what is on screen, which is not wrong */ }
|
|
2002
2220
|
if (!report || openReportId !== bugId) return;
|
|
2003
2221
|
|
|
2222
|
+
mountVerification(body, report);
|
|
2004
2223
|
const pinned = atLatest(thread);
|
|
2005
2224
|
thread.innerHTML = threadHtml(report.timeline);
|
|
2006
2225
|
// Only follow the conversation down if they were already at the bottom.
|
|
@@ -2227,12 +2446,11 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2227
2446
|
/**
|
|
2228
2447
|
* The call, on top of the report it is about.
|
|
2229
2448
|
*
|
|
2230
|
-
* A bar rather than a screen of its own,
|
|
2231
|
-
*
|
|
2232
|
-
*
|
|
2233
|
-
*
|
|
2234
|
-
*
|
|
2235
|
-
* report, which is what both people are talking about.
|
|
2449
|
+
* A bar rather than a screen of its own, unlike the one the composer's button
|
|
2450
|
+
* opens — and for a reason that only applies here. A call has nothing to show
|
|
2451
|
+
* while it runs, because nobody is listening to it but the person on the
|
|
2452
|
+
* other end, so what is worth looking at is the report both people are
|
|
2453
|
+
* talking about.
|
|
2236
2454
|
*/
|
|
2237
2455
|
function showLiveCall(body, thread, handle) {
|
|
2238
2456
|
const bar = document.createElement('div');
|
|
@@ -2267,10 +2485,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2267
2485
|
hangBtn.classList.remove('danger');
|
|
2268
2486
|
};
|
|
2269
2487
|
|
|
2270
|
-
const off = client.on('call', ({ state
|
|
2271
|
-
// The agent call emits on the same channel. Its own panel is listening.
|
|
2272
|
-
if (!live) return;
|
|
2273
|
-
|
|
2488
|
+
const off = client.on('call', ({ state }) => {
|
|
2274
2489
|
if (state === 'ringing') who.textContent = 'Calling the team…';
|
|
2275
2490
|
|
|
2276
2491
|
if (state === 'connecting') {
|