@whooshbang/sdk 5.1.0 → 7.0.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 (73) hide show
  1. package/README.md +232 -83
  2. package/dist/cli/commands.js +2 -0
  3. package/dist/cli/commands.js.map +1 -1
  4. package/dist/client.d.ts +169 -1
  5. package/dist/client.js +534 -4
  6. package/dist/client.js.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/interactions.js +18 -3
  10. package/dist/interactions.js.map +1 -1
  11. package/dist/recipient/digest-section.d.ts +29 -0
  12. package/dist/recipient/digest-section.js +400 -0
  13. package/dist/recipient/digest-section.js.map +1 -0
  14. package/dist/recipient/element.js +784 -14
  15. package/dist/recipient/element.js.map +1 -1
  16. package/dist/recipient/i18n/ar.js +114 -0
  17. package/dist/recipient/i18n/ar.js.map +1 -1
  18. package/dist/recipient/i18n/catalog.d.ts +113 -0
  19. package/dist/recipient/i18n/catalog.js.map +1 -1
  20. package/dist/recipient/i18n/de.js +119 -0
  21. package/dist/recipient/i18n/de.js.map +1 -1
  22. package/dist/recipient/i18n/en.js +119 -0
  23. package/dist/recipient/i18n/en.js.map +1 -1
  24. package/dist/recipient/i18n/es.js +119 -0
  25. package/dist/recipient/i18n/es.js.map +1 -1
  26. package/dist/recipient/i18n/fa.js +114 -0
  27. package/dist/recipient/i18n/fa.js.map +1 -1
  28. package/dist/recipient/i18n/fr.js +119 -0
  29. package/dist/recipient/i18n/fr.js.map +1 -1
  30. package/dist/recipient/i18n/hi.js +119 -0
  31. package/dist/recipient/i18n/hi.js.map +1 -1
  32. package/dist/recipient/i18n/id.js +119 -0
  33. package/dist/recipient/i18n/id.js.map +1 -1
  34. package/dist/recipient/i18n/it.js +119 -0
  35. package/dist/recipient/i18n/it.js.map +1 -1
  36. package/dist/recipient/i18n/nl.js +119 -0
  37. package/dist/recipient/i18n/nl.js.map +1 -1
  38. package/dist/recipient/i18n/pl.js +119 -0
  39. package/dist/recipient/i18n/pl.js.map +1 -1
  40. package/dist/recipient/i18n/pt-BR.js +119 -0
  41. package/dist/recipient/i18n/pt-BR.js.map +1 -1
  42. package/dist/recipient/i18n/ru.js +119 -0
  43. package/dist/recipient/i18n/ru.js.map +1 -1
  44. package/dist/recipient/i18n/tr.js +119 -0
  45. package/dist/recipient/i18n/tr.js.map +1 -1
  46. package/dist/recipient/i18n/uk.js +119 -0
  47. package/dist/recipient/i18n/uk.js.map +1 -1
  48. package/dist/recipient/i18n/zh-Hans.js +114 -0
  49. package/dist/recipient/i18n/zh-Hans.js.map +1 -1
  50. package/dist/recipient/mount.d.ts +5 -0
  51. package/dist/recipient/mount.js +2 -0
  52. package/dist/recipient/mount.js.map +1 -1
  53. package/dist/recipient/order-section.d.ts +41 -0
  54. package/dist/recipient/order-section.js +547 -0
  55. package/dist/recipient/order-section.js.map +1 -0
  56. package/dist/recipient/order-view.d.ts +123 -0
  57. package/dist/recipient/order-view.js +304 -0
  58. package/dist/recipient/order-view.js.map +1 -0
  59. package/dist/recipient/platform-mark.d.ts +1 -0
  60. package/dist/recipient/platform-mark.js +57 -0
  61. package/dist/recipient/platform-mark.js.map +1 -0
  62. package/dist/recipient/quiet-section.d.ts +51 -0
  63. package/dist/recipient/quiet-section.js +617 -0
  64. package/dist/recipient/quiet-section.js.map +1 -0
  65. package/dist/recipient/quiet-view.d.ts +104 -0
  66. package/dist/recipient/quiet-view.js +304 -0
  67. package/dist/recipient/quiet-view.js.map +1 -0
  68. package/dist/recipient/view.d.ts +2 -0
  69. package/dist/recipient/view.js.map +1 -1
  70. package/dist/webhooks.d.ts +30 -1
  71. package/dist/webhooks.js +57 -4
  72. package/dist/webhooks.js.map +1 -1
  73. package/package.json +12 -2
@@ -3,7 +3,13 @@ import { WhooshBangRecipientSessionClient } from "../client.js";
3
3
  import { WhooshBangProblemError } from "../errors.js";
4
4
  import { recipientPanelCopy, resolveRecipientPanelCopy, } from "./i18n/catalog.js";
5
5
  import { createOpenerNonce, isExpectedHandoffCompletion, launchHandoff, WHOOSHBANG_HOSTED_ORIGIN, } from "./handoff.js";
6
- import { recipientView, } from "./view.js";
6
+ import { whooshbangMark } from "./platform-mark.js";
7
+ import { ORDER_SECTION_STYLES, orderPreviewText, renderOrderSection, } from "./order-section.js";
8
+ import { addStep, draftChanged, moveStep, refreshDraft, removeStep, saveRequest, setWait, startDraft, stepKey, } from "./order-view.js";
9
+ import { QUIET_SECTION_STYLES, renderQuietSection } from "./quiet-section.js";
10
+ import { DIGEST_SECTION_STYLES, digestSectionOffered, renderDigestSection, } from "./digest-section.js";
11
+ import { addQuietWindow, browserTimeZone, QUIET_CHANNELS, quietChanged, quietDraftErrors, quietEdited, quietErrorControl, quietProblemErrors, quietSaveRequest, withCurrentCatchUp, removeQuietWindow, setQuietWindow, startQuietDraft, toggleQuietDay, } from "./quiet-view.js";
12
+ import { channelLabel, recipientView, } from "./view.js";
7
13
  export const RECIPIENT_SETTINGS_ELEMENT = "whooshbang-recipient-settings";
