@devaccel-labs/app-comments-toolbar 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/README.md +148 -30
  2. package/dist/react.js +417 -64
  3. package/dist/server.js +342 -14
  4. package/dist/types/capture/api.d.ts +30 -0
  5. package/dist/types/capture/editor.d.ts +10 -0
  6. package/dist/types/capture/ignore.d.ts +19 -0
  7. package/dist/types/capture/index.d.ts +1 -0
  8. package/dist/types/capture/picker.d.ts +9 -0
  9. package/dist/types/capture/picture.d.ts +29 -0
  10. package/dist/types/capture/surface.d.ts +39 -0
  11. package/dist/types/core/index.d.ts +2 -2
  12. package/dist/types/core/resolve.d.ts +2 -1
  13. package/dist/types/core/types.d.ts +11 -0
  14. package/dist/types/debug-context.d.ts +7 -0
  15. package/dist/types/dictation.d.ts +9 -0
  16. package/dist/types/embed/controller.d.ts +61 -0
  17. package/dist/types/embed/index.d.ts +1 -0
  18. package/dist/types/embed/options.d.ts +35 -0
  19. package/dist/types/help/index.d.ts +1 -0
  20. package/dist/types/help/panel-index.d.ts +1 -0
  21. package/dist/types/help/panel-styles.d.ts +1 -0
  22. package/dist/types/help/panel.d.ts +4 -0
  23. package/dist/types/help/pill-styles.d.ts +11 -0
  24. package/dist/types/help/pill.d.ts +44 -0
  25. package/dist/types/help/react.d.ts +12 -0
  26. package/dist/types/help/requests.d.ts +2 -0
  27. package/dist/types/help/types.d.ts +119 -0
  28. package/dist/types/help/wire.d.ts +61 -0
  29. package/dist/types/native-wire.d.ts +63 -0
  30. package/dist/types/native.d.ts +43 -0
  31. package/dist/types/react.d.ts +7 -0
  32. package/dist/types/replay.d.ts +6 -0
  33. package/dist/types/runtime/api.d.ts +250 -0
  34. package/dist/types/runtime/attachment-model.d.ts +16 -0
  35. package/dist/types/runtime/attachments.d.ts +43 -7
  36. package/dist/types/runtime/boot.d.ts +39 -0
  37. package/dist/types/runtime/browser-label.d.ts +5 -0
  38. package/dist/types/runtime/build-id.d.ts +1 -0
  39. package/dist/types/runtime/capture-ui.d.ts +45 -21
  40. package/dist/types/runtime/clarity-model.d.ts +260 -0
  41. package/dist/types/runtime/clarity-ui.d.ts +62 -0
  42. package/dist/types/runtime/client.d.ts +6 -197
  43. package/dist/types/runtime/composer-size.d.ts +30 -0
  44. package/dist/types/runtime/dashboard.d.ts +17 -2
  45. package/dist/types/runtime/debug-context-core.d.ts +88 -0
  46. package/dist/types/runtime/debug-context-host.d.ts +39 -0
  47. package/dist/types/runtime/debug-context-model.d.ts +352 -0
  48. package/dist/types/runtime/debug-context-redact.d.ts +48 -0
  49. package/dist/types/runtime/debug-context.d.ts +36 -0
  50. package/dist/types/runtime/details-model.d.ts +7 -0
  51. package/dist/types/runtime/details.d.ts +7 -5
  52. package/dist/types/runtime/dictation-engine.d.ts +98 -0
  53. package/dist/types/runtime/dictation-model.d.ts +28 -0
  54. package/dist/types/runtime/dictation-ui.d.ts +56 -0
  55. package/dist/types/runtime/dictation.d.ts +66 -0
  56. package/dist/types/runtime/draft-dock.d.ts +32 -0
  57. package/dist/types/runtime/draft-model.d.ts +104 -0
  58. package/dist/types/runtime/draft-store.d.ts +15 -0
  59. package/dist/types/runtime/edits.d.ts +1 -1
  60. package/dist/types/runtime/font.d.ts +16 -0
  61. package/dist/types/runtime/format.d.ts +30 -1
  62. package/dist/types/runtime/host-watch.d.ts +20 -0
  63. package/dist/types/runtime/icons.d.ts +16 -2
  64. package/dist/types/runtime/index.d.ts +2 -0
  65. package/dist/types/runtime/ink.d.ts +8 -0
  66. package/dist/types/runtime/kind-model.d.ts +14 -0
  67. package/dist/types/runtime/live-model.d.ts +25 -0
  68. package/dist/types/runtime/loader.d.ts +5 -3
  69. package/dist/types/runtime/markup-editor.d.ts +54 -0
  70. package/dist/types/runtime/markup-model.d.ts +126 -0
  71. package/dist/types/runtime/markup.d.ts +69 -0
  72. package/dist/types/runtime/mention-parse.d.ts +18 -0
  73. package/dist/types/runtime/mentions.d.ts +4 -4
  74. package/dist/types/runtime/page-background.d.ts +9 -0
  75. package/dist/types/runtime/page-image.d.ts +64 -11
  76. package/dist/types/runtime/placement.d.ts +6 -3
  77. package/dist/types/runtime/record.d.ts +18 -2
  78. package/dist/types/runtime/related.d.ts +1 -1
  79. package/dist/types/runtime/replay-model.d.ts +80 -0
  80. package/dist/types/runtime/replay.d.ts +5 -38
  81. package/dist/types/runtime/screen.d.ts +47 -0
  82. package/dist/types/runtime/screenshot.d.ts +60 -8
  83. package/dist/types/runtime/session-source.d.ts +45 -0
  84. package/dist/types/runtime/session.d.ts +20 -18
  85. package/dist/types/runtime/shield.d.ts +9 -0
  86. package/dist/types/runtime/shot.d.ts +45 -0
  87. package/dist/types/runtime/sketch.d.ts +26 -0
  88. package/dist/types/runtime/styles.d.ts +1 -1
  89. package/dist/types/runtime/sync.d.ts +1 -0
  90. package/dist/types/runtime/thread-model.d.ts +18 -0
  91. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  92. package/dist/types/runtime/ticket-form.d.ts +9 -0
  93. package/dist/types/runtime/toolbar.d.ts +37 -1
  94. package/dist/types/runtime/ui-scale.d.ts +17 -0
  95. package/dist/types/runtime/update.d.ts +32 -8
  96. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  97. package/dist/types/server/index.d.ts +22 -0
  98. package/dist/types/server/support.d.ts +20 -0
  99. package/dist/types/server.d.ts +2 -0
  100. package/dist/types/vite/plugin.d.ts +15 -0
  101. package/dist/vite.js +121 -41
  102. package/package.json +24 -11
  103. package/src/capture/api.ts +128 -0
  104. package/src/capture/editor.tsx +178 -0
  105. package/src/capture/ignore.ts +38 -0
  106. package/src/capture/index.ts +27 -0
  107. package/src/capture/picker.tsx +171 -0
  108. package/src/capture/picture.ts +90 -0
  109. package/src/capture/surface.tsx +185 -0
  110. package/src/compiler/annotate.ts +202 -0
  111. package/src/compiler/babel.d.ts +11 -0
  112. package/src/compiler/enabled.ts +55 -0
  113. package/src/compiler/index.ts +2 -0
  114. package/src/core/index.ts +8 -0
  115. package/src/core/resolve.ts +307 -0
  116. package/src/core/types.ts +58 -0
  117. package/src/debug-context.ts +47 -0
  118. package/src/dictation.ts +17 -0
  119. package/src/embed/controller.ts +215 -0
  120. package/src/embed/index.ts +68 -0
  121. package/src/embed/options.ts +100 -0
  122. package/src/help/index.ts +38 -0
  123. package/src/help/panel-index.ts +17 -0
  124. package/src/help/panel-styles.ts +123 -0
  125. package/src/help/panel.ts +840 -0
  126. package/src/help/pill-styles.ts +75 -0
  127. package/src/help/pill.ts +338 -0
  128. package/src/help/react.tsx +143 -0
  129. package/src/help/requests.ts +111 -0
  130. package/src/help/types.ts +124 -0
  131. package/src/help/wire.ts +55 -0
  132. package/src/native-wire.ts +124 -0
  133. package/src/native.ts +282 -0
  134. package/src/react.ts +27 -0
  135. package/src/replay.ts +13 -0
  136. package/src/runtime/api.ts +531 -0
  137. package/src/runtime/attachment-model.ts +42 -0
  138. package/src/runtime/attachments.tsx +1260 -0
  139. package/src/runtime/boot.ts +95 -0
  140. package/src/runtime/browser-label.ts +31 -0
  141. package/src/runtime/build-id.ts +10 -0
  142. package/src/runtime/capture-ui.tsx +306 -0
  143. package/src/runtime/capture.ts +508 -0
  144. package/src/runtime/clarity-model.ts +634 -0
  145. package/src/runtime/clarity-ui.tsx +454 -0
  146. package/src/runtime/client.ts +151 -0
  147. package/src/runtime/composer-size.tsx +146 -0
  148. package/src/runtime/dashboard.tsx +1130 -0
  149. package/src/runtime/debug-context-core.ts +681 -0
  150. package/src/runtime/debug-context-host.ts +56 -0
  151. package/src/runtime/debug-context-model.ts +1319 -0
  152. package/src/runtime/debug-context-redact.ts +259 -0
  153. package/src/runtime/debug-context.ts +613 -0
  154. package/src/runtime/details-model.ts +30 -0
  155. package/src/runtime/details.tsx +395 -0
  156. package/src/runtime/dictation-engine.ts +616 -0
  157. package/src/runtime/dictation-model.ts +79 -0
  158. package/src/runtime/dictation-ui.tsx +363 -0
  159. package/src/runtime/dictation.ts +271 -0
  160. package/src/runtime/dock.ts +108 -0
  161. package/src/runtime/draft-dock.tsx +124 -0
  162. package/src/runtime/draft-model.ts +251 -0
  163. package/src/runtime/draft-store.ts +149 -0
  164. package/src/runtime/edits.tsx +165 -0
  165. package/src/runtime/font.ts +42 -0
  166. package/src/runtime/format.ts +129 -0
  167. package/src/runtime/freshness.ts +23 -0
  168. package/src/runtime/handoff.ts +76 -0
  169. package/src/runtime/host-watch.ts +64 -0
  170. package/src/runtime/icons.tsx +295 -0
  171. package/src/runtime/idle.ts +20 -0
  172. package/src/runtime/index.ts +11 -0
  173. package/src/runtime/ink.ts +6 -0
  174. package/src/runtime/kind-model.ts +20 -0
  175. package/src/runtime/live-model.ts +32 -0
  176. package/src/runtime/loader.tsx +87 -0
  177. package/src/runtime/markup-editor.tsx +1333 -0
  178. package/src/runtime/markup-model.ts +485 -0
  179. package/src/runtime/markup.ts +382 -0
  180. package/src/runtime/members.ts +58 -0
  181. package/src/runtime/mention-parse.ts +56 -0
  182. package/src/runtime/mentions.tsx +205 -0
  183. package/src/runtime/page-background.ts +51 -0
  184. package/src/runtime/page-image.ts +1592 -0
  185. package/src/runtime/placement.ts +336 -0
  186. package/src/runtime/record.ts +402 -0
  187. package/src/runtime/redact.ts +30 -0
  188. package/src/runtime/related.ts +34 -0
  189. package/src/runtime/render-page.ts +2 -0
  190. package/src/runtime/replay-model.ts +232 -0
  191. package/src/runtime/replay.ts +173 -0
  192. package/src/runtime/runtime-version.ts +7 -0
  193. package/src/runtime/runtime.tsx +51 -0
  194. package/src/runtime/screen.ts +182 -0
  195. package/src/runtime/screenshot.ts +260 -0
  196. package/src/runtime/session-source.ts +112 -0
  197. package/src/runtime/session.ts +108 -0
  198. package/src/runtime/shield.ts +34 -0
  199. package/src/runtime/shot.tsx +422 -0
  200. package/src/runtime/sketch.ts +139 -0
  201. package/src/runtime/styles.ts +1541 -0
  202. package/src/runtime/sync.ts +300 -0
  203. package/src/runtime/thread-model.ts +71 -0
  204. package/src/runtime/ticket-form-model.ts +128 -0
  205. package/src/runtime/ticket-form.tsx +342 -0
  206. package/src/runtime/toolbar.tsx +4427 -0
  207. package/src/runtime/ui-scale.ts +75 -0
  208. package/src/runtime/update.ts +98 -0
  209. package/src/runtime/use-debug-context.ts +81 -0
  210. package/src/server/index.ts +133 -0
  211. package/src/server/support.ts +499 -0
  212. package/src/server.ts +4 -0
  213. package/src/vite/plugin.ts +102 -0
  214. package/src/vite.ts +1 -0
