@lightworkai.official/debug-capture 0.14.2 → 0.15.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 +33 -3
- package/dist/config.d.ts +11 -0
- package/dist/debug-capture.js +135 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +67 -17
- package/dist/live.d.ts +19 -0
- package/dist/portal.d.ts +5 -0
- package/dist/ui/ring.d.ts +19 -0
- package/dist/ui/strings.d.ts +21 -2
- package/dist/ui/styles.d.ts +1 -1
- package/package.json +1 -1
- package/src/config.ts +12 -0
- package/src/index.ts +2 -0
- package/src/install.ts +9 -0
- package/src/live.ts +227 -0
- package/src/portal.ts +3 -0
- package/src/ui/element.ts +84 -38
- package/src/ui/ring.ts +248 -0
- package/src/ui/strings.ts +58 -9
- package/src/ui/styles.ts +16 -5
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface RingCall {
|
|
2
|
+
id: string;
|
|
3
|
+
ticketKey: string;
|
|
4
|
+
ticketTitle: string;
|
|
5
|
+
}
|
|
6
|
+
export interface RingHandlers {
|
|
7
|
+
/** Runs INSIDE the press: a tab can be opened only there, before anything is awaited. */
|
|
8
|
+
open: () => void;
|
|
9
|
+
decline: () => void;
|
|
10
|
+
}
|
|
11
|
+
/** "เรื่อง CALL-12 · title" — the line under the card's title. Pure, for the tests. */
|
|
12
|
+
export declare function ringAbout(template: string, call: RingCall): string;
|
|
13
|
+
/** Show the ring for this call — once; the same call again changes nothing. */
|
|
14
|
+
export declare function showRing(call: RingCall, handlers: RingHandlers): void;
|
|
15
|
+
/** Opened: the card says where to answer, and stops ringing. `blockedUrl` — the browser refused the tab: a link, which is a fresh press. */
|
|
16
|
+
export declare function ringOpened(blockedUrl?: string): void;
|
|
17
|
+
/** The call is no longer ringing for this person — answered, declined, missed or ended. */
|
|
18
|
+
export declare function hideRing(id?: string): void;
|
|
19
|
+
export declare function ringShownFor(): string | null;
|
package/dist/ui/strings.d.ts
CHANGED
|
@@ -66,14 +66,23 @@ export interface Strings {
|
|
|
66
66
|
handoffOpen: string;
|
|
67
67
|
chooseTitle: string;
|
|
68
68
|
kindProblem: string;
|
|
69
|
-
kindProblemHint: string;
|
|
70
69
|
kindRequest: string;
|
|
71
|
-
|
|
70
|
+
askInstead: string;
|
|
71
|
+
askInsteadAction: string;
|
|
72
|
+
problemInstead: string;
|
|
73
|
+
problemInsteadAction: string;
|
|
74
|
+
/** The same, from a request that began with no screen: the share will be asked for. */
|
|
75
|
+
problemInsteadShare: string;
|
|
72
76
|
/** On the cards before anything is asked of the browser: what each will do. */
|
|
73
77
|
chooseProblemHint: string;
|
|
74
78
|
chooseRequestHint: string;
|
|
75
79
|
requestTitle: string;
|
|
76
80
|
requestTypeLabel: string;
|
|
81
|
+
/** Over a long list of types: the search, and what it says when nothing matches. */
|
|
82
|
+
requestTypeSearch: string;
|
|
83
|
+
requestTypeNone: string;
|
|
84
|
+
/** Carries `{n}`. */
|
|
85
|
+
requestTypeCount: string;
|
|
77
86
|
/** Carries `{time}`. */
|
|
78
87
|
requestTarget: string;
|
|
79
88
|
requestSummaryLabel: string;
|
|
@@ -94,5 +103,15 @@ export interface Strings {
|
|
|
94
103
|
/** Carry `{n}`. */
|
|
95
104
|
days: string;
|
|
96
105
|
hours: string;
|
|
106
|
+
/** A call from the support team (ui/ring). `ringAbout` carries `{key}` and `{title}`. */
|
|
107
|
+
ringTitle: string;
|
|
108
|
+
ringAbout: string;
|
|
109
|
+
ringHow: string;
|
|
110
|
+
ringOpen: string;
|
|
111
|
+
ringDecline: string;
|
|
112
|
+
ringOpened: string;
|
|
113
|
+
ringBlocked: string;
|
|
114
|
+
ringClose: string;
|
|
115
|
+
ringTab: string;
|
|
97
116
|
}
|
|
98
117
|
export declare function strings(locale: "th" | "en" | undefined): Strings;
|
package/dist/ui/styles.d.ts
CHANGED
|
@@ -10,4 +10,4 @@
|
|
|
10
10
|
* Everything is explicit for the same reason: the host's `body` font and colours
|
|
11
11
|
* do not inherit past the boundary, so stating them is not belt-and-braces.
|
|
12
12
|
*/
|
|
13
|
-
export declare const CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n\n.backdrop {\n position: fixed; inset: 0; z-index: 2147483000;\n display: flex; align-items: center; justify-content: center;\n padding: 24px; background: rgba(15, 23, 42, 0.55);\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans Thai\", sans-serif;\n color: #0f172a;\n}\n.panel {\n display: flex; flex-direction: column; gap: 16px;\n width: min(820px, 100%); max-height: 90vh; overflow-y: auto;\n /* No bottom padding: the footer supplies its own and sits flush, so it can\n stick to the panel's edge without a gap under it. */\n padding: 24px 24px 0; border-radius: 16px; background: #fff;\n box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);\n}\n.head { display: flex; align-items: flex-start; gap: 12px; }\nh2 { margin: 0; font-size: 20px; font-weight: 700; flex: 1; }\nlabel { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }\n.req { color: #dc2626; }\n.hint { font-weight: 400; color: #64748b; }\ninput[type=\"text\"], textarea, select {\n width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\ninput[type=\"text\"]:focus, textarea:focus, select:focus {\n outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);\n}\ntextarea { min-height: 88px; resize: vertical; }\n\n.meta {\n padding: 12px 14px; border-radius: 10px; background: #f8fafc;\n font-size: 13px; line-height: 1.7; color: #475569; word-break: break-all;\n}\n.meta b { color: #0f172a; font-weight: 600; }\n\n.tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.sep { width: 1px; align-self: stretch; background: #e2e8f0; margin: 0 4px; }\nbutton {\n display: inline-flex; align-items: center; gap: 6px; cursor: pointer;\n padding: 8px 14px; font: inherit; font-size: 14px; font-weight: 500;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\nbutton:hover:not(:disabled) { background: #f1f5f9; }\nbutton:disabled { opacity: 0.5; cursor: not-allowed; }\nbutton[aria-pressed=\"true\"] { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; }\nbutton.primary { background: #2563eb; border-color: #2563eb; color: #fff; }\nbutton.primary:hover:not(:disabled) { background: #1d4ed8; }\nbutton.icon { padding: 6px 10px; border-color: transparent; font-size: 18px; line-height: 1; }\n\n/* The on-image text editor. Absolutely placed over the canvas and matched to\n the shape it becomes \u2014 same weight, same red, same white plate \u2014 so what you\n type is what gets drawn, at the position you clicked. Sized in JS, because\n the font size tracks the image's own scale. */\n.text-edit {\n position: absolute; z-index: 2; margin: 0; padding: 2px 4px;\n font-family: system-ui, sans-serif; font-weight: 600; line-height: 1.25;\n color: #e11d48; background: rgba(255, 255, 255, 0.92);\n border: 1px dashed #e11d48; border-radius: 4px; outline: none;\n box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);\n}\n.text-edit::placeholder { color: rgba(225, 29, 72, 0.45); }\n\ncanvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; border-radius: 10px; touch-action: none; }\n.shot { position: relative; }\n.status { font-size: 13px; color: #64748b; }\n.status[data-tone=\"error\"] { color: #dc2626; }\n/* Pinned to the bottom of the scrolling panel.\n A screenshot is tall \u2014 often taller than the viewport \u2014 so \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 sat\n below the fold and the last step of filing a report was \"scroll to the end\n and find the button\". Sticky, with its own ground and a rule above it so the\n annotated image scrolls underneath rather than bleeding into it. */\n.foot {\n position: sticky; bottom: 0; z-index: 3;\n display: flex; align-items: center; gap: 12px;\n margin: 4px -24px 0; padding: 12px 24px;\n background: #fff; border-top: 1px solid #e2e8f0;\n border-radius: 0 0 16px 16px;\n}\n.foot .grow { flex: 1; }\n\n/* \u0E02\u0E2D\u0E43\u0E2B\u0E49\u0E17\u0E35\u0E21\u0E14\u0E33\u0E40\u0E19\u0E34\u0E19\u0E01\u0E32\u0E23 \u2014 the same dialog turned into a request (docs/WIDGET-ASK-PLAN.md):\n \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 \u00B7 \u0E04\u0E33\u0E02\u0E2D as two cards under the heading, the way the desk's own\n \u0E2A\u0E23\u0E49\u0E32\u0E07\u0E40\u0E27\u0E34\u0E23\u0E4C\u0E01\u0E42\u0E1F\u0E25\u0E27\u0E4C asks it; what is asked for as cards, the way the portal lists it. */\n[hidden] { display: none !important; }\n.kinds { display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
|
|
13
|
+
export declare const CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n\n.backdrop {\n position: fixed; inset: 0; z-index: 2147483000;\n display: flex; align-items: center; justify-content: center;\n padding: 24px; background: rgba(15, 23, 42, 0.55);\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans Thai\", sans-serif;\n color: #0f172a;\n}\n.panel {\n display: flex; flex-direction: column; gap: 16px;\n width: min(820px, 100%); max-height: 90vh; overflow-y: auto;\n /* No bottom padding: the footer supplies its own and sits flush, so it can\n stick to the panel's edge without a gap under it. */\n padding: 24px 24px 0; border-radius: 16px; background: #fff;\n box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);\n}\n.head { display: flex; align-items: flex-start; gap: 12px; }\nh2 { margin: 0; font-size: 20px; font-weight: 700; flex: 1; }\nlabel { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }\n.req { color: #dc2626; }\n.hint { font-weight: 400; color: #64748b; }\ninput[type=\"text\"], input[type=\"search\"], textarea, select {\n width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\ninput[type=\"text\"]:focus, input[type=\"search\"]:focus, textarea:focus, select:focus {\n outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);\n}\ntextarea { min-height: 88px; resize: vertical; }\n\n.meta {\n padding: 12px 14px; border-radius: 10px; background: #f8fafc;\n font-size: 13px; line-height: 1.7; color: #475569; word-break: break-all;\n}\n.meta b { color: #0f172a; font-weight: 600; }\n\n.tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.sep { width: 1px; align-self: stretch; background: #e2e8f0; margin: 0 4px; }\nbutton {\n display: inline-flex; align-items: center; gap: 6px; cursor: pointer;\n padding: 8px 14px; font: inherit; font-size: 14px; font-weight: 500;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\nbutton:hover:not(:disabled) { background: #f1f5f9; }\nbutton:disabled { opacity: 0.5; cursor: not-allowed; }\nbutton[aria-pressed=\"true\"] { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; }\nbutton.primary { background: #2563eb; border-color: #2563eb; color: #fff; }\nbutton.primary:hover:not(:disabled) { background: #1d4ed8; }\nbutton.icon { padding: 6px 10px; border-color: transparent; font-size: 18px; line-height: 1; }\n\n/* The on-image text editor. Absolutely placed over the canvas and matched to\n the shape it becomes \u2014 same weight, same red, same white plate \u2014 so what you\n type is what gets drawn, at the position you clicked. Sized in JS, because\n the font size tracks the image's own scale. */\n.text-edit {\n position: absolute; z-index: 2; margin: 0; padding: 2px 4px;\n font-family: system-ui, sans-serif; font-weight: 600; line-height: 1.25;\n color: #e11d48; background: rgba(255, 255, 255, 0.92);\n border: 1px dashed #e11d48; border-radius: 4px; outline: none;\n box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);\n}\n.text-edit::placeholder { color: rgba(225, 29, 72, 0.45); }\n\ncanvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; border-radius: 10px; touch-action: none; }\n.shot { position: relative; }\n.status { font-size: 13px; color: #64748b; }\n.status[data-tone=\"error\"] { color: #dc2626; }\n/* Pinned to the bottom of the scrolling panel.\n A screenshot is tall \u2014 often taller than the viewport \u2014 so \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 sat\n below the fold and the last step of filing a report was \"scroll to the end\n and find the button\". Sticky, with its own ground and a rule above it so the\n annotated image scrolls underneath rather than bleeding into it. */\n.foot {\n position: sticky; bottom: 0; z-index: 3;\n display: flex; align-items: center; gap: 12px;\n margin: 4px -24px 0; padding: 12px 24px;\n background: #fff; border-top: 1px solid #e2e8f0;\n border-radius: 0 0 16px 16px;\n}\n.foot .grow { flex: 1; }\n\n/* \u0E02\u0E2D\u0E43\u0E2B\u0E49\u0E17\u0E35\u0E21\u0E14\u0E33\u0E40\u0E19\u0E34\u0E19\u0E01\u0E32\u0E23 \u2014 the same dialog turned into a request (docs/WIDGET-ASK-PLAN.md):\n \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 \u00B7 \u0E04\u0E33\u0E02\u0E2D as two cards under the heading, the way the desk's own\n \u0E2A\u0E23\u0E49\u0E32\u0E07\u0E40\u0E27\u0E34\u0E23\u0E4C\u0E01\u0E42\u0E1F\u0E25\u0E27\u0E4C asks it; what is asked for as cards, the way the portal lists it. */\n[hidden] { display: none !important; }\n/* The press's choice: two cards in a smaller dialog, before anything is asked of the browser. */\n.kinds { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }\n/* In the form, the choice is made: one quiet line under the heading to switch. */\n.door { margin: -8px 0 0; font-size: 13px; color: #64748b; }\n.panel.narrow { width: min(560px, 100%); }\n.kinds.choose { margin: 0 0 24px; }\n.kinds.choose button.kind { padding: 16px 18px; gap: 6px; }\n.kinds.choose .kind-hint { font-size: 13px; }\nbutton.kind {\n position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: 4px;\n padding: 12px 34px 12px 14px; text-align: left; border-radius: 12px;\n}\n.kind-top { display: flex; align-items: center; gap: 8px; font-size: 15px; }\n.kind-top b { font-weight: 600; }\n.kind-hint { font-size: 13px; font-weight: 400; line-height: 1.5; color: #64748b; }\nbutton.kind .icon { width: 18px; height: 18px; flex-shrink: 0; color: #64748b; }\nbutton.kind[data-kind=\"request\"] .icon { color: #1d4ed8; }\nbutton.kind .tick { position: absolute; top: 12px; right: 12px; width: 16px; height: 16px; color: #2563eb; opacity: 0; }\nbutton.kind[aria-checked=\"true\"], button.kind[aria-checked=\"true\"]:hover:not(:disabled) {\n border-color: #2563eb; background: #eff6ff; box-shadow: inset 0 0 0 1px #2563eb;\n}\nbutton.kind[aria-checked=\"true\"] .tick { opacity: 1; }\nbutton.kind:focus-visible, button.type:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25); }\nbutton.kind[aria-checked=\"true\"]:focus-visible { box-shadow: inset 0 0 0 1px #2563eb, 0 0 0 3px rgba(37, 99, 235, 0.25); }\n.types { display: grid; gap: 6px; }\n/* A long list scrolls in its own height, so the title and \u0E2A\u0E48\u0E07\u0E04\u0E33\u0E02\u0E2D stay near. */\n.types.long { max-height: 300px; overflow-y: auto; padding: 2px; margin: -2px; overscroll-behavior: contain; }\n.type-search { margin-bottom: 8px; }\n.ask .type-count { margin: 6px 0 0; font-size: 12px; }\n/* The rest are one line: only the chosen one opens up with its words and the team's time. */\nbutton.type:not([aria-checked=\"true\"]) { padding: 8px 12px; align-items: center; }\nbutton.type:not([aria-checked=\"true\"]) .type-hint, button.type:not([aria-checked=\"true\"]) .type-due { display: none; }\nbutton.type:not([aria-checked=\"true\"]) .type-body b { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\nbutton.type:not([aria-checked=\"true\"]) .icon { margin-top: 0; }\nbutton.type {\n display: flex; align-items: flex-start; gap: 10px; width: 100%;\n padding: 10px 12px; text-align: left; border-radius: 10px;\n}\nbutton.type .icon { width: 16px; height: 16px; margin-top: 3px; flex-shrink: 0; color: #64748b; }\nbutton.type[aria-checked=\"true\"], button.type[aria-checked=\"true\"]:hover:not(:disabled) { border-color: #2563eb; background: #eff6ff; }\nbutton.type[aria-checked=\"true\"] .icon { color: #2563eb; }\n.type-body { display: grid; gap: 2px; min-width: 0; }\n.type-body b { font-size: 14px; font-weight: 600; }\n.type-hint { font-size: 13px; font-weight: 400; color: #64748b; }\n.type-due { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 400; color: #64748b; }\nbutton.type .type-due .icon { width: 12px; height: 12px; margin: 0; }\n/* A phone: the two kinds still side by side \u2014 their names are the choice; the hints stay short. */\n@media (max-width: 480px) {\n .kind-hint { font-size: 12px; }\n button.kind { padding: 10px 30px 10px 12px; }\n}\nbutton.link {\n display: inline; padding: 0; border: 0; border-radius: 4px; background: none;\n font-size: 13px; font-weight: 600; color: #2563eb;\n text-decoration: underline; text-underline-offset: 3px;\n}\nbutton.link:hover:not(:disabled) { background: none; color: #1d4ed8; }\nbutton.link:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }\n.ask p { margin: 0; }\n.shot-choice { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 500; }\n.shot-choice input { width: 16px; height: 16px; margin: 0; accent-color: #2563eb; }\n.dropped { opacity: 0.35; pointer-events: none; }\n.draft { margin: -8px 0 0; padding: 8px 12px; border-radius: 10px; background: #eff6ff; font-size: 13px; color: #1e3a8a; }\n";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightworkai.official/debug-capture",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Report-a-problem widget: rolling network/console/action capture, optional rrweb session replay, annotated screenshot, and submission to a Lightwork Support host",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.mjs",
|
package/src/config.ts
CHANGED
|
@@ -149,6 +149,18 @@ export interface DebugCaptureConfig {
|
|
|
149
149
|
/** UI language. Only affects the built-in reporter's own strings. */
|
|
150
150
|
locale?: "th" | "en";
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The ring for a call from the support team. Default true.
|
|
154
|
+
*
|
|
155
|
+
* With `identity` set and calls switched on for the desk, the reporter's own
|
|
156
|
+
* live connection to the support host is held here — the one their portal
|
|
157
|
+
* tab holds, proven with the same signed token — so a call from the team
|
|
158
|
+
* rings in this app, as a card, and opens the reporter's request on the
|
|
159
|
+
* support host to answer it (the call itself happens there). `false` leaves
|
|
160
|
+
* calls to the support host and the invitation the team sends.
|
|
161
|
+
*/
|
|
162
|
+
ring?: boolean;
|
|
163
|
+
|
|
152
164
|
/**
|
|
153
165
|
* Where this library's own notifications go.
|
|
154
166
|
*
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,8 @@ export { redactBody, redactHeaders, redactLabel, redactUrl, REDACTED } from "./c
|
|
|
42
42
|
// `defineReporterElement()` builds it in the browser, where it exists.
|
|
43
43
|
export { defineReporterElement, setErroredQueriesSource, REPORTER_TAG } from "./ui/element";
|
|
44
44
|
export { showToast } from "./ui/toast";
|
|
45
|
+
// A call from the support team, rung in the host app — started by install; a host that renders its own UI may start or stop it.
|
|
46
|
+
export { startLiveRing, stopLiveRing } from "./live";
|
|
45
47
|
|
|
46
48
|
/*
|
|
47
49
|
* The annotator ENGINE, shared by every framework package.
|
package/src/install.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { strings } from "./ui/strings";
|
|
|
14
14
|
import { showToast } from "./ui/toast";
|
|
15
15
|
import { defineReporterElement, REPORTER_TAG } from "./ui/element";
|
|
16
16
|
import { loadRequestTypes } from "./requests";
|
|
17
|
+
import { startLiveRing } from "./live";
|
|
17
18
|
|
|
18
19
|
let armed = false;
|
|
19
20
|
|
|
@@ -69,4 +70,12 @@ export function installDebugCapture(options: InstallOptions = {}): void {
|
|
|
69
70
|
const idle = (window as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }).requestIdleCallback;
|
|
70
71
|
if (idle) idle(() => void loadRequestTypes(), { timeout: 5000 });
|
|
71
72
|
else setTimeout(() => void loadRequestTypes(), 2000);
|
|
73
|
+
|
|
74
|
+
/*
|
|
75
|
+
* A call from the support team rings here (live.ts) — when the host vouches
|
|
76
|
+
* for who is signed in and the desk has calls on. Late, like the request
|
|
77
|
+
* types: the page came for something else.
|
|
78
|
+
*/
|
|
79
|
+
if (idle) idle(() => startLiveRing(), { timeout: 8000 });
|
|
80
|
+
else setTimeout(() => startLiveRing(), 3000);
|
|
72
81
|
}
|
package/src/live.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ring for a call from the support team, in the host app.
|
|
3
|
+
*
|
|
4
|
+
* A reporter lives in the app they report from, not on the support host; a
|
|
5
|
+
* call from the team used to reach them only by the invitation in their
|
|
6
|
+
* email. So, when the host vouches for who is signed in (`identity`) and the
|
|
7
|
+
* desk has calls switched on, the widget holds the reporter's OWN live
|
|
8
|
+
* connection to the support host — the one their portal tab holds, traded
|
|
9
|
+
* for the host's signed token at `/ingest/live-ticket`, told about their own
|
|
10
|
+
* requests and calls and nothing else — and a call rings here as a card.
|
|
11
|
+
*
|
|
12
|
+
* Answering happens on the support host, where the call's media lives: the
|
|
13
|
+
* card's button opens the reporter's request there, signed in by the same
|
|
14
|
+
* hand-off the portal button uses — a tab opened INSIDE the press (a browser
|
|
15
|
+
* allows nothing later), its address set once the token is in hand.
|
|
16
|
+
*
|
|
17
|
+
* Quiet by design. No identity, calls off, a support host older than this
|
|
18
|
+
* package, a token refused: no socket, no card, nothing in the console — the
|
|
19
|
+
* widget does exactly what it did before.
|
|
20
|
+
*/
|
|
21
|
+
import { getConfig } from "./config";
|
|
22
|
+
import { preparePortalUrl } from "./portal";
|
|
23
|
+
import { hideRing, ringOpened, ringShownFor, showRing } from "./ui/ring";
|
|
24
|
+
|
|
25
|
+
export interface RingingCall {
|
|
26
|
+
id: string;
|
|
27
|
+
ticketId: string;
|
|
28
|
+
ticketKey: string;
|
|
29
|
+
ticketTitle: string;
|
|
30
|
+
status: string;
|
|
31
|
+
ringUntil: string;
|
|
32
|
+
side?: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const RETRY_MS = [2000, 5000, 15_000, 30_000, 60_000];
|
|
36
|
+
const PING_MS = 20_000;
|
|
37
|
+
|
|
38
|
+
let started = false;
|
|
39
|
+
let stopped = false;
|
|
40
|
+
/** Asked for a token and got none — nobody signed in yet. A few slow tries, then quiet: never a request a minute forever. */
|
|
41
|
+
let tokenless = 0;
|
|
42
|
+
const TOKENLESS_MAX = 5;
|
|
43
|
+
let socket: WebSocket | null = null;
|
|
44
|
+
let retry = 0;
|
|
45
|
+
let pinger: ReturnType<typeof setInterval> | undefined;
|
|
46
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
+
let expiry: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
+
|
|
49
|
+
const base = (host: string) => (host.endsWith("/") ? host : `${host}/`);
|
|
50
|
+
|
|
51
|
+
/** `wss://support.example/api/presence/ws?t=…` — the support host's scheme, the socket's own path. Pure, for the tests. */
|
|
52
|
+
export function liveSocketUrl(host: string, ticket: string): string {
|
|
53
|
+
const url = new URL("api/presence/ws", base(host));
|
|
54
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
55
|
+
url.searchParams.set("t", ticket);
|
|
56
|
+
return url.toString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Where the card sends them: their request, with the call — the support host rings it there. Pure, for the tests. */
|
|
60
|
+
export function answerPath(call: Pick<RingingCall, "id" | "ticketId">): string {
|
|
61
|
+
return `/me/tickets/${call.ticketId}?call=${call.id}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Is this a call ringing for the person at this widget? */
|
|
65
|
+
export function ringsHere(call: Pick<RingingCall, "status" | "side">): boolean {
|
|
66
|
+
return call.status === "ringing" && (call.side === "reporter" || call.side === undefined || call.side === null);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function hostToken(): Promise<string | null> {
|
|
70
|
+
const provider = getConfig().identity;
|
|
71
|
+
if (!provider) return null;
|
|
72
|
+
try {
|
|
73
|
+
return (await provider()) ?? null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function post(path: string, token: string): Promise<Response> {
|
|
80
|
+
const config = getConfig();
|
|
81
|
+
return fetch(new URL(path, base(config.host)).toString(), {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "content-type": "application/json", "x-realm-key": config.realmKey, authorization: `Bearer ${token}` },
|
|
84
|
+
body: "{}",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Start listening for calls. Idempotent; a no-op without identity, or with `ring: false`. */
|
|
89
|
+
export function startLiveRing(): void {
|
|
90
|
+
if (started || typeof window === "undefined" || typeof WebSocket === "undefined") return;
|
|
91
|
+
const config = getConfig();
|
|
92
|
+
if (!config.identity || config.ring === false) return;
|
|
93
|
+
started = true;
|
|
94
|
+
stopped = false;
|
|
95
|
+
void connect();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Let go — the socket, the card, the timers. For a host that unmounts, and for the tests. */
|
|
99
|
+
export function stopLiveRing(): void {
|
|
100
|
+
stopped = true;
|
|
101
|
+
started = false;
|
|
102
|
+
clearTimeout(reconnectTimer);
|
|
103
|
+
clearInterval(pinger);
|
|
104
|
+
clearTimeout(expiry);
|
|
105
|
+
try {
|
|
106
|
+
socket?.close(1000, "bye");
|
|
107
|
+
} catch {
|
|
108
|
+
/* already */
|
|
109
|
+
}
|
|
110
|
+
socket = null;
|
|
111
|
+
hideRing();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function schedule(slow = false): void {
|
|
115
|
+
if (stopped) return;
|
|
116
|
+
const wait = slow ? RETRY_MS[RETRY_MS.length - 1]! : RETRY_MS[Math.min(retry++, RETRY_MS.length - 1)]!;
|
|
117
|
+
clearTimeout(reconnectTimer);
|
|
118
|
+
reconnectTimer = setTimeout(() => void connect(), wait);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function connect(): Promise<void> {
|
|
122
|
+
if (stopped) return;
|
|
123
|
+
const token = await hostToken();
|
|
124
|
+
// Nobody signed in to the host yet: ask again, slowly — a few times, then stop.
|
|
125
|
+
if (!token) {
|
|
126
|
+
if (++tokenless >= TOKENLESS_MAX) {
|
|
127
|
+
stopped = true;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
return schedule(true);
|
|
131
|
+
}
|
|
132
|
+
tokenless = 0;
|
|
133
|
+
let res: Response;
|
|
134
|
+
try {
|
|
135
|
+
res = await post("ingest/live-ticket", token);
|
|
136
|
+
} catch {
|
|
137
|
+
return schedule();
|
|
138
|
+
}
|
|
139
|
+
// A token the desk does not accept, or a support host from before the ring: nothing to do, and nothing to say.
|
|
140
|
+
if (res.status === 401 || res.status === 404 || res.status === 403) {
|
|
141
|
+
stopped = true;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!res.ok) return schedule(res.status === 429);
|
|
145
|
+
let out: { calls?: boolean; ticket?: string | null; call?: RingingCall | null };
|
|
146
|
+
try {
|
|
147
|
+
out = await res.json();
|
|
148
|
+
} catch {
|
|
149
|
+
return schedule();
|
|
150
|
+
}
|
|
151
|
+
// Calls off for this desk: no socket at all.
|
|
152
|
+
if (!out.calls || !out.ticket) {
|
|
153
|
+
stopped = true;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (out.call) hear(out.call);
|
|
157
|
+
const ws = new WebSocket(liveSocketUrl(getConfig().host, out.ticket));
|
|
158
|
+
socket = ws;
|
|
159
|
+
ws.onopen = () => {
|
|
160
|
+
retry = 0;
|
|
161
|
+
clearInterval(pinger);
|
|
162
|
+
pinger = setInterval(() => {
|
|
163
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
|
|
164
|
+
}, PING_MS);
|
|
165
|
+
};
|
|
166
|
+
ws.onmessage = (e) => {
|
|
167
|
+
try {
|
|
168
|
+
const frame = JSON.parse(String(e.data)) as { type?: string; call?: RingingCall };
|
|
169
|
+
if (frame.type === "call-state" && frame.call) hear(frame.call);
|
|
170
|
+
} catch {
|
|
171
|
+
/* not ours to read */
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
ws.onclose = () => {
|
|
175
|
+
clearInterval(pinger);
|
|
176
|
+
if (socket === ws) socket = null;
|
|
177
|
+
schedule();
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The support host's word on a call: ringing here shows the card; anything else takes it away. */
|
|
182
|
+
function hear(call: RingingCall): void {
|
|
183
|
+
if (!ringsHere(call)) return hideRing(call.id);
|
|
184
|
+
showRing(call, { open: () => answer(call), decline: () => void decline(call) });
|
|
185
|
+
// Never longer than it rings: a socket that dropped mid-ring must not leave a card up for good.
|
|
186
|
+
clearTimeout(expiry);
|
|
187
|
+
const left = new Date(call.ringUntil).getTime() - Date.now();
|
|
188
|
+
expiry = setTimeout(() => {
|
|
189
|
+
if (ringShownFor() === call.id) hideRing(call.id);
|
|
190
|
+
}, Math.max(5000, left + 5000));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* เปิดเพื่อรับสาย. The tab is opened HERE, inside the press — after an await the
|
|
195
|
+
* browser would call it an unasked-for popup — then sent to the signed-in
|
|
196
|
+
* link once the host's token arrives. The support host is told the reporter
|
|
197
|
+
* is coming, so the ring waits for the hand-off and the team sees it.
|
|
198
|
+
*/
|
|
199
|
+
function answer(call: RingingCall): void {
|
|
200
|
+
const tab = window.open("about:blank", "_blank");
|
|
201
|
+
void (async () => {
|
|
202
|
+
const token = await hostToken();
|
|
203
|
+
if (token) void post(`ingest/calls/${call.id}/coming`, token).catch(() => undefined);
|
|
204
|
+
let url: string;
|
|
205
|
+
try {
|
|
206
|
+
url = await preparePortalUrl(answerPath(call));
|
|
207
|
+
} catch {
|
|
208
|
+
tab?.close();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (tab && !tab.closed) {
|
|
212
|
+
tab.location.href = url;
|
|
213
|
+
try {
|
|
214
|
+
tab.opener = null;
|
|
215
|
+
} catch {
|
|
216
|
+
/* open either way */
|
|
217
|
+
}
|
|
218
|
+
ringOpened();
|
|
219
|
+
} else ringOpened(url);
|
|
220
|
+
})();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function decline(call: RingingCall): Promise<void> {
|
|
224
|
+
hideRing(call.id);
|
|
225
|
+
const token = await hostToken();
|
|
226
|
+
if (token) await post(`ingest/calls/${call.id}/decline`, token).catch(() => undefined);
|
|
227
|
+
}
|
package/src/portal.ts
CHANGED
|
@@ -79,6 +79,9 @@ async function prepareUrl(returnTo?: string): Promise<string> {
|
|
|
79
79
|
return supportPortalUrl(config.host, config.realmKey, token, returnTo);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** The signed-in link to a path on the support host — for a caller that opened its own tab first (the call ring, ui/ring). */
|
|
83
|
+
export const preparePortalUrl = prepareUrl;
|
|
84
|
+
|
|
82
85
|
/** False when the browser refused — a popup blocker, almost always. */
|
|
83
86
|
function navigate(url: string, target: OpenPortalOptions["target"]): boolean {
|
|
84
87
|
if (target === "_self") {
|
package/src/ui/element.ts
CHANGED
|
@@ -311,41 +311,47 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
311
311
|
head.append(heading, closeBtn);
|
|
312
312
|
|
|
313
313
|
/*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
314
|
+
* The kind was chosen at the press (renderChooser); the form names it in
|
|
315
|
+
* its heading and keeps one quiet line to switch — the owner: "no need
|
|
316
|
+
* to be card style since that select it on the first dialog". Switching
|
|
317
|
+
* keeps what was typed; to แจ้งปัญหา from a request that began with no
|
|
318
|
+
* screen, the share is asked for at that press (switchToReport). Shown
|
|
319
|
+
* only where the desk offers something to ask.
|
|
319
320
|
*/
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
card.addEventListener("click", () => setMode(kind));
|
|
328
|
-
return card;
|
|
329
|
-
};
|
|
330
|
-
// From a request that began with no screen, แจ้งปัญหา will ask for it — said on the card, as the chooser says it.
|
|
331
|
-
const problemCard = kindCard("problem", BUG, t.kindProblem, screenTaken ? t.kindProblemHint : t.chooseProblemHint);
|
|
332
|
-
const requestCard = kindCard("request", WORKFLOW, t.kindRequest, t.kindRequestHint);
|
|
333
|
-
kinds.append(problemCard, requestCard);
|
|
334
|
-
// A radio group's own keys: an arrow moves the choice, and the focus with it.
|
|
335
|
-
kinds.addEventListener("keydown", (e) => {
|
|
336
|
-
if (!ARROWS.includes(e.key)) return;
|
|
337
|
-
e.preventDefault();
|
|
338
|
-
setMode(mode === "problem" ? "request" : "problem");
|
|
339
|
-
(mode === "problem" ? problemCard : requestCard).focus();
|
|
340
|
-
});
|
|
321
|
+
const door = el("p", "door");
|
|
322
|
+
door.hidden = true;
|
|
323
|
+
const doorText = el("span");
|
|
324
|
+
const doorBtn = button("", "link");
|
|
325
|
+
doorBtn.dataset.switchKind = "";
|
|
326
|
+
doorBtn.addEventListener("click", () => setMode(mode === "problem" ? "request" : "problem"));
|
|
327
|
+
door.append(doorText, doorBtn);
|
|
341
328
|
|
|
342
|
-
|
|
329
|
+
/*
|
|
330
|
+
* What is asked for: a row each. The one chosen opens up — its words and
|
|
331
|
+
* how long the team means to take, as the portal shows them — and the
|
|
332
|
+
* rest stay one line, so a desk with 10 or 20 types is still a list to
|
|
333
|
+
* read, not a page to scroll. From SEARCH_FROM types there is a search
|
|
334
|
+
* over them, and the list scrolls within its own height.
|
|
335
|
+
*/
|
|
343
336
|
const ask = el("div", "ask");
|
|
344
337
|
ask.hidden = true;
|
|
338
|
+
const typeSearch = document.createElement("input");
|
|
339
|
+
// A search, not a text field: the title stays the dialog's one text input.
|
|
340
|
+
typeSearch.type = "search";
|
|
341
|
+
typeSearch.className = "type-search";
|
|
342
|
+
typeSearch.placeholder = t.requestTypeSearch;
|
|
343
|
+
typeSearch.setAttribute("aria-label", t.requestTypeSearch);
|
|
344
|
+
typeSearch.dataset.requestTypeSearch = "";
|
|
345
|
+
typeSearch.hidden = true;
|
|
345
346
|
const typeList = el("div", "types");
|
|
346
347
|
typeList.setAttribute("role", "radiogroup");
|
|
347
348
|
typeList.setAttribute("aria-label", t.requestTypeLabel);
|
|
348
|
-
|
|
349
|
+
const typeNone = el("p", "status", t.requestTypeNone);
|
|
350
|
+
typeNone.hidden = true;
|
|
351
|
+
// A long list says how many it holds — the rows past its edge are otherwise unseen.
|
|
352
|
+
const typeCount = el("p", "status type-count", "");
|
|
353
|
+
typeCount.hidden = true;
|
|
354
|
+
ask.append(labelFor(t.requestTypeLabel, "", true), typeSearch, typeList, typeNone, typeCount);
|
|
349
355
|
const typeCards: HTMLButtonElement[] = [];
|
|
350
356
|
const markType = () => {
|
|
351
357
|
for (const card of typeCards) {
|
|
@@ -353,6 +359,13 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
353
359
|
card.setAttribute("aria-checked", String(on));
|
|
354
360
|
card.tabIndex = on ? 0 : -1;
|
|
355
361
|
}
|
|
362
|
+
// The chosen one in view inside the list's own scroll, never the dialog's.
|
|
363
|
+
const on = typeCards.find((c) => c.dataset.requestType === chosen?.id);
|
|
364
|
+
if (on && typeList.scrollHeight > typeList.clientHeight) {
|
|
365
|
+
const top = on.offsetTop - typeList.offsetTop;
|
|
366
|
+
if (top < typeList.scrollTop) typeList.scrollTop = top;
|
|
367
|
+
else if (top + on.offsetHeight > typeList.scrollTop + typeList.clientHeight) typeList.scrollTop = top + on.offsetHeight - typeList.clientHeight;
|
|
368
|
+
}
|
|
356
369
|
};
|
|
357
370
|
|
|
358
371
|
const summary = document.createElement("input");
|
|
@@ -408,12 +421,11 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
408
421
|
|
|
409
422
|
const apply = () => {
|
|
410
423
|
const asking = mode === "request";
|
|
411
|
-
heading.textContent =
|
|
424
|
+
heading.textContent = asking ? t.requestTitle : t.title;
|
|
412
425
|
panel.setAttribute("aria-label", asking ? t.requestTitle : t.title);
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
}
|
|
426
|
+
doorText.textContent = `${asking ? t.problemInstead : t.askInstead} `;
|
|
427
|
+
// From a request that began with no screen, แจ้งปัญหา will ask for it — said on the line.
|
|
428
|
+
doorBtn.textContent = asking ? (screenTaken ? t.problemInsteadAction : t.problemInsteadShare) : t.askInsteadAction;
|
|
417
429
|
ask.hidden = !asking;
|
|
418
430
|
fillLabel(summaryLabel, asking ? t.requestSummaryLabel : t.summaryLabel, asking ? "" : t.summaryRequired, true);
|
|
419
431
|
summary.placeholder = asking ? t.requestSummaryPlaceholder : t.summaryPlaceholder;
|
|
@@ -472,17 +484,49 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
472
484
|
typeCards.push(card);
|
|
473
485
|
}
|
|
474
486
|
typeList.replaceChildren(...typeCards);
|
|
487
|
+
// ↑ ↓ among the rows the search leaves.
|
|
475
488
|
typeList.onkeydown = (e) => {
|
|
476
489
|
if (!ARROWS.includes(e.key) || !chosen) return;
|
|
477
490
|
e.preventDefault();
|
|
478
|
-
const
|
|
491
|
+
const shown = types.filter((x, i) => !typeCards[i]!.hidden);
|
|
492
|
+
if (!shown.length) return;
|
|
493
|
+
const at = shown.findIndex((x) => x.id === chosen!.id);
|
|
479
494
|
const step = e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 1;
|
|
480
|
-
chosen =
|
|
495
|
+
chosen = shown[at < 0 ? 0 : (at + step + shown.length) % shown.length]!;
|
|
481
496
|
markType();
|
|
482
497
|
typeCards.find((c) => c.dataset.requestType === chosen!.id)?.focus();
|
|
483
498
|
};
|
|
499
|
+
typeList.classList.toggle("long", types.length >= SEARCH_FROM);
|
|
500
|
+
typeSearch.hidden = types.length < SEARCH_FROM;
|
|
501
|
+
const count = (n: number) => {
|
|
502
|
+
typeCount.hidden = types.length < SEARCH_FROM || n === 0;
|
|
503
|
+
typeCount.textContent = t.requestTypeCount.replace("{n}", String(n));
|
|
504
|
+
};
|
|
505
|
+
count(types.length);
|
|
506
|
+
typeSearch.oninput = () => {
|
|
507
|
+
const q = typeSearch.value.trim().toLowerCase();
|
|
508
|
+
let first: RequestTypeOption | null = null;
|
|
509
|
+
types.forEach((x, i) => {
|
|
510
|
+
const hit = !q || `${x.name} ${x.description}`.toLowerCase().includes(q);
|
|
511
|
+
typeCards[i]!.hidden = !hit;
|
|
512
|
+
if (hit && !first) first = x;
|
|
513
|
+
});
|
|
514
|
+
typeNone.hidden = !!first;
|
|
515
|
+
count(typeCards.filter((c) => !c.hidden).length);
|
|
516
|
+
// The choice follows the search when the one chosen is filtered out.
|
|
517
|
+
if (first && chosen && typeCards[types.indexOf(chosen)]!.hidden) chosen = first;
|
|
518
|
+
markType();
|
|
519
|
+
ready();
|
|
520
|
+
};
|
|
521
|
+
// Enter in the search goes to the chosen row, not the send button.
|
|
522
|
+
typeSearch.onkeydown = (e) => {
|
|
523
|
+
if (e.key === "ArrowDown" || e.key === "Enter") {
|
|
524
|
+
e.preventDefault();
|
|
525
|
+
typeCards.find((c) => c.dataset.requestType === chosen?.id && !c.hidden)?.focus();
|
|
526
|
+
}
|
|
527
|
+
};
|
|
484
528
|
markType();
|
|
485
|
-
|
|
529
|
+
door.hidden = false;
|
|
486
530
|
apply();
|
|
487
531
|
};
|
|
488
532
|
/*
|
|
@@ -511,7 +555,7 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
511
555
|
const offerAndRestore = (types: RequestTypeOption[]) => {
|
|
512
556
|
offer(types);
|
|
513
557
|
// The kind is the press's choice; a request's type comes back as it was.
|
|
514
|
-
if (mode !== "request" ||
|
|
558
|
+
if (mode !== "request" || door.hidden || !restored?.typeId) return;
|
|
515
559
|
const was = types.find((x) => x.id === restored.typeId);
|
|
516
560
|
if (was) {
|
|
517
561
|
chosen = was;
|
|
@@ -522,7 +566,7 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
522
566
|
else if (reason.type === "manual") void loadRequestTypes().then(offerAndRestore);
|
|
523
567
|
this.keep = () => ({ title: summary.value, detail: detail.value, asking: mode === "request", typeId: chosen?.id ?? null });
|
|
524
568
|
|
|
525
|
-
panel.append(head,
|
|
569
|
+
panel.append(head, door, again, ask, summaryLabel, summary, detailLabel, detail, meta, shotChoice, ...shot);
|
|
526
570
|
const foot = el("div", "foot");
|
|
527
571
|
const cancel = button(t.close);
|
|
528
572
|
cancel.addEventListener("click", () => this.close());
|
|
@@ -656,6 +700,8 @@ function hoursWords(t: Strings, h: number): string {
|
|
|
656
700
|
}
|
|
657
701
|
|
|
658
702
|
const ARROWS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
|
|
703
|
+
/** From this many request types, the list gets a search and scrolls in its own height. */
|
|
704
|
+
const SEARCH_FROM = 7;
|
|
659
705
|
|
|
660
706
|
/*
|
|
661
707
|
* The desk's own icons (lucide, as its สร้างเวิร์กโฟลว์ and the portal draw
|