@sphoro/bugcatch-web-sdk 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +104 -6
  2. package/dist/bugcatch-call.es.js +587 -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 +3292 -0
  7. package/dist/bugcatch-core.es.js.map +1 -0
  8. package/dist/bugcatch-panel.es.js +2531 -0
  9. package/dist/bugcatch-panel.es.js.map +1 -0
  10. package/dist/bugcatch-panel.umd.js +1311 -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 +1619 -0
  15. package/dist/bugcatch.standalone.umd.js.map +1 -0
  16. package/dist/bugcatch.umd.js +195 -1318
  17. package/dist/bugcatch.umd.js.map +1 -1
  18. package/package.json +6 -6
  19. package/src/accent.js +38 -0
  20. package/src/announcements.js +147 -0
  21. package/src/busy.js +18 -0
  22. package/src/call.js +7 -0
  23. package/src/capture.js +9 -0
  24. package/src/chunks.js +56 -0
  25. package/src/gesture.js +33 -0
  26. package/src/icons.js +18 -0
  27. package/src/identity.js +13 -8
  28. package/src/incoming-call.js +25 -45
  29. package/src/index.d.ts +93 -2
  30. package/src/index.js +164 -42
  31. package/src/lazy.js +23 -0
  32. package/src/notice.js +68 -0
  33. package/src/panel/call.js +242 -0
  34. package/src/panel/compose.js +416 -0
  35. package/src/panel/composer.js +235 -0
  36. package/src/panel/icons.js +36 -0
  37. package/src/panel/index.js +265 -0
  38. package/src/panel/known.js +130 -0
  39. package/src/panel/messages.js +176 -0
  40. package/src/panel/notices.js +34 -0
  41. package/src/panel/picks.js +65 -0
  42. package/src/panel/refs.js +321 -0
  43. package/src/panel/render.js +132 -0
  44. package/src/panel/signin.js +228 -0
  45. package/src/panel/styles.js +1016 -0
  46. package/src/panel/thread.js +494 -0
  47. package/src/session.js +8 -3
  48. package/src/umd/call.js +6 -0
  49. package/src/umd/lazy.js +98 -0
  50. package/src/umd/panel.js +6 -0
  51. package/src/updates.js +97 -34
  52. package/src/widget.js +385 -3376
package/src/index.d.ts CHANGED
@@ -117,6 +117,31 @@ export interface JoinedIssue {
117
117
  watchToken?: string | null;
118
118
  }
119
119
 
