@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.
Files changed (51) hide show
  1. package/README.md +69 -4
  2. package/dist/bugcatch-call.es.js +586 -0
  3. package/dist/bugcatch-call.es.js.map +1 -0
  4. package/dist/bugcatch-call.umd.js +2 -0
  5. package/dist/bugcatch-call.umd.js.map +1 -0
  6. package/dist/bugcatch-core.es.js +3214 -0
  7. package/dist/bugcatch-core.es.js.map +1 -0
  8. package/dist/bugcatch-panel.es.js +2402 -0
  9. package/dist/bugcatch-panel.es.js.map +1 -0
  10. package/dist/bugcatch-panel.umd.js +1240 -0
  11. package/dist/bugcatch-panel.umd.js.map +1 -0
  12. package/dist/bugcatch.es.js +2 -5625
  13. package/dist/bugcatch.es.js.map +1 -1
  14. package/dist/bugcatch.standalone.umd.js +1536 -0
  15. package/dist/bugcatch.standalone.umd.js.map +1 -0
  16. package/dist/bugcatch.umd.js +89 -1224
  17. package/dist/bugcatch.umd.js.map +1 -1
  18. package/package.json +6 -6
  19. package/src/announcements.js +147 -0
  20. package/src/busy.js +18 -0
  21. package/src/call.js +7 -0
  22. package/src/capture.js +9 -0
  23. package/src/chunks.js +56 -0
  24. package/src/gesture.js +33 -0
  25. package/src/icons.js +18 -0
  26. package/src/identity.js +13 -8
  27. package/src/incoming-call.js +8 -30
  28. package/src/index.d.ts +50 -0
  29. package/src/index.js +138 -35
  30. package/src/lazy.js +23 -0
  31. package/src/notice.js +68 -0
  32. package/src/panel/call.js +242 -0
  33. package/src/panel/compose.js +416 -0
  34. package/src/panel/composer.js +235 -0
  35. package/src/panel/icons.js +34 -0
  36. package/src/panel/index.js +251 -0
  37. package/src/panel/known.js +130 -0
  38. package/src/panel/messages.js +176 -0
  39. package/src/panel/notices.js +34 -0
  40. package/src/panel/picks.js +61 -0
  41. package/src/panel/refs.js +321 -0
  42. package/src/panel/render.js +124 -0
  43. package/src/panel/signin.js +228 -0
  44. package/src/panel/styles.js +960 -0
  45. package/src/panel/thread.js +384 -0
  46. package/src/session.js +8 -3
  47. package/src/umd/call.js +6 -0
  48. package/src/umd/lazy.js +98 -0
  49. package/src/umd/panel.js +6 -0
  50. package/src/updates.js +20 -7
  51. package/src/widget.js +250 -3260