8
14
  /**
9
15
  * How long the component keeps asking after a handoff opens.
@@ -125,12 +131,45 @@ a.fallback { color: inherit; }
125
131
  padding-top: .75rem;
126
132
  text-align: end;
127
133
  }
128
- .platform-brand a { color: inherit; opacity: .78; text-decoration: none; }
134
+ .platform-brand a {
135
+ align-items: center;
136
+ color: inherit;
137
+ display: inline-flex;
138
+ gap: .35rem;
139
+ opacity: .78;
140
+ text-decoration: none;
141
+ }
142
+ .platform-brand .platform-mark { flex: 0 0 auto; }
129
143
  .platform-brand a:hover { opacity: 1; text-decoration: underline; }
144
+ .tabs {
145
+ border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent);
146
+ display: flex;
147
+ gap: .25rem;
148
+ margin: .25rem 0 1rem;
149
+ }
150
+ .tabs button.tab {
151
+ background: transparent;
152
+ border-bottom: 2px solid transparent;
153
+ border-radius: 0;
154
+ color: inherit;
155
+ flex: 1 1 auto;
156
+ margin-bottom: -1px;
157
+ min-height: 2.75rem;
158
+ opacity: .75;
159
+ padding: .5rem .5rem;
160
+ }
161
+ .tabs button.tab[aria-selected="true"] {
162
+ border-bottom-color: var(--whooshbang-accent, ${RECIPIENT_BRAND_DEFAULT_ACCENT_COLOR});
163
+ font-weight: 600;
164
+ opacity: 1;
165
+ }
166
+ [role="tabpanel"][hidden] { display: none; }
130
167
  @media (max-width: 30rem) {
131
168
  li { align-items: flex-start; flex-direction: column; }
132
169
  }
133
- `;
170
+ ${ORDER_SECTION_STYLES}
171
+ ${QUIET_SECTION_STYLES}
172
+ ${DIGEST_SECTION_STYLES}`;
134
173
  function statusText(channel, copy) {
135
174
  if (channel.paused) {
136
175
  return copy.paused;
@@ -185,10 +224,51 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
185
224
  #sessionInput = null;
186
225
  #view;
187
226
  #visibilityListener = null;
227
+ /**
228
+ * The recipient's own escalation order, by tier (FXO-3672). Null until it
229
+ * loads, or when the service does not offer it, and then the section is
230
+ * simply absent.
231
+ */
232
+ #orders = null;
233
+ #orderLevel = "critical";
234
+ /** An unsaved edit; it belongs to one tier and survives refreshes. */
235
+ #orderDraft = null;
236
+ /** The section's own status line, kept for the element's life like `#live`. */
237
+ #orderStatus;
238
+ /** Where focus goes after an order action that removes its own control. */
239
+ #orderFocus = null;
240
+ /**
241
+ * The recipient's quiet hours (FXO-2103). Null until they load, or when the
242
+ * service does not offer them, and then the tab is simply absent.
243
+ */
244
+ #quiet = null;
245
+ /** An unsaved edit; it survives refreshes. */
246
+ #quietDraft = null;
247
+ /** What the last save attempt refused, by field. */
248
+ #quietErrors = {};
249
+ /** Switched on or off before anything is saved; otherwise what is saved. */
250
+ #quietOn = undefined;
251
+ /** The tab's own status line, kept for the element's life like `#live`. */
252
+ #quietStatus;
253
+ /** The zone times are shown in when the service knows none. */
254
+ #browserZone = browserTimeZone();
255
+ /**
256
+ * The recipient's digest settings (FXO-3729). Null until they load, or
257
+ * when the service does not offer them; the tab appears only when the
258
+ * owner lets them choose something.
259
+ */
260
+ #digest = null;
261
+ /** The digest tab's own status line, kept for the element's life. */
262
+ #digestStatus;
263
+ /** The panel tab on show; kept across refreshes and polls. */
264
+ #tab = "channels";
188
265
  constructor() {
189
266
  super();
190
267
  this.#root = this.attachShadow({ mode: "open" });
191
268
  this.#live = document.createElement("p");
269
+ this.#orderStatus = document.createElement("p");
270
+ this.#quietStatus = document.createElement("p");
271
+ this.#digestStatus = document.createElement("p");
192
272
  this.#view = recipientView({ session: null, state: "loading" });
193
273
  }
