@sphoro/bugcatch-web-sdk 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -6
- package/dist/bugcatch-call.es.js +587 -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 +3292 -0
- package/dist/bugcatch-core.es.js.map +1 -0
- package/dist/bugcatch-panel.es.js +2531 -0
- package/dist/bugcatch-panel.es.js.map +1 -0
- package/dist/bugcatch-panel.umd.js +1311 -0
- package/dist/bugcatch-panel.umd.js.map +1 -0
- package/dist/bugcatch.es.js +2 -5625
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.standalone.umd.js +1619 -0
- package/dist/bugcatch.standalone.umd.js.map +1 -0
- package/dist/bugcatch.umd.js +195 -1318
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/accent.js +38 -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 +13 -8
- package/src/incoming-call.js +25 -45
- package/src/index.d.ts +93 -2
- package/src/index.js +164 -42
- 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 +36 -0
- package/src/panel/index.js +265 -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 +65 -0
- package/src/panel/refs.js +321 -0
- package/src/panel/render.js +132 -0
- package/src/panel/signin.js +228 -0
- package/src/panel/styles.js +1016 -0
- package/src/panel/thread.js +494 -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 +97 -34
- package/src/widget.js +385 -3376
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// What happens to a report once it is filed: the confirmation, the
|
|
2
|
+
// reporter's list of everything they have filed, and one report as a
|
|
3
|
+
// conversation — with the assistant's live card over it, the "does the fix
|
|
4
|
+
// work" and "how did we do" questions under it, and the reply box at the
|
|
5
|
+
// bottom (composer.js).
|
|
6
|
+
|
|
7
|
+
import * as ICONS from './icons.js';
|
|
8
|
+
import { threadHtml, typingHtml } from './messages.js';
|
|
9
|
+
import {
|
|
10
|
+
headHtml, emptyHtml, skeletonRows, skeletonBubbles, escapeHtml, statusWord, relativeDay,
|
|
11
|
+
atLatest, scrollToLatest,
|
|
12
|
+
} from './render.js';
|
|
13
|
+
import { mountComposer } from './composer.js';
|
|
14
|
+
|
|
15
|
+
// What each open thread was last drawn from, so a poll that brings nothing new
|
|
16
|
+
// leaves the DOM (and the reader's place in it) alone.
|
|
17
|
+
const threadDrawn = new WeakMap();
|
|
18
|
+
|
|
19
|
+
/** What a drawn rating box was drawn from, so a refresh can tell if it moved. */
|
|
20
|
+
function ratingKey(rating) {
|
|
21
|
+
return JSON.stringify(rating ? [rating.score, rating.current, rating.comment || null] : null);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The rating's word on a history row: the score they gave, or a nudge on a
|
|
26
|
+
* settled report that is waiting for one. Nothing on an open report.
|
|
27
|
+
*/
|
|
28
|
+
function historyRating(r) {
|
|
29
|
+
if (r.rating) {
|
|
30
|
+
return ` · <span class="rated" aria-label="You rated this ${Number(r.rating)} out of 5">${ICONS.star}${Number(r.rating)}</span>`;
|
|
31
|
+
}
|
|
32
|
+
return r.ratingAsked ? ' · <span class="rate-ask">Rate it</span>' : '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mountThread(ctx) {
|
|
36
|
+
const {
|
|
37
|
+
client, scrim, close,
|
|
38
|
+
} = ctx;
|
|
39
|
+
|
|
40
|
+
// The report currently open, if any. A live update for a different report
|
|
41
|
+
// must not redraw the one being read.
|
|
42
|
+
let openReportId = null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The confirmation, and the one answer somebody actually wants from it.
|
|
46
|
+
*
|
|
47
|
+
* "How long will this take" is the first thing anybody thinks after reporting
|
|
48
|
+
* a bug, and until now the answer was nothing at all — so the honest reading
|
|
49
|
+
* of a confirmation screen was "into the void". The API computes the figure
|
|
50
|
+
* on the same request that files the report, precisely so it can be said
|
|
51
|
+
* here: an estimate that needed a second call would arrive after this panel
|
|
52
|
+
* had closed itself.
|
|
53
|
+
*
|
|
54
|
+
* Absent whenever the project has decided not to tell reporters, or has
|
|
55
|
+
* nothing to base a figure on. Nothing is drawn in that case — no "unknown",
|
|
56
|
+
* no placeholder — because a confirmation that admits it cannot answer is
|
|
57
|
+
* worse than one that does not raise the question.
|
|
58
|
+
*
|
|
59
|
+
* The dwell is longer when there is something to read. Two seconds is right
|
|
60
|
+
* for one line and is not enough for two, and a panel that closes over the
|
|
61
|
+
* sentence somebody started reading is the reason they open it again.
|
|
62
|
+
*/
|
|
63
|
+
function succeed(res, { callFailed = null } = {}) {
|
|
64
|
+
// The assistant: straight into the report's conversation, where the
|
|
65
|
+
// investigation shows itself working ("Analyzing the issue…") and asks
|
|
66
|
+
// what it needs. The plain "Thanks" below stays for when that view cannot
|
|
67
|
+
// load — a report filed without a way to follow it.
|
|
68
|
+
if (res?.id && !callFailed) {
|
|
69
|
+
showReport(res.id, { fresh: true }).then((ok) => { if (!ok) doneScreen(res, { callFailed }); });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
doneScreen(res, { callFailed });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function doneScreen(res, { callFailed = null } = {}) {
|
|
76
|
+
const ref = res?.id ? String(res.id).slice(0, 8) : null;
|
|
77
|
+
const eta = res?.estimate?.label ? String(res.estimate.label) : null;
|
|
78
|
+
// A press of Call that filed the report and then could not ring anybody.
|
|
79
|
+
// Shown as a plain "Thanks" it read as a call that had quietly worked, and
|
|
80
|
+
// the reporter sat waiting for a phone that was never going to ring.
|
|
81
|
+
const failed = callFailed
|
|
82
|
+
? `<p class="eta">${escapeHtml(callFailed)} Your report is filed, and the team will reply in the chat.</p>`
|
|
83
|
+
: '';
|
|
84
|
+
scrim.querySelector('.panel').innerHTML = `
|
|
85
|
+
<div class="done">
|
|
86
|
+
<div class="tick">${ICONS.check}</div>
|
|
87
|
+
<h3>Thanks — that's on its way.</h3>
|
|
88
|
+
<p>${ref ? `Filed as <code>${escapeHtml(ref)}</code>.` : 'The team has what they need to reproduce it.'}</p>
|
|
89
|
+
${failed}
|
|
90
|
+
${eta ? `<p class="eta">Reports like this usually take <strong>${escapeHtml(eta)}</strong>. We'll let you know when it moves.</p>` : ''}
|
|
91
|
+
</div>`;
|
|
92
|
+
setTimeout(close, failed ? 6000 : eta ? 4000 : 2000);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The reporter's own history: what they have filed and where each one got to.
|
|
97
|
+
*
|
|
98
|
+
* Replaces the panel rather than opening a second surface — it is the same
|
|
99
|
+
* dialog answering a different question, and the reporter came here from one
|
|
100
|
+
* launcher.
|
|
101
|
+
*/
|
|
102
|
+
async function showHistory() {
|
|
103
|
+
const panel = scrim.querySelector('.panel');
|
|
104
|
+
clearTimeout(assistantTimer);
|
|
105
|
+
openReportId = null;
|
|
106
|
+
panel.classList.remove('is-chat');
|
|
107
|
+
panel.innerHTML = `
|
|
108
|
+
${headHtml({ title: 'Your reports', sub: 'Where each one has got to.', theme: ctx.themeLabel() })}
|
|
109
|
+
<div class="history" aria-busy="true">${skeletonRows(3)}</div>`;
|
|
110
|
+
|
|
111
|
+
panel.querySelector('[data-act="close"]').addEventListener('click', close);
|
|
112
|
+
|
|
113
|
+
let reports = [];
|
|
114
|
+
try {
|
|
115
|
+
({ reports } = await client.myReports());
|
|
116
|
+
} catch {
|
|
117
|
+
const failed = panel.querySelector('.history');
|
|
118
|
+
if (failed) failed.innerHTML = emptyHtml('Couldn\'t load your reports just now.', ICONS.alert);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The panel can be closed while the request is in flight.
|
|
123
|
+
const list = panel.querySelector('.history');
|
|
124
|
+
if (!list) return;
|
|
125
|
+
|
|
126
|
+
list.removeAttribute('aria-busy');
|
|
127
|
+
list.innerHTML = reports.length
|
|
128
|
+
? reports.map((r, i) => `
|
|
129
|
+
<button class="row" type="button" data-report="${escapeHtml(r.id)}" style="animation-delay:${Math.min(i, 8) * 30}ms">
|
|
130
|
+
<span class="row-ico" aria-hidden="true">${ICONS.chat}</span>
|
|
131
|
+
<span class="who">
|
|
132
|
+
<span class="ttl">${escapeHtml(r.title)}</span>
|
|
133
|
+
<span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}${historyRating(r)}</span>
|
|
134
|
+
</span>
|
|
135
|
+
<span class="pill ${escapeHtml(String(r.status).toLowerCase())}">${escapeHtml(statusWord(r.status))}</span>
|
|
136
|
+
<span class="chev" aria-hidden="true">${ICONS.chevronRight}</span>
|
|
137
|
+
</button>`).join('')
|
|
138
|
+
: emptyHtml('You haven\'t reported anything yet.', ICONS.inbox);
|
|
139
|
+
|
|
140
|
+
for (const row of list.querySelectorAll('[data-report]')) {
|
|
141
|
+
row.addEventListener('click', () => showReport(row.dataset.report));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One report as a conversation.
|
|
147
|
+
*
|
|
148
|
+
* This screen used to be a timeline: everything that had happened, read-only,
|
|
149
|
+
* newest first. It answered "what happened to my report" and nothing else —
|
|
150
|
+
* so when the team's reply was a question ("which browser?"), the person best
|
|
151
|
+
* placed to answer it was looking straight at it with no way to.
|
|
152
|
+
*
|
|
153
|
+
* Now it is a thread. Status changes still appear, as the quiet centred lines
|
|
154
|
+
* that mark events in any chat; the messages between the two sides are
|
|
155
|
+
* bubbles either side of them; and there is a box at the bottom. Oldest at
|
|
156
|
+
* the top, because that is the direction a conversation is read.
|
|
157
|
+
*/
|
|
158
|
+
async function showReport(bugId, { fresh = false } = {}) {
|
|
159
|
+
const panel = scrim.querySelector('.panel');
|
|
160
|
+
clearTimeout(assistantTimer);
|
|
161
|
+
panel.classList.add('is-chat');
|
|
162
|
+
panel.innerHTML = `
|
|
163
|
+
${headHtml({
|
|
164
|
+
back: true,
|
|
165
|
+
title: fresh ? 'Looking into it' : 'Your report',
|
|
166
|
+
sub: fresh ? 'It may ask you a question here.' : 'Updates, and a way to reply.',
|
|
167
|
+
theme: ctx.themeLabel(),
|
|
168
|
+
})}
|
|
169
|
+
<div class="history chatting" aria-busy="true"><div class="thread">${skeletonBubbles()}</div></div>`;
|
|
170
|
+
|
|
171
|
+
panel.querySelector('[data-act="close"]').addEventListener('click', close);
|
|
172
|
+
panel.querySelector('[data-act="back"]').addEventListener('click', showHistory);
|
|
173
|
+
|
|
174
|
+
let report = null;
|
|
175
|
+
try {
|
|
176
|
+
report = await client.myReport(bugId);
|
|
177
|
+
} catch {
|
|
178
|
+
report = null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const body = panel.querySelector('.history');
|
|
182
|
+
// The panel can be closed, or navigated away from, while this is in flight.
|
|
183
|
+
if (!body || !panel.querySelector('[data-act="back"]')) return false;
|
|
184
|
+
|
|
185
|
+
if (!report) {
|
|
186
|
+
if (fresh) { panel.classList.remove('is-chat'); return false; }
|
|
187
|
+
body.removeAttribute('aria-busy');
|
|
188
|
+
body.innerHTML = emptyHtml('Couldn\'t load that report just now.', ICONS.alert);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
openReportId = bugId;
|
|
193
|
+
// Reading the report is reading its updates. Leaving a "your report was
|
|
194
|
+
// fixed" card up over the screen where they are reading that it was fixed
|
|
195
|
+
// would be the notification arguing with itself.
|
|
196
|
+
client.markReportRead(bugId);
|
|
197
|
+
const drawn = threadHtml(report.timeline);
|
|
198
|
+
body.classList.add('chatting');
|
|
199
|
+
body.removeAttribute('aria-busy');
|
|
200
|
+
body.innerHTML = `
|
|
201
|
+
<div class="report-head">
|
|
202
|
+
<span class="ttl">${escapeHtml(report.title)}</span>
|
|
203
|
+
<span class="sub">${escapeHtml(report.reference)} · ${escapeHtml(report.project || '')}</span>
|
|
204
|
+
<span class="pill ${escapeHtml(String(report.status).toLowerCase())}">${escapeHtml(statusWord(report.status))}</span>
|
|
205
|
+
${report.estimate?.label
|
|
206
|
+
// The same figure they were given when they filed it, so coming back
|
|
207
|
+
// to check is not a worse experience than not checking. Absent on a
|
|
208
|
+
// report that has already been settled — the API stops sending it —
|
|
209
|
+
// because a prediction beside a finished report is either redundant
|
|
210
|
+
// or an argument.
|
|
211
|
+
? `<span class="eta-line">Usually takes ${escapeHtml(String(report.estimate.label))}</span>`
|
|
212
|
+
: ''}
|
|
213
|
+
</div>
|
|
214
|
+
<div class="thread" role="log" aria-live="polite" aria-label="Conversation">${drawn}</div>`;
|
|
215
|
+
|
|
216
|
+
const thread = body.querySelector('.thread');
|
|
217
|
+
threadDrawn.set(thread, drawn);
|
|
218
|
+
scrollToLatest(thread);
|
|
219
|
+
mountVerification(body, report);
|
|
220
|
+
mountRating(body, report);
|
|
221
|
+
if (report.canReply) mountComposer(ctx, body, thread, bugId);
|
|
222
|
+
mountAssistant(body, report, bugId);
|
|
223
|
+
// Again, now the cards above it have taken their share of the height: the
|
|
224
|
+
// latest message — and any quick answers under it — is what they came for.
|
|
225
|
+
scrollToLatest(thread);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The assistant's live card: where the investigation is, and a gentle
|
|
231
|
+
* refresh while it works. Analyzing polls every few seconds until the
|
|
232
|
+
* outcome lands — a question, a found cause, or a hand-off — so the
|
|
233
|
+
* reporter watches it happen rather than wondering whether anything is.
|
|
234
|
+
*/
|
|
235
|
+
let assistantTimer = null;
|
|
236
|
+
function mountAssistant(body, report, bugId) {
|
|
237
|
+
clearTimeout(assistantTimer);
|
|
238
|
+
const a = report.assistant;
|
|
239
|
+
const thread = body.querySelector('.thread');
|
|
240
|
+
let card = body.querySelector('.assistant');
|
|
241
|
+
if (!a) {
|
|
242
|
+
card?.remove();
|
|
243
|
+
thread?.querySelector('.typing-row')?.remove();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Updated in place rather than rebuilt: the card is polled every few
|
|
248
|
+
// seconds while the assistant works, and a card that re-entered on every
|
|
249
|
+
// poll would flicker — and re-announce itself to a screen reader — for
|
|
250
|
+
// nothing. It only animates when the phase actually moves.
|
|
251
|
+
const previous = card?.dataset.phase || null;
|
|
252
|
+
if (!card) {
|
|
253
|
+
card = document.createElement('div');
|
|
254
|
+
card.setAttribute('role', 'status');
|
|
255
|
+
card.setAttribute('aria-live', 'polite');
|
|
256
|
+
body.querySelector('.report-head')?.after(card);
|
|
257
|
+
}
|
|
258
|
+
card.className = `assistant ${escapeHtml(a.phase)}`;
|
|
259
|
+
card.dataset.phase = a.phase;
|
|
260
|
+
|
|
261
|
+
const steps = a.steps || [];
|
|
262
|
+
const done = steps.filter((st) => st.state === 'done').length;
|
|
263
|
+
const active = steps.some((st) => st.state === 'active') ? 0.5 : 0;
|
|
264
|
+
const pct = steps.length ? Math.round(((done + active) / steps.length) * 100) : 0;
|
|
265
|
+
const content = `
|
|
266
|
+
<div class="as-body${previous && previous !== a.phase ? ' entering' : ''}">
|
|
267
|
+
<div class="as-top">
|
|
268
|
+
<span class="as-orb${a.phase === 'calling' || a.phase === 'resolved' ? '' : ' orb'}" aria-hidden="true">${
|
|
269
|
+
a.phase === 'calling' ? ICONS.phone : a.phase === 'resolved' ? ICONS.check : ''}</span>
|
|
270
|
+
<div class="as-text">
|
|
271
|
+
<div class="as-title">${escapeHtml(a.title)}</div>
|
|
272
|
+
<div class="as-detail">${escapeHtml(a.detail)}</div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
${steps.length ? `
|
|
277
|
+
<div class="as-progress" role="progressbar" aria-label="Progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}"><i style="width:${pct}%"></i></div>
|
|
278
|
+
<ol class="as-steps">${steps.map((st) => `<li class="${escapeHtml(st.state)}"${st.state === 'active' ? ' aria-current="step"' : ''}><span class="dot"></span><span class="lbl">${escapeHtml(st.label)}</span></li>`).join('')}</ol>` : ''}`;
|
|
279
|
+
const key = JSON.stringify([a.phase, a.title, a.detail, steps]);
|
|
280
|
+
if (card.dataset.key !== key) {
|
|
281
|
+
const bar = card.querySelector('.as-progress i');
|
|
282
|
+
const from = bar?.style.width;
|
|
283
|
+
card.innerHTML = content;
|
|
284
|
+
card.dataset.key = key;
|
|
285
|
+
// Let the bar travel from where it was to where it is now.
|
|
286
|
+
const next = card.querySelector('.as-progress i');
|
|
287
|
+
if (next && from) {
|
|
288
|
+
next.style.width = from;
|
|
289
|
+
requestAnimationFrame(() => { next.style.width = `${pct}%`; });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// While it works, a "thinking" bubble sits where its next message will land.
|
|
294
|
+
// Never during a call: somebody is on the phone about this report, nothing
|
|
295
|
+
// is being analysed (the analysis waits for the transcript), and a bubble
|
|
296
|
+
// typing away underneath a live call is the assistant talking over them.
|
|
297
|
+
if (thread) {
|
|
298
|
+
const pinned = atLatest(thread);
|
|
299
|
+
const typing = thread.querySelector('.typing-row');
|
|
300
|
+
if (a.phase !== 'analyzing') typing?.remove();
|
|
301
|
+
else if (!typing || typing !== thread.lastElementChild) {
|
|
302
|
+
typing?.remove();
|
|
303
|
+
thread.insertAdjacentHTML('beforeend', typingHtml());
|
|
304
|
+
if (pinned) scrollToLatest(thread);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (a.phase === 'analyzing') {
|
|
309
|
+
assistantTimer = setTimeout(() => refreshThread(bugId), 3000);
|
|
310
|
+
} else if (a.phase === 'calling') {
|
|
311
|
+
// Slower: the call's own card is what the reporter is watching, and this
|
|
312
|
+
// only has to notice when the call ends and the analysis begins.
|
|
313
|
+
assistantTimer = setTimeout(() => refreshThread(bugId), 10000);
|
|
314
|
+
} else if (a.phase === 'needs_info' && previous !== 'needs_info') {
|
|
315
|
+
body.querySelector('.composer textarea')?.focus();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function mountVerification(body, report) {
|
|
320
|
+
body.querySelector('.fix-verification')?.remove();
|
|
321
|
+
if (!report.resolvedAt || !['RESOLVED', 'CLOSED'].includes(report.status)) return;
|
|
322
|
+
const box = document.createElement('div'); box.className = 'fix-verification';
|
|
323
|
+
if (report.verification) { box.textContent = report.verification === 'CONFIRMED' ? 'You confirmed that this fix works.' : 'Your feedback was recorded.'; }
|
|
324
|
+
else {
|
|
325
|
+
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>';
|
|
326
|
+
box.querySelectorAll('[data-result]').forEach((btn) => { btn.onclick = async () => {
|
|
327
|
+
box.querySelectorAll('button').forEach((b) => { b.disabled = true; });
|
|
328
|
+
try { await client.verifyFix(report.id, { result: btn.dataset.result, resolvedAt: report.resolvedAt, note: box.querySelector('textarea').value }); await showReport(report.id); }
|
|
329
|
+
catch (err) { box.querySelector('.feedback-status').textContent = err.message; box.querySelectorAll('button').forEach((b) => { b.disabled = false; }); }
|
|
330
|
+
}; });
|
|
331
|
+
}
|
|
332
|
+
body.querySelector('.report-head')?.after(box);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* "How did we do?" — five stars under a settled report.
|
|
337
|
+
*
|
|
338
|
+
* Only where the API says a rating is being asked for: the report is settled
|
|
339
|
+
* and the project has not switched ratings off. One tap sends the score; a
|
|
340
|
+
* note is offered after it rather than before, because a form that asks for
|
|
341
|
+
* a sentence first is a form people close, and the score is the part that
|
|
342
|
+
* counts.
|
|
343
|
+
*
|
|
344
|
+
* Redrawn only when the rating itself changed. The thread refreshes on every
|
|
345
|
+
* update and every few seconds while the assistant works, and a box rebuilt
|
|
346
|
+
* on each of those would eat the note somebody was halfway through.
|
|
347
|
+
*/
|
|
348
|
+
function mountRating(body, report) {
|
|
349
|
+
let box = body.querySelector('.fix-rating');
|
|
350
|
+
const settled = ['RESOLVED', 'CLOSED'].includes(report.status);
|
|
351
|
+
if (!settled || !report.ratingAsked) {
|
|
352
|
+
box?.remove();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (box && box.dataset.key === ratingKey(report.rating)) return;
|
|
356
|
+
if (!box) {
|
|
357
|
+
box = document.createElement('div');
|
|
358
|
+
box.className = 'fix-rating';
|
|
359
|
+
(body.querySelector('.fix-verification') || body.querySelector('.report-head'))?.after(box);
|
|
360
|
+
}
|
|
361
|
+
drawRating(box, report, report.rating || null);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function drawRating(box, report, rating, { justRated = false } = {}) {
|
|
365
|
+
box.dataset.key = ratingKey(rating);
|
|
366
|
+
// A rating given for an earlier fix stands, but it is not this fix's: the
|
|
367
|
+
// stars start empty and the sentence says what the old score was.
|
|
368
|
+
const shown = rating?.current ? rating.score : 0;
|
|
369
|
+
const question = !rating
|
|
370
|
+
? 'How did we do?'
|
|
371
|
+
: !rating.current
|
|
372
|
+
? `You rated an earlier fix ${rating.score} out of 5. How did this one go?`
|
|
373
|
+
: justRated
|
|
374
|
+
? 'Thanks — the team will see that.'
|
|
375
|
+
: `You rated this ${rating.score} out of 5.`;
|
|
376
|
+
const askNote = justRated && rating?.current && !rating.comment;
|
|
377
|
+
box.innerHTML = `
|
|
378
|
+
<p class="fr-q">${escapeHtml(question)}</p>
|
|
379
|
+
<div class="stars" role="group" aria-label="Rate how this was handled, 1 to 5">${[1, 2, 3, 4, 5].map((n) => `
|
|
380
|
+
<button type="button" data-score="${n}" aria-pressed="${n === shown}" aria-label="${n} out of 5${n === 1 ? ', poor' : n === 5 ? ', great' : ''}" class="${n <= shown ? 'on' : ''}">${ICONS.star}</button>`).join('')}
|
|
381
|
+
</div>
|
|
382
|
+
${rating?.current && rating.comment ? `<p class="fr-said">“${escapeHtml(rating.comment)}”</p>` : ''}
|
|
383
|
+
${askNote ? `
|
|
384
|
+
<div class="fr-note">
|
|
385
|
+
<textarea maxlength="2000" aria-label="Add a note to your rating" placeholder="${rating.score <= 2 ? 'What went wrong? (optional)' : 'Anything to add? (optional)'}"></textarea>
|
|
386
|
+
<button type="button" data-act="rating-note">Add note</button>
|
|
387
|
+
</div>` : ''}
|
|
388
|
+
${rating ? '' : '<p class="fr-hint">1 is poor, 5 is great.</p>'}
|
|
389
|
+
<p class="feedback-status" role="status"></p>`;
|
|
390
|
+
|
|
391
|
+
const stars = [...box.querySelectorAll('[data-score]')];
|
|
392
|
+
const paint = (upTo) => stars.forEach((b) => b.classList.toggle('on', Number(b.dataset.score) <= upTo));
|
|
393
|
+
const lock = (on) => box.querySelectorAll('button, textarea').forEach((el) => { el.disabled = on; });
|
|
394
|
+
const status = box.querySelector('.feedback-status');
|
|
395
|
+
|
|
396
|
+
async function send(score, comment) {
|
|
397
|
+
lock(true);
|
|
398
|
+
status.textContent = '';
|
|
399
|
+
try {
|
|
400
|
+
const saved = await client.rate(report.id, { score, comment });
|
|
401
|
+
drawRating(box, report, saved, { justRated: true });
|
|
402
|
+
box.querySelector('.fr-note textarea')?.focus();
|
|
403
|
+
} catch (err) {
|
|
404
|
+
status.textContent = err.message;
|
|
405
|
+
lock(false);
|
|
406
|
+
paint(shown);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
for (const btn of stars) {
|
|
411
|
+
const n = Number(btn.dataset.score);
|
|
412
|
+
btn.addEventListener('mouseenter', () => paint(n));
|
|
413
|
+
btn.addEventListener('focus', () => paint(n));
|
|
414
|
+
btn.addEventListener('blur', () => paint(shown));
|
|
415
|
+
btn.addEventListener('click', () => send(n));
|
|
416
|
+
}
|
|
417
|
+
box.querySelector('.stars').addEventListener('mouseleave', () => paint(shown));
|
|
418
|
+
|
|
419
|
+
box.querySelector('[data-act="rating-note"]')?.addEventListener('click', () => {
|
|
420
|
+
const note = box.querySelector('.fr-note textarea').value.trim();
|
|
421
|
+
if (!note) { box.querySelector('.fr-note')?.remove(); return; }
|
|
422
|
+
send(rating.score, note);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Redraw the open thread in place, keeping whatever is half-typed.
|
|
428
|
+
*
|
|
429
|
+
* Rebuilding the whole screen on every incoming message would be simpler and
|
|
430
|
+
* would also throw away a draft, a staged file and the scroll position — for
|
|
431
|
+
* someone mid-sentence, a reply arriving would look like the widget crashed.
|
|
432
|
+
*/
|
|
433
|
+
async function refreshThread(bugId) {
|
|
434
|
+
const body = scrim.querySelector('.history.chatting');
|
|
435
|
+
const thread = body?.querySelector('.thread');
|
|
436
|
+
if (!thread || openReportId !== bugId) return;
|
|
437
|
+
|
|
438
|
+
let report = null;
|
|
439
|
+
try {
|
|
440
|
+
report = await client.myReport(bugId);
|
|
441
|
+
} catch { /* a failed refresh leaves what is on screen, which is not wrong */ }
|
|
442
|
+
// Closed while it was asking: nothing to draw, and no next poll to set.
|
|
443
|
+
if (ctx.closed || !report || openReportId !== bugId) return;
|
|
444
|
+
|
|
445
|
+
mountVerification(body, report);
|
|
446
|
+
mountRating(body, report);
|
|
447
|
+
const pinned = atLatest(thread);
|
|
448
|
+
const html = threadHtml(report.timeline);
|
|
449
|
+
// Only rewritten when something changed, so a poll that brings nothing new
|
|
450
|
+
// does not replay every bubble's entrance or re-announce the log.
|
|
451
|
+
if (threadDrawn.get(thread) !== html) {
|
|
452
|
+
thread.innerHTML = html;
|
|
453
|
+
threadDrawn.set(thread, html);
|
|
454
|
+
}
|
|
455
|
+
mountAssistant(body, report, bugId);
|
|
456
|
+
// Only follow the conversation down if they were already at the bottom.
|
|
457
|
+
// Yanking someone back from something they scrolled up to read is worse
|
|
458
|
+
// than making them scroll.
|
|
459
|
+
if (pinned) scrollToLatest(thread);
|
|
460
|
+
|
|
461
|
+
const pill = body.querySelector('.report-head .pill');
|
|
462
|
+
if (pill) {
|
|
463
|
+
pill.className = `pill ${String(report.status).toLowerCase()}`;
|
|
464
|
+
pill.textContent = statusWord(report.status);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Re-read the open thread when something happens to it. Without this the
|
|
469
|
+
// reader is looking at a conversation that stopped being true while they read
|
|
470
|
+
// it — the very screen where a stale view is most obvious, and now the screen
|
|
471
|
+
// where the other side may be typing.
|
|
472
|
+
const stopListening = client.on('update', (event) => {
|
|
473
|
+
if (!openReportId || event?.bugId !== openReportId) return;
|
|
474
|
+
// Already on screen, so already read — and marking it here, before the
|
|
475
|
+
// client draws its notifications, is what keeps a card from appearing over
|
|
476
|
+
// the thread that is about to show the same thing.
|
|
477
|
+
client.markReportRead(openReportId);
|
|
478
|
+
refreshThread(openReportId);
|
|
479
|
+
});
|
|
480
|
+
// Stopped with the panel: a frame arriving after it has gone would redraw a
|
|
481
|
+
// panel that is no longer on the page. The assistant's poll too — it reads
|
|
482
|
+
// the thread out of the detached panel, finds it still there, and would go
|
|
483
|
+
// on asking every three seconds for as long as the analysis ran.
|
|
484
|
+
ctx.onDestroy(() => {
|
|
485
|
+
stopListening();
|
|
486
|
+
clearTimeout(assistantTimer);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
scrim.querySelector('[data-act="history"]')?.addEventListener('click', showHistory);
|
|
490
|
+
|
|
491
|
+
ctx.succeed = succeed;
|
|
492
|
+
ctx.refreshThread = refreshThread;
|
|
493
|
+
ctx.openReportId = () => openReportId;
|
|
494
|
+
}
|
package/src/session.js
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
// mounts two projects' widgets is two independent sign-ins, and signing out of
|
|
23
23
|
// one leaves the other alone.
|
|
24
24
|
|
|
25
|
+
import { tooManyMessage } from './busy.js';
|
|
26
|
+
|
|
25
27
|
const KEY_PREFIX = 'bugcatch.session.';
|
|
26
28
|
|
|
27
29
|
const storageKey = (projectId) => `${KEY_PREFIX}${projectId}`;
|
|
@@ -91,12 +93,15 @@ function headers({ apiKey, token }) {
|
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
async function readError(res) {
|
|
96
|
+
let message = '';
|
|
94
97
|
try {
|
|
95
98
|
const body = await res.json();
|
|
96
|
-
|
|
97
|
-
if (message) return message;
|
|
99
|
+
message = Array.isArray(body.message) ? body.message.join(', ') : body.message || '';
|
|
98
100
|
} catch { /* non-JSON error */ }
|
|
99
|
-
|
|
101
|
+
// Asking for codes too fast. The server's sentence says so; a proxy's page
|
|
102
|
+
// would not, and "Request failed (HTTP 429)" reads as a broken form.
|
|
103
|
+
if (res.status === 429) return tooManyMessage(message);
|
|
104
|
+
return message || `Request failed (HTTP ${res.status})`;
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
/**
|
package/src/umd/call.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The call code as a script of its own, for the script-tag build: run, it
|
|
2
|
+
// hands its exports to the loader that added it (lazy.js beside this file).
|
|
3
|
+
import * as call from '../call.js';
|
|
4
|
+
import { registerChunk } from '../chunks.js';
|
|
5
|
+
|
|
6
|
+
registerChunk('call', call);
|
package/src/umd/lazy.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The script-tag build's loader: ../lazy.js's exports, fetched with <script> tags
|
|
2
|
+
// from the place this script itself was loaded from.
|
|
3
|
+
//
|
|
4
|
+
// Vite cannot split a UMD bundle, so the parts are built as scripts of their
|
|
5
|
+
// own — bugcatch-panel.umd.js and bugcatch-call.umd.js, see vite.config.js —
|
|
6
|
+
// that register their exports when they run (panel.js and call.js beside
|
|
7
|
+
// this file, through ../chunks.js). This adds the script, waits for it to
|
|
8
|
+
// load, and reads them back.
|
|
9
|
+
//
|
|
10
|
+
// # From where
|
|
11
|
+
//
|
|
12
|
+
// Beside this file, found while it is still running: document.currentScript is
|
|
13
|
+
// only set during a script's first run, so it is read here, at the top, and
|
|
14
|
+
// never again. That covers the CDN, which serves dist/ as a directory, and a
|
|
15
|
+
// copy of dist/ on the site's own server. It cannot cover a copy with no
|
|
16
|
+
// address of its own — the bundle pasted into an inline <script>, or
|
|
17
|
+
// concatenated into one of the site's files — and there init({ chunkBase })
|
|
18
|
+
// says where the parts are.
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
registeredChunk, chunkUnavailable, once, CHUNK_VERSION,
|
|
22
|
+
} from '../chunks.js';
|
|
23
|
+
|
|
24
|
+
const self = typeof document !== 'undefined' ? document.currentScript : null;
|
|
25
|
+
const loadedFrom = self?.src || null;
|
|
26
|
+
// Carried over to the scripts this adds, so a page that let this one in lets
|
|
27
|
+
// them in the same way: its CSP nonce, and the CORS and referrer settings the
|
|
28
|
+
// page chose for this tag.
|
|
29
|
+
const nonce = self?.nonce || '';
|
|
30
|
+
const crossOrigin = self?.getAttribute('crossorigin') ?? null;
|
|
31
|
+
const referrerPolicy = self?.referrerPolicy || '';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The folder the parts are in, with a trailing slash — or null when there is
|
|
35
|
+
* no way to know. `loadedFrom` is this script's own address; `baseURI` the
|
|
36
|
+
* page's, which a relative `chunkBase` is read against.
|
|
37
|
+
*
|
|
38
|
+
* A bare package address, https://unpkg.com/@sphoro/bugcatch-web-sdk@0.17.0,
|
|
39
|
+
* is answered by the CDN with this file but names no folder to be beside, so
|
|
40
|
+
* its parts are looked for under dist/, which is where that file lives.
|
|
41
|
+
*/
|
|
42
|
+
export function chunkFolder({ chunkBase, loadedFrom: from, baseURI }) {
|
|
43
|
+
if (chunkBase) return new URL(String(chunkBase).replace(/\/*$/, '/'), baseURI).href;
|
|
44
|
+
if (!from) return null;
|
|
45
|
+
const url = new URL(from);
|
|
46
|
+
if (!/\.js$/i.test(url.pathname)) return new URL(`${url.pathname.replace(/\/*$/, '/')}dist/`, url).href;
|
|
47
|
+
return new URL('.', url).href;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function loadScript(name, chunkBase) {
|
|
51
|
+
const arrived = registeredChunk(name);
|
|
52
|
+
if (arrived) return Promise.resolve(arrived);
|
|
53
|
+
|
|
54
|
+
const folder = chunkFolder({ chunkBase, loadedFrom, baseURI: document.baseURI });
|
|
55
|
+
if (!folder) {
|
|
56
|
+
return Promise.reject(chunkUnavailable(`bugcatch-${name}.umd.js could not be loaded: this copy of `
|
|
57
|
+
+ 'the SDK was not loaded from an address, so there is nowhere to look for it. Set '
|
|
58
|
+
+ 'init({ chunkBase }) to the folder that holds it.'));
|
|
59
|
+
}
|
|
60
|
+
// The version in the address as well as in the registry: a file cached from
|
|
61
|
+
// an older release, under an address that does not change between releases,
|
|
62
|
+
// is fetched afresh rather than found stale.
|
|
63
|
+
const src = `${folder}bugcatch-${name}.umd.js?v=${encodeURIComponent(CHUNK_VERSION)}`;
|
|
64
|
+
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const script = document.createElement('script');
|
|
67
|
+
script.src = src;
|
|
68
|
+
script.async = true;
|
|
69
|
+
if (nonce) script.nonce = nonce;
|
|
70
|
+
if (crossOrigin !== null) script.crossOrigin = crossOrigin;
|
|
71
|
+
if (referrerPolicy) script.referrerPolicy = referrerPolicy;
|
|
72
|
+
const settle = () => {
|
|
73
|
+
script.onload = null;
|
|
74
|
+
script.onerror = null;
|
|
75
|
+
script.remove();
|
|
76
|
+
};
|
|
77
|
+
script.onload = () => {
|
|
78
|
+
settle();
|
|
79
|
+
const exports = registeredChunk(name);
|
|
80
|
+
if (exports) resolve(exports);
|
|
81
|
+
else {
|
|
82
|
+
reject(chunkUnavailable(`${src} loaded, but not as version ${CHUNK_VERSION} of the SDK — `
|
|
83
|
+
+ 'serve every file in dist/ from the same release.'));
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
script.onerror = () => {
|
|
87
|
+
settle();
|
|
88
|
+
reject(chunkUnavailable(`could not load ${src}. The files in dist/ are served together: `
|
|
89
|
+
+ 'a self-hosted copy needs all of them (or init({ chunkBase }) pointing at them), and a '
|
|
90
|
+
+ 'Content-Security-Policy has to allow them in script-src.'));
|
|
91
|
+
};
|
|
92
|
+
(document.head || document.documentElement).appendChild(script);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const loadPanel = once((chunkBase) => loadScript('panel', chunkBase));
|
|
97
|
+
|
|
98
|
+
export const loadCall = once((chunkBase) => loadScript('call', chunkBase));
|
package/src/umd/panel.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The report panel as a script of its own, for the script-tag build: run, it
|
|
2
|
+
// hands its exports to the loader that added it (lazy.js beside this file).
|
|
3
|
+
import * as panel from '../panel/index.js';
|
|
4
|
+
import { registerChunk } from '../chunks.js';
|
|
5
|
+
|
|
6
|
+
registerChunk('panel', panel);
|