@whooshbang/sdk 5.0.0 → 6.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.
- package/README.md +232 -83
- package/dist/cli/commands.js +2 -0
- package/dist/cli/commands.js.map +1 -1
- package/dist/client.d.ts +209 -1
- package/dist/client.js +540 -5
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/interactions.js +18 -3
- package/dist/interactions.js.map +1 -1
- package/dist/recipient/element.js +641 -14
- package/dist/recipient/element.js.map +1 -1
- package/dist/recipient/i18n/ar.js +77 -0
- package/dist/recipient/i18n/ar.js.map +1 -1
- package/dist/recipient/i18n/catalog.d.ts +75 -0
- package/dist/recipient/i18n/catalog.js.map +1 -1
- package/dist/recipient/i18n/de.js +82 -0
- package/dist/recipient/i18n/de.js.map +1 -1
- package/dist/recipient/i18n/en.js +82 -0
- package/dist/recipient/i18n/en.js.map +1 -1
- package/dist/recipient/i18n/es.js +82 -0
- package/dist/recipient/i18n/es.js.map +1 -1
- package/dist/recipient/i18n/fa.js +77 -0
- package/dist/recipient/i18n/fa.js.map +1 -1
- package/dist/recipient/i18n/fr.js +82 -0
- package/dist/recipient/i18n/fr.js.map +1 -1
- package/dist/recipient/i18n/hi.js +82 -0
- package/dist/recipient/i18n/hi.js.map +1 -1
- package/dist/recipient/i18n/id.js +82 -0
- package/dist/recipient/i18n/id.js.map +1 -1
- package/dist/recipient/i18n/it.js +82 -0
- package/dist/recipient/i18n/it.js.map +1 -1
- package/dist/recipient/i18n/nl.js +82 -0
- package/dist/recipient/i18n/nl.js.map +1 -1
- package/dist/recipient/i18n/pl.js +82 -0
- package/dist/recipient/i18n/pl.js.map +1 -1
- package/dist/recipient/i18n/pt-BR.js +82 -0
- package/dist/recipient/i18n/pt-BR.js.map +1 -1
- package/dist/recipient/i18n/ru.js +82 -0
- package/dist/recipient/i18n/ru.js.map +1 -1
- package/dist/recipient/i18n/tr.js +82 -0
- package/dist/recipient/i18n/tr.js.map +1 -1
- package/dist/recipient/i18n/uk.js +82 -0
- package/dist/recipient/i18n/uk.js.map +1 -1
- package/dist/recipient/i18n/zh-Hans.js +77 -0
- package/dist/recipient/i18n/zh-Hans.js.map +1 -1
- package/dist/recipient/order-section.d.ts +41 -0
- package/dist/recipient/order-section.js +547 -0
- package/dist/recipient/order-section.js.map +1 -0
- package/dist/recipient/order-view.d.ts +123 -0
- package/dist/recipient/order-view.js +304 -0
- package/dist/recipient/order-view.js.map +1 -0
- package/dist/recipient/platform-mark.d.ts +1 -0
- package/dist/recipient/platform-mark.js +57 -0
- package/dist/recipient/platform-mark.js.map +1 -0
- package/dist/recipient/quiet-section.d.ts +47 -0
- package/dist/recipient/quiet-section.js +490 -0
- package/dist/recipient/quiet-section.js.map +1 -0
- package/dist/recipient/quiet-view.d.ts +84 -0
- package/dist/recipient/quiet-view.js +250 -0
- package/dist/recipient/quiet-view.js.map +1 -0
- package/dist/recipient/view.d.ts +2 -0
- package/dist/recipient/view.js.map +1 -1
- package/dist/webhooks.d.ts +30 -1
- package/dist/webhooks.js +57 -4
- package/dist/webhooks.js.map +1 -1
- package/package.json +12 -2
|
@@ -3,7 +3,12 @@ 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 {
|
|
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 { addQuietWindow, browserTimeZone, QUIET_CHANNELS, quietChanged, quietDraftErrors, quietEdited, quietErrorControl, quietProblemErrors, quietSaveRequest, removeQuietWindow, setQuietWindow, startQuietDraft, toggleQuietDay, } from "./quiet-view.js";
|
|
11
|
+
import { channelLabel, recipientView, } from "./view.js";
|
|
7
12
|
export const RECIPIENT_SETTINGS_ELEMENT = "whooshbang-recipient-settings";
|
|
8
13
|
/**
|
|
9
14
|
* How long the component keeps asking after a handoff opens.
|
|
@@ -125,12 +130,44 @@ a.fallback { color: inherit; }
|
|
|
125
130
|
padding-top: .75rem;
|
|
126
131
|
text-align: end;
|
|
127
132
|
}
|
|
128
|
-
.platform-brand a {
|
|
133
|
+
.platform-brand a {
|
|
134
|
+
align-items: center;
|
|
135
|
+
color: inherit;
|
|
136
|
+
display: inline-flex;
|
|
137
|
+
gap: .35rem;
|
|
138
|
+
opacity: .78;
|
|
139
|
+
text-decoration: none;
|
|
140
|
+
}
|
|
141
|
+
.platform-brand .platform-mark { flex: 0 0 auto; }
|
|
129
142
|
.platform-brand a:hover { opacity: 1; text-decoration: underline; }
|
|
143
|
+
.tabs {
|
|
144
|
+
border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
|
145
|
+
display: flex;
|
|
146
|
+
gap: .25rem;
|
|
147
|
+
margin: .25rem 0 1rem;
|
|
148
|
+
}
|
|
149
|
+
.tabs button.tab {
|
|
150
|
+
background: transparent;
|
|
151
|
+
border-bottom: 2px solid transparent;
|
|
152
|
+
border-radius: 0;
|
|
153
|
+
color: inherit;
|
|
154
|
+
flex: 1 1 auto;
|
|
155
|
+
margin-bottom: -1px;
|
|
156
|
+
min-height: 2.75rem;
|
|
157
|
+
opacity: .75;
|
|
158
|
+
padding: .5rem .5rem;
|
|
159
|
+
}
|
|
160
|
+
.tabs button.tab[aria-selected="true"] {
|
|
161
|
+
border-bottom-color: var(--whooshbang-accent, ${RECIPIENT_BRAND_DEFAULT_ACCENT_COLOR});
|
|
162
|
+
font-weight: 600;
|
|
163
|
+
opacity: 1;
|
|
164
|
+
}
|
|
165
|
+
[role="tabpanel"][hidden] { display: none; }
|
|
130
166
|
@media (max-width: 30rem) {
|
|
131
167
|
li { align-items: flex-start; flex-direction: column; }
|
|
132
168
|
}
|
|
133
|
-
|
|
169
|
+
${ORDER_SECTION_STYLES}
|
|
170
|
+
${QUIET_SECTION_STYLES}`;
|
|
134
171
|
function statusText(channel, copy) {
|
|
135
172
|
if (channel.paused) {
|
|
136
173
|
return copy.paused;
|
|
@@ -185,10 +222,40 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
|
|
|
185
222
|
#sessionInput = null;
|
|
186
223
|
#view;
|
|
187
224
|
#visibilityListener = null;
|
|
225
|
+
/**
|
|
226
|
+
* The recipient's own escalation order, by tier (FXO-3672). Null until it
|
|
227
|
+
* loads, or when the service does not offer it, and then the section is
|
|
228
|
+
* simply absent.
|
|
229
|
+
*/
|
|
230
|
+
#orders = null;
|
|
231
|
+
#orderLevel = "critical";
|
|
232
|
+
/** An unsaved edit; it belongs to one tier and survives refreshes. */
|
|
233
|
+
#orderDraft = null;
|
|
234
|
+
/** The section's own status line, kept for the element's life like `#live`. */
|
|
235
|
+
#orderStatus;
|
|
236
|
+
/** Where focus goes after an order action that removes its own control. */
|
|
237
|
+
#orderFocus = null;
|
|
238
|
+
/**
|
|
239
|
+
* The recipient's quiet hours (FXO-2103). Null until they load, or when the
|
|
240
|
+
* service does not offer them, and then the tab is simply absent.
|
|
241
|
+
*/
|
|
242
|
+
#quiet = null;
|
|
243
|
+
/** An unsaved edit; it survives refreshes. */
|
|
244
|
+
#quietDraft = null;
|
|
245
|
+
/** What the last save attempt refused, by field. */
|
|
246
|
+
#quietErrors = {};
|
|
247
|
+
/** The tab's own status line, kept for the element's life like `#live`. */
|
|
248
|
+
#quietStatus;
|
|
249
|
+
/** The zone times are shown in when the service knows none. */
|
|
250
|
+
#browserZone = browserTimeZone();
|
|
251
|
+
/** The panel tab on show; kept across refreshes and polls. */
|
|
252
|
+
#tab = "channels";
|
|
188
253
|
constructor() {
|
|
189
254
|
super();
|
|
190
255
|
this.#root = this.attachShadow({ mode: "open" });
|
|
191
256
|
this.#live = document.createElement("p");
|
|
257
|
+
this.#orderStatus = document.createElement("p");
|
|
258
|
+
this.#quietStatus = document.createElement("p");
|
|
192
259
|
this.#view = recipientView({ session: null, state: "loading" });
|
|
193
260
|
}
|
|
194
261
|
get session() {
|
|
@@ -210,6 +277,13 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
|
|
|
210
277
|
this.#categories = [];
|
|
211
278
|
this.#armedUnsubscribe = null;
|
|
212
279
|
this.#connecting = null;
|
|
280
|
+
this.#orders = null;
|
|
281
|
+
this.#orderDraft = null;
|
|
282
|
+
this.#orderStatus.textContent = "";
|
|
283
|
+
this.#quiet = null;
|
|
284
|
+
this.#quietDraft = null;
|
|
285
|
+
this.#quietErrors = {};
|
|
286
|
+
this.#quietStatus.textContent = "";
|
|
213
287
|
if (!value) {
|
|
214
288
|
this.#releaseLogo();
|
|
215
289
|
this.#client = null;
|
|
@@ -437,22 +511,37 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
|
|
|
437
511
|
}
|
|
438
512
|
const generation = this.#generation;
|
|
439
513
|
try {
|
|
440
|
-
const [session, categoryPreferences] = await Promise.all([
|
|
514
|
+
const [session, categoryPreferences, orders, quiet] = await Promise.all([
|
|
441
515
|
client.getSession({ timeoutMs: REQUEST_TIMEOUT_MS }),
|
|
442
516
|
client
|
|
443
517
|
.getCategoryPreferences({ timeoutMs: REQUEST_TIMEOUT_MS })
|
|
444
518
|
.catch(() => null),
|
|
519
|
+
// A service without personal orders leaves the section out.
|
|
520
|
+
client
|
|
521
|
+
.getEscalationOrders({ timeoutMs: REQUEST_TIMEOUT_MS })
|
|
522
|
+
.catch(() => null),
|
|
523
|
+
// A service without quiet hours leaves the tab out.
|
|
524
|
+
client
|
|
525
|
+
.getQuietHours({ timeoutMs: REQUEST_TIMEOUT_MS })
|
|
526
|
+
.catch(() => null),
|
|
445
527
|
]);
|
|
446
528
|
if (generation !== this.#generation) {
|
|
447
529
|
return;
|
|
448
530
|
}
|
|
449
531
|
this.#session = session;
|
|
532
|
+
// A failed read keeps what was shown; the next refresh tries again.
|
|
533
|
+
this.#orders = orders?.items ?? this.#orders;
|
|
534
|
+
this.#quiet = quiet ?? this.#quiet;
|
|
535
|
+
const refreshed = this.#currentOrder();
|
|
536
|
+
if (this.#orderDraft && refreshed?.level === this.#orderDraft.level)
|
|
537
|
+
this.#orderDraft = refreshDraft(this.#orderDraft, refreshed);
|
|
450
538
|
this.#categories = (categoryPreferences?.items ?? []).map((category) => ({
|
|
451
539
|
key: category.key,
|
|
452
540
|
name: category.name,
|
|
453
541
|
...(category.description ? { description: category.description } : {}),
|
|
454
542
|
enabled: category.can_turn_off ? category.enabled : true,
|
|
455
543
|
canTurnOff: category.can_turn_off,
|
|
544
|
+
level: category.level,
|
|
456
545
|
}));
|
|
457
546
|
this.#render(recipientView({
|
|
458
547
|
categories: this.#categories,
|
|
@@ -659,6 +748,13 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
|
|
|
659
748
|
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
660
749
|
});
|
|
661
750
|
this.#session = session;
|
|
751
|
+
// Whether the quiet-hours channel can be used follows its binding: a
|
|
752
|
+
// pause or resume changes the warning the Quiet hours tab shows.
|
|
753
|
+
const quiet = await client
|
|
754
|
+
.getQuietHours({ timeoutMs: REQUEST_TIMEOUT_MS })
|
|
755
|
+
.catch(() => null);
|
|
756
|
+
if (quiet !== null)
|
|
757
|
+
this.#quiet = quiet;
|
|
662
758
|
this.#notice = null;
|
|
663
759
|
this.#render(recipientView({
|
|
664
760
|
categories: this.#categories,
|
|
@@ -722,6 +818,494 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
|
|
|
722
818
|
this.#renderFailure(error);
|
|
723
819
|
}
|
|
724
820
|
}
|
|
821
|
+
#currentOrder() {
|
|
822
|
+
return this.#orders?.find((order) => order.level === this.#orderLevel);
|
|
823
|
+
}
|
|
824
|
+
#draftFor(order) {
|
|
825
|
+
return this.#orderDraft?.level === order.level
|
|
826
|
+
? this.#orderDraft
|
|
827
|
+
: startDraft(order);
|
|
828
|
+
}
|
|
829
|
+
#pickOrderLevel(level) {
|
|
830
|
+
// A tier change drops an unsaved edit, exactly as the canvas does.
|
|
831
|
+
this.#orderLevel = level;
|
|
832
|
+
this.#orderDraft = null;
|
|
833
|
+
this.#orderStatus.textContent = "";
|
|
834
|
+
this.#render(this.#view);
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Applies one edit. `focus` names the control that should hold focus
|
|
838
|
+
* afterwards, for edits that move or remove the one that was pressed.
|
|
839
|
+
*/
|
|
840
|
+
#editOrder(change, options = {}) {
|
|
841
|
+
const order = this.#currentOrder();
|
|
842
|
+
if (!order || this.#pending)
|
|
843
|
+
return;
|
|
844
|
+
const before = draftChanged(this.#orderDraft, order);
|
|
845
|
+
this.#orderDraft = change(this.#draftFor(order));
|
|
846
|
+
this.#orderStatus.textContent = "";
|
|
847
|
+
if (options.focus !== undefined)
|
|
848
|
+
this.#orderFocus = options.focus;
|
|
849
|
+
if (options.render !== false ||
|
|
850
|
+
before !== draftChanged(this.#orderDraft, order)) {
|
|
851
|
+
this.#render(this.#view);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
// Typing a wait updates the preview in place, keeping the box it is in.
|
|
855
|
+
const preview = this.#root.querySelector(".order .preview-text");
|
|
856
|
+
if (preview)
|
|
857
|
+
preview.textContent = orderPreviewText({
|
|
858
|
+
channelName: (channel) => channelLabel(channel, this.#view.locale),
|
|
859
|
+
copy: this.#copy,
|
|
860
|
+
draft: this.#orderDraft,
|
|
861
|
+
order,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
#replaceOrder(order) {
|
|
865
|
+
this.#orders = (this.#orders ?? []).map((current) => current.level === order.level ? order : current);
|
|
866
|
+
}
|
|
867
|
+
async #reloadOrders() {
|
|
868
|
+
const client = this.#client;
|
|
869
|
+
if (!client)
|
|
870
|
+
return;
|
|
871
|
+
const generation = this.#generation;
|
|
872
|
+
try {
|
|
873
|
+
const orders = await client.getEscalationOrders({
|
|
874
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
875
|
+
});
|
|
876
|
+
if (generation === this.#generation)
|
|
877
|
+
this.#orders = orders.items;
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
// The section keeps what it showed; the status line says what failed.
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
async #orderFailure(error) {
|
|
884
|
+
const code = error instanceof WhooshBangProblemError ? error.problem.code : null;
|
|
885
|
+
if (code === "recipient_capability_invalid" ||
|
|
886
|
+
code === "resource_expired") {
|
|
887
|
+
this.#renderFailure(error);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
this.#emit("error", { ...(code ? { code } : {}), recoverable: true });
|
|
891
|
+
if (code === "escalation_order_conflict" ||
|
|
892
|
+
code === "escalation_order_step_ineligible") {
|
|
893
|
+
// The tier changed under the edit: show what is true now, and keep
|
|
894
|
+
// nothing that was made against the old version.
|
|
895
|
+
const generation = this.#generation;
|
|
896
|
+
await this.#reloadOrders();
|
|
897
|
+
if (generation !== this.#generation)
|
|
898
|
+
return;
|
|
899
|
+
this.#orderDraft = null;
|
|
900
|
+
this.#orderStatus.textContent =
|
|
901
|
+
code === "escalation_order_conflict"
|
|
902
|
+
? this.#copy.orderConflict
|
|
903
|
+
: this.#copy.orderSaveFailed;
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
// Anything else keeps the edit, so trying again costs nothing.
|
|
907
|
+
this.#orderStatus.textContent = this.#copy.orderSaveFailed;
|
|
908
|
+
}
|
|
909
|
+
async #saveOrder() {
|
|
910
|
+
const client = this.#client;
|
|
911
|
+
const order = this.#currentOrder();
|
|
912
|
+
const draft = this.#orderDraft;
|
|
913
|
+
if (!client || !order || !draft || draft.level !== order.level)
|
|
914
|
+
return;
|
|
915
|
+
if (this.#pending)
|
|
916
|
+
return;
|
|
917
|
+
await this.#writeOrder(() => client.saveEscalationOrder(order.level, saveRequest(draft, order), {
|
|
918
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
919
|
+
}), "order:reset");
|
|
920
|
+
}
|
|
921
|
+
async #resetOrder() {
|
|
922
|
+
const client = this.#client;
|
|
923
|
+
const order = this.#currentOrder();
|
|
924
|
+
if (!client || !order || this.#pending)
|
|
925
|
+
return;
|
|
926
|
+
await this.#writeOrder(() => client.resetEscalationOrder(order.level, {
|
|
927
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
928
|
+
}), `order:level:${order.level}`);
|
|
929
|
+
}
|
|
930
|
+
async #writeOrder(write, focusAfter) {
|
|
931
|
+
const generation = this.#generation;
|
|
932
|
+
this.#pending = true;
|
|
933
|
+
this.#orderFocus = focusAfter;
|
|
934
|
+
this.#render(this.#view);
|
|
935
|
+
try {
|
|
936
|
+
const written = await write();
|
|
937
|
+
if (generation !== this.#generation)
|
|
938
|
+
return;
|
|
939
|
+
this.#replaceOrder(written);
|
|
940
|
+
this.#orderDraft = null;
|
|
941
|
+
this.#orderStatus.textContent = this.#copy.orderSaved;
|
|
942
|
+
this.#emit("escalation-order-changed", {
|
|
943
|
+
level: written.level,
|
|
944
|
+
source: written.source,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
catch (error) {
|
|
948
|
+
if (generation !== this.#generation)
|
|
949
|
+
return;
|
|
950
|
+
await this.#orderFailure(error);
|
|
951
|
+
}
|
|
952
|
+
finally {
|
|
953
|
+
// Always released: a session replaced mid-save must not leave every
|
|
954
|
+
// control in the new one refusing to act.
|
|
955
|
+
this.#pending = false;
|
|
956
|
+
if (generation === this.#generation) {
|
|
957
|
+
if (this.#view.state !== "expired") {
|
|
958
|
+
this.#orderFocus ??= focusAfter;
|
|
959
|
+
this.#render(this.#view);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
#orderSection(view) {
|
|
965
|
+
const order = this.#currentOrder();
|
|
966
|
+
if (!order ||
|
|
967
|
+
(view.state !== "ready" && view.state !== "handoff") ||
|
|
968
|
+
view.channels.length === 0)
|
|
969
|
+
return null;
|
|
970
|
+
const draft = this.#draftFor(order);
|
|
971
|
+
const copy = this.#copy;
|
|
972
|
+
this.#orderStatus.className = "order-status";
|
|
973
|
+
this.#orderStatus.setAttribute("role", "status");
|
|
974
|
+
this.#orderStatus.setAttribute("aria-live", "polite");
|
|
975
|
+
return renderOrderSection({
|
|
976
|
+
canReconnect: (channel) => view.channels.some((candidate) => candidate.channel === channel &&
|
|
977
|
+
!candidate.connected &&
|
|
978
|
+
!candidate.paused) && this.#connecting === null,
|
|
979
|
+
changed: draftChanged(this.#orderDraft, order),
|
|
980
|
+
channelName: (channel) => channelLabel(channel, view.locale),
|
|
981
|
+
copy,
|
|
982
|
+
disabled: this.#pending,
|
|
983
|
+
draft,
|
|
984
|
+
locale: view.locale,
|
|
985
|
+
on: {
|
|
986
|
+
add: (candidate) => {
|
|
987
|
+
this.#editOrder((current) => addStep(current, candidate), {
|
|
988
|
+
focus: `order:remove:${candidate.bindingId}`,
|
|
989
|
+
});
|
|
990
|
+
},
|
|
991
|
+
discard: () => {
|
|
992
|
+
this.#orderDraft = null;
|
|
993
|
+
this.#orderFocus = `order:level:${order.level}`;
|
|
994
|
+
this.#render(this.#view);
|
|
995
|
+
},
|
|
996
|
+
move: (from, to) => {
|
|
997
|
+
const moving = draft.steps[from];
|
|
998
|
+
if (!moving || to < 0 || to >= draft.steps.length)
|
|
999
|
+
return;
|
|
1000
|
+
const pressed = to < from ? "up" : "down";
|
|
1001
|
+
this.#editOrder((current) => moveStep(current, from, to), {
|
|
1002
|
+
focus: `order:${pressed}:${stepKey(moving, to)}`,
|
|
1003
|
+
});
|
|
1004
|
+
},
|
|
1005
|
+
pickLevel: (level) => {
|
|
1006
|
+
this.#pickOrderLevel(level);
|
|
1007
|
+
},
|
|
1008
|
+
reconnect: (channel) => {
|
|
1009
|
+
void this.#connect(channel);
|
|
1010
|
+
},
|
|
1011
|
+
remove: (index) => {
|
|
1012
|
+
const removed = draft.steps[index];
|
|
1013
|
+
const next = draft.steps[index + 1] ?? draft.steps[index - 1];
|
|
1014
|
+
this.#editOrder((current) => removeStep(current, index), {
|
|
1015
|
+
focus: removed?.bindingId !== undefined && !removed.unavailable
|
|
1016
|
+
? `order:add:${removed.bindingId}`
|
|
1017
|
+
: next
|
|
1018
|
+
? `order:remove:${stepKey(next, index < draft.steps.length - 1 ? index : index - 1)}`
|
|
1019
|
+
: `order:level:${order.level}`,
|
|
1020
|
+
});
|
|
1021
|
+
},
|
|
1022
|
+
reset: () => {
|
|
1023
|
+
void this.#resetOrder();
|
|
1024
|
+
},
|
|
1025
|
+
save: () => {
|
|
1026
|
+
void this.#saveOrder();
|
|
1027
|
+
},
|
|
1028
|
+
setWait: (index, typed, commit) => {
|
|
1029
|
+
this.#editOrder((current) => setWait(current, index, typed), {
|
|
1030
|
+
render: commit,
|
|
1031
|
+
});
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
order,
|
|
1035
|
+
status: this.#orderStatus,
|
|
1036
|
+
usedFor: view.categories
|
|
1037
|
+
.filter((category) => category.level === order.level && category.enabled)
|
|
1038
|
+
.map((category) => category.name),
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
#quietShown(quiet) {
|
|
1042
|
+
return this.#quietDraft ?? startQuietDraft(quiet, this.#browserZone);
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Applies one quiet-hours edit. Typing a time does not rebuild the tab, so
|
|
1046
|
+
* the box keeps its caret; only Save and Discard learn whether there is
|
|
1047
|
+
* now something to save.
|
|
1048
|
+
*/
|
|
1049
|
+
#editQuiet(change, options = {}) {
|
|
1050
|
+
const quiet = this.#quiet;
|
|
1051
|
+
if (!quiet || this.#pending)
|
|
1052
|
+
return;
|
|
1053
|
+
this.#quietDraft = change(this.#quietShown(quiet));
|
|
1054
|
+
this.#quietStatus.textContent = "";
|
|
1055
|
+
if (options.focus !== undefined)
|
|
1056
|
+
this.#orderFocus = options.focus;
|
|
1057
|
+
if (options.render !== false) {
|
|
1058
|
+
this.#render(this.#view);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const save = this.#root.querySelector('[data-action="quiet:save"]');
|
|
1062
|
+
if (save)
|
|
1063
|
+
save.disabled = !quietChanged(this.#quietDraft, quiet, this.#browserZone);
|
|
1064
|
+
const discard = this.#root.querySelector('[data-action="quiet:discard"]');
|
|
1065
|
+
if (discard)
|
|
1066
|
+
discard.disabled = !quietEdited(this.#quietDraft, quiet, this.#browserZone);
|
|
1067
|
+
}
|
|
1068
|
+
/** Shows what a save refused, field by field, and moves focus to the first. */
|
|
1069
|
+
#refuseQuiet(errors, draft) {
|
|
1070
|
+
this.#quietErrors = errors;
|
|
1071
|
+
this.#quietStatus.textContent = this.#copy.quietFixFields;
|
|
1072
|
+
const [first] = Object.keys(errors);
|
|
1073
|
+
const control = first === undefined ? null : quietErrorControl(first, draft);
|
|
1074
|
+
if (control !== null)
|
|
1075
|
+
this.#orderFocus = control;
|
|
1076
|
+
}
|
|
1077
|
+
async #saveQuiet() {
|
|
1078
|
+
const client = this.#client;
|
|
1079
|
+
const quiet = this.#quiet;
|
|
1080
|
+
if (!client || !quiet || this.#pending)
|
|
1081
|
+
return;
|
|
1082
|
+
const draft = this.#quietShown(quiet);
|
|
1083
|
+
const local = quietDraftErrors(draft);
|
|
1084
|
+
if (Object.keys(local).length > 0) {
|
|
1085
|
+
this.#refuseQuiet(local, draft);
|
|
1086
|
+
this.#render(this.#view);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
await this.#writeQuiet(() => client.saveQuietHours(quietSaveRequest(draft), {
|
|
1090
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
1091
|
+
}), this.#copy.quietSaved, draft);
|
|
1092
|
+
}
|
|
1093
|
+
async #turnOffQuiet() {
|
|
1094
|
+
const client = this.#client;
|
|
1095
|
+
if (!client || !this.#quiet || this.#pending)
|
|
1096
|
+
return;
|
|
1097
|
+
await this.#writeQuiet(() => client.resetQuietHours({ timeoutMs: REQUEST_TIMEOUT_MS }), this.#copy.quietTurnedOff, null);
|
|
1098
|
+
}
|
|
1099
|
+
async #writeQuiet(write, done, draft) {
|
|
1100
|
+
const generation = this.#generation;
|
|
1101
|
+
this.#pending = true;
|
|
1102
|
+
this.#render(this.#view);
|
|
1103
|
+
this.#orderFocus = null;
|
|
1104
|
+
try {
|
|
1105
|
+
const written = await write();
|
|
1106
|
+
if (generation !== this.#generation)
|
|
1107
|
+
return;
|
|
1108
|
+
this.#quiet = written;
|
|
1109
|
+
this.#quietDraft = null;
|
|
1110
|
+
this.#quietErrors = {};
|
|
1111
|
+
this.#quietStatus.textContent = done;
|
|
1112
|
+
// Save has nothing left to do; the next thing is turning it off, or,
|
|
1113
|
+
// once off, turning it on again.
|
|
1114
|
+
this.#orderFocus = written.configured ? "quiet:turn-off" : "quiet:save";
|
|
1115
|
+
this.#emit("quiet-hours-changed", { configured: written.configured });
|
|
1116
|
+
}
|
|
1117
|
+
catch (error) {
|
|
1118
|
+
if (generation !== this.#generation)
|
|
1119
|
+
return;
|
|
1120
|
+
const problem = error instanceof WhooshBangProblemError ? error.problem : null;
|
|
1121
|
+
if (problem?.code === "recipient_capability_invalid" ||
|
|
1122
|
+
problem?.code === "resource_expired") {
|
|
1123
|
+
this.#renderFailure(error);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
this.#emit("error", {
|
|
1127
|
+
...(problem ? { code: problem.code } : {}),
|
|
1128
|
+
recoverable: true,
|
|
1129
|
+
});
|
|
1130
|
+
const refused = problem?.code === "request_invalid" ? quietProblemErrors(problem) : {};
|
|
1131
|
+
// The edit is kept either way, so trying again costs nothing.
|
|
1132
|
+
if (draft !== null && Object.keys(refused).length > 0)
|
|
1133
|
+
this.#refuseQuiet(refused, draft);
|
|
1134
|
+
else
|
|
1135
|
+
this.#quietStatus.textContent = this.#copy.quietSaveFailed;
|
|
1136
|
+
}
|
|
1137
|
+
finally {
|
|
1138
|
+
this.#pending = false;
|
|
1139
|
+
if (generation === this.#generation && this.#view.state !== "expired") {
|
|
1140
|
+
this.#orderFocus ??= "quiet:save";
|
|
1141
|
+
this.#render(this.#view);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
#quietSection(view) {
|
|
1146
|
+
const quiet = this.#quiet;
|
|
1147
|
+
if (!quiet || (view.state !== "ready" && view.state !== "handoff"))
|
|
1148
|
+
return null;
|
|
1149
|
+
const draft = this.#quietShown(quiet);
|
|
1150
|
+
const offered = new Set(view.channels.map((channel) => channel.channel));
|
|
1151
|
+
const channels = QUIET_CHANNELS.filter((channel) => offered.has(channel));
|
|
1152
|
+
this.#quietStatus.className = "quiet-status";
|
|
1153
|
+
this.#quietStatus.setAttribute("role", "status");
|
|
1154
|
+
this.#quietStatus.setAttribute("aria-live", "polite");
|
|
1155
|
+
const clear = (field) => {
|
|
1156
|
+
if (this.#quietErrors[field] === undefined)
|
|
1157
|
+
return;
|
|
1158
|
+
this.#quietErrors = Object.fromEntries(Object.entries(this.#quietErrors).filter(([key]) => key !== field));
|
|
1159
|
+
};
|
|
1160
|
+
return renderQuietSection({
|
|
1161
|
+
changed: quietChanged(this.#quietDraft, quiet, this.#browserZone),
|
|
1162
|
+
channelName: (channel) => channelLabel(channel, view.locale),
|
|
1163
|
+
channels: channels.length > 0 ? channels : QUIET_CHANNELS,
|
|
1164
|
+
copy: this.#copy,
|
|
1165
|
+
disabled: this.#pending,
|
|
1166
|
+
draft,
|
|
1167
|
+
edited: quietEdited(this.#quietDraft, quiet, this.#browserZone),
|
|
1168
|
+
errors: this.#quietErrors,
|
|
1169
|
+
locale: view.locale,
|
|
1170
|
+
on: {
|
|
1171
|
+
addWindow: () => {
|
|
1172
|
+
clear("/windows");
|
|
1173
|
+
this.#editQuiet(addQuietWindow, {
|
|
1174
|
+
focus: `quiet:remove:${String(draft.windows.length)}`,
|
|
1175
|
+
});
|
|
1176
|
+
},
|
|
1177
|
+
discard: () => {
|
|
1178
|
+
this.#quietDraft = null;
|
|
1179
|
+
this.#quietErrors = {};
|
|
1180
|
+
this.#quietStatus.textContent = "";
|
|
1181
|
+
this.#orderFocus = "quiet:save";
|
|
1182
|
+
this.#render(this.#view);
|
|
1183
|
+
},
|
|
1184
|
+
removeWindow: (index) => {
|
|
1185
|
+
// Errors are kept by position, which a removal shifts.
|
|
1186
|
+
this.#quietErrors = {};
|
|
1187
|
+
const remaining = draft.windows.length - 1;
|
|
1188
|
+
this.#editQuiet((current) => removeQuietWindow(current, index), {
|
|
1189
|
+
focus: remaining === 0
|
|
1190
|
+
? "quiet:add"
|
|
1191
|
+
: `quiet:remove:${String(Math.min(index, remaining - 1))}`,
|
|
1192
|
+
});
|
|
1193
|
+
},
|
|
1194
|
+
save: () => {
|
|
1195
|
+
void this.#saveQuiet();
|
|
1196
|
+
},
|
|
1197
|
+
setBehaviour: (channel, behaviour) => {
|
|
1198
|
+
clear(`/channels/${channel}`);
|
|
1199
|
+
this.#editQuiet((current) => ({
|
|
1200
|
+
...current,
|
|
1201
|
+
channels: { ...current.channels, [channel]: behaviour },
|
|
1202
|
+
}));
|
|
1203
|
+
},
|
|
1204
|
+
setCritical: (on) => {
|
|
1205
|
+
this.#editQuiet((current) => ({
|
|
1206
|
+
...current,
|
|
1207
|
+
criticalBreakthrough: on,
|
|
1208
|
+
}));
|
|
1209
|
+
},
|
|
1210
|
+
setQuietChannel: (bindingId) => {
|
|
1211
|
+
clear("/quiet_channel_binding_id");
|
|
1212
|
+
this.#editQuiet((current) => ({
|
|
1213
|
+
...current,
|
|
1214
|
+
quietChannelBindingId: bindingId,
|
|
1215
|
+
}));
|
|
1216
|
+
},
|
|
1217
|
+
setTime: (index, edge, value) => {
|
|
1218
|
+
this.#editQuiet((current) => setQuietWindow(current, index, { [edge]: value }), { render: false });
|
|
1219
|
+
},
|
|
1220
|
+
setTimeZone: (zone) => {
|
|
1221
|
+
clear("/time_zone");
|
|
1222
|
+
this.#editQuiet((current) => ({ ...current, timeZone: zone }));
|
|
1223
|
+
},
|
|
1224
|
+
setWhenWithheld: (value) => {
|
|
1225
|
+
clear("/quiet_channel_binding_id");
|
|
1226
|
+
this.#editQuiet((current) => ({ ...current, whenWithheld: value }));
|
|
1227
|
+
},
|
|
1228
|
+
toggleDay: (index, day) => {
|
|
1229
|
+
clear(`/windows/${String(index)}/days`);
|
|
1230
|
+
this.#editQuiet((current) => toggleQuietDay(current, index, day));
|
|
1231
|
+
},
|
|
1232
|
+
turnOff: () => {
|
|
1233
|
+
void this.#turnOffQuiet();
|
|
1234
|
+
},
|
|
1235
|
+
},
|
|
1236
|
+
quiet,
|
|
1237
|
+
status: this.#quietStatus,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* The panel's parts as tabs: channels, the escalation order, quiet hours
|
|
1242
|
+
* and the kinds of message. Every part is in the tree; only the chosen one is shown, so a
|
|
1243
|
+
* refresh or poll never moves the recipient off the tab they are on.
|
|
1244
|
+
*/
|
|
1245
|
+
#appendParts(surface, parts, copy) {
|
|
1246
|
+
const [only] = parts;
|
|
1247
|
+
if (parts.length === 1 && only) {
|
|
1248
|
+
surface.append(only.content);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
const selected = parts.some((part) => part.key === this.#tab)
|
|
1252
|
+
? this.#tab
|
|
1253
|
+
: "channels";
|
|
1254
|
+
const tablist = document.createElement("div");
|
|
1255
|
+
tablist.className = "tabs";
|
|
1256
|
+
tablist.setAttribute("role", "tablist");
|
|
1257
|
+
tablist.setAttribute("aria-label", copy.heading);
|
|
1258
|
+
const choose = (key, focus) => {
|
|
1259
|
+
this.#tab = key;
|
|
1260
|
+
if (focus)
|
|
1261
|
+
this.#orderFocus = `tab:${key}`;
|
|
1262
|
+
this.#render(this.#view);
|
|
1263
|
+
};
|
|
1264
|
+
parts.forEach((part, index) => {
|
|
1265
|
+
const chosen = part.key === selected;
|
|
1266
|
+
const tab = document.createElement("button");
|
|
1267
|
+
tab.type = "button";
|
|
1268
|
+
tab.className = "tab";
|
|
1269
|
+
tab.id = `tab-${part.key}`;
|
|
1270
|
+
tab.dataset["action"] = `tab:${part.key}`;
|
|
1271
|
+
tab.setAttribute("role", "tab");
|
|
1272
|
+
tab.setAttribute("aria-controls", `panel-${part.key}`);
|
|
1273
|
+
tab.setAttribute("aria-selected", String(chosen));
|
|
1274
|
+
tab.tabIndex = chosen ? 0 : -1;
|
|
1275
|
+
tab.textContent = part.label;
|
|
1276
|
+
tab.addEventListener("click", () => {
|
|
1277
|
+
choose(part.key, false);
|
|
1278
|
+
});
|
|
1279
|
+
// Arrow keys move between tabs, Home and End to the ends.
|
|
1280
|
+
tab.addEventListener("keydown", (event) => {
|
|
1281
|
+
const last = parts.length - 1;
|
|
1282
|
+
const target = event.key === "ArrowRight"
|
|
1283
|
+
? index === last
|
|
1284
|
+
? 0
|
|
1285
|
+
: index + 1
|
|
1286
|
+
: event.key === "ArrowLeft"
|
|
1287
|
+
? index === 0
|
|
1288
|
+
? last
|
|
1289
|
+
: index - 1
|
|
1290
|
+
: event.key === "Home"
|
|
1291
|
+
? 0
|
|
1292
|
+
: event.key === "End"
|
|
1293
|
+
? last
|
|
1294
|
+
: null;
|
|
1295
|
+
const next = target === null ? undefined : parts[target];
|
|
1296
|
+
if (!next)
|
|
1297
|
+
return;
|
|
1298
|
+
event.preventDefault();
|
|
1299
|
+
choose(next.key, true);
|
|
1300
|
+
});
|
|
1301
|
+
tablist.append(tab);
|
|
1302
|
+
part.content.id = `panel-${part.key}`;
|
|
1303
|
+
part.content.setAttribute("role", "tabpanel");
|
|
1304
|
+
part.content.setAttribute("aria-labelledby", `tab-${part.key}`);
|
|
1305
|
+
part.content.hidden = !chosen;
|
|
1306
|
+
});
|
|
1307
|
+
surface.append(tablist, ...parts.map((part) => part.content));
|
|
1308
|
+
}
|
|
725
1309
|
/**
|
|
726
1310
|
* Rebuilds the shadow tree.
|
|
727
1311
|
*
|
|
@@ -889,16 +1473,36 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
|
|
|
889
1473
|
item.append(actions);
|
|
890
1474
|
list.append(item);
|
|
891
1475
|
}
|
|
892
|
-
|
|
1476
|
+
const channelsPanel = document.createElement("div");
|
|
1477
|
+
channelsPanel.append(list);
|
|
1478
|
+
const parts = [
|
|
1479
|
+
{ content: channelsPanel, key: "channels", label: copy.tabChannels },
|
|
1480
|
+
];
|
|
1481
|
+
const orderSection = this.#orderSection(view);
|
|
1482
|
+
if (orderSection)
|
|
1483
|
+
parts.push({
|
|
1484
|
+
content: orderSection,
|
|
1485
|
+
key: "order",
|
|
1486
|
+
label: copy.orderHeading,
|
|
1487
|
+
});
|
|
1488
|
+
const quietSection = this.#quietSection(view);
|
|
1489
|
+
if (quietSection)
|
|
1490
|
+
parts.push({
|
|
1491
|
+
content: quietSection,
|
|
1492
|
+
key: "quiet",
|
|
1493
|
+
label: copy.quietHeading,
|
|
1494
|
+
});
|
|
893
1495
|
if (view.categories.length > 0) {
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1496
|
+
const categoriesPanel = document.createElement("div");
|
|
1497
|
+
parts.push({
|
|
1498
|
+
content: categoriesPanel,
|
|
1499
|
+
key: "categories",
|
|
1500
|
+
label: copy.categoriesHeading,
|
|
1501
|
+
});
|
|
898
1502
|
const categoryBody = document.createElement("p");
|
|
899
1503
|
categoryBody.className = "status";
|
|
900
1504
|
categoryBody.textContent = copy.categoriesBody;
|
|
901
|
-
|
|
1505
|
+
categoriesPanel.append(categoryBody);
|
|
902
1506
|
const categories = document.createElement("ul");
|
|
903
1507
|
for (const category of view.categories) {
|
|
904
1508
|
const item = document.createElement("li");
|
|
@@ -941,8 +1545,9 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
|
|
|
941
1545
|
item.append(control);
|
|
942
1546
|
categories.append(item);
|
|
943
1547
|
}
|
|
944
|
-
|
|
1548
|
+
categoriesPanel.append(categories);
|
|
945
1549
|
}
|
|
1550
|
+
this.#appendParts(surface, parts, copy);
|
|
946
1551
|
// Customer identity owns the panel heading; this quiet attribution names
|
|
947
1552
|
// the product operating the component without replacing or competing with
|
|
948
1553
|
// the customer's configured name and logo.
|
|
@@ -955,12 +1560,34 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
|
|
|
955
1560
|
platformLink.href = "https://whooshbang.com";
|
|
956
1561
|
platformLink.rel = "noreferrer";
|
|
957
1562
|
platformLink.target = "_blank";
|
|
958
|
-
platformLink.
|
|
1563
|
+
platformLink.append(whooshbangMark(), "Powered by WhooshBang");
|
|
959
1564
|
platformBrand.append(platformLink);
|
|
960
1565
|
surface.append(platformBrand);
|
|
961
1566
|
root.append(surface);
|
|
962
|
-
|
|
963
|
-
|
|
1567
|
+
const wanted = this.#orderFocus ?? focusedControl;
|
|
1568
|
+
this.#orderFocus = null;
|
|
1569
|
+
if (wanted) {
|
|
1570
|
+
const find = (key) => root.querySelector(`[data-action="${key}"], [data-focus-key="${key}"]`);
|
|
1571
|
+
let restored = find(wanted);
|
|
1572
|
+
// An arrow that just reached the end of the list is disabled; its
|
|
1573
|
+
// partner is the natural next press. Any other vanished order control
|
|
1574
|
+
// hands focus to the tier the recipient is editing.
|
|
1575
|
+
if (wanted.startsWith("order:") && (!restored || restored.disabled)) {
|
|
1576
|
+
const partner = wanted.startsWith("order:up:")
|
|
1577
|
+
? wanted.replace("order:up:", "order:down:")
|
|
1578
|
+
: wanted.startsWith("order:down:")
|
|
1579
|
+
? wanted.replace("order:down:", "order:up:")
|
|
1580
|
+
: null;
|
|
1581
|
+
const alternate = partner ? find(partner) : null;
|
|
1582
|
+
restored =
|
|
1583
|
+
alternate && !alternate.disabled
|
|
1584
|
+
? alternate
|
|
1585
|
+
: find(`order:level:${this.#orderLevel}`);
|
|
1586
|
+
}
|
|
1587
|
+
// A quiet-hours control that went away, or cannot take focus, hands
|
|
1588
|
+
// focus to the tab.
|
|
1589
|
+
if (wanted.startsWith("quiet:") && (!restored || restored.disabled))
|
|
1590
|
+
restored = find("tab:quiet");
|
|
964
1591
|
restored?.focus();
|
|
965
1592
|
}
|
|
966
1593
|
else if (fallbackUrl) {
|