@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.
- 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 +169 -1
- package/dist/client.js +534 -4
- 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/digest-section.d.ts +29 -0
- package/dist/recipient/digest-section.js +400 -0
- package/dist/recipient/digest-section.js.map +1 -0
- package/dist/recipient/element.js +784 -14
- package/dist/recipient/element.js.map +1 -1
- package/dist/recipient/i18n/ar.js +114 -0
- package/dist/recipient/i18n/ar.js.map +1 -1
- package/dist/recipient/i18n/catalog.d.ts +113 -0
- package/dist/recipient/i18n/catalog.js.map +1 -1
- package/dist/recipient/i18n/de.js +119 -0
- package/dist/recipient/i18n/de.js.map +1 -1
- package/dist/recipient/i18n/en.js +119 -0
- package/dist/recipient/i18n/en.js.map +1 -1
- package/dist/recipient/i18n/es.js +119 -0
- package/dist/recipient/i18n/es.js.map +1 -1
- package/dist/recipient/i18n/fa.js +114 -0
- package/dist/recipient/i18n/fa.js.map +1 -1
- package/dist/recipient/i18n/fr.js +119 -0
- package/dist/recipient/i18n/fr.js.map +1 -1
- package/dist/recipient/i18n/hi.js +119 -0
- package/dist/recipient/i18n/hi.js.map +1 -1
- package/dist/recipient/i18n/id.js +119 -0
- package/dist/recipient/i18n/id.js.map +1 -1
- package/dist/recipient/i18n/it.js +119 -0
- package/dist/recipient/i18n/it.js.map +1 -1
- package/dist/recipient/i18n/nl.js +119 -0
- package/dist/recipient/i18n/nl.js.map +1 -1
- package/dist/recipient/i18n/pl.js +119 -0
- package/dist/recipient/i18n/pl.js.map +1 -1
- package/dist/recipient/i18n/pt-BR.js +119 -0
- package/dist/recipient/i18n/pt-BR.js.map +1 -1
- package/dist/recipient/i18n/ru.js +119 -0
- package/dist/recipient/i18n/ru.js.map +1 -1
- package/dist/recipient/i18n/tr.js +119 -0
- package/dist/recipient/i18n/tr.js.map +1 -1
- package/dist/recipient/i18n/uk.js +119 -0
- package/dist/recipient/i18n/uk.js.map +1 -1
- package/dist/recipient/i18n/zh-Hans.js +114 -0
- package/dist/recipient/i18n/zh-Hans.js.map +1 -1
- package/dist/recipient/mount.d.ts +5 -0
- package/dist/recipient/mount.js +2 -0
- package/dist/recipient/mount.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 +51 -0
- package/dist/recipient/quiet-section.js +617 -0
- package/dist/recipient/quiet-section.js.map +1 -0
- package/dist/recipient/quiet-view.d.ts +104 -0
- package/dist/recipient/quiet-view.js +304 -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,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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1706
|
+
platformLink.append(whooshbangMark(), "Powered by WhooshBang");
|
|
959
1707
|
platformBrand.append(platformLink);
|
|
960
1708
|
surface.append(platformBrand);
|
|
961
1709
|
root.append(surface);
|
|
962
|
-
|
|
963
|
-
|
|
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) {
|