@sphoro/bugcatch-web-sdk 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -5
- package/dist/bugcatch-call.es.js +586 -0
- package/dist/bugcatch-call.es.js.map +1 -0
- package/dist/bugcatch-call.umd.js +2 -0
- package/dist/bugcatch-call.umd.js.map +1 -0
- package/dist/bugcatch-core.es.js +3214 -0
- package/dist/bugcatch-core.es.js.map +1 -0
- package/dist/bugcatch-panel.es.js +2402 -0
- package/dist/bugcatch-panel.es.js.map +1 -0
- package/dist/bugcatch-panel.umd.js +1240 -0
- package/dist/bugcatch-panel.umd.js.map +1 -0
- package/dist/bugcatch.es.js +2 -5522
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.standalone.umd.js +1536 -0
- package/dist/bugcatch.standalone.umd.js.map +1 -0
- package/dist/bugcatch.umd.js +89 -1183
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/agent-issues.js +30 -0
- package/src/announcements.js +147 -0
- package/src/busy.js +18 -0
- package/src/call.js +7 -0
- package/src/capture.js +9 -0
- package/src/chunks.js +56 -0
- package/src/gesture.js +33 -0
- package/src/icons.js +18 -0
- package/src/identity.js +14 -9
- package/src/incoming-call.js +8 -30
- package/src/index.d.ts +50 -0
- package/src/index.js +170 -37
- package/src/lazy.js +23 -0
- package/src/notice.js +68 -0
- package/src/panel/call.js +242 -0
- package/src/panel/compose.js +416 -0
- package/src/panel/composer.js +235 -0
- package/src/panel/icons.js +34 -0
- package/src/panel/index.js +251 -0
- package/src/panel/known.js +130 -0
- package/src/panel/messages.js +176 -0
- package/src/panel/notices.js +34 -0
- package/src/panel/picks.js +61 -0
- package/src/panel/refs.js +321 -0
- package/src/panel/render.js +124 -0
- package/src/panel/signin.js +228 -0
- package/src/panel/styles.js +960 -0
- package/src/panel/thread.js +384 -0
- package/src/session.js +8 -3
- package/src/umd/call.js +6 -0
- package/src/umd/lazy.js +98 -0
- package/src/umd/panel.js +6 -0
- package/src/updates.js +20 -7
- package/src/widget.js +243 -3164
package/src/index.js
CHANGED
|
@@ -16,10 +16,17 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
loginState, startSignIn, confirmSignIn, endSignIn, readSession, clearSession,
|
|
18
18
|
} from './session.js';
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// Not live-call.js and incoming-call.js: the call code is fetched when a call
|
|
20
|
+
// is placed or rings, not with every page (see lazy.js).
|
|
21
|
+
import { loadCall } from './lazy.js';
|
|
22
|
+
import { pageTouched } from './gesture.js';
|
|
21
23
|
import { referenceFields } from './references.js';
|
|
24
|
+
import { fetchAgentIssues } from './agent-issues.js';
|
|
25
|
+
import {
|
|
26
|
+
fetchAnnouncements, visibleAnnouncements, dismissAnnouncement as rememberDismissed,
|
|
27
|
+
} from './announcements.js';
|
|
22
28
|
import { normalizeUser, mergeContext, checkOptions, cleanApiBase } from './identity.js';
|
|
29
|
+
import { tooManyMessage } from './busy.js';
|
|
23
30
|
|
|
24
31
|
// Replaced at build time from package.json. The guard is for anyone importing
|
|
25
32
|
// src/ directly, where the define never runs.
|
|
@@ -43,6 +50,11 @@ const DEFAULTS = {
|
|
|
43
50
|
// 'auto' follows the visitor's system setting; 'light' / 'dark' pin it.
|
|
44
51
|
theme: 'auto',
|
|
45
52
|
accent: '#4F46E5',
|
|
53
|
+
// Draw what the project's team is telling visitors — written on the Widget
|
|
54
|
+
// announcements settings page — in the panel, and beside the button for the
|
|
55
|
+
// ones written for that. false draws nothing; announcements() still answers,
|
|
56
|
+
// for a site that shows them its own way.
|
|
57
|
+
announcements: true,
|
|
46
58
|
// Screenshot / screen recording / file attach. Capture needs getDisplayMedia,
|
|
47
59
|
// so it is offered only where the browser has it — file attach always works.
|
|
48
60
|
attachments: true,
|
|
@@ -94,12 +106,34 @@ const DEFAULTS = {
|
|
|
94
106
|
onError: null,
|
|
95
107
|
// Business context for the screen the SDK starts on — see setContext().
|
|
96
108
|
context: null,
|
|
109
|
+
// The script-tag build fetches the report panel and the call code when they
|
|
110
|
+
// are first needed, from the folder it was itself loaded from. Set this to
|
|
111
|
+
// that folder's URL when it cannot tell — a copy of the bundle inlined into
|
|
112
|
+
// the page, or served from somewhere other than beside its dist/ siblings.
|
|
113
|
+
// The ES build ignores it: the bundler decides where its chunks live.
|
|
114
|
+
chunkBase: null,
|
|
97
115
|
// Log what the SDK is doing (identify, context, each report's shape) to the
|
|
98
116
|
// console. Off in production; the warnings about misconfiguration are
|
|
99
117
|
// printed either way.
|
|
100
118
|
debug: false,
|
|
101
119
|
};
|
|
102
120
|
|
|
121
|
+
/**
|
|
122
|
+
* A report's body, plus the watch token this browser already holds for this
|
|
123
|
+
* project.
|
|
124
|
+
*
|
|
125
|
+
* The token that comes back from filing names the reports this browser filed —
|
|
126
|
+
* not every report ever filed under the address it sent, which anybody holding
|
|
127
|
+
* the public key could claim. Sending the held one back is what makes the new
|
|
128
|
+
* token cover the earlier reports too, so "Your reports" keeps its history
|
|
129
|
+
* without anybody signing in. Added after beforeSend, which has no business
|
|
130
|
+
* with it, and absent when there is nothing held.
|
|
131
|
+
*/
|
|
132
|
+
function withHeldToken(payload) {
|
|
133
|
+
const held = readToken();
|
|
134
|
+
return held ? { ...payload, watchToken: held } : payload;
|
|
135
|
+
}
|
|
136
|
+
|
|
103
137
|
class BugCatchClient {
|
|
104
138
|
constructor() {
|
|
105
139
|
this.config = { ...DEFAULTS };
|
|
@@ -500,7 +534,7 @@ class BugCatchClient {
|
|
|
500
534
|
* copies of this would drift, and the half that drifted would be the one that
|
|
501
535
|
* stopped attaching the console.
|
|
502
536
|
*/
|
|
503
|
-
async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [], context = null }) {
|
|
537
|
+
async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [], context = null, issue = null }) {
|
|
504
538
|
// This report's own context over the screen's, without keeping it.
|
|
505
539
|
const ctx = context ? mergeContext(this.context, context, (m) => this.warn(m.replace('setContext()', 'report({ context })'))) : this.context;
|
|
506
540
|
return {
|
|
@@ -541,6 +575,9 @@ class BugCatchClient {
|
|
|
541
575
|
// description it is being sent anyway. A payload is a claim, and there is
|
|
542
576
|
// no reason to make the join key one when the source text is right here.
|
|
543
577
|
...(references.length ? { references } : {}),
|
|
578
|
+
// A predefined issue the reporter picked (agentIssues()). The API keeps
|
|
579
|
+
// it only if this project offers it.
|
|
580
|
+
...(issue ? { issue } : {}),
|
|
544
581
|
};
|
|
545
582
|
}
|
|
546
583
|
|
|
@@ -596,6 +633,9 @@ class BugCatchClient {
|
|
|
596
633
|
apiKey: this.config.apiKey,
|
|
597
634
|
token: session?.token,
|
|
598
635
|
payload,
|
|
636
|
+
// See report(): the token this browser holds, so the one that comes
|
|
637
|
+
// back still covers what it covered.
|
|
638
|
+
watchToken: readToken(),
|
|
599
639
|
});
|
|
600
640
|
} catch (err) {
|
|
601
641
|
// A session the server no longer recognises, dropped exactly as
|
|
@@ -775,6 +815,11 @@ class BugCatchClient {
|
|
|
775
815
|
|
|
776
816
|
/** The body of startCall, so the in-flight flag clears on every path. */
|
|
777
817
|
async #ringTheTeam(bugId) {
|
|
818
|
+
// The call code first: fetched now, when somebody has asked for a call,
|
|
819
|
+
// rather than on every page load for the few that ever place one. A failure
|
|
820
|
+
// here rejects before anything is asked for or filed.
|
|
821
|
+
const { joinLiveCall, liveCallSupported, openMicrophone } = await loadCall(this.config.chunkBase);
|
|
822
|
+
|
|
778
823
|
// Before the call is placed, not after. A permission prompt takes as long
|
|
779
824
|
// as the person takes to read it, and asking afterwards would have a room
|
|
780
825
|
// full of dashboards ringing about a call its own caller may yet refuse to
|
|
@@ -871,7 +916,7 @@ class BugCatchClient {
|
|
|
871
916
|
*/
|
|
872
917
|
#ring(event) {
|
|
873
918
|
const call = event.incomingCall;
|
|
874
|
-
if (!this.config.voice
|
|
919
|
+
if (!this.config.voice) return;
|
|
875
920
|
// Already ringing about this one — a reconnect replaying the frame, or a
|
|
876
921
|
// second delivery. One card either way.
|
|
877
922
|
if (this.ringing?.callId === call.callId) return;
|
|
@@ -881,36 +926,56 @@ class BugCatchClient {
|
|
|
881
926
|
|
|
882
927
|
this.#stopRinging();
|
|
883
928
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
929
|
+
// The card's code is fetched now, the first time anything rings. The slot
|
|
930
|
+
// is held while it arrives, so a second delivery of this frame finds it,
|
|
931
|
+
// and a withdrawal or a teardown that lands first takes it away before any
|
|
932
|
+
// card is drawn.
|
|
933
|
+
const pending = { callId: call.callId, dismiss: () => {} };
|
|
934
|
+
this.ringing = pending;
|
|
935
|
+
loadCall(this.config.chunkBase).then(({ liveCallSupported, showIncomingCall }) => {
|
|
936
|
+
if (this.ringing !== pending) return;
|
|
937
|
+
this.ringing = null;
|
|
938
|
+
if (!liveCallSupported()) return;
|
|
939
|
+
if (this.call || this.callStarting) return;
|
|
940
|
+
|
|
941
|
+
const dismiss = showIncomingCall({
|
|
942
|
+
call,
|
|
943
|
+
accent: this.config.accent,
|
|
944
|
+
position: this.config.widgetPosition,
|
|
945
|
+
// Whether the page has been touched, which is when the card may ring
|
|
946
|
+
// out loud — tracked since load, which the card's own code cannot be.
|
|
947
|
+
touched: pageTouched,
|
|
948
|
+
onAnswer: () => { void this.#answer(event.bugId, call); },
|
|
949
|
+
onDecline: () => {
|
|
950
|
+
this.ringing = null;
|
|
951
|
+
declineIncomingCall({
|
|
952
|
+
apiBase: this.config.apiBase, bugId: event.bugId, callId: call.callId,
|
|
953
|
+
});
|
|
954
|
+
this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'declined' });
|
|
955
|
+
},
|
|
956
|
+
// Nothing is reported here. The member who placed it is watching it ring
|
|
957
|
+
// on their own screen and their browser tells the API when it stops —
|
|
958
|
+
// the same rule as the other direction, where whoever placed the call is
|
|
959
|
+
// the one that reports nobody answered.
|
|
960
|
+
onExpire: () => {
|
|
961
|
+
this.ringing = null;
|
|
962
|
+
this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'missed' });
|
|
963
|
+
},
|
|
964
|
+
});
|
|
905
965
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
966
|
+
this.ringing = { callId: call.callId, dismiss };
|
|
967
|
+
if (typeof this.config.onIncomingCall === 'function') {
|
|
968
|
+
// After the card, not instead of it. This is for a host application that
|
|
969
|
+
// wants to know — to duck its own audio, to pause a video — and not a
|
|
970
|
+
// hook that can suppress a call the person can see ringing.
|
|
971
|
+
try { this.config.onIncomingCall({ bugId: event.bugId, ...call }); } catch { /* theirs */ }
|
|
972
|
+
}
|
|
973
|
+
this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'ringing' });
|
|
974
|
+
}, () => {
|
|
975
|
+
// No card can be drawn without its code. chunkUnavailable has said why in
|
|
976
|
+
// the console; the member ringing sees it go unanswered.
|
|
977
|
+
if (this.ringing === pending) this.ringing = null;
|
|
978
|
+
});
|
|
914
979
|
}
|
|
915
980
|
|
|
916
981
|
/** Take the card away, whatever it is showing. */
|
|
@@ -936,8 +1001,12 @@ class BugCatchClient {
|
|
|
936
1001
|
this.callStarting = true;
|
|
937
1002
|
|
|
938
1003
|
let microphone;
|
|
1004
|
+
let joinLiveCall;
|
|
939
1005
|
try {
|
|
940
|
-
|
|
1006
|
+
// Already here: the card that was answered was drawn by this code.
|
|
1007
|
+
const calling = await loadCall(this.config.chunkBase);
|
|
1008
|
+
joinLiveCall = calling.joinLiveCall;
|
|
1009
|
+
microphone = await calling.openMicrophone();
|
|
941
1010
|
} catch (err) {
|
|
942
1011
|
this.callStarting = false;
|
|
943
1012
|
// Refused the prompt, or no microphone on this machine. The call is still
|
|
@@ -1136,7 +1205,7 @@ class BugCatchClient {
|
|
|
1136
1205
|
/** File a bug with everything captured so far. */
|
|
1137
1206
|
async report({
|
|
1138
1207
|
title, description = '', priority = 'MEDIUM', metadata = {}, attachments = [],
|
|
1139
|
-
references = [], context = null,
|
|
1208
|
+
references = [], context = null, issue = null,
|
|
1140
1209
|
} = {}) {
|
|
1141
1210
|
if (!this.started) throw new Error('[BugCatch] call init() first.');
|
|
1142
1211
|
if (typeof title !== 'string' || !title.trim()) throw new Error('[BugCatch] a title is required.');
|
|
@@ -1171,6 +1240,7 @@ class BugCatchClient {
|
|
|
1171
1240
|
let payload = await this.#payload({
|
|
1172
1241
|
title: title.trim(), description: typeof description === 'string' ? description : String(description ?? ''),
|
|
1173
1242
|
priority: String(priority).toUpperCase(), metadata, media, references: refs, context,
|
|
1243
|
+
issue: typeof issue === 'string' && issue ? issue.slice(0, 32) : null,
|
|
1174
1244
|
});
|
|
1175
1245
|
|
|
1176
1246
|
const screened = this.#screen(payload);
|
|
@@ -1196,7 +1266,7 @@ class BugCatchClient {
|
|
|
1196
1266
|
// reporter would undo the requirement.
|
|
1197
1267
|
...(session?.token ? { 'X-BugCatch-Session': session.token } : {}),
|
|
1198
1268
|
},
|
|
1199
|
-
body: JSON.stringify(payload),
|
|
1269
|
+
body: JSON.stringify(withHeldToken(payload)),
|
|
1200
1270
|
});
|
|
1201
1271
|
} catch (cause) {
|
|
1202
1272
|
// No response at all: the wrong apiBase, the API down, or CORS refusing
|
|
@@ -1222,7 +1292,13 @@ class BugCatchClient {
|
|
|
1222
1292
|
// The two refusals an integrator hits first, said in their terms.
|
|
1223
1293
|
if (res.status === 401 && !session) detail ||= 'the apiKey was not accepted';
|
|
1224
1294
|
if (res.status === 403) detail ||= 'the apiKey is not allowed to report to this projectId';
|
|
1225
|
-
|
|
1295
|
+
// Too many, too fast: the one refusal that is nobody's mistake and that
|
|
1296
|
+
// waiting fixes. Said as a sentence, because the widget puts this string
|
|
1297
|
+
// in front of the person reporting, and "report failed (HTTP 429)" reads
|
|
1298
|
+
// as though they broke something.
|
|
1299
|
+
const err = res.status === 429
|
|
1300
|
+
? new Error(`[BugCatch] ${tooManyMessage(detail)}`)
|
|
1301
|
+
: new Error(`[BugCatch] report failed (HTTP ${res.status})${detail ? ': ' + detail : ''}`);
|
|
1226
1302
|
err.status = res.status;
|
|
1227
1303
|
this.#failed('report', err);
|
|
1228
1304
|
throw err;
|
|
@@ -1291,6 +1367,63 @@ class BugCatchClient {
|
|
|
1291
1367
|
return this.refFields;
|
|
1292
1368
|
}
|
|
1293
1369
|
|
|
1370
|
+
/**
|
|
1371
|
+
* The questions this project's assistant can answer and close by itself —
|
|
1372
|
+
* "When is my next EMI due?", "Is my KYC complete?" — for a form to offer
|
|
1373
|
+
* as one-tap choices.
|
|
1374
|
+
*
|
|
1375
|
+
* const issues = await BugCatch.agentIssues();
|
|
1376
|
+
* // [{ key: 'emi', title: 'When is my next EMI due?', hint: 'The date and the amount' }]
|
|
1377
|
+
* BugCatch.report({ title: issues[0].title, issue: issues[0].key });
|
|
1378
|
+
*
|
|
1379
|
+
* The assistant looks the reporter up (identify() makes that instant),
|
|
1380
|
+
* answers from their record, and asks whether that settled it: yes resolves
|
|
1381
|
+
* the report, no hands it to the team. Empty unless the project has turned
|
|
1382
|
+
* this on and connected a data source that can answer. The widget calls it
|
|
1383
|
+
* itself; cached for the life of the page.
|
|
1384
|
+
*/
|
|
1385
|
+
agentIssues() {
|
|
1386
|
+
if (this.agentIssueList) return this.agentIssueList;
|
|
1387
|
+
this.agentIssueList = fetchAgentIssues({
|
|
1388
|
+
apiBase: this.config.apiBase,
|
|
1389
|
+
apiKey: this.config.apiKey,
|
|
1390
|
+
projectId: this.config.projectId,
|
|
1391
|
+
});
|
|
1392
|
+
return this.agentIssueList;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* What this project's team is telling the people using its widget, narrowed
|
|
1397
|
+
* to the visitor's own day and to what this browser has not closed.
|
|
1398
|
+
*
|
|
1399
|
+
* const notices = await BugCatch.announcements();
|
|
1400
|
+
* // [{ id, title, body, tone: 'info' | 'success' | 'warning' | 'critical',
|
|
1401
|
+
* // ctaLabel, ctaHref, placement: 'panel' | 'launcher', dismissible }]
|
|
1402
|
+
* BugCatch.dismissAnnouncement(notices[0]);
|
|
1403
|
+
*
|
|
1404
|
+
* Written and changed on the project's settings page, with no redeploy here;
|
|
1405
|
+
* a browser asks at most every five minutes, so a change reaches visitors
|
|
1406
|
+
* within that. The widget draws them itself — this is for a site running
|
|
1407
|
+
* `widget: false` or `announcements: false`. Empty on every failure.
|
|
1408
|
+
*/
|
|
1409
|
+
async announcements() {
|
|
1410
|
+
const list = await fetchAnnouncements({
|
|
1411
|
+
apiBase: this.config.apiBase,
|
|
1412
|
+
apiKey: this.config.apiKey,
|
|
1413
|
+
projectId: this.config.projectId,
|
|
1414
|
+
});
|
|
1415
|
+
return visibleAnnouncements(list);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Stop showing an announcement in this browser, until the team uses "Show it
|
|
1420
|
+
* again". Takes one of announcements()' results; one that may not be closed
|
|
1421
|
+
* is left alone.
|
|
1422
|
+
*/
|
|
1423
|
+
dismissAnnouncement(announcement) {
|
|
1424
|
+
rememberDismissed(announcement);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1294
1427
|
/**
|
|
1295
1428
|
* Who this browser is signed in as on this project, from storage alone.
|
|
1296
1429
|
*
|
package/src/lazy.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Where the parts of the SDK that most pages never use come from.
|
|
2
|
+
//
|
|
3
|
+
// Two parts are left out of the script every page loads: the report panel
|
|
4
|
+
// (panel/), fetched the first time the launcher is pressed — or a little
|
|
5
|
+
// before, see widget.js — and the call code (call.js), fetched when a call is
|
|
6
|
+
// offered, placed, or rings.
|
|
7
|
+
//
|
|
8
|
+
// This is the ES build's way of fetching them: import(), which the host app's
|
|
9
|
+
// bundler turns into chunks of its own and loads from wherever it put them.
|
|
10
|
+
// The script-tag build has no bundler behind it and gets umd/lazy.js in this
|
|
11
|
+
// file's place, with the same exports — see vite.config.js.
|
|
12
|
+
//
|
|
13
|
+
// Each resolves to the part's exports, and to the same promise every time once
|
|
14
|
+
// it has arrived. `chunkBase` is the script-tag build's; a bundler decides for
|
|
15
|
+
// itself where its chunks live.
|
|
16
|
+
|
|
17
|
+
import { chunkUnavailable, once } from './chunks.js';
|
|
18
|
+
|
|
19
|
+
export const loadPanel = once(() => import('./panel/index.js')
|
|
20
|
+
.catch((cause) => { throw chunkUnavailable('the report panel could not be loaded.', cause); }));
|
|
21
|
+
|
|
22
|
+
export const loadCall = once(() => import('./call.js')
|
|
23
|
+
.catch((cause) => { throw chunkUnavailable('the calling code could not be loaded.', cause); }));
|
package/src/notice.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// One announcement as a card: beside the launcher, or at the top of the panel.
|
|
2
|
+
// The rules for it are in the launcher's sheet (widget.js), because the card
|
|
3
|
+
// beside the button is drawn before the panel's code exists.
|
|
4
|
+
//
|
|
5
|
+
// Built from nodes rather than markup. Every word on it was typed into a
|
|
6
|
+
// settings page and lands on somebody else's site, and textContent cannot be
|
|
7
|
+
// talked into being HTML.
|
|
8
|
+
|
|
9
|
+
import * as ICONS from './icons.js';
|
|
10
|
+
|
|
11
|
+
const TONE_ICON = {
|
|
12
|
+
info: ICONS.info, success: ICONS.check, warning: ICONS.warn, critical: ICONS.alert,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `where` is 'launcher' or 'panel'. `onDismiss` is called with the
|
|
17
|
+
* announcement when its × is pressed; a notice that may not be closed has none.
|
|
18
|
+
*/
|
|
19
|
+
export function noticeNode(a, { where, onDismiss }) {
|
|
20
|
+
const card = document.createElement('div');
|
|
21
|
+
card.className = `notice tone-${a.tone} at-${where}`;
|
|
22
|
+
// A status, not an alert: it is news the visitor did not ask for, and a
|
|
23
|
+
// screen reader should get to it when it is done with what they were doing.
|
|
24
|
+
card.setAttribute('role', 'status');
|
|
25
|
+
|
|
26
|
+
const ico = document.createElement('span');
|
|
27
|
+
ico.className = 'notice-ico';
|
|
28
|
+
ico.setAttribute('aria-hidden', 'true');
|
|
29
|
+
ico.innerHTML = TONE_ICON[a.tone] || ICONS.info;
|
|
30
|
+
|
|
31
|
+
const text = document.createElement('div');
|
|
32
|
+
text.className = 'notice-text';
|
|
33
|
+
const title = document.createElement('strong');
|
|
34
|
+
title.textContent = a.title;
|
|
35
|
+
text.append(title);
|
|
36
|
+
if (a.body) {
|
|
37
|
+
const body = document.createElement('p');
|
|
38
|
+
body.textContent = a.body;
|
|
39
|
+
text.append(body);
|
|
40
|
+
}
|
|
41
|
+
if (a.ctaHref && a.ctaLabel) {
|
|
42
|
+
const link = document.createElement('a');
|
|
43
|
+
link.className = 'notice-cta';
|
|
44
|
+
link.href = a.ctaHref;
|
|
45
|
+
link.textContent = a.ctaLabel;
|
|
46
|
+
// A path is a page on this site and opens here, like any other link on
|
|
47
|
+
// it. An address is somewhere else, and leaving the page for it would
|
|
48
|
+
// take a half-written report with it.
|
|
49
|
+
if (/^https?:/i.test(a.ctaHref)) {
|
|
50
|
+
link.target = '_blank';
|
|
51
|
+
link.rel = 'noopener noreferrer';
|
|
52
|
+
}
|
|
53
|
+
text.append(link);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
card.append(ico, text);
|
|
57
|
+
|
|
58
|
+
if (a.dismissible && onDismiss) {
|
|
59
|
+
const close = document.createElement('button');
|
|
60
|
+
close.type = 'button';
|
|
61
|
+
close.className = 'notice-x';
|
|
62
|
+
close.setAttribute('aria-label', 'Dismiss this notice');
|
|
63
|
+
close.innerHTML = ICONS.close;
|
|
64
|
+
close.addEventListener('click', () => onDismiss(a));
|
|
65
|
+
card.append(close);
|
|
66
|
+
}
|
|
67
|
+
return card;
|
|
68
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Talking to the team instead of typing: the offer on the form, the screen
|
|
2
|
+
// that replaces the form while the team is rung, and the bar that sits over a
|
|
3
|
+
// report's thread during a call about it.
|
|
4
|
+
//
|
|
5
|
+
// Only what the reporter sees. Placing the call, the microphone and the
|
|
6
|
+
// connection are the client's (startCall, startLiveCall in index.js), and their
|
|
7
|
+
// code is fetched only when a call is actually placed.
|
|
8
|
+
|
|
9
|
+
import * as ICONS from './icons.js';
|
|
10
|
+
import { humanDuration } from '../capture.js';
|
|
11
|
+
import { escapeHtml } from './render.js';
|
|
12
|
+
|
|
13
|
+
export function mountCallOffer(ctx) {
|
|
14
|
+
const {
|
|
15
|
+
client, scrim, say, callBtn, close,
|
|
16
|
+
} = ctx;
|
|
17
|
+
|
|
18
|
+
// Asked once, when the dialog opens, and the button stays hidden unless the
|
|
19
|
+
// answer is yes. Doing it here rather than at init keeps one request off the
|
|
20
|
+
// page load of every site this SDK is on, for a button most of them will
|
|
21
|
+
// never show.
|
|
22
|
+
//
|
|
23
|
+
// The wording is fixed because there is one kind of call: it rings a room and
|
|
24
|
+
// may go unanswered. A button that said "talk to us" would have somebody
|
|
25
|
+
// expecting an instant answer sit through twenty seconds of ringing.
|
|
26
|
+
let callOffered = false;
|
|
27
|
+
function offerCall() {
|
|
28
|
+
client.callKind()
|
|
29
|
+
.then((kind) => {
|
|
30
|
+
if (!kind) return;
|
|
31
|
+
callOffered = true;
|
|
32
|
+
// Offered, so fetch what placing it needs now: the press should ring,
|
|
33
|
+
// not wait for code.
|
|
34
|
+
ctx.preloadCall();
|
|
35
|
+
// Never over a panel that is still asking who this is: the call would
|
|
36
|
+
// file a report, and on a project that requires sign-in that is the one
|
|
37
|
+
// thing this panel is holding back.
|
|
38
|
+
callBtn.hidden = ctx.mustSignIn();
|
|
39
|
+
})
|
|
40
|
+
.catch(() => { /* not available is the safe answer to every failure */ });
|
|
41
|
+
}
|
|
42
|
+
offerCall();
|
|
43
|
+
|
|
44
|
+
callBtn.addEventListener('click', async () => {
|
|
45
|
+
ctx.sending = true;
|
|
46
|
+
say('');
|
|
47
|
+
ctx.refreshSendState();
|
|
48
|
+
callBtn.disabled = true;
|
|
49
|
+
callBtn.innerHTML = '<span class="spinner"></span>Ringing…';
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// No id: this files the report from what the page was doing and rings the
|
|
53
|
+
// team about it, in one round trip.
|
|
54
|
+
const handle = await client.startCall();
|
|
55
|
+
showCall(handle);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
ctx.sending = false;
|
|
58
|
+
callBtn.disabled = false;
|
|
59
|
+
callBtn.innerHTML = `${ICONS.phone}Talk to the team`;
|
|
60
|
+
ctx.refreshSendState();
|
|
61
|
+
// A report filed with no call is still a report. Saying "the call
|
|
62
|
+
// failed" and nothing else would leave the reporter thinking their
|
|
63
|
+
// context went nowhere, and pressing Send again would file it twice.
|
|
64
|
+
if (err.filed) ctx.succeed(err.filed, { callFailed: err.reason || 'The call could not be started.' });
|
|
65
|
+
else {
|
|
66
|
+
say(err.message.replace('[BugCatch] ', ''));
|
|
67
|
+
// The same follow-up the Send button does, because a call can fail for
|
|
68
|
+
// the same reason a report can: this browser was signed out while the
|
|
69
|
+
// panel sat open. Without it the message says sign in while the row
|
|
70
|
+
// under it still names who signed in, which reads as a broken widget.
|
|
71
|
+
ctx.recheckSignIn();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The call, once the team is being rung.
|
|
78
|
+
*
|
|
79
|
+
* Replaces the panel for the same reason the history view does: it is the
|
|
80
|
+
* same dialog answering a different question, and the reporter got here from
|
|
81
|
+
* one launcher. There is no way back to the form — the report has already
|
|
82
|
+
* been filed, and a Cancel that implied otherwise would be a lie.
|
|
83
|
+
*/
|
|
84
|
+
function showCall(handle) {
|
|
85
|
+
const panel = scrim.querySelector('.panel');
|
|
86
|
+
const ref = handle?.bugId ? String(handle.bugId).slice(0, 8) : null;
|
|
87
|
+
|
|
88
|
+
panel.innerHTML = `
|
|
89
|
+
<div class="call">
|
|
90
|
+
<div class="call-head">
|
|
91
|
+
<span class="call-dot" aria-hidden="true"></span>
|
|
92
|
+
<strong class="call-state">Ringing…</strong>
|
|
93
|
+
</div>
|
|
94
|
+
<p class="call-note">
|
|
95
|
+
We're ringing the team now. Someone will pick up if they're free — your report${
|
|
96
|
+
ref ? ` <code>${escapeHtml(ref)}</code>` : ''} is already filed either way, so nothing is lost if nobody answers.
|
|
97
|
+
</p>
|
|
98
|
+
<div class="foot">
|
|
99
|
+
<span class="spacer"></span>
|
|
100
|
+
<button class="primary" type="button" data-act="hangup">Cancel</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>`;
|
|
103
|
+
|
|
104
|
+
const stateEl = panel.querySelector('.call-state');
|
|
105
|
+
const dot = panel.querySelector('.call-dot');
|
|
106
|
+
|
|
107
|
+
// A call spends its first twenty seconds ringing, and can end by nobody
|
|
108
|
+
// picking up — which is not a failure and must not read as one.
|
|
109
|
+
const WORDING = {
|
|
110
|
+
ringing: 'Ringing the team…',
|
|
111
|
+
connecting: 'Someone answered — connecting…',
|
|
112
|
+
connected: 'Connected',
|
|
113
|
+
missed: 'Nobody was free',
|
|
114
|
+
ended: 'Call ended',
|
|
115
|
+
failed: 'The call dropped',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// `missed` is over too, and is the ordinary way a live call ends when the
|
|
119
|
+
// team is busy. Left out of this list it would leave the dialog showing
|
|
120
|
+
// "Nobody was free" above a Cancel button for a call that has already
|
|
121
|
+
// stopped.
|
|
122
|
+
const OVER = ['ended', 'failed', 'missed'];
|
|
123
|
+
|
|
124
|
+
const offState = client.on('call', ({ state }) => {
|
|
125
|
+
stateEl.textContent = WORDING[state] || state;
|
|
126
|
+
dot.classList.toggle('off', OVER.includes(state));
|
|
127
|
+
if (OVER.includes(state)) {
|
|
128
|
+
panel.querySelector('[data-act="hangup"]').textContent = 'Close';
|
|
129
|
+
if (state === 'missed') {
|
|
130
|
+
// Said plainly, and said as the non-event it is. The report is filed
|
|
131
|
+
// and the thread already says the call went unanswered, so the one
|
|
132
|
+
// thing left to tell them is that they have not lost anything.
|
|
133
|
+
const note = panel.querySelector('.call-note');
|
|
134
|
+
if (note) {
|
|
135
|
+
note.textContent = 'Nobody was free to pick up just now. Your report is filed '
|
|
136
|
+
+ 'and the team will reply in the chat.';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
panel.querySelector('[data-act="hangup"]').addEventListener('click', () => {
|
|
143
|
+
offState();
|
|
144
|
+
client.endCall();
|
|
145
|
+
close();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The call, on top of the report it is about.
|
|
151
|
+
*
|
|
152
|
+
* A bar rather than a screen of its own, unlike the one the composer's button
|
|
153
|
+
* opens — and for a reason that only applies here. A call has nothing to show
|
|
154
|
+
* while it runs, because nobody is listening to it but the person on the
|
|
155
|
+
* other end, so what is worth looking at is the report both people are
|
|
156
|
+
* talking about.
|
|
157
|
+
*/
|
|
158
|
+
function showLiveCall(body, thread, handle) {
|
|
159
|
+
const bar = document.createElement('div');
|
|
160
|
+
bar.className = 'livecall';
|
|
161
|
+
bar.innerHTML = `
|
|
162
|
+
<span class="call-dot" aria-hidden="true"></span>
|
|
163
|
+
<span class="who" role="status" aria-live="polite">Calling the team…</span>
|
|
164
|
+
<span class="secs" hidden></span>
|
|
165
|
+
${handle?.recorded ? '<span class="call-kept">This call is recorded.</span>' : ''}
|
|
166
|
+
<button class="act" type="button" data-act="mute" hidden>Mute</button>
|
|
167
|
+
<button class="act danger" type="button" data-act="hangup">Cancel</button>`;
|
|
168
|
+
body.insertBefore(bar, thread);
|
|
169
|
+
|
|
170
|
+
const who = bar.querySelector('.who');
|
|
171
|
+
const secs = bar.querySelector('.secs');
|
|
172
|
+
const dot = bar.querySelector('.call-dot');
|
|
173
|
+
const muteBtn = bar.querySelector('[data-act="mute"]');
|
|
174
|
+
const hangBtn = bar.querySelector('[data-act="hangup"]');
|
|
175
|
+
|
|
176
|
+
let ticker = null;
|
|
177
|
+
const stopTicking = () => { if (ticker) { clearInterval(ticker); ticker = null; } };
|
|
178
|
+
|
|
179
|
+
/** Left up rather than removed, so somebody who looked away can see how it
|
|
180
|
+
* ended — and dismissed by hand, or by the next call replacing it. */
|
|
181
|
+
const settle = (text) => {
|
|
182
|
+
stopTicking();
|
|
183
|
+
dot.classList.add('off');
|
|
184
|
+
bar.classList.add('over');
|
|
185
|
+
who.textContent = text;
|
|
186
|
+
secs.hidden = true;
|
|
187
|
+
muteBtn.hidden = true;
|
|
188
|
+
hangBtn.textContent = 'Dismiss';
|
|
189
|
+
hangBtn.classList.remove('danger');
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const off = client.on('call', ({ state }) => {
|
|
193
|
+
if (state === 'ringing') who.textContent = 'Calling the team…';
|
|
194
|
+
|
|
195
|
+
if (state === 'connecting') {
|
|
196
|
+
who.textContent = 'Someone answered — connecting…';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (state === 'connected') {
|
|
200
|
+
who.textContent = "You're on a call with the team";
|
|
201
|
+
muteBtn.hidden = false;
|
|
202
|
+
secs.hidden = false;
|
|
203
|
+
const from = Date.now();
|
|
204
|
+
stopTicking();
|
|
205
|
+
// Started on connect rather than on answer: the seconds a person is
|
|
206
|
+
// charged for looking at are the ones they could hear.
|
|
207
|
+
ticker = setInterval(() => {
|
|
208
|
+
secs.textContent = humanDuration(Math.floor((Date.now() - from) / 1000));
|
|
209
|
+
}, 1000);
|
|
210
|
+
secs.textContent = '0s';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (state === 'missed') {
|
|
214
|
+
settle('Nobody was free. Send a message instead and the team will pick it up.');
|
|
215
|
+
}
|
|
216
|
+
if (state === 'ended') settle('Call ended.');
|
|
217
|
+
if (state === 'failed') settle('The call dropped.');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
muteBtn.addEventListener('click', () => {
|
|
221
|
+
const muted = handle.setMuted(!handle.isMuted());
|
|
222
|
+
muteBtn.textContent = muted ? 'Unmute' : 'Mute';
|
|
223
|
+
// Announced rather than only shown: somebody who muted by accident finds
|
|
224
|
+
// out when the other person stops replying, which is a long way in.
|
|
225
|
+
who.textContent = muted ? "You're muted" : "You're on a call with the team";
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
hangBtn.addEventListener('click', () => {
|
|
229
|
+
off();
|
|
230
|
+
stopTicking();
|
|
231
|
+
client.endCall();
|
|
232
|
+
bar.remove();
|
|
233
|
+
// The note about the call lands in the thread a moment after it ends, and
|
|
234
|
+
// the reporter is looking straight at where it goes.
|
|
235
|
+
ctx.refreshThread(ctx.openReportId());
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
ctx.offerCall = offerCall;
|
|
240
|
+
ctx.callOffered = () => callOffered;
|
|
241
|
+
ctx.showLiveCall = showLiveCall;
|
|
242
|
+
}
|