@@ -0,0 +1,840 @@
1
+ /**
2
+ * The panel the pill opens: a small dialog anchored to the pill, not modal, with two parts.
3
+ *
4
+ * - **New request**: what the person needs, pictures of the page from the capture script, and Send. The
5
+ * host's `submit` records it. A failed send keeps everything and the next send carries the same id.
6
+ * - **Your requests** (when the host gives `list`): what this person has sent and where each stands. Read
7
+ * when the part is opened and after a send, never on a timer.
8
+ *
9
+ * Plain DOM, no React: `help-panel.js` is fetched the first time someone reaches for the pill and should be
10
+ * on the page at once. What is written and attached is kept here for as long as the pill is mounted, so it
11
+ * survives closing the panel; it is dropped with the pill, because the next person to sign in on the same
12
+ * page must not find it.
13
+ */
14
+ import type { CommentToolCaptureApi } from "../capture/api.js";
15
+ import { redactSecretSearchParams } from "../runtime/replay-model.js";
16
+ import { HELP_PANEL_CSS } from "./panel-styles.js";
17
+ import { ensureHelpStyles, type PanelHost, type PanelView } from "./pill.js";
18
+ import {
19
+ isHelpError,
20
+ type HelpImage,
21
+ type HelpReceipt,
22
+ type HelpRequest,
23
+ type HelpTheme,
24
+ type SubmittedRequest,
25
+ type SubmittedRequestStatus,
26
+ } from "./types.js";
27
+ import { HELP_LIMITS } from "./wire.js";
28
+
29
+ const STYLE_ID = "cth-style-panel";
30
+ const DEFAULT_LABEL = "Need help?";
31
+ const NOT_SENT = "That did not send. Check your connection and try again.";
32
+ const NO_PICTURES = "Pictures are not available right now.";
33
+ const NOT_PICTURED = "This page could not be pictured.";
34
+ const NOT_LISTED = "Your requests could not be loaded.";
35
+ const NOTHING_YET = "Nothing yet. What you send appears here.";
36
+ /** The count of characters left is shown from here on. */
37
+ const COUNT_FROM = 200;
38
+
39
+ /** Said of a request the team merged into one it already had. The status follows it. */
40
+ const CONSOLIDATED =
41
+ "We already have a ticket tracking this issue, so we\u2019ve consolidated your request into it.";
42
+
43
+ const STATUS_WORDS: Record<SubmittedRequestStatus, string> = {
44
+ received: "Received",
45
+ in_progress: "In progress",
46
+ resolved: "Resolved",
47
+ };
48
+
49
+ const ICONS = {
50
+ close: "M4 4l8 8M12 4l-8 8",
51
+ camera:
52
+ "M2.5 5.5h2l1-1.5h5l1 1.5h2a1 1 0 0 1 1 1v5.5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V6.5a1 1 0 0 1 1-1zM8 11a2.2 2.2 0 1 0 0-4.4A2.2 2.2 0 0 0 8 11z",
53
+ pointer: "M3.5 2.5l9 4.2-3.8 1.5-1.5 3.8z M9.5 9.5l3 3",
54
+ check: "M3.5 8.5l3 3 6-7",
55
+ } as const;
56
+
57
+ type Part = "new" | "requests";
58
+
59
+ type Picture = {
60
+ key: string;
61
+ image: HelpImage;
62
+ /** A `blob:` address for the thumbnail. */
63
+ url: string;
64
+ /** Taken by pointing: it goes when the pointing goes. */
65
+ pointed: boolean;
66
+ };
67
+
68
+ type Draft = {
69
+ id: string;
70
+ message: string;
71
+ pointed: { label: string } | null;
72
+ pictures: Picture[];
73
+ };
74
+
75
+ type Listing =
76
+ | { state: "idle" }
77
+ | { state: "loading" }
78
+ | { state: "failed" }
79
+ | { state: "ready"; rows: readonly SubmittedRequest[] };
80
+
81
+ type Child = Node | string | null | false | undefined;
82
+ type Attributes = Record<string, string | boolean | null | undefined | ((event: Event) => void)>;
83
+
84
+ function h<K extends keyof HTMLElementTagNameMap>(
85
+ tag: K,
86
+ attributes: Attributes = {},
87
+ ...children: Child[]
88
+ ): HTMLElementTagNameMap[K] {
89
+ const node = document.createElement(tag);
90
+ for (const [name, value] of Object.entries(attributes)) {
91
+ if (value === null || value === undefined || value === false) continue;
92
+ if (typeof value === "function") node.addEventListener(name.slice(2), value);
93
+ else if (name === "class") node.className = String(value);
94
+ else node.setAttribute(name, value === true ? "" : value);
95
+ }
96
+ for (const child of children) {
97
+ if (child === null || child === undefined || child === false) continue;
98
+ node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
99
+ }
100
+ return node;
101
+ }
102
+
103
+ function icon(name: keyof typeof ICONS): SVGSVGElement {
104
+ const namespace = "http://www.w3.org/2000/svg";
105
+ const svg = document.createElementNS(namespace, "svg");
106
+ svg.setAttribute("class", "cth-icon");
107
+ svg.setAttribute("viewBox", "0 0 16 16");
108
+ svg.setAttribute("aria-hidden", "true");
109
+ const path = document.createElementNS(namespace, "path");
110
+ path.setAttribute("d", ICONS[name]);
111
+ svg.appendChild(path);
112
+ return svg;
113
+ }
114
+
115
+ /** A request's own id: a random UUID, also where `crypto.randomUUID` is kept from insecure pages. */
116
+ function newId(): string {
117
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
118
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
119
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
120
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
121
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
122
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
123
+ }
124
+
125
+ /** An address the panel may link to: the host's own word for it, but never a script. */
126
+ function linkable(value: unknown): string | null {
127
+ if (typeof value !== "string" || !value.trim()) return null;
128
+ try {
129
+ const url = new URL(value, window.location.href);
130
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ function oneLine(value: unknown, limit: number): string {
137
+ return typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, limit) : "";
138
+ }
139
+
140
+ /** "Just now", "5 minutes ago", "Yesterday", then the date. */
141
+ export function whenWords(iso: string, now: number): string {
142
+ const then = Date.parse(iso);
143
+ if (Number.isNaN(then)) return "";
144
+ const minutes = Math.floor((now - then) / 60_000);
145
+ if (minutes < 1) return "Just now";
146
+ if (minutes < 60) return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
147
+ const hours = Math.floor(minutes / 60);
148
+ if (hours < 24) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
149
+ const days = Math.floor(hours / 24);
150
+ if (days === 1) return "Yesterday";
151
+ if (days < 7) return `${days} days ago`;
152
+ const date = new Date(then);
153
+ return date.toLocaleDateString(undefined, {
154
+ day: "numeric",
155
+ month: "short",
156
+ ...(date.getFullYear() === new Date(now).getFullYear() ? {} : { year: "numeric" }),
157
+ });
158
+ }
159
+
160
+ /** The page's address as a request carries it: credentials in its query are not the support team's to see. */
161
+ function pageAddress(): string {
162
+ const url = new URL(window.location.href);
163
+ redactSecretSearchParams(url);
164
+ // A fragment written as parameters (`#access_token=…`) is held to the same rule.
165
+ if (url.hash.includes("=")) {
166
+ const inner = new URL(`http://fragment.invalid/?${url.hash.slice(1)}`);
167
+ redactSecretSearchParams(inner);
168
+ url.hash = inner.search.slice(1);
169
+ }
170
+ return url.href.slice(0, HELP_LIMITS.url);
171
+ }
172
+
173
+ function isStatus(value: unknown): value is SubmittedRequestStatus {
174
+ return value === "received" || value === "in_progress" || value === "resolved";
175
+ }
176
+
177
+ /** The rows a host's `list` gave, newest first: only what is shaped like a request, and no more than fit. */
178
+ function rowsOf(given: unknown): SubmittedRequest[] {
179
+ if (!Array.isArray(given)) throw new Error("list() must resolve with an array.");
180
+ return (given as Partial<SubmittedRequest>[])
181
+ .filter(
182
+ (row) =>
183
+ typeof row === "object" &&
184
+ row !== null &&
185
+ typeof row.id === "string" &&
186
+ typeof row.createdAt === "string" &&
187
+ isStatus(row.status),
188
+ )
189
+ .map((row) => row as SubmittedRequest)
190
+ .sort((a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0))
191
+ .slice(0, HELP_LIMITS.listed);
192
+ }
193
+
194
+ export function createPanel(host: PanelHost): PanelView {
195
+ ensureHelpStyles(STYLE_ID, HELP_PANEL_CSS);
196
+
197
+ let part: Part = "new";
198
+ let draft: Draft = { id: newId(), message: "", pointed: null, pictures: [] };
199
+ let sending = false;
200
+ let capturing = false;
201
+ let failure: string | null = null;
202
+ let notice: string | null = null;
203
+ let receipt: HelpReceipt | null = null;
204
+ let listing: Listing = { state: "idle" };
205
+ let listRun = 0;
206
+ let pictureSeq = 0;
207
+ let isOpen = false;
208
+ let destroyed = false;
209
+ let announceTimer: ReturnType<typeof setTimeout> | undefined;
210
+
211
+ const content = h("div", { class: "cth-content" });
212
+ const live = h("div", { class: "cth-live", role: "status", "aria-live": "polite" });
213
+ const section = h(
214
+ "section",
215
+ {
216
+ class: "cth-panel",
217
+ id: "cth-panel",
218
+ role: "dialog",
219
+ "aria-modal": "false",
220
+ tabindex: "-1",
221
+ "data-cs-help-ui": "panel",
222
+ onkeydown: (event) => onKey(event as KeyboardEvent),
223
+ },
224
+ content,
225
+ live,
226
+ );
227
+
228
+ const options = () => host.options();
229
+ const title = () => oneLine(options().label, 60) || DEFAULT_LABEL;
230
+ const theme = (): HelpTheme => options().theme ?? "auto";
231
+ const hasList = () => typeof options().list === "function";
232
+ const capturesOn = () => {
233
+ const captures = options().captures;
234
+ try {
235
+ return typeof captures === "function" ? captures() !== false : captures !== false;
236
+ } catch {
237
+ return false;
238
+ }
239
+ };
240
+ const canSend = () => {
241
+ const length = draft.message.trim().length;
242
+ return !sending && !capturing && length > 0 && length <= HELP_LIMITS.message;
243
+ };
244
+
245
+ /** Told to a screen reader. Cleared first, so the same sentence twice is said twice. */
246
+ function announce(message: string) {
247
+ live.textContent = "";
248
+ clearTimeout(announceTimer);
249
+ announceTimer = setTimeout(() => {
250
+ live.textContent = message;
251
+ }, 50);
252
+ }
253
+
254
+ function release(pictures: readonly Picture[]) {
255
+ for (const picture of pictures) URL.revokeObjectURL(picture.url);
256
+ }
257
+
258
+ // ----- Sending ------------------------------------------------------------------------------------------
259
+
260
+ function sentWords(given: HelpReceipt): string {
261
+ const reference = oneLine(given.reference, 60);
262
+ return reference ? `Request ${reference} received.` : "Request received.";
263
+ }
264
+
265
+ async function send() {
266
+ if (!canSend()) return;
267
+ const sent = draft;
268
+ const request: HelpRequest = {
269
+ id: sent.id,
270
+ message: sent.message.trim(),
271
+ pageUrl: pageAddress(),
272
+ route: window.location.pathname,
273
+ pointed: sent.pointed,
274
+ images: sent.pictures.map((picture) => picture.image),
275
+ client: {
276
+ userAgent: navigator.userAgent,
277
+ language: navigator.language,
278
+ viewport: { width: window.innerWidth, height: window.innerHeight },
279
+ },
280
+ };
281
+ sending = true;
282
+ failure = null;
283
+ notice = null;
284
+ render();
285
+ try {
286
+ const given = (await (async () => options().submit(request))()) as HelpReceipt | null;
287
+ if (destroyed) return;
288
+ receipt = {
289
+ reference: oneLine(given?.reference, 60) || undefined,
290
+ url: linkable(given?.url) ?? undefined,
291
+ note: oneLine(given?.note, 200) || undefined,
292
+ };
293
+ release(sent.pictures);
294
+ draft = { id: newId(), message: "", pointed: null, pictures: [] };
295
+ sending = false;
296
+ render("sent");
297
+ announce([sentWords(receipt), receipt.note].filter(Boolean).join(" "));
298
+ // Read past the kept rows: the request just sent belongs at the top of them.
299
+ if (hasList()) void readList();
300
+ } catch (error) {
301
+ if (destroyed) return;
302
+ failure = isHelpError(error) ? oneLine(error.message, 300) : NOT_SENT;
303
+ sending = false;
304
+ render("send");
305
+ }
306
+ }
307
+
308
+ // ----- Your requests ------------------------------------------------------------------------------------
309
+
310
+ async function readList() {
311
+ const list = options().list;
312
+ if (typeof list !== "function") return;
313
+ const run = ++listRun;
314
+ // Rows already shown stay while they are read again.
315
+ if (listing.state !== "ready") {
316
+ listing = { state: "loading" };
317
+ render();
318
+ }
319
+ try {
320
+ const rows = rowsOf(await (async () => list())());
321
+ if (run !== listRun || destroyed) return;
322
+ listing = { state: "ready", rows };
323
+ } catch {
324
+ if (run !== listRun || destroyed) return;
325
+ listing = { state: "failed" };
326
+ }
327
+ render();
328
+ }
329
+
330
+ function show(next: Part) {
331
+ if (part === next) return;
332
+ part = next;
333
+ render(next === "new" ? "tab-new" : "tab-requests");
334
+ if (next === "requests") void readList();
335
+ }
336
+
337
+ // ----- Pictures -----------------------------------------------------------------------------------------
338
+
339
+ /**
340
+ * Runs one capture with the pill and the panel out of the way, and brings them back. The root is marked
341
+ * `data-cs-capture-ignore`, so it is in no picture even before it is hidden.
342
+ */
343
+ async function capture<T>(
344
+ focus: string,
345
+ run: (api: CommentToolCaptureApi) => Promise<T | null>,
346
+ ): Promise<T | null> {
347
+ if (capturing || sending) return null;
348
+ notice = null;
349
+ if (!capturesOn()) {
350
+ render();
351
+ return null;
352
+ }
353
+ capturing = true;
354
+ render(focus);
355
+ let api: CommentToolCaptureApi;
356
+ try {
357
+ api = await host.capture();
358
+ } catch {
359
+ if (destroyed) return null;
360
+ capturing = false;
361
+ notice = NO_PICTURES;
362
+ render(focus);
363
+ return null;
364
+ }
365
+ if (destroyed) return null;
366
+ let taken: T | null = null;
367
+ host.hide(true);
368
+ try {
369
+ taken = await run(api);
370
+ } catch {
371
+ notice = NOT_PICTURED;
372
+ }
373
+ if (destroyed) return null;
374
+ host.hide(false);
375
+ capturing = false;
376
+ return taken;
377
+ }
378
+
379
+ function attach(image: HelpImage, pointed: boolean): boolean {
380
+ if (draft.pictures.length >= HELP_LIMITS.images) return false;
381
+ pictureSeq += 1;
382
+ draft.pictures.push({
383
+ key: `picture-${pictureSeq}`,
384
+ image,
385
+ url: URL.createObjectURL(image.blob),
386
+ pointed,
387
+ });
388
+ return true;
389
+ }
390
+
391
+ function detach(match: (picture: Picture) => boolean) {
392
+ release(draft.pictures.filter(match));
393
+ draft.pictures = draft.pictures.filter((picture) => !match(picture));
394
+ }
395
+
396
+ async function addPicture() {
397
+ if (draft.pictures.length >= HELP_LIMITS.images) return;
398
+ const image = await capture("add-picture", (api) => api.screenshot({ theme: theme() }));
399
+ if (destroyed) return;
400
+ if (image && attach(image, false)) {
401
+ announce(`Picture added. ${draft.pictures.length} of ${HELP_LIMITS.images}.`);
402
+ }
403
+ render("add-picture");
404
+ }
405
+
406
+ async function pointAt() {
407
+ const target = await capture("point", (api) => api.pick({ theme: theme() }));
408
+ if (destroyed) return;
409
+ if (target) {
410
+ // One thing is pointed at: a second pointing takes the first one's place, picture and all.
411
+ detach((picture) => picture.pointed);
412
+ draft.pointed = {
413
+ label: oneLine(target.label, HELP_LIMITS.label) || "Something on the page",
414
+ };
415
+ if (target.image) attach(target.image, true);
416
+ announce(`Pointing at ${draft.pointed.label}.`);
417
+ }
418
+ render("point");
419
+ }
420
+
421
+ // ----- Drawing ------------------------------------------------------------------------------------------
422
+
423
+ function button(
424
+ key: string,
425
+ className: string,
426
+ onclick: () => void,
427
+ attributes: Attributes,
428
+ ...children: Child[]
429
+ ) {
430
+ return h(
431
+ "button",
432
+ { type: "button", class: className, "data-key": key, onclick, ...attributes },
433
+ ...children,
434
+ );
435
+ }
436
+
437
+ function tabs(): HTMLElement | null {
438
+ if (!hasList()) return null;
439
+ const tab = (which: Part, words: string) =>
440
+ button(
441
+ `tab-${which}`,
442
+ "cth-tab",
443
+ () => show(which),
444
+ {
445
+ role: "tab",
446
+ id: `cth-tab-${which}`,
447
+ "aria-selected": String(part === which),
448
+ "aria-controls": "cth-part",
449
+ tabindex: part === which ? "0" : "-1",
450
+ "data-cs-help-ui": `tab-${which}`,
451
+ },
452
+ words,
453
+ );
454
+ return h(
455
+ "div",
456
+ {
457
+ class: "cth-tabs",
458
+ role: "tablist",
459
+ "aria-label": title(),
460
+ onkeydown: (event) => {
461
+ const key = (event as KeyboardEvent).key;
462
+ if (key !== "ArrowLeft" && key !== "ArrowRight") return;
463
+ event.preventDefault();
464
+ show(part === "new" ? "requests" : "new");
465
+ },
466
+ },
467
+ tab("new", "New request"),
468
+ tab("requests", "Your requests"),
469
+ );
470
+ }
471
+
472
+ function form(): HTMLElement {
473
+ const left = HELP_LIMITS.message - draft.message.length;
474
+ const count = h(
475
+ "div",
476
+ { class: "cth-count", "data-cs-help-ui": "count" },
477
+ left <= COUNT_FROM ? `${left} left` : "",
478
+ );
479
+ const sendButton = button(
480
+ "send",
481
+ "cth-send",
482
+ () => void send(),
483
+ {
484
+ disabled: !canSend(),
485
+ "aria-busy": sending ? "true" : null,
486
+ "data-cs-help-ui": "send",
487
+ },
488
+ sending ? "Sending\u2026" : "Send",
489
+ );
490
+ const text = h("textarea", {
491
+ class: "cth-text",
492
+ id: "cth-message",
493
+ "data-key": "message",
494
+ "data-cs-help-ui": "message",
495
+ rows: "4",
496
+ maxlength: String(HELP_LIMITS.message),
497
+ placeholder: "Say what happened, and what you expected.",
498
+ readonly: sending,
499
+ oninput: () => {
500
+ draft.message = text.value;
501
+ const remaining = HELP_LIMITS.message - text.value.length;
502
+ count.textContent = remaining <= COUNT_FROM ? `${remaining} left` : "";
503
+ sendButton.disabled = !canSend();
504
+ },
505
+ onkeydown: (event) => {
506
+ const key = event as KeyboardEvent;
507
+ if (key.key === "Enter" && (key.metaKey || key.ctrlKey)) {
508
+ key.preventDefault();
509
+ void send();
510
+ }
511
+ },
512
+ });
513
+ text.value = draft.message;
514
+
515
+ const full = draft.pictures.length >= HELP_LIMITS.images;
516
+ const busy = sending || capturing;
517
+ const tools = capturesOn()
518
+ ? h(
519
+ "div",
520
+ { class: "cth-tools" },
521
+ button(
522
+ "add-picture",
523
+ "cth-tool",
524
+ () => void addPicture(),
525
+ { disabled: busy || full, "data-cs-help-ui": "add-picture" },
526
+ icon("camera"),
527
+ "Add a picture of this page",
528
+ draft.pictures.length > 0
529
+ ? h(
530
+ "span",
531
+ { class: "cth-tool-more" },
532
+ `${draft.pictures.length} of ${HELP_LIMITS.images}`,
533
+ )
534
+ : null,
535
+ ),
536
+ button(
537
+ "point",
538
+ "cth-tool",
539
+ () => void pointAt(),
540
+ { disabled: busy, "data-cs-help-ui": "point" },
541
+ icon("pointer"),
542
+ draft.pointed ? "Point at something else" : "Point at what\u2019s wrong",
543
+ ),
544
+ )
545
+ : null;
546
+
547
+ const pointed = draft.pointed
548
+ ? h(
549
+ "div",
550
+ { class: "cth-chip", "data-cs-help-ui": "pointed" },
551
+ h(
552
+ "span",
553
+ { class: "cth-chip-text" },
554
+ "Pointing at ",
555
+ h("span", { class: "cth-chip-what" }, draft.pointed.label),
556
+ ),
557
+ button(
558
+ "unpoint",
559
+ "cth-x",
560
+ () => {
561
+ draft.pointed = null;
562
+ detach((picture) => picture.pointed);
563
+ render("point");
564
+ },
565
+ { "aria-label": "Stop pointing at it", disabled: busy },
566
+ icon("close"),
567
+ ),
568
+ )
569
+ : null;
570
+
571
+ const thumbs =
572
+ draft.pictures.length > 0
573
+ ? h(
574
+ "ul",
575
+ { class: "cth-thumbs", "aria-label": "Pictures" },
576
+ ...draft.pictures.map((picture, index) =>
577
+ h(
578
+ "li",
579
+ { class: "cth-thumb", "data-cs-help-ui": "picture" },
580
+ h("img", {
581
+ class: "cth-thumb-img",
582
+ src: picture.url,
583
+ alt: picture.pointed
584
+ ? `Picture ${index + 1}: what you pointed at`
585
+ : `Picture ${index + 1} of this page`,
586
+ }),
587
+ button(
588
+ `remove-${picture.key}`,
589
+ "cth-thumb-x",
590
+ () => {
591
+ detach((each) => each === picture);
592
+ render("add-picture");
593
+ announce("Picture removed.");
594
+ },
595
+ { "aria-label": `Remove picture ${index + 1}`, disabled: busy },
596
+ icon("close"),
597
+ ),
598
+ ),
599
+ ),
600
+ )
601
+ : null;
602
+
603
+ const more = options().moreOptions;
604
+ return h(
605
+ "form",
606
+ {
607
+ class: "cth-form",
608
+ novalidate: true,
609
+ onsubmit: (event) => {
610
+ event.preventDefault();
611
+ void send();
612
+ },
613
+ },
614
+ h("label", { class: "cth-q", for: "cth-message" }, "What do you need help with?"),
615
+ text,
616
+ count,
617
+ tools,
618
+ pointed,
619
+ thumbs,
620
+ notice ? h("p", { class: "cth-hint", "data-cs-help-ui": "notice" }, notice) : null,
621
+ failure
622
+ ? h("p", { class: "cth-alert", role: "alert", "data-cs-help-ui": "failure" }, failure)
623
+ : null,
624
+ h(
625
+ "div",
626
+ { class: "cth-foot" },
627
+ typeof more === "function"
628
+ ? button(
629
+ "more",
630
+ "cth-plain",
631
+ () => {
632
+ close();
633
+ more({ message: draft.message });
634
+ },
635
+ { disabled: busy, "data-cs-help-ui": "more" },
636
+ "More options",
637
+ )
638
+ : null,
639
+ sendButton,
640
+ ),
641
+ );
642
+ }
643
+
644
+ function sent(given: HelpReceipt): HTMLElement {
645
+ const address = linkable(given.url);
646
+ return h(
647
+ "div",
648
+ { class: "cth-sent", "data-cs-help-ui": "sent" },
649
+ h("span", { class: "cth-sent-mark" }, icon("check")),
650
+ h("p", { class: "cth-sent-title", "data-key": "sent", tabindex: "-1" }, sentWords(given)),
651
+ given.note ? h("p", { class: "cth-hint" }, given.note) : null,
652
+ h(
653
+ "p",
654
+ { class: "cth-hint" },
655
+ hasList() ? "It is in Your requests." : "The support team has it.",
656
+ ),
657
+ h(
658
+ "div",
659
+ { class: "cth-sent-actions" },
660
+ button(
661
+ "another",
662
+ "cth-plain",
663
+ () => {
664
+ receipt = null;
665
+ render("message");
666
+ },
667
+ { "data-cs-help-ui": "another" },
668
+ "Write another",
669
+ ),
670
+ address
671
+ ? h(
672
+ "a",
673
+ { class: "cth-plain", href: address, "data-key": "open", "data-cs-help-ui": "open" },
674
+ "Open it",
675
+ )
676
+ : null,
677
+ ),
678
+ );
679
+ }
680
+
681
+ function requests(): HTMLElement {
682
+ switch (listing.state) {
683
+ case "idle":
684
+ case "loading":
685
+ return h(
686
+ "p",
687
+ { class: "cth-empty", "data-cs-help-ui": "requests-loading" },
688
+ "Loading your requests\u2026",
689
+ );
690
+ case "failed":
691
+ return h(
692
+ "div",
693
+ { class: "cth-empty", "data-cs-help-ui": "requests-failed" },
694
+ h("p", { role: "alert" }, NOT_LISTED),
695
+ button("retry", "cth-plain", () => void readList(), {}, "Try again"),
696
+ );
697
+ case "ready": {
698
+ if (listing.rows.length === 0) {
699
+ return h("p", { class: "cth-empty", "data-cs-help-ui": "requests-empty" }, NOTHING_YET);
700
+ }
701
+ const now = Date.now();
702
+ return h(
703
+ "ul",
704
+ { class: "cth-list", "data-cs-help-ui": "requests" },
705
+ ...listing.rows.map((row) => {
706
+ const address = linkable(row.url);
707
+ const reference = oneLine(row.reference, 60);
708
+ const when = whenWords(row.createdAt, now);
709
+ const status = oneLine(row.statusLabel, 40) || STATUS_WORDS[row.status];
710
+ const inner: Child[] = [
711
+ h("span", { class: "cth-row-summary" }, oneLine(row.summary, 200) || "(no words)"),
712
+ h(
713
+ "span",
714
+ { class: "cth-row-meta" },
715
+ h("time", { datetime: row.createdAt }, when),
716
+ reference ? ` \u00b7 ${reference}` : null,
717
+ ),
718
+ h("span", { class: "cth-status", "data-status": row.status }, status),
719
+ row.consolidated === true
720
+ ? h(
721
+ "span",
722
+ { class: "cth-row-note", "data-cs-help-ui": "request-consolidated" },
723
+ `${CONSOLIDATED} Status: ${status}`,
724
+ )
725
+ : null,
726
+ ];
727
+ return h(
728
+ "li",
729
+ { class: "cth-row", "data-cs-help-ui": "request" },
730
+ address
731
+ ? h("a", { class: "cth-row-main", href: address }, ...inner)
732
+ : h("div", { class: "cth-row-main" }, ...inner),
733
+ );
734
+ }),
735
+ );
736
+ }
737
+ default: {
738
+ const unreachable: never = listing;
739
+ throw new Error(`Unhandled listing ${String(unreachable)}`);
740
+ }
741
+ }
742
+ }
743
+
744
+ /** Draws the panel as it stands. `focus` names the control that takes focus; left out, focus stays put. */
745
+ function render(focus?: string) {
746
+ if (!isOpen || destroyed) return;
747
+ const active = document.activeElement;
748
+ const held =
749
+ focus ??
750
+ (active instanceof HTMLElement && section.contains(active) ? active.dataset.key : undefined);
751
+ const caret =
752
+ active instanceof HTMLTextAreaElement && section.contains(active)
753
+ ? ([active.selectionStart, active.selectionEnd] as const)
754
+ : null;
755
+ const shown: Part = hasList() ? part : "new";
756
+ section.setAttribute("aria-label", title());
757
+ const tabList = tabs();
758
+ content.replaceChildren(
759
+ h(
760
+ "header",
761
+ { class: "cth-head" },
762
+ h("h2", { class: "cth-title" }, title()),
763
+ button("close", "cth-x", () => close(), { "aria-label": "Close" }, icon("close")),
764
+ ),
765
+ ...(tabList ? [tabList] : []),
766
+ h(
767
+ "div",
768
+ {
769
+ class: "cth-body",
770
+ id: "cth-part",
771
+ ...(hasList() ? { role: "tabpanel", "aria-labelledby": `cth-tab-${shown}` } : {}),
772
+ },
773
+ shown === "requests" ? requests() : receipt ? sent(receipt) : form(),
774
+ ),
775
+ );
776
+ if (held === undefined) return;
777
+ const next = section.querySelector<HTMLElement>(`[data-key="${held}"]`);
778
+ const target = next && !(next as HTMLButtonElement).disabled ? next : section;
779
+ target.focus({ preventScroll: true });
780
+ if (caret && target instanceof HTMLTextAreaElement) target.setSelectionRange(...caret);
781
+ }
782
+
783
+ function onKey(event: KeyboardEvent) {
784
+ if (event.key !== "Escape" || event.defaultPrevented) return;
785
+ event.preventDefault();
786
+ event.stopPropagation();
787
+ close();
788
+ }
789
+
790
+ const pillKey = (event: KeyboardEvent) => {
791
+ if (event.key === "Escape" && isOpen) {
792
+ event.preventDefault();
793
+ close();
794
+ }
795
+ };
796
+ host.pill.addEventListener("keydown", pillKey);
797
+
798
+ function open() {
799
+ if (destroyed || isOpen) return;
800
+ isOpen = true;
801
+ host.root.appendChild(section);
802
+ host.pill.setAttribute("aria-expanded", "true");
803
+ host.pill.setAttribute("aria-controls", section.id);
804
+ const shown: Part = hasList() ? part : "new";
805
+ render(shown === "requests" ? "tab-requests" : receipt ? "sent" : "message");
806
+ if (shown === "requests") void readList();
807
+ }
808
+
809
+ function close() {
810
+ if (!isOpen) return;
811
+ const within = section.contains(document.activeElement);
812
+ isOpen = false;
813
+ section.remove();
814
+ host.pill.setAttribute("aria-expanded", "false");
815
+ // A confirmation has been read once the panel is closed: the next opening is a new request.
816
+ receipt = null;
817
+ if (within || document.activeElement === document.body) {
818
+ host.pill.focus({ preventScroll: true });
819
+ }
820
+ }
821
+
822
+ return {
823
+ get isOpen() {
824
+ return isOpen;
825
+ },
826
+ open,
827
+ close,
828
+ update: () => render(),
829
+ destroy() {
830
+ destroyed = true;
831
+ isOpen = false;
832
+ clearTimeout(announceTimer);
833
+ host.pill.removeEventListener("keydown", pillKey);
834
+ host.pill.setAttribute("aria-expanded", "false");
835
+ section.remove();
836
+ release(draft.pictures);
837
+ draft.pictures = [];
838
+ },
839
+ };
840
+ }