194
274
  get session() {
@@ -210,6 +290,14 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
210
290
  this.#categories = [];
211
291
  this.#armedUnsubscribe = null;
212
292
  this.#connecting = null;
293
+ this.#orders = null;
294
+ this.#orderDraft = null;
295
+ this.#orderStatus.textContent = "";
296
+ this.#quiet = null;
297
+ this.#quietDraft = null;
298
+ this.#quietOn = undefined;
299
+ this.#quietErrors = {};
300
+ this.#quietStatus.textContent = "";
213
301
  if (!value) {
214
302
  this.#releaseLogo();
215
303
  this.#client = null;
@@ -437,22 +525,42 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
437
525
  }
438
526
  const generation = this.#generation;
439
527
  try {
440
- const [session, categoryPreferences] = await Promise.all([
528
+ const [session, categoryPreferences, orders, quiet, digest] = await Promise.all([
441
529
  client.getSession({ timeoutMs: REQUEST_TIMEOUT_MS }),
442
530
  client
443
531
  .getCategoryPreferences({ timeoutMs: REQUEST_TIMEOUT_MS })
444
532
  .catch(() => null),
533
+ // A service without personal orders leaves the section out.
534
+ client
535
+ .getEscalationOrders({ timeoutMs: REQUEST_TIMEOUT_MS })
536
+ .catch(() => null),
537
+ // A service without quiet hours leaves the tab out.
538
+ client
539
+ .getQuietHours({ timeoutMs: REQUEST_TIMEOUT_MS })
540
+ .catch(() => null),
541
+ // A service without digest settings leaves the tab out.
542
+ client
543
+ .getDigestSettings({ timeoutMs: REQUEST_TIMEOUT_MS })
544
+ .catch(() => null),
445
545
  ]);
446
546
  if (generation !== this.#generation) {
447
547
  return;
448
548
  }
449
549
  this.#session = session;
550
+ // A failed read keeps what was shown; the next refresh tries again.
551
+ this.#orders = orders?.items ?? this.#orders;
552
+ this.#quiet = quiet ?? this.#quiet;
553
+ this.#digest = digest ?? this.#digest;
554
+ const refreshed = this.#currentOrder();
555
+ if (this.#orderDraft && refreshed?.level === this.#orderDraft.level)
556
+ this.#orderDraft = refreshDraft(this.#orderDraft, refreshed);
450
557
  this.#categories = (categoryPreferences?.items ?? []).map((category) => ({
451
558
  key: category.key,
452
559
  name: category.name,
453
560
  ...(category.description ? { description: category.description } : {}),
454
561
  enabled: category.can_turn_off ? category.enabled : true,
455
562
  canTurnOff: category.can_turn_off,
563
+ level: category.level,
456
564
  }));
457
565
  this.#render(recipientView({
458
566
  categories: this.#categories,
@@ -659,6 +767,13 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
659
767
  timeoutMs: REQUEST_TIMEOUT_MS,
660
768
  });
661
769
  this.#session = session;
770
+ // Whether the quiet-hours channel can be used follows its binding: a
771
+ // pause or resume changes the warning the Quiet hours tab shows.
772
+ const quiet = await client
773
+ .getQuietHours({ timeoutMs: REQUEST_TIMEOUT_MS })
774
+ .catch(() => null);
775
+ if (quiet !== null)
776
+ this.#quiet = quiet;
662
777
  this.#notice = null;
663
778
  this.#render(recipientView({
664
779
  categories: this.#categories,
@@ -722,6 +837,604 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
722
837
  this.#renderFailure(error);
723
838
  }
724
839
  }
