@sphoro/bugcatch-web-sdk 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -4
- 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 -5625
- 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 -1224
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +6 -6
- 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 +8 -30
- package/src/index.d.ts +50 -0
- package/src/index.js +138 -35
- 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 +250 -3260
|
@@ -0,0 +1,384 @@
|
|
|
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" question under it, and the reply box at the bottom (composer.js).
|
|
5
|
+
|
|
6
|
+
import * as ICONS from './icons.js';
|
|
7
|
+
import { threadHtml, typingHtml } from './messages.js';
|
|
8
|
+
import {
|
|
9
|
+
headHtml, emptyHtml, skeletonRows, skeletonBubbles, escapeHtml, statusWord, relativeDay,
|
|
10
|
+
atLatest, scrollToLatest,
|
|
11
|
+
} from './render.js';
|
|
12
|
+
import { mountComposer } from './composer.js';
|
|
13
|
+
|
|
14
|
+
// What each open thread was last drawn from, so a poll that brings nothing new
|
|
15
|
+
// leaves the DOM (and the reader's place in it) alone.
|
|
16
|
+
const threadDrawn = new WeakMap();
|
|
17
|
+
|
|
18
|
+
export function mountThread(ctx) {
|
|
19
|
+
const {
|
|
20
|
+
client, scrim, close,
|
|
21
|
+
} = ctx;
|
|
22
|
+
|
|
23
|
+
// The report currently open, if any. A live update for a different report
|
|
24
|
+
// must not redraw the one being read.
|
|
25
|
+
let openReportId = null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The confirmation, and the one answer somebody actually wants from it.
|
|
29
|
+
*
|
|
30
|
+
* "How long will this take" is the first thing anybody thinks after reporting
|
|
31
|
+
* a bug, and until now the answer was nothing at all — so the honest reading
|
|
32
|
+
* of a confirmation screen was "into the void". The API computes the figure
|
|
33
|
+
* on the same request that files the report, precisely so it can be said
|
|
34
|
+
* here: an estimate that needed a second call would arrive after this panel
|
|
35
|
+
* had closed itself.
|
|
36
|
+
*
|
|
37
|
+
* Absent whenever the project has decided not to tell reporters, or has
|
|
38
|
+
* nothing to base a figure on. Nothing is drawn in that case — no "unknown",
|
|
39
|
+
* no placeholder — because a confirmation that admits it cannot answer is
|
|
40
|
+
* worse than one that does not raise the question.
|
|
41
|
+
*
|
|
42
|
+
* The dwell is longer when there is something to read. Two seconds is right
|
|
43
|
+
* for one line and is not enough for two, and a panel that closes over the
|
|
44
|
+
* sentence somebody started reading is the reason they open it again.
|
|
45
|
+
*/
|
|
46
|
+
function succeed(res, { callFailed = null } = {}) {
|
|
47
|
+
// The assistant: straight into the report's conversation, where the
|
|
48
|
+
// investigation shows itself working ("Analyzing the issue…") and asks
|
|
49
|
+
// what it needs. The plain "Thanks" below stays for when that view cannot
|
|
50
|
+
// load — a report filed without a way to follow it.
|
|
51
|
+
if (res?.id && !callFailed) {
|
|
52
|
+
showReport(res.id, { fresh: true }).then((ok) => { if (!ok) doneScreen(res, { callFailed }); });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
doneScreen(res, { callFailed });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function doneScreen(res, { callFailed = null } = {}) {
|
|
59
|
+
const ref = res?.id ? String(res.id).slice(0, 8) : null;
|
|
60
|
+
const eta = res?.estimate?.label ? String(res.estimate.label) : null;
|
|
61
|
+
// A press of Call that filed the report and then could not ring anybody.
|
|
62
|
+
// Shown as a plain "Thanks" it read as a call that had quietly worked, and
|
|
63
|
+
// the reporter sat waiting for a phone that was never going to ring.
|
|
64
|
+
const failed = callFailed
|
|
65
|
+
? `<p class="eta">${escapeHtml(callFailed)} Your report is filed, and the team will reply in the chat.</p>`
|
|
66
|
+
: '';
|
|
67
|
+
scrim.querySelector('.panel').innerHTML = `
|
|
68
|
+
<div class="done">
|
|
69
|
+
<div class="tick">${ICONS.check}</div>
|
|
70
|
+
<h3>Thanks — that's on its way.</h3>
|
|
71
|
+
<p>${ref ? `Filed as <code>${escapeHtml(ref)}</code>.` : 'The team has what they need to reproduce it.'}</p>
|
|
72
|
+
${failed}
|
|
73
|
+
${eta ? `<p class="eta">Reports like this usually take <strong>${escapeHtml(eta)}</strong>. We'll let you know when it moves.</p>` : ''}
|
|
74
|
+
</div>`;
|
|
75
|
+
setTimeout(close, failed ? 6000 : eta ? 4000 : 2000);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The reporter's own history: what they have filed and where each one got to.
|
|
80
|
+
*
|
|
81
|
+
* Replaces the panel rather than opening a second surface — it is the same
|
|
82
|
+
* dialog answering a different question, and the reporter came here from one
|
|
83
|
+
* launcher.
|
|
84
|
+
*/
|
|
85
|
+
async function showHistory() {
|
|
86
|
+
const panel = scrim.querySelector('.panel');
|
|
87
|
+
clearTimeout(assistantTimer);
|
|
88
|
+
openReportId = null;
|
|
89
|
+
panel.classList.remove('is-chat');
|
|
90
|
+
panel.innerHTML = `
|
|
91
|
+
${headHtml({ title: 'Your reports', sub: "Everything you've reported, and where each one got to." })}
|
|
92
|
+
<div class="history" aria-busy="true">${skeletonRows(3)}</div>`;
|
|
93
|
+
|
|
94
|
+
panel.querySelector('[data-act="close"]').addEventListener('click', close);
|
|
95
|
+
|
|
96
|
+
let reports = [];
|
|
97
|
+
try {
|
|
98
|
+
({ reports } = await client.myReports());
|
|
99
|
+
} catch {
|
|
100
|
+
const failed = panel.querySelector('.history');
|
|
101
|
+
if (failed) failed.innerHTML = emptyHtml('Couldn\'t load your reports just now.', ICONS.alert);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The panel can be closed while the request is in flight.
|
|
106
|
+
const list = panel.querySelector('.history');
|
|
107
|
+
if (!list) return;
|
|
108
|
+
|
|
109
|
+
list.removeAttribute('aria-busy');
|
|
110
|
+
list.innerHTML = reports.length
|
|
111
|
+
? reports.map((r, i) => `
|
|
112
|
+
<button class="row" type="button" data-report="${escapeHtml(r.id)}" style="animation-delay:${Math.min(i, 8) * 30}ms">
|
|
113
|
+
<span class="row-ico" aria-hidden="true">${ICONS.chat}</span>
|
|
114
|
+
<span class="who">
|
|
115
|
+
<span class="ttl">${escapeHtml(r.title)}</span>
|
|
116
|
+
<span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}</span>
|
|
117
|
+
</span>
|
|
118
|
+
<span class="pill ${escapeHtml(String(r.status).toLowerCase())}">${escapeHtml(statusWord(r.status))}</span>
|
|
119
|
+
<span class="chev" aria-hidden="true">${ICONS.chevronRight}</span>
|
|
120
|
+
</button>`).join('')
|
|
121
|
+
: emptyHtml('You haven\'t reported anything yet.', ICONS.inbox);
|
|
122
|
+
|
|
123
|
+
for (const row of list.querySelectorAll('[data-report]')) {
|
|
124
|
+
row.addEventListener('click', () => showReport(row.dataset.report));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* One report as a conversation.
|
|
130
|
+
*
|
|
131
|
+
* This screen used to be a timeline: everything that had happened, read-only,
|
|
132
|
+
* newest first. It answered "what happened to my report" and nothing else —
|
|
133
|
+
* so when the team's reply was a question ("which browser?"), the person best
|
|
134
|
+
* placed to answer it was looking straight at it with no way to.
|
|
135
|
+
*
|
|
136
|
+
* Now it is a thread. Status changes still appear, as the quiet centred lines
|
|
137
|
+
* that mark events in any chat; the messages between the two sides are
|
|
138
|
+
* bubbles either side of them; and there is a box at the bottom. Oldest at
|
|
139
|
+
* the top, because that is the direction a conversation is read.
|
|
140
|
+
*/
|
|
141
|
+
async function showReport(bugId, { fresh = false } = {}) {
|
|
142
|
+
const panel = scrim.querySelector('.panel');
|
|
143
|
+
clearTimeout(assistantTimer);
|
|
144
|
+
panel.classList.add('is-chat');
|
|
145
|
+
panel.innerHTML = `
|
|
146
|
+
${headHtml({
|
|
147
|
+
back: true,
|
|
148
|
+
title: fresh ? 'Looking into it' : 'Your report',
|
|
149
|
+
sub: fresh ? 'Our assistant is on it — it may ask you a question here.' : 'Everything that has happened, and a way to answer.',
|
|
150
|
+
})}
|
|
151
|
+
<div class="history chatting" aria-busy="true"><div class="thread">${skeletonBubbles()}</div></div>`;
|
|
152
|
+
|
|
153
|
+
panel.querySelector('[data-act="close"]').addEventListener('click', close);
|
|
154
|
+
panel.querySelector('[data-act="back"]').addEventListener('click', showHistory);
|
|
155
|
+
|
|
156
|
+
let report = null;
|
|
157
|
+
try {
|
|
158
|
+
report = await client.myReport(bugId);
|
|
159
|
+
} catch {
|
|
160
|
+
report = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const body = panel.querySelector('.history');
|
|
164
|
+
// The panel can be closed, or navigated away from, while this is in flight.
|
|
165
|
+
if (!body || !panel.querySelector('[data-act="back"]')) return false;
|
|
166
|
+
|
|
167
|
+
if (!report) {
|
|
168
|
+
if (fresh) { panel.classList.remove('is-chat'); return false; }
|
|
169
|
+
body.removeAttribute('aria-busy');
|
|
170
|
+
body.innerHTML = emptyHtml('Couldn\'t load that report just now.', ICONS.alert);
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
openReportId = bugId;
|
|
175
|
+
// Reading the report is reading its updates. Leaving a "your report was
|
|
176
|
+
// fixed" card up over the screen where they are reading that it was fixed
|
|
177
|
+
// would be the notification arguing with itself.
|
|
178
|
+
client.markReportRead(bugId);
|
|
179
|
+
const drawn = threadHtml(report.timeline);
|
|
180
|
+
body.classList.add('chatting');
|
|
181
|
+
body.removeAttribute('aria-busy');
|
|
182
|
+
body.innerHTML = `
|
|
183
|
+
<div class="report-head">
|
|
184
|
+
<span class="ttl">${escapeHtml(report.title)}</span>
|
|
185
|
+
<span class="sub">${escapeHtml(report.reference)} · ${escapeHtml(report.project || '')}</span>
|
|
186
|
+
<span class="pill ${escapeHtml(String(report.status).toLowerCase())}">${escapeHtml(statusWord(report.status))}</span>
|
|
187
|
+
${report.estimate?.label
|
|
188
|
+
// The same figure they were given when they filed it, so coming back
|
|
189
|
+
// to check is not a worse experience than not checking. Absent on a
|
|
190
|
+
// report that has already been settled — the API stops sending it —
|
|
191
|
+
// because a prediction beside a finished report is either redundant
|
|
192
|
+
// or an argument.
|
|
193
|
+
? `<span class="eta-line">Usually takes ${escapeHtml(String(report.estimate.label))}</span>`
|
|
194
|
+
: ''}
|
|
195
|
+
</div>
|
|
196
|
+
<div class="thread" role="log" aria-live="polite" aria-label="Conversation">${drawn}</div>`;
|
|
197
|
+
|
|
198
|
+
const thread = body.querySelector('.thread');
|
|
199
|
+
threadDrawn.set(thread, drawn);
|
|
200
|
+
scrollToLatest(thread);
|
|
201
|
+
mountVerification(body, report);
|
|
202
|
+
if (report.canReply) mountComposer(ctx, body, thread, bugId);
|
|
203
|
+
mountAssistant(body, report, bugId);
|
|
204
|
+
// Again, now the cards above it have taken their share of the height: the
|
|
205
|
+
// latest message — and any quick answers under it — is what they came for.
|
|
206
|
+
scrollToLatest(thread);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The assistant's live card: where the investigation is, and a gentle
|
|
212
|
+
* refresh while it works. Analyzing polls every few seconds until the
|
|
213
|
+
* outcome lands — a question, a found cause, or a hand-off — so the
|
|
214
|
+
* reporter watches it happen rather than wondering whether anything is.
|
|
215
|
+
*/
|
|
216
|
+
let assistantTimer = null;
|
|
217
|
+
function mountAssistant(body, report, bugId) {
|
|
218
|
+
clearTimeout(assistantTimer);
|
|
219
|
+
const a = report.assistant;
|
|
220
|
+
const thread = body.querySelector('.thread');
|
|
221
|
+
let card = body.querySelector('.assistant');
|
|
222
|
+
if (!a) {
|
|
223
|
+
card?.remove();
|
|
224
|
+
thread?.querySelector('.typing-row')?.remove();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Updated in place rather than rebuilt: the card is polled every few
|
|
229
|
+
// seconds while the assistant works, and a card that re-entered on every
|
|
230
|
+
// poll would flicker — and re-announce itself to a screen reader — for
|
|
231
|
+
// nothing. It only animates when the phase actually moves.
|
|
232
|
+
const previous = card?.dataset.phase || null;
|
|
233
|
+
if (!card) {
|
|
234
|
+
card = document.createElement('div');
|
|
235
|
+
card.setAttribute('role', 'status');
|
|
236
|
+
card.setAttribute('aria-live', 'polite');
|
|
237
|
+
body.querySelector('.report-head')?.after(card);
|
|
238
|
+
}
|
|
239
|
+
card.className = `assistant ${escapeHtml(a.phase)}`;
|
|
240
|
+
card.dataset.phase = a.phase;
|
|
241
|
+
|
|
242
|
+
const steps = a.steps || [];
|
|
243
|
+
const done = steps.filter((st) => st.state === 'done').length;
|
|
244
|
+
const active = steps.some((st) => st.state === 'active') ? 0.5 : 0;
|
|
245
|
+
const pct = steps.length ? Math.round(((done + active) / steps.length) * 100) : 0;
|
|
246
|
+
const content = `
|
|
247
|
+
<div class="as-body${previous && previous !== a.phase ? ' entering' : ''}">
|
|
248
|
+
<div class="as-top">
|
|
249
|
+
<span class="as-orb" aria-hidden="true">${a.phase === 'analyzing' ? '<span class="as-spin"></span>'
|
|
250
|
+
: a.phase === 'calling' ? ICONS.phone
|
|
251
|
+
: a.phase === 'resolved' ? ICONS.check : ICONS.sparkle}</span>
|
|
252
|
+
<div class="as-text">
|
|
253
|
+
<div class="as-title">${escapeHtml(a.title)}</div>
|
|
254
|
+
<div class="as-detail">${escapeHtml(a.detail)}</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
${steps.length ? `
|
|
259
|
+
<div class="as-progress" role="progressbar" aria-label="Progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}"><i style="width:${pct}%"></i></div>
|
|
260
|
+
<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>` : ''}`;
|
|
261
|
+
const key = JSON.stringify([a.phase, a.title, a.detail, steps]);
|
|
262
|
+
if (card.dataset.key !== key) {
|
|
263
|
+
const bar = card.querySelector('.as-progress i');
|
|
264
|
+
const from = bar?.style.width;
|
|
265
|
+
card.innerHTML = content;
|
|
266
|
+
card.dataset.key = key;
|
|
267
|
+
// Let the bar travel from where it was to where it is now.
|
|
268
|
+
const next = card.querySelector('.as-progress i');
|
|
269
|
+
if (next && from) {
|
|
270
|
+
next.style.width = from;
|
|
271
|
+
requestAnimationFrame(() => { next.style.width = `${pct}%`; });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// While it works, a "thinking" bubble sits where its next message will land.
|
|
276
|
+
// Never during a call: somebody is on the phone about this report, nothing
|
|
277
|
+
// is being analysed (the analysis waits for the transcript), and a bubble
|
|
278
|
+
// typing away underneath a live call is the assistant talking over them.
|
|
279
|
+
if (thread) {
|
|
280
|
+
const pinned = atLatest(thread);
|
|
281
|
+
const typing = thread.querySelector('.typing-row');
|
|
282
|
+
if (a.phase !== 'analyzing') typing?.remove();
|
|
283
|
+
else if (!typing || typing !== thread.lastElementChild) {
|
|
284
|
+
typing?.remove();
|
|
285
|
+
thread.insertAdjacentHTML('beforeend', typingHtml());
|
|
286
|
+
if (pinned) scrollToLatest(thread);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (a.phase === 'analyzing') {
|
|
291
|
+
assistantTimer = setTimeout(() => refreshThread(bugId), 3000);
|
|
292
|
+
} else if (a.phase === 'calling') {
|
|
293
|
+
// Slower: the call's own card is what the reporter is watching, and this
|
|
294
|
+
// only has to notice when the call ends and the analysis begins.
|
|
295
|
+
assistantTimer = setTimeout(() => refreshThread(bugId), 10000);
|
|
296
|
+
} else if (a.phase === 'needs_info' && previous !== 'needs_info') {
|
|
297
|
+
body.querySelector('.composer textarea')?.focus();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function mountVerification(body, report) {
|
|
302
|
+
body.querySelector('.fix-verification')?.remove();
|
|
303
|
+
if (!report.resolvedAt || !['RESOLVED', 'CLOSED'].includes(report.status)) return;
|
|
304
|
+
const box = document.createElement('div'); box.className = 'fix-verification';
|
|
305
|
+
if (report.verification) { box.textContent = report.verification === 'CONFIRMED' ? 'You confirmed that this fix works.' : 'Your feedback was recorded.'; }
|
|
306
|
+
else {
|
|
307
|
+
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>';
|
|
308
|
+
box.querySelectorAll('[data-result]').forEach((btn) => { btn.onclick = async () => {
|
|
309
|
+
box.querySelectorAll('button').forEach((b) => { b.disabled = true; });
|
|
310
|
+
try { await client.verifyFix(report.id, { result: btn.dataset.result, resolvedAt: report.resolvedAt, note: box.querySelector('textarea').value }); await showReport(report.id); }
|
|
311
|
+
catch (err) { box.querySelector('.feedback-status').textContent = err.message; box.querySelectorAll('button').forEach((b) => { b.disabled = false; }); }
|
|
312
|
+
}; });
|
|
313
|
+
}
|
|
314
|
+
body.querySelector('.report-head')?.after(box);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Redraw the open thread in place, keeping whatever is half-typed.
|
|
319
|
+
*
|
|
320
|
+
* Rebuilding the whole screen on every incoming message would be simpler and
|
|
321
|
+
* would also throw away a draft, a staged file and the scroll position — for
|
|
322
|
+
* someone mid-sentence, a reply arriving would look like the widget crashed.
|
|
323
|
+
*/
|
|
324
|
+
async function refreshThread(bugId) {
|
|
325
|
+
const body = scrim.querySelector('.history.chatting');
|
|
326
|
+
const thread = body?.querySelector('.thread');
|
|
327
|
+
if (!thread || openReportId !== bugId) return;
|
|
328
|
+
|
|
329
|
+
let report = null;
|
|
330
|
+
try {
|
|
331
|
+
report = await client.myReport(bugId);
|
|
332
|
+
} catch { /* a failed refresh leaves what is on screen, which is not wrong */ }
|
|
333
|
+
// Closed while it was asking: nothing to draw, and no next poll to set.
|
|
334
|
+
if (ctx.closed || !report || openReportId !== bugId) return;
|
|
335
|
+
|
|
336
|
+
mountVerification(body, report);
|
|
337
|
+
const pinned = atLatest(thread);
|
|
338
|
+
const html = threadHtml(report.timeline);
|
|
339
|
+
// Only rewritten when something changed, so a poll that brings nothing new
|
|
340
|
+
// does not replay every bubble's entrance or re-announce the log.
|
|
341
|
+
if (threadDrawn.get(thread) !== html) {
|
|
342
|
+
thread.innerHTML = html;
|
|
343
|
+
threadDrawn.set(thread, html);
|
|
344
|
+
}
|
|
345
|
+
mountAssistant(body, report, bugId);
|
|
346
|
+
// Only follow the conversation down if they were already at the bottom.
|
|
347
|
+
// Yanking someone back from something they scrolled up to read is worse
|
|
348
|
+
// than making them scroll.
|
|
349
|
+
if (pinned) scrollToLatest(thread);
|
|
350
|
+
|
|
351
|
+
const pill = body.querySelector('.report-head .pill');
|
|
352
|
+
if (pill) {
|
|
353
|
+
pill.className = `pill ${String(report.status).toLowerCase()}`;
|
|
354
|
+
pill.textContent = statusWord(report.status);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Re-read the open thread when something happens to it. Without this the
|
|
359
|
+
// reader is looking at a conversation that stopped being true while they read
|
|
360
|
+
// it — the very screen where a stale view is most obvious, and now the screen
|
|
361
|
+
// where the other side may be typing.
|
|
362
|
+
const stopListening = client.on('update', (event) => {
|
|
363
|
+
if (!openReportId || event?.bugId !== openReportId) return;
|
|
364
|
+
// Already on screen, so already read — and marking it here, before the
|
|
365
|
+
// client draws its notifications, is what keeps a card from appearing over
|
|
366
|
+
// the thread that is about to show the same thing.
|
|
367
|
+
client.markReportRead(openReportId);
|
|
368
|
+
refreshThread(openReportId);
|
|
369
|
+
});
|
|
370
|
+
// Stopped with the panel: a frame arriving after it has gone would redraw a
|
|
371
|
+
// panel that is no longer on the page. The assistant's poll too — it reads
|
|
372
|
+
// the thread out of the detached panel, finds it still there, and would go
|
|
373
|
+
// on asking every three seconds for as long as the analysis ran.
|
|
374
|
+
ctx.onDestroy(() => {
|
|
375
|
+
stopListening();
|
|
376
|
+
clearTimeout(assistantTimer);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
scrim.querySelector('[data-act="history"]')?.addEventListener('click', showHistory);
|
|
380
|
+
|
|
381
|
+
ctx.succeed = succeed;
|
|
382
|
+
ctx.refreshThread = refreshThread;
|
|
383
|
+
ctx.openReportId = () => openReportId;
|
|
384
|
+
}
|
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);
|
package/src/updates.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// let anyone read anyone's reports. Holding this token is proof of having
|
|
12
12
|
// filed them.
|
|
13
13
|
|
|
14
|
+
import { tooManyMessage } from './busy.js';
|
|
15
|
+
|
|
14
16
|
const TOKEN_KEY = 'bugcatch.watch';
|
|
15
17
|
const BACKOFF_MS = [2000, 5000, 15000, 30000, 60000];
|
|
16
18
|
|
|
@@ -308,6 +310,7 @@ export async function sendMessage({ apiBase, bugId, body, attachments }) {
|
|
|
308
310
|
const problem = await res.json();
|
|
309
311
|
detail = Array.isArray(problem.message) ? problem.message.join(', ') : problem.message || '';
|
|
310
312
|
} catch { /* non-JSON error */ }
|
|
313
|
+
if (res.status === 429) throw new Error(tooManyMessage(detail));
|
|
311
314
|
throw new Error(detail || `[BugCatch] could not send that (HTTP ${res.status}).`);
|
|
312
315
|
}
|
|
313
316
|
return res.json();
|
|
@@ -384,7 +387,7 @@ export async function callKindsForProject({ apiBase, apiKey, projectId }) {
|
|
|
384
387
|
* call could be placed — which is a success for the report and a failure only
|
|
385
388
|
* for the call, and the caller is expected to tell them apart.
|
|
386
389
|
*/
|
|
387
|
-
export async function reportByCall({ apiBase, apiKey, token, payload }) {
|
|
390
|
+
export async function reportByCall({ apiBase, apiKey, token, payload, watchToken }) {
|
|
388
391
|
const res = await fetch(`${apiBase}/ingest/calls`, {
|
|
389
392
|
method: 'POST',
|
|
390
393
|
headers: {
|
|
@@ -396,7 +399,9 @@ export async function reportByCall({ apiBase, apiKey, token, payload }) {
|
|
|
396
399
|
// is saying, an inch below the message, who they are signed in as.
|
|
397
400
|
...(token ? { 'X-BugCatch-Session': token } : {}),
|
|
398
401
|
},
|
|
399
|
-
|
|
402
|
+
// The watch token this browser holds, so the one filing hands back covers
|
|
403
|
+
// the reports it already could — see withHeldToken in index.js.
|
|
404
|
+
body: JSON.stringify(watchToken ? { ...payload, watchToken } : payload),
|
|
400
405
|
});
|
|
401
406
|
|
|
402
407
|
if (!res.ok) {
|
|
@@ -406,7 +411,8 @@ export async function reportByCall({ apiBase, apiKey, token, payload }) {
|
|
|
406
411
|
detail = Array.isArray(problem.message) ? problem.message.join(', ') : problem.message || '';
|
|
407
412
|
} catch { /* non-JSON error */ }
|
|
408
413
|
const err = new Error(
|
|
409
|
-
|
|
414
|
+
res.status === 429 ? tooManyMessage(detail)
|
|
415
|
+
: detail || `[BugCatch] the call could not be started (HTTP ${res.status}).`,
|
|
410
416
|
);
|
|
411
417
|
// Carried so a session the server has stopped recognising can be told from
|
|
412
418
|
// every other refusal and forgotten, rather than pressing Call into the
|
|
@@ -1020,12 +1026,18 @@ export async function matchKnownIssues({ apiBase, apiKey, projectId, q, sessionT
|
|
|
1020
1026
|
export async function joinKnownIssue({
|
|
1021
1027
|
apiBase, apiKey, projectId, bugId, reporterEmail, reporterName, metadata, logs, sessionToken,
|
|
1022
1028
|
}) {
|
|
1029
|
+
// The token this browser already holds goes back with the join, so the one
|
|
1030
|
+
// that comes back still covers the reports it filed as well as this issue.
|
|
1031
|
+
const held = readToken();
|
|
1023
1032
|
const res = await fetch(
|
|
1024
1033
|
`${apiBase}/ingest/known-issues/${encodeURIComponent(bugId)}/join`,
|
|
1025
1034
|
{
|
|
1026
1035
|
method: 'POST',
|
|
1027
1036
|
headers: knownIssueHeaders(apiKey, sessionToken),
|
|
1028
|
-
body: JSON.stringify({
|
|
1037
|
+
body: JSON.stringify({
|
|
1038
|
+
projectId, reporterEmail, reporterName, metadata, logs,
|
|
1039
|
+
...(held ? { watchToken: held } : {}),
|
|
1040
|
+
}),
|
|
1029
1041
|
},
|
|
1030
1042
|
);
|
|
1031
1043
|
if (!res.ok) throw new Error(await readJoinError(res));
|
|
@@ -1043,12 +1055,13 @@ function knownIssueHeaders(apiKey, sessionToken) {
|
|
|
1043
1055
|
}
|
|
1044
1056
|
|
|
1045
1057
|
async function readJoinError(res) {
|
|
1058
|
+
let message = '';
|
|
1046
1059
|
try {
|
|
1047
1060
|
const body = await res.json();
|
|
1048
|
-
|
|
1049
|
-
if (message) return message;
|
|
1061
|
+
message = Array.isArray(body.message) ? body.message.join(', ') : body.message || '';
|
|
1050
1062
|
} catch { /* non-JSON error */ }
|
|
1051
|
-
|
|
1063
|
+
if (res.status === 429) return tooManyMessage(message);
|
|
1064
|
+
return message || `Could not add you to this issue (HTTP ${res.status})`;
|
|
1052
1065
|
}
|
|
1053
1066
|
|
|
1054
1067
|
export async function verifyFix({ apiBase, bugId, ...feedback }) {
|