120
+ /**
121
+ * Something the project's team is telling the people using its widget, written
122
+ * on the Widget announcements settings page. Already narrowed to the visitor's
123
+ * own day and to what this browser has not closed.
124
+ */
125
+ export interface Announcement {
126
+ id: string;
127
+ /** Bumped by "Show it again", which is what reopens it for people who closed it. */
128
+ revision: number;
129
+ title: string;
130
+ /** '' when there is none. */
131
+ body: string;
132
+ /** Picks the colour and the icon; the site's accent is 'info'. */
133
+ tone: 'info' | 'success' | 'warning' | 'critical';
134
+ /** Both, or both ''. A path is a page on your own site. */
135
+ ctaLabel: string;
136
+ ctaHref: string;
137
+ /** 'launcher' is also shown beside the button, and is always dismissible. */
138
+ placement: 'panel' | 'launcher';
139
+ dismissible: boolean;
140
+ /** YYYY-MM-DD, inclusive, or ''. */
141
+ startsOn: string;
142
+ endsOn: string;
143
+ }
144
+
120
145
  export interface BugCatchConfig {
121
146
  /**
122
147
  * Log what the SDK does — identify, context, each report's shape — to the
@@ -137,6 +162,16 @@ export interface BugCatchConfig {
137
162
  onError?: ((event: BugCatchErrorEvent) => void) | null;
138
163
  /** Recover unfinished widget reports on this device for seven days. Default true. */
139
164
  drafts?: boolean;
165
+ /**
166
+ * Where the script-tag build fetches the rest of itself from: the folder
167
+ * holding bugcatch-panel.umd.js and bugcatch-call.umd.js, which it loads the
168
+ * first time the report panel opens or a call is placed. By default the
169
+ * folder bugcatch.umd.js itself was loaded from — set this only when that
170
+ * cannot be told (the bundle inlined into the page, or its dist/ files served
171
+ * from somewhere else). A relative URL is read against the page. Ignored by
172
+ * the ES build, whose chunks are the bundler's to place.
173
+ */
174
+ chunkBase?: string | null;
140
175
  /** Your app's version, attached to every report. */
141
176
  release?: string;
142
177
  /** e.g. 'production' / 'staging', attached to every report. */
@@ -161,8 +196,12 @@ export interface BugCatchConfig {
161
196
  widgetPosition?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
162
197
  /** 'auto' follows the visitor's system setting. Default 'auto'. */
163
198
  theme?: 'auto' | 'light' | 'dark';
164
- /** Any CSS colour. Default '#4F46E5'. */
165
- accent?: string;
199
+ /**
200
+ * Any CSS colour, for the launcher, the buttons and the assistant's orb. The
201
+ * words on it are white or near-black, whichever reads. Default null: ink,
202
+ * which is a black button on light and a white one on dark.
203
+ */
204
+ accent?: string | null;
166
205
  /** Offer screenshot / recording / file attach in the widget. Default true. */
167
206
  attachments?: boolean;
168
207
  /**
@@ -172,6 +211,13 @@ export interface BugCatchConfig {
172
211
  * the feature on and somebody lists an individual report.
173
212
  */
174
213
  knownIssues?: boolean;
214
+ /**
215
+ * Draw what the project's team is telling visitors: at the top of the panel,
216
+ * and beside the button for the ones written for that. Default true — and
217
+ * nothing is drawn until the project publishes one. false draws nothing;
218
+ * announcements() still answers, for a site that shows them its own way.
219
+ */
220
+ announcements?: boolean;
175
221
  /** Default 5. */
176
222
  maxAttachments?: number;
177
223
  /** Default 25 MB. */
@@ -420,6 +466,25 @@ export interface MyReport {
420
466
  project: string;
421
467
  workspace: string;
422
468
  attachments: number;
469
+ /** Their score for how it was handled, 1–5, or null when they have not rated it. */
470
+ rating: number | null;
471
+ /** Settled, on a project that asks for ratings — so a list can say "rate it". */
472
+ ratingAsked: boolean;
473
+ }
474
+
475
+ /**
476
+ * A reporter's own rating of how a settled report was handled.
477
+ *
478
+ * `current` is false when the report has been reopened and settled again since
479
+ * it was given — the rating stands, but it was for an earlier fix.
480
+ */
481
+ export interface ReportRating {
482
+ /** 1–5, five best. */
483
+ score: number;
484
+ comment: string | null;
485
+ ratedAt: string;
486
+ via: 'widget' | 'portal' | 'email';
487
+ current: boolean;
423
488
  }
424
489
 
425
490
  /** A report of this browser's changed status. */
@@ -510,6 +575,18 @@ export interface ReportDetail {
510
575
  attachments: { type: string; mime: string; createdAt: string; url: string | null }[];
511
576
  /** Every step from filing to now, oldest first, both sides' messages included. */
512
577
  timeline: ReportTimelineEntry[];
578
+ /** When it was last settled, or null while it is open. */
579
+ resolvedAt: string | null;
580
+ /** Their answer to "does the fix work?", or null when they have not given one. */
581
+ verification: 'CONFIRMED' | 'STILL_HAPPENING' | null;
582
+ /** Their rating of how it was handled, or null. See rate(). */
583
+ rating: ReportRating | null;
584
+ /**
585
+ * Whether a rating is being asked for: the report is settled and the project
586
+ * has not switched ratings off. The widget draws its stars only when this is
587
+ * true; an integration's own view should do the same.
588
+ */
589
+ ratingAsked: boolean;
513
590
  /**
514
591
  * The same figure they were given when they filed it, so coming back to check
515
592
  * is not a worse experience than not checking. Null on a settled report — the
@@ -745,6 +822,12 @@ export declare class BugCatchClient {
745
822
  endCall(): this;
746
823
  /** Tell the team whether their fix worked. STILL_HAPPENING re-sends this browser's context. */
747
824
  verifyFix(bugId: string, feedback: { result: 'CONFIRMED' | 'STILL_HAPPENING'; resolvedAt: string; note?: string }): Promise<{ verification: string; status: string }>;
825
+ /**
826
+ * Rate how a settled report was handled, 1–5, five best. Rating again
827
+ * replaces the earlier score. Rejects while the report is open, or on a
828
+ * project that does not ask for ratings (`ratingAsked` is false).
829
+ */
830
+ rate(bugId: string, rating: { score: 1 | 2 | 3 | 4 | 5; comment?: string }): Promise<ReportRating>;
748
831
  /** One report with its full timeline, or null if this browser cannot prove it filed it. */
749
832
  myReport(bugId: string): Promise<ReportDetail | null>;
750
833
  /**
@@ -813,6 +896,14 @@ export declare class BugCatchClient {
813
896
  * draw nothing rather than an empty heading.
814
897
  */
815
898
  knownIssues(): Promise<KnownIssue[]>;
899
+ /**
900
+ * What this project's team is telling the people using its widget, for a
901
+ * site drawing its own UI (`widget: false` or `announcements: false`). A
902
+ * browser asks at most every five minutes. Empty on every failure.
903
+ */
904
+ announcements(): Promise<Announcement[]>;
905
+ /** Stop showing one in this browser until the team uses "Show it again". */
906
+ dismissAnnouncement(announcement: Announcement): void;
816
907
  /**
817
908
  * The known issues that look like what somebody is describing.
818
909
  *
package/src/index.js CHANGED
@@ -6,7 +6,7 @@ import { mountWidget } from './widget.js';
6
6
  import { uploadAttachment, normalizeMime, REPORT_MIMES } from './capture.js';
7
7
  import {
8
8
  watchUpdates, createUpdateNotifier, fetchMyReports, fetchReport, writeToken, readToken,
9
- clearToken, scopeReports, sendMessage, verifyFix, callKindsForProject,
9
+ clearToken, scopeReports, sendMessage, verifyFix, rateReport, callKindsForProject,
10
10
  reportByCall, liveCallAvailable, requestLiveCall, giveUpLiveCall,
11
11
  answerIncomingCall, declineIncomingCall,
12
12
  readUnread, rememberUpdate, forgetUpdate, forgetReportUpdates, forgetAllUpdates,
@@ -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.
@@ -43,7 +49,14 @@ const DEFAULTS = {
43
49
  widgetPosition: 'bottom-right',
44
50
  // 'auto' follows the visitor's system setting; 'light' / 'dark' pin it.
45
51
  theme: 'auto',
46
- accent: '#4F46E5',
52
+ // null is ink: black buttons on light, white on dark (accent.js). Any CSS
53
+ // colour makes the buttons, the launcher and the assistant's orb that colour.
54
+ accent: null,
55
+ // Draw what the project's team is telling visitors — written on the Widget
56
+ // announcements settings page — in the panel, and beside the button for the
57
+ // ones written for that. false draws nothing; announcements() still answers,
58
+ // for a site that shows them its own way.
59
+ announcements: true,
47
60
  // Screenshot / screen recording / file attach. Capture needs getDisplayMedia,
48
61
  // so it is offered only where the browser has it — file attach always works.
49
62
  attachments: true,
@@ -95,12 +108,34 @@ const DEFAULTS = {
95
108
  onError: null,
96
109
  // Business context for the screen the SDK starts on — see setContext().
97
110
  context: null,
111
+ // The script-tag build fetches the report panel and the call code when they
112
+ // are first needed, from the folder it was itself loaded from. Set this to
113
+ // that folder's URL when it cannot tell — a copy of the bundle inlined into
114
+ // the page, or served from somewhere other than beside its dist/ siblings.
115
+ // The ES build ignores it: the bundler decides where its chunks live.
116
+ chunkBase: null,
98
117
  // Log what the SDK is doing (identify, context, each report's shape) to the
99
118
  // console. Off in production; the warnings about misconfiguration are
100
119
  // printed either way.
101
120
  debug: false,
102
121
  };
103
122
 
123
+ /**
124
+ * A report's body, plus the watch token this browser already holds for this
125
+ * project.
126
+ *
127
+ * The token that comes back from filing names the reports this browser filed —
128
+ * not every report ever filed under the address it sent, which anybody holding
129
+ * the public key could claim. Sending the held one back is what makes the new
130
+ * token cover the earlier reports too, so "Your reports" keeps its history
131
+ * without anybody signing in. Added after beforeSend, which has no business
132
+ * with it, and absent when there is nothing held.
133
+ */
134
+ function withHeldToken(payload) {
135
+ const held = readToken();
136
+ return held ? { ...payload, watchToken: held } : payload;
137
+ }
138
+
104
139
  class BugCatchClient {
105
140
  constructor() {
106
141
  this.config = { ...DEFAULTS };
@@ -423,6 +458,28 @@ class BugCatchClient {
423
458
  this.notifier.render(unread);
424
459
  }
425
460
 
461
+ /**
462
+ * Tell the team whether their fix worked. STILL_HAPPENING reopens the report
463
+ * and re-sends this browser's context with it.
464
+ */
465
+ async verifyFix(bugId, { result, resolvedAt, note }) {
466
+ const context = result === 'STILL_HAPPENING' ? await this.#payload({}) : {};
467
+ return verifyFix({ apiBase: this.config.apiBase, bugId, result, resolvedAt, note, metadata: context.metadata, logs: context.logs });
468
+ }
469
+
470
+ /**
471
+ * Rate how a settled report was handled, 1 to 5, five best.
472
+ *
473
+ * await BugCatch.rate(report.id, { score: 4, comment: 'Quick, thanks' });
474
+ *
475
+ * Only once the report is RESOLVED or CLOSED, on a project that asks for
476
+ * ratings — `myReport(id).ratingAsked` says whether it does. Rating again
477
+ * replaces the earlier score rather than adding a second one.
478
+ */
479
+ async rate(bugId, { score, comment } = {}) {
480
+ return rateReport({ apiBase: this.config.apiBase, bugId, score, comment });
481
+ }
482
+
426
483
  /**
427
484
  * One report in full, with its timeline — every step from filing to now.
428
485
  *
@@ -431,11 +488,6 @@ class BugCatchClient {
431
488
  *
432
489
  * Resolves to null when this browser cannot prove it filed that report.
433
490
  */
434
- async verifyFix(bugId, { result, resolvedAt, note }) {
435
- const context = result === 'STILL_HAPPENING' ? await this.#payload({}) : {};
436
- return verifyFix({ apiBase: this.config.apiBase, bugId, result, resolvedAt, note, metadata: context.metadata, logs: context.logs });
437
- }
438
-
439
491
  async myReport(bugId) {
440
492
  const report = await fetchReport({ apiBase: this.config.apiBase, bugId });
441
493
  this.#notePhase(bugId, report?.assistant);
@@ -600,6 +652,9 @@ class BugCatchClient {
600
652
  apiKey: this.config.apiKey,
601
653
  token: session?.token,
602
654
  payload,
655
+ // See report(): the token this browser holds, so the one that comes
656
+ // back still covers what it covered.
657
+ watchToken: readToken(),
603
658
  });
604
659
  } catch (err) {
605
660
  // A session the server no longer recognises, dropped exactly as
@@ -779,6 +834,11 @@ class BugCatchClient {
779
834
 
780
835
  /** The body of startCall, so the in-flight flag clears on every path. */
781
836
  async #ringTheTeam(bugId) {
837
+ // The call code first: fetched now, when somebody has asked for a call,
838
+ // rather than on every page load for the few that ever place one. A failure
839
+ // here rejects before anything is asked for or filed.
840
+ const { joinLiveCall, liveCallSupported, openMicrophone } = await loadCall(this.config.chunkBase);
841
+
782
842
  // Before the call is placed, not after. A permission prompt takes as long
783
843
  // as the person takes to read it, and asking afterwards would have a room
784
844
  // full of dashboards ringing about a call its own caller may yet refuse to
@@ -875,7 +935,7 @@ class BugCatchClient {
875
935
  */
876
936
  #ring(event) {
877
937
  const call = event.incomingCall;
878
- if (!this.config.voice || !liveCallSupported()) return;
938
+ if (!this.config.voice) return;
879
939
  // Already ringing about this one — a reconnect replaying the frame, or a
880
940
  // second delivery. One card either way.
881
941
  if (this.ringing?.callId === call.callId) return;
@@ -885,36 +945,56 @@ class BugCatchClient {
885
945
 
886
946
  this.#stopRinging();
887
947
 
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
- });
948
+ // The card's code is fetched now, the first time anything rings. The slot
949
+ // is held while it arrives, so a second delivery of this frame finds it,
950
+ // and a withdrawal or a teardown that lands first takes it away before any
951
+ // card is drawn.
952
+ const pending = { callId: call.callId, dismiss: () => {} };
953
+ this.ringing = pending;
954
+ loadCall(this.config.chunkBase).then(({ liveCallSupported, showIncomingCall }) => {
955
+ if (this.ringing !== pending) return;
956
+ this.ringing = null;
957
+ if (!liveCallSupported()) return;
958
+ if (this.call || this.callStarting) return;
959
+
960
+ const dismiss = showIncomingCall({
961
+ call,
962
+ accent: this.config.accent,
963
+ position: this.config.widgetPosition,
964
+ // Whether the page has been touched, which is when the card may ring
965
+ // out loud — tracked since load, which the card's own code cannot be.
966
+ touched: pageTouched,
967
+ onAnswer: () => { void this.#answer(event.bugId, call); },
968
+ onDecline: () => {
969
+ this.ringing = null;
970
+ declineIncomingCall({
971
+ apiBase: this.config.apiBase, bugId: event.bugId, callId: call.callId,
972
+ });
973
+ this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'declined' });
974
+ },
975
+ // Nothing is reported here. The member who placed it is watching it ring
976
+ // on their own screen and their browser tells the API when it stops —
977
+ // the same rule as the other direction, where whoever placed the call is
978
+ // the one that reports nobody answered.
979
+ onExpire: () => {
980
+ this.ringing = null;
981
+ this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'missed' });
982
+ },
983
+ });
909
984
 
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' });
985
+ this.ringing = { callId: call.callId, dismiss };
986
+ if (typeof this.config.onIncomingCall === 'function') {
987
+ // After the card, not instead of it. This is for a host application that
988
+ // wants to know — to duck its own audio, to pause a video — and not a
989
+ // hook that can suppress a call the person can see ringing.
990
+ try { this.config.onIncomingCall({ bugId: event.bugId, ...call }); } catch { /* theirs */ }
991
+ }
992
+ this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'ringing' });
993
+ }, () => {
994
+ // No card can be drawn without its code. chunkUnavailable has said why in
995
+ // the console; the member ringing sees it go unanswered.
996
+ if (this.ringing === pending) this.ringing = null;
997
+ });
918
998
  }