package/src/index.js CHANGED
@@ -16,11 +16,17 @@ import {
16
16
  import {
17
17
  loginState, startSignIn, confirmSignIn, endSignIn, readSession, clearSession,
18
18
  } from './session.js';
19
- import { joinLiveCall, liveCallSupported, openMicrophone } from './live-call.js';
20
- import { showIncomingCall } from './incoming-call.js';
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';
22
24
  import { fetchAgentIssues } from './agent-issues.js';
25
+ import {
26
+ fetchAnnouncements, visibleAnnouncements, dismissAnnouncement as rememberDismissed,
27
+ } from './announcements.js';
23
28
  import { normalizeUser, mergeContext, checkOptions, cleanApiBase } from './identity.js';
29
+ import { tooManyMessage } from './busy.js';
24
30
 
25
31
  // Replaced at build time from package.json. The guard is for anyone importing
26
32
  // src/ directly, where the define never runs.
@@ -44,6 +50,11 @@ const DEFAULTS = {
44
50
  // 'auto' follows the visitor's system setting; 'light' / 'dark' pin it.
45
51
  theme: 'auto',
46
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,
47
58
  // Screenshot / screen recording / file attach. Capture needs getDisplayMedia,
48
59
  // so it is offered only where the browser has it — file attach always works.
49
60
  attachments: true,
@@ -95,12 +106,34 @@ const DEFAULTS = {
95
106
  onError: null,
96
107
  // Business context for the screen the SDK starts on — see setContext().
97
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,
98
115
  // Log what the SDK is doing (identify, context, each report's shape) to the
99
116
  // console. Off in production; the warnings about misconfiguration are
100
117
  // printed either way.
101
118
  debug: false,
102
119
  };
103
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
+
104
137
  class BugCatchClient {
105
138
  constructor() {
106
139
  this.config = { ...DEFAULTS };
@@ -600,6 +633,9 @@ class BugCatchClient {
600
633
  apiKey: this.config.apiKey,
601
634
  token: session?.token,
602
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(),
603
639
  });
604
640
  } catch (err) {
605
641
  // A session the server no longer recognises, dropped exactly as
@@ -779,6 +815,11 @@ class BugCatchClient {
779
815
 
780
816
  /** The body of startCall, so the in-flight flag clears on every path. */
781
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
+
782
823
  // Before the call is placed, not after. A permission prompt takes as long
783
824
  // as the person takes to read it, and asking afterwards would have a room
784
825
  // full of dashboards ringing about a call its own caller may yet refuse to
@@ -875,7 +916,7 @@ class BugCatchClient {
875
916
  */
876
917
  #ring(event) {
877
918
  const call = event.incomingCall;
878
- if (!this.config.voice || !liveCallSupported()) return;
919
+ if (!this.config.voice) return;
879
920
  // Already ringing about this one — a reconnect replaying the frame, or a
880
921
  // second delivery. One card either way.
881
922
  if (this.ringing?.callId === call.callId) return;
@@ -885,36 +926,56 @@ class BugCatchClient {
885
926
 
886
927
  this.#stopRinging();
887
928
 
888
- const dismiss = showIncomingCall({
889
- call,
890
- accent: this.config.accent,
891
- position: this.config.widgetPosition,
892
- onAnswer: () => { void this.#answer(event.bugId, call); },
893
- onDecline: () => {
894
- this.ringing = null;
895
- declineIncomingCall({
896
- apiBase: this.config.apiBase, bugId: event.bugId, callId: call.callId,
897
- });
898
- this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'declined' });
899
- },
900
- // Nothing is reported here. The member who placed it is watching it ring
901
- // on their own screen and their browser tells the API when it stops —
902
- // the same rule as the other direction, where whoever placed the call is
903
- // the one that reports nobody answered.
904
- onExpire: () => {
905
- this.ringing = null;
906
- this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'missed' });
907
- },
908
- });
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
+ });
909
965
 
910
- this.ringing = { callId: call.callId, dismiss };
911
- if (typeof this.config.onIncomingCall === 'function') {
912
- // After the card, not instead of it. This is for a host application that
913
- // wants to know — to duck its own audio, to pause a video — and not a
914
- // hook that can suppress a call the person can see ringing.
915
- try { this.config.onIncomingCall({ bugId: event.bugId, ...call }); } catch { /* theirs */ }
916
- }
917
- this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'ringing' });
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
+ });
918
979
  }
919
980
 
920
981
  /** Take the card away, whatever it is showing. */
@@ -940,8 +1001,12 @@ class BugCatchClient {
940
1001
  this.callStarting = true;
941
1002
 
942
1003
  let microphone;
1004
+ let joinLiveCall;
943
1005
  try {
944
- microphone = await openMicrophone();
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();
945
1010
  } catch (err) {
946
1011
  this.callStarting = false;
947
1012
  // Refused the prompt, or no microphone on this machine. The call is still
@@ -1201,7 +1266,7 @@ class BugCatchClient {
1201
1266
  // reporter would undo the requirement.
1202
1267
  ...(session?.token ? { 'X-BugCatch-Session': session.token } : {}),
1203
1268
  },
1204
- body: JSON.stringify(payload),
1269
+ body: JSON.stringify(withHeldToken(payload)),
1205
1270
  });
1206
1271
  } catch (cause) {
1207
1272
  // No response at all: the wrong apiBase, the API down, or CORS refusing
@@ -1227,7 +1292,13 @@ class BugCatchClient {
1227
1292
  // The two refusals an integrator hits first, said in their terms.
1228
1293
  if (res.status === 401 && !session) detail ||= 'the apiKey was not accepted';
1229
1294
  if (res.status === 403) detail ||= 'the apiKey is not allowed to report to this projectId';
1230
- const err = new Error(`[BugCatch] report failed (HTTP ${res.status})${detail ? ': ' + detail : ''}`);
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 : ''}`);
1231
1302
  err.status = res.status;
1232
1303
  this.#failed('report', err);
1233
1304
  throw err;
@@ -1321,6 +1392,38 @@ class BugCatchClient {
1321
1392
  return this.agentIssueList;
1322
1393
  }
1323
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
+
1324
1427
  /**
1325
1428
  * Who this browser is signed in as on this project, from storage alone.
1326
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
+ }