840
+ #currentOrder() {
841
+ return this.#orders?.find((order) => order.level === this.#orderLevel);
842
+ }
843
+ #draftFor(order) {
844
+ return this.#orderDraft?.level === order.level
845
+ ? this.#orderDraft
846
+ : startDraft(order);
847
+ }
848
+ #pickOrderLevel(level) {
849
+ // A tier change drops an unsaved edit, exactly as the canvas does.
850
+ this.#orderLevel = level;
851
+ this.#orderDraft = null;
852
+ this.#orderStatus.textContent = "";
853
+ this.#render(this.#view);
854
+ }
855
+ /**
856
+ * Applies one edit. `focus` names the control that should hold focus
857
+ * afterwards, for edits that move or remove the one that was pressed.
858
+ */
859
+ #editOrder(change, options = {}) {
860
+ const order = this.#currentOrder();
861
+ if (!order || this.#pending)
862
+ return;
863
+ const before = draftChanged(this.#orderDraft, order);
864
+ this.#orderDraft = change(this.#draftFor(order));
865
+ this.#orderStatus.textContent = "";
866
+ if (options.focus !== undefined)
867
+ this.#orderFocus = options.focus;
868
+ if (options.render !== false ||
869
+ before !== draftChanged(this.#orderDraft, order)) {
870
+ this.#render(this.#view);
871
+ return;
872
+ }
873
+ // Typing a wait updates the preview in place, keeping the box it is in.
874
+ const preview = this.#root.querySelector(".order .preview-text");
875
+ if (preview)
876
+ preview.textContent = orderPreviewText({
877
+ channelName: (channel) => channelLabel(channel, this.#view.locale),
878
+ copy: this.#copy,
879
+ draft: this.#orderDraft,
880
+ order,
881
+ });
882
+ }
883
+ #replaceOrder(order) {
884
+ this.#orders = (this.#orders ?? []).map((current) => current.level === order.level ? order : current);
885
+ }
886
+ async #reloadOrders() {
887
+ const client = this.#client;
888
+ if (!client)
889
+ return;
890
+ const generation = this.#generation;
891
+ try {
892
+ const orders = await client.getEscalationOrders({
893
+ timeoutMs: REQUEST_TIMEOUT_MS,
894
+ });
895
+ if (generation === this.#generation)
896
+ this.#orders = orders.items;
897
+ }
898
+ catch {
899
+ // The section keeps what it showed; the status line says what failed.
900
+ }
901
+ }
902
+ async #orderFailure(error) {
903
+ const code = error instanceof WhooshBangProblemError ? error.problem.code : null;
904
+ if (code === "recipient_capability_invalid" ||
905
+ code === "resource_expired") {
906
+ this.#renderFailure(error);
907
+ return;
908
+ }
909
+ this.#emit("error", { ...(code ? { code } : {}), recoverable: true });
910
+ if (code === "escalation_order_conflict" ||
911
+ code === "escalation_order_step_ineligible") {
912
+ // The tier changed under the edit: show what is true now, and keep
913
+ // nothing that was made against the old version.
914
+ const generation = this.#generation;
915
+ await this.#reloadOrders();
916
+ if (generation !== this.#generation)
917
+ return;
918
+ this.#orderDraft = null;
919
+ this.#orderStatus.textContent =
920
+ code === "escalation_order_conflict"
921
+ ? this.#copy.orderConflict
922
+ : this.#copy.orderSaveFailed;
923
+ return;
924
+ }
925
+ // Anything else keeps the edit, so trying again costs nothing.
926
+ this.#orderStatus.textContent = this.#copy.orderSaveFailed;
927
+ }
928
+ async #saveOrder() {
929
+ const client = this.#client;
930
+ const order = this.#currentOrder();
931
+ const draft = this.#orderDraft;
932
+ if (!client || !order || !draft || draft.level !== order.level)
933
+ return;
934
+ if (this.#pending)
935
+ return;
936
+ await this.#writeOrder(() => client.saveEscalationOrder(order.level, saveRequest(draft, order), {
937
+ timeoutMs: REQUEST_TIMEOUT_MS,
938
+ }), "order:reset");
939
+ }
940
+ async #resetOrder() {
941
+ const client = this.#client;
942
+ const order = this.#currentOrder();
943
+ if (!client || !order || this.#pending)
944
+ return;
945
+ await this.#writeOrder(() => client.resetEscalationOrder(order.level, {
946
+ timeoutMs: REQUEST_TIMEOUT_MS,
947
+ }), `order:level:${order.level}`);
948
+ }
949
+ async #writeOrder(write, focusAfter) {
950
+ const generation = this.#generation;
951
+ this.#pending = true;
952
+ this.#orderFocus = focusAfter;
953
+ this.#render(this.#view);
954
+ try {
955
+ const written = await write();
956
+ if (generation !== this.#generation)
957
+ return;
958
+ this.#replaceOrder(written);
959
+ this.#orderDraft = null;
960
+ this.#orderStatus.textContent = this.#copy.orderSaved;
961
+ this.#emit("escalation-order-changed", {
962
+ level: written.level,
963
+ source: written.source,
964
+ });
965
+ }
966
+ catch (error) {
967
+ if (generation !== this.#generation)
968
+ return;
969
+ await this.#orderFailure(error);
970
+ }
971
+ finally {
972
+ // Always released: a session replaced mid-save must not leave every
973
+ // control in the new one refusing to act.
974
+ this.#pending = false;
975
+ if (generation === this.#generation) {
976
+ if (this.#view.state !== "expired") {
977
+ this.#orderFocus ??= focusAfter;
978
+ this.#render(this.#view);
979
+ }
980
+ }
981
+ }
982
+ }
983
+ #orderSection(view) {
984
+ const order = this.#currentOrder();
985
+ if (!order ||
986
+ (view.state !== "ready" && view.state !== "handoff") ||
987
+ view.channels.length === 0)
988
+ return null;
989
+ const draft = this.#draftFor(order);
990
+ const copy = this.#copy;
991
+ this.#orderStatus.className = "order-status";
992
+ this.#orderStatus.setAttribute("role", "status");
993
+ this.#orderStatus.setAttribute("aria-live", "polite");
994
+ return renderOrderSection({
995
+ canReconnect: (channel) => view.channels.some((candidate) => candidate.channel === channel &&
996
+ !candidate.connected &&
997
+ !candidate.paused) && this.#connecting === null,
998
+ changed: draftChanged(this.#orderDraft, order),
999
+ channelName: (channel) => channelLabel(channel, view.locale),
1000
+ copy,
1001
+ disabled: this.#pending,
1002
+ draft,
1003
+ locale: view.locale,
1004
+ on: {
1005
+ add: (candidate) => {
1006
+ this.#editOrder((current) => addStep(current, candidate), {
1007
+ focus: `order:remove:${candidate.bindingId}`,
1008
+ });
1009
+ },
1010
+ discard: () => {
1011
+ this.#orderDraft = null;
1012
+ this.#orderFocus = `order:level:${order.level}`;
1013
+ this.#render(this.#view);
1014
+ },
1015
+ move: (from, to) => {
1016
+ const moving = draft.steps[from];
1017
+ if (!moving || to < 0 || to >= draft.steps.length)
1018
+ return;
1019
+ const pressed = to < from ? "up" : "down";
1020
+ this.#editOrder((current) => moveStep(current, from, to), {
1021
+ focus: `order:${pressed}:${stepKey(moving, to)}`,
1022
+ });
1023
+ },
1024
+ pickLevel: (level) => {
1025
+ this.#pickOrderLevel(level);
1026
+ },
1027
+ reconnect: (channel) => {
1028
+ void this.#connect(channel);
1029
+ },
1030
+ remove: (index) => {
1031
+ const removed = draft.steps[index];
1032
+ const next = draft.steps[index + 1] ?? draft.steps[index - 1];
1033
+ this.#editOrder((current) => removeStep(current, index), {
1034
+ focus: removed?.bindingId !== undefined && !removed.unavailable
1035
+ ? `order:add:${removed.bindingId}`
1036
+ : next
1037
+ ? `order:remove:${stepKey(next, index < draft.steps.length - 1 ? index : index - 1)}`
1038
+ : `order:level:${order.level}`,
1039
+ });
1040
+ },
1041
+ reset: () => {
1042
+ void this.#resetOrder();
1043
+ },
1044
+ save: () => {
1045
+ void this.#saveOrder();
1046
+ },
1047
+ setWait: (index, typed, commit) => {
1048
+ this.#editOrder((current) => setWait(current, index, typed), {
1049
+ render: commit,
1050
+ });
1051
+ },
1052
+ },
1053
+ order,
1054
+ status: this.#orderStatus,
1055
+ usedFor: view.categories
1056
+ .filter((category) => category.level === order.level && category.enabled)
1057
+ .map((category) => category.name),
1058
+ });
1059
+ }
1060
+ #quietShown(quiet) {
1061
+ return this.#quietDraft ?? startQuietDraft(quiet, this.#browserZone);
1062
+ }
1063
+ /**
1064
+ * Applies one quiet-hours edit. Typing a time does not rebuild the tab, so
1065
+ * the box keeps its caret; only Save and Discard learn whether there is
1066
+ * now something to save.
1067
+ */
1068
+ #editQuiet(change, options = {}) {
1069
+ const quiet = this.#quiet;
1070
+ if (!quiet || this.#pending)
1071
+ return;
1072
+ this.#quietDraft = change(this.#quietShown(quiet));
1073
+ this.#quietStatus.textContent = "";
1074
+ if (options.focus !== undefined)
1075
+ this.#orderFocus = options.focus;
1076
+ if (options.render !== false) {
1077
+ this.#render(this.#view);
1078
+ return;
1079
+ }
1080
+ const save = this.#root.querySelector('[data-action="quiet:save"]');
1081
+ if (save)
1082
+ save.disabled = !quietChanged(this.#quietDraft, quiet, this.#browserZone);
1083
+ const discard = this.#root.querySelector('[data-action="quiet:discard"]');
1084
+ if (discard)
1085
+ discard.disabled = !quietEdited(this.#quietDraft, quiet, this.#browserZone);
1086
+ }
1087
+ /** Shows what a save refused, field by field, and moves focus to the first. */
1088
+ #refuseQuiet(errors, draft) {
1089
+ this.#quietErrors = errors;
1090
+ this.#quietStatus.textContent = this.#copy.quietFixFields;
1091
+ const [first] = Object.keys(errors);
1092
+ const control = first === undefined ? null : quietErrorControl(first, draft);
1093
+ if (control !== null)
1094
+ this.#orderFocus = control;
1095
+ }
1096
+ async #saveQuiet() {
1097
+ const client = this.#client;
1098
+ const quiet = this.#quiet;
1099
+ if (!client || !quiet || this.#pending)
1100
+ return;
1101
+ const draft = this.#quietShown(quiet);
1102
+ const local = quietDraftErrors(draft);
1103
+ if (Object.keys(local).length > 0) {
1104
+ this.#refuseQuiet(local, draft);
1105
+ this.#render(this.#view);
1106
+ return;
1107
+ }
1108
+ await this.#writeQuiet(() => client.saveQuietHours(quietSaveRequest(draft), {
1109
+ timeoutMs: REQUEST_TIMEOUT_MS,
1110
+ }), this.#copy.quietSaved, draft);
1111
+ }
1112
+ /**
1113
+ * Saves one digest choice (FXO-3729): `key` is a category, or `*` for the
1114
+ * Default; null clears it. A refused choice keeps what was saved, and the
1115
+ * status line says why.
1116
+ */
1117
+ async #saveDigest(key, preference) {
1118
+ const client = this.#client;
1119
+ if (!client || this.#pending)
1120
+ return;
1121
+ const generation = this.#generation;
1122
+ this.#pending = true;
1123
+ this.#render(this.#view);
1124
+ try {
1125
+ const written = key === "*"
1126
+ ? await client.setDefaultDigestPreference({ preference }, { timeoutMs: REQUEST_TIMEOUT_MS })
1127
+ : await client.setCategoryDigestPreference(key, { preference }, { timeoutMs: REQUEST_TIMEOUT_MS });
1128
+ if (generation !== this.#generation)
1129
+ return;
1130
+ this.#digest = written;
1131
+ this.#digestStatus.textContent = this.#copy.digestSaved;
1132
+ this.#emit("digest-preference-changed", { category: key });
1133
+ }
1134
+ catch (error) {
1135
+ if (generation !== this.#generation)
1136
+ return;
1137
+ const problem = error instanceof WhooshBangProblemError ? error.problem : null;
1138
+ if (problem?.code === "recipient_capability_invalid" ||
1139
+ problem?.code === "resource_expired") {
1140
+ this.#renderFailure(error);
1141
+ return;
1142
+ }
1143
+ this.#emit("error", {
1144
+ ...(problem ? { code: problem.code } : {}),
1145
+ recoverable: true,
1146
+ });
1147
+ this.#digestStatus.textContent =
1148
+ problem?.field_errors?.[0]?.detail ?? this.#copy.digestSaveFailed;
1149
+ }
1150
+ finally {
1151
+ this.#pending = false;
1152
+ if (generation === this.#generation && this.#view.state !== "expired")
1153
+ this.#render(this.#view);
1154
+ }
1155
+ }
1156
+ /** The digest part, when the owner offers something to choose. */
1157
+ #digestSection(view) {
1158
+ const onlyCategory = this.getAttribute("digest-category") ?? undefined;
1159
+ if ((view.state !== "ready" && view.state !== "handoff") ||
1160
+ !digestSectionOffered(this.#digest, onlyCategory) ||
1161
+ this.#digest === null)
1162
+ return null;
1163
+ return renderDigestSection({
1164
+ copy: this.#copy,
1165
+ disabled: this.#pending,
1166
+ locale: view.locale,
1167
+ onlyCategory,
1168
+ save: (key, preference) => {
1169
+ void this.#saveDigest(key, preference);
1170
+ },
1171
+ settings: this.#digest,
1172
+ status: this.#digestStatus,
1173
+ timeZone: this.#browserZone,
1174
+ });
1175
+ }
1176
+ async #turnOffQuiet() {
1177
+ const client = this.#client;
1178
+ if (!client || !this.#quiet || this.#pending)
1179
+ return;
1180
+ await this.#writeQuiet(() => client.resetQuietHours({ timeoutMs: REQUEST_TIMEOUT_MS }), this.#copy.quietTurnedOff, null);
1181
+ }
1182
+ async #writeQuiet(write, done, draft) {
1183
+ const generation = this.#generation;
1184
+ this.#pending = true;
1185
+ this.#render(this.#view);
1186
+ this.#orderFocus = null;
1187
+ try {
1188
+ const written = await write();
1189
+ if (generation !== this.#generation)
1190
+ return;
1191
+ this.#quiet = written;
1192
+ this.#quietDraft = null;
1193
+ this.#quietOn = undefined;
1194
+ this.#quietErrors = {};
1195
+ this.#quietStatus.textContent = done;
1196
+ // Save has nothing left to do; the next thing is the switch.
1197
+ this.#orderFocus = "quiet:enabled";
1198
+ this.#emit("quiet-hours-changed", { configured: written.configured });
1199
+ }
1200
+ catch (error) {
1201
+ if (generation !== this.#generation)
1202
+ return;
1203
+ const problem = error instanceof WhooshBangProblemError ? error.problem : null;
1204
+ if (problem?.code === "recipient_capability_invalid" ||
1205
+ problem?.code === "resource_expired") {
1206
+ this.#renderFailure(error);
1207
+ return;
1208
+ }
1209
+ this.#emit("error", {
1210
+ ...(problem ? { code: problem.code } : {}),
1211
+ recoverable: true,
1212
+ });
1213
+ const refused = problem?.code === "request_invalid" ? quietProblemErrors(problem) : {};
1214
+ const client = this.#client;
1215
+ // The project changed the catch-up since this panel loaded: take its
1216
+ // current setting, keep every other edit, and let them save again.
1217
+ if (draft !== null &&
1218
+ client &&
1219
+ Object.keys(refused).some((field) => field.startsWith("/catch_up"))) {
1220
+ const fresh = await client
1221
+ .getQuietHours({ timeoutMs: REQUEST_TIMEOUT_MS })
1222
+ .catch(() => null);
1223
+ if (generation !== this.#generation)
1224
+ return;
1225
+ if (fresh !== null) {
1226
+ this.#quiet = fresh;
1227
+ const next = withCurrentCatchUp(draft, fresh);
1228
+ this.#quietDraft = next;
1229
+ // Any other field the service refused still shows at once.
1230
+ const others = Object.fromEntries(Object.entries(refused).filter(([field]) => !field.startsWith("/catch_up")));
1231
+ if (Object.keys(others).length > 0)
1232
+ this.#refuseQuiet(others, next);
1233
+ else
1234
+ this.#quietStatus.textContent = this.#copy.quietSaveFailed;
1235
+ return;
1236
+ }
1237
+ }
1238
+ // The edit is kept either way, so trying again costs nothing.
1239
+ if (draft !== null && Object.keys(refused).length > 0)
1240
+ this.#refuseQuiet(refused, draft);
1241
+ else
1242
+ this.#quietStatus.textContent = this.#copy.quietSaveFailed;
1243
+ }
1244
+ finally {
1245
+ this.#pending = false;
1246
+ if (generation === this.#generation && this.#view.state !== "expired") {
1247
+ this.#orderFocus ??= "quiet:save";
1248
+ this.#render(this.#view);
1249
+ }
1250
+ }
1251
+ }
1252
+ #quietSection(view) {
1253
+ const quiet = this.#quiet;
1254
+ if (!quiet || (view.state !== "ready" && view.state !== "handoff"))
1255
+ return null;
1256
+ const draft = this.#quietShown(quiet);
1257
+ const offered = new Set(view.channels.map((channel) => channel.channel));
1258
+ const channels = QUIET_CHANNELS.filter((channel) => offered.has(channel));
1259
+ this.#quietStatus.className = "quiet-status";
1260
+ this.#quietStatus.setAttribute("role", "status");
1261
+ this.#quietStatus.setAttribute("aria-live", "polite");
1262
+ const clear = (field) => {
1263
+ if (this.#quietErrors[field] === undefined)
1264
+ return;
1265
+ this.#quietErrors = Object.fromEntries(Object.entries(this.#quietErrors).filter(([key]) => key !== field));
1266
+ };
1267
+ return renderQuietSection({
1268
+ changed: quietChanged(this.#quietDraft, quiet, this.#browserZone),
1269
+ channelName: (channel) => channelLabel(channel, view.locale),
1270
+ channels: channels.length > 0 ? channels : QUIET_CHANNELS,
1271
+ copy: this.#copy,
1272
+ disabled: this.#pending,
1273
+ draft,
1274
+ edited: quietEdited(this.#quietDraft, quiet, this.#browserZone),
1275
+ enabled: this.#quietOn ?? quiet.configured,
1276
+ errors: this.#quietErrors,
1277
+ locale: view.locale,
1278
+ on: {
1279
+ addWindow: () => {
1280
+ clear("/windows");
1281
+ this.#editQuiet(addQuietWindow, {
1282
+ focus: `quiet:remove:${String(draft.windows.length)}`,
1283
+ });
1284
+ },
1285
+ setEnabled: (on) => {
1286
+ this.#quietStatus.textContent = "";
1287
+ if (on) {
1288
+ this.#quietOn = true;
1289
+ this.#orderFocus = "quiet:save";
1290
+ this.#render(this.#view);
1291
+ return;
1292
+ }
1293
+ this.#quietDraft = null;
1294
+ this.#quietErrors = {};
1295
+ if (quiet.configured) {
1296
+ void this.#turnOffQuiet();
1297
+ return;
1298
+ }
1299
+ this.#quietOn = false;
1300
+ this.#orderFocus = "quiet:enabled";
1301
+ this.#render(this.#view);
1302
+ },
1303
+ discard: () => {
1304
+ this.#quietDraft = null;
1305
+ this.#quietErrors = {};
1306
+ this.#quietStatus.textContent = "";
1307
+ this.#orderFocus = "quiet:save";
1308
+ this.#render(this.#view);
1309
+ },
1310
+ removeWindow: (index) => {
1311
+ // Errors are kept by position, which a removal shifts.
1312
+ this.#quietErrors = {};
1313
+ const remaining = draft.windows.length - 1;
1314
+ this.#editQuiet((current) => removeQuietWindow(current, index), {
1315
+ focus: remaining === 0
1316
+ ? "quiet:add"
1317
+ : `quiet:remove:${String(Math.min(index, remaining - 1))}`,
1318
+ });
1319
+ },
1320
+ save: () => {
1321
+ void this.#saveQuiet();
1322
+ },
1323
+ setBehaviour: (channel, behaviour) => {
1324
+ clear(`/channels/${channel}`);
1325
+ this.#editQuiet((current) => ({
1326
+ ...current,
1327
+ channels: { ...current.channels, [channel]: behaviour },
1328
+ }));
1329
+ },
1330
+ setCritical: (on) => {
1331
+ this.#editQuiet((current) => ({
1332
+ ...current,
1333
+ criticalBreakthrough: on,
1334
+ }));
1335
+ },
1336
+ setCatchUp: (change) => {
1337
+ clear("/catch_up");
1338
+ this.#editQuiet((current) => current.catchUp === undefined
1339
+ ? current
1340
+ : { ...current, catchUp: { ...current.catchUp, ...change } });
1341
+ },
1342
+ setQuietChannel: (bindingId) => {
1343
+ clear("/quiet_channel_binding_id");
1344
+ this.#editQuiet((current) => ({
1345
+ ...current,
1346
+ quietChannelBindingId: bindingId,
1347
+ }));
1348
+ },
1349
+ setTime: (index, edge, value) => {
1350
+ this.#editQuiet((current) => setQuietWindow(current, index, { [edge]: value }), { render: false });
1351
+ },
1352
+ setTimeZone: (zone) => {
1353
+ clear("/time_zone");
1354
+ this.#editQuiet((current) => ({ ...current, timeZone: zone }));
1355
+ },
1356
+ setWhenWithheld: (value) => {
1357
+ clear("/quiet_channel_binding_id");
1358
+ this.#editQuiet((current) => ({ ...current, whenWithheld: value }));
1359
+ },
1360
+ toggleDay: (index, day) => {
1361
+ clear(`/windows/${String(index)}/days`);
1362
+ this.#editQuiet((current) => toggleQuietDay(current, index, day));
1363
+ },
1364
+ },
1365
+ quiet,
1366
+ status: this.#quietStatus,
1367
+ });
1368
+ }
1369
+ /**
1370
+ * The panel's parts as tabs: channels, the escalation order, quiet hours
1371
+ * and the kinds of message. Every part is in the tree; only the chosen one is shown, so a
1372
+ * refresh or poll never moves the recipient off the tab they are on.
1373
+ */
1374
+ #appendParts(surface, parts, copy) {
1375
+ const [only] = parts;
1376
+ if (parts.length === 1 && only) {
1377
+ surface.append(only.content);
1378
+ return;
1379
+ }
1380
+ const selected = parts.some((part) => part.key === this.#tab)
1381
+ ? this.#tab
1382
+ : "channels";
1383
+ const tablist = document.createElement("div");
1384
+ tablist.className = "tabs";
1385
+ tablist.setAttribute("role", "tablist");
1386
+ tablist.setAttribute("aria-label", copy.heading);
1387
+ const choose = (key, focus) => {
1388
+ this.#tab = key;
1389
+ if (focus)
1390
+ this.#orderFocus = `tab:${key}`;
1391
+ this.#render(this.#view);
1392
+ };
1393
+ parts.forEach((part, index) => {
1394
+ const chosen = part.key === selected;
1395
+ const tab = document.createElement("button");
1396
+ tab.type = "button";
1397
+ tab.className = "tab";
1398
+ tab.id = `tab-${part.key}`;
1399
+ tab.dataset["action"] = `tab:${part.key}`;
1400
+ tab.setAttribute("role", "tab");
1401
+ tab.setAttribute("aria-controls", `panel-${part.key}`);
1402
+ tab.setAttribute("aria-selected", String(chosen));
1403
+ tab.tabIndex = chosen ? 0 : -1;
1404
+ tab.textContent = part.label;
1405
+ tab.addEventListener("click", () => {
1406
+ choose(part.key, false);
1407
+ });
1408
+ // Arrow keys move between tabs, Home and End to the ends.
1409
+ tab.addEventListener("keydown", (event) => {
1410
+ const last = parts.length - 1;
1411
+ const target = event.key === "ArrowRight"
1412
+ ? index === last
1413
+ ? 0
1414
+ : index + 1
1415
+ : event.key === "ArrowLeft"
1416
+ ? index === 0
1417
+ ? last
1418
+ : index - 1
1419
+ : event.key === "Home"
1420
+ ? 0
1421
+ : event.key === "End"
1422
+ ? last
1423
+ : null;
1424
+ const next = target === null ? undefined : parts[target];
1425
+ if (!next)
1426
+ return;
1427
+ event.preventDefault();
1428
+ choose(next.key, true);
1429
+ });
1430
+ tablist.append(tab);
1431
+ part.content.id = `panel-${part.key}`;
1432
+ part.content.setAttribute("role", "tabpanel");
1433
+ part.content.setAttribute("aria-labelledby", `tab-${part.key}`);
1434
+ part.content.hidden = !chosen;
1435
+ });
1436
+ surface.append(tablist, ...parts.map((part) => part.content));
1437
+ }
725
1438
  /**
726
1439
  * Rebuilds the shadow tree.
727
1440
  *
@@ -889,16 +1602,43 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
889
1602
  item.append(actions);
890
1603
  list.append(item);
891
1604
  }
892
- surface.append(list);
1605
+ const channelsPanel = document.createElement("div");
1606
+ channelsPanel.append(list);
1607
+ const parts = [
1608
+ { content: channelsPanel, key: "channels", label: copy.tabChannels },
1609
+ ];
1610
+ const orderSection = this.#orderSection(view);
1611
+ if (orderSection)
1612
+ parts.push({
1613
+ content: orderSection,
1614
+ key: "order",
1615
+ label: copy.orderHeading,
1616
+ });
1617
+ const quietSection = this.#quietSection(view);
1618
+ if (quietSection)
1619
+ parts.push({
1620
+ content: quietSection,
1621
+ key: "quiet",
1622
+ label: copy.quietHeading,
1623
+ });
1624
+ const digestSection = this.#digestSection(view);
1625
+ if (digestSection)
1626
+ parts.push({
1627
+ content: digestSection,
1628
+ key: "digests",
1629
+ label: copy.digestsHeading,
1630
+ });
893
1631
  if (view.categories.length > 0) {
894
- const categoryHeading = document.createElement("h3");
895
- categoryHeading.className = "categories-heading";
896
- categoryHeading.textContent = copy.categoriesHeading;
897
- surface.append(categoryHeading);
1632
+ const categoriesPanel = document.createElement("div");
1633
+ parts.push({
1634
+ content: categoriesPanel,
1635
+ key: "categories",
1636
+ label: copy.categoriesHeading,
1637
+ });
898
1638
  const categoryBody = document.createElement("p");
899
1639
  categoryBody.className = "status";
900
1640
  categoryBody.textContent = copy.categoriesBody;
901
- surface.append(categoryBody);
1641
+ categoriesPanel.append(categoryBody);
902
1642
  const categories = document.createElement("ul");
903
1643
  for (const category of view.categories) {
904
1644
  const item = document.createElement("li");
@@ -941,8 +1681,16 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
941
1681
  item.append(control);
942
1682
  categories.append(item);
943
1683
  }
944
- surface.append(categories);
1684
+ categoriesPanel.append(categories);
945
1685
  }
1686
+ // An embedded control bound to one category shows only its digest.
1687
+ // Until that category's digest is offered (or loaded), it shows nothing
1688
+ // rather than the whole panel in the host's slot.
1689
+ const bound = this.hasAttribute("digest-category")
1690
+ ? parts.filter((part) => part.key === "digests")
1691
+ : parts;
1692
+ if (bound.length > 0)
1693
+ this.#appendParts(surface, bound, copy);
946
1694
  // Customer identity owns the panel heading; this quiet attribution names
947
1695
  // the product operating the component without replacing or competing with
948
1696
  // the customer's configured name and logo.
@@ -955,12 +1703,34 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
955
1703
  platformLink.href = "https://whooshbang.com";
956
1704
  platformLink.rel = "noreferrer";
957
1705
  platformLink.target = "_blank";
958
- platformLink.textContent = "Powered by WhooshBang";
1706
+ platformLink.append(whooshbangMark(), "Powered by WhooshBang");
959
1707
  platformBrand.append(platformLink);
960
1708
  surface.append(platformBrand);
961
1709
  root.append(surface);
962
- if (focusedControl) {
963
- const restored = root.querySelector(`[data-action="${focusedControl}"], [data-focus-key="${focusedControl}"]`);
1710
+ const wanted = this.#orderFocus ?? focusedControl;
1711
+ this.#orderFocus = null;
1712
+ if (wanted) {
1713
+ const find = (key) => root.querySelector(`[data-action="${key}"], [data-focus-key="${key}"]`);
1714
+ let restored = find(wanted);
1715
+ // An arrow that just reached the end of the list is disabled; its
1716
+ // partner is the natural next press. Any other vanished order control
1717
+ // hands focus to the tier the recipient is editing.
1718
+ if (wanted.startsWith("order:") && (!restored || restored.disabled)) {
1719
+ const partner = wanted.startsWith("order:up:")
1720
+ ? wanted.replace("order:up:", "order:down:")
1721
+ : wanted.startsWith("order:down:")
1722
+ ? wanted.replace("order:down:", "order:up:")
1723
+ : null;
1724
+ const alternate = partner ? find(partner) : null;
1725
+ restored =
1726
+ alternate && !alternate.disabled
1727
+ ? alternate
1728
+ : find(`order:level:${this.#orderLevel}`);
1729
+ }
1730
+ // A quiet-hours control that went away, or cannot take focus, hands
1731
+ // focus to the tab.
1732
+ if (wanted.startsWith("quiet:") && (!restored || restored.disabled))
1733
+ restored = find("tab:quiet");
964
1734
  restored?.focus();
965
1735
  }
966
1736
  else if (fallbackUrl) {