919
999
 
920
1000
  /** Take the card away, whatever it is showing. */
@@ -940,8 +1020,12 @@ class BugCatchClient {
940
1020
  this.callStarting = true;
941
1021
 
942
1022
  let microphone;
1023
+ let joinLiveCall;
943
1024
  try {
944
- microphone = await openMicrophone();
1025
+ // Already here: the card that was answered was drawn by this code.
1026
+ const calling = await loadCall(this.config.chunkBase);
1027
+ joinLiveCall = calling.joinLiveCall;
1028
+ microphone = await calling.openMicrophone();
945
1029
  } catch (err) {
946
1030
  this.callStarting = false;
947
1031
  // Refused the prompt, or no microphone on this machine. The call is still
@@ -1201,7 +1285,7 @@ class BugCatchClient {
1201
1285
  // reporter would undo the requirement.
1202
1286
  ...(session?.token ? { 'X-BugCatch-Session': session.token } : {}),
1203
1287
  },
1204
- body: JSON.stringify(payload),
1288
+ body: JSON.stringify(withHeldToken(payload)),
1205
1289
  });
1206
1290
  } catch (cause) {
1207
1291
  // No response at all: the wrong apiBase, the API down, or CORS refusing
@@ -1227,7 +1311,13 @@ class BugCatchClient {
1227
1311
  // The two refusals an integrator hits first, said in their terms.
1228
1312
  if (res.status === 401 && !session) detail ||= 'the apiKey was not accepted';
1229
1313
  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 : ''}`);
1314
+ // Too many, too fast: the one refusal that is nobody's mistake and that
1315
+ // waiting fixes. Said as a sentence, because the widget puts this string
1316
+ // in front of the person reporting, and "report failed (HTTP 429)" reads
1317
+ // as though they broke something.
1318
+ const err = res.status === 429
1319
+ ? new Error(`[BugCatch] ${tooManyMessage(detail)}`)
1320
+ : new Error(`[BugCatch] report failed (HTTP ${res.status})${detail ? ': ' + detail : ''}`);
1231
1321
  err.status = res.status;
1232
1322
  this.#failed('report', err);
1233
1323
  throw err;
@@ -1321,6 +1411,38 @@ class BugCatchClient {
1321
1411
  return this.agentIssueList;
1322
1412
  }
1323
1413
 
1414
+ /**
1415
+ * What this project's team is telling the people using its widget, narrowed
1416
+ * to the visitor's own day and to what this browser has not closed.
1417
+ *
1418
+ * const notices = await BugCatch.announcements();
1419
+ * // [{ id, title, body, tone: 'info' | 'success' | 'warning' | 'critical',
1420
+ * // ctaLabel, ctaHref, placement: 'panel' | 'launcher', dismissible }]
1421
+ * BugCatch.dismissAnnouncement(notices[0]);
1422
+ *
1423
+ * Written and changed on the project's settings page, with no redeploy here;
1424
+ * a browser asks at most every five minutes, so a change reaches visitors
1425
+ * within that. The widget draws them itself — this is for a site running
1426
+ * `widget: false` or `announcements: false`. Empty on every failure.
1427
+ */
1428
+ async announcements() {
1429
+ const list = await fetchAnnouncements({
1430
+ apiBase: this.config.apiBase,
1431
+ apiKey: this.config.apiKey,
1432
+ projectId: this.config.projectId,
1433
+ });
1434
+ return visibleAnnouncements(list);
1435
+ }
1436
+
1437
+ /**
1438
+ * Stop showing an announcement in this browser, until the team uses "Show it
1439
+ * again". Takes one of announcements()' results; one that may not be closed
1440
+ * is left alone.
1441
+ */
1442
+ dismissAnnouncement(announcement) {
1443
+ rememberDismissed(announcement);
1444
+ }
1445
+
1324
1446
  /**
1325
1447
  * Who this browser is signed in as on this project, from storage alone.
1326
1448
  *
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
+ }