@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,4427 @@
1
+ "use client";
2
+
3
+ import type { Anchor, Sketch } from "../core/index.js";
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ useSyncExternalStore,
12
+ type ButtonHTMLAttributes,
13
+ type CSSProperties,
14
+ type HTMLAttributes,
15
+ type KeyboardEvent as ReactKeyboardEvent,
16
+ type PointerEvent as ReactPointerEvent,
17
+ type ReactNode,
18
+ } from "react";
19
+ import { createPortal } from "react-dom";
20
+ import { captureAnchor, describeElement, elementAt, resolveInDocument } from "./capture.js";
21
+ import {
22
+ adoptStoredMember,
23
+ finishMsfSignIn,
24
+ listenForMsfHandoff,
25
+ msfAuthorization,
26
+ relayMsfHandoff,
27
+ signInOptions,
28
+ type Access,
29
+ type SignInOptions,
30
+ createThread,
31
+ editMessage,
32
+ fetchTicketForm,
33
+ getProfile,
34
+ memberIsCurrent,
35
+ replyToThread,
36
+ saveSettings,
37
+ storageKey,
38
+ updateProfile,
39
+ updateThread,
40
+ uploadAttachment,
41
+ type Capture,
42
+ type CommentMessage,
43
+ type Delivery,
44
+ type Details,
45
+ type Member,
46
+ type Profile,
47
+ type Settings,
48
+ type Thread,
49
+ transcribeAudio,
50
+ } from "./client.js";
51
+ import {
52
+ DetailsEditor,
53
+ DetailsInput,
54
+ FieldValues,
55
+ KindInput,
56
+ RoleBadge,
57
+ TicketBadge,
58
+ SettingsPanel,
59
+ detailsEnabled,
60
+ detailsPayload,
61
+ hasFieldValues,
62
+ noDetails,
63
+ } from "./details.js";
64
+ import {
65
+ TicketFormInput,
66
+ missingTicketFields,
67
+ ticketDraftPayload,
68
+ type TicketForm,
69
+ type TicketValues,
70
+ } from "./ticket-form.js";
71
+ import {
72
+ ago,
73
+ colorFor,
74
+ deliveryText,
75
+ initials,
76
+ openPinCardSpot,
77
+ paintThreadStatuses,
78
+ statusNote,
79
+ targetName,
80
+ } from "./format.js";
81
+ import * as Icon from "./icons.js";
82
+ import {
83
+ AttachButtons,
84
+ AttachmentTray,
85
+ MediaPane,
86
+ MessageAttachments,
87
+ filesFromClipboard,
88
+ pendingPreviews,
89
+ useAttachments,
90
+ visualAttachments,
91
+ } from "./attachments.js";
92
+ import { DraftDock, draftDockMode } from "./draft-dock.js";
93
+ import {
94
+ createDraftStore,
95
+ describeDraft,
96
+ recoverRecording,
97
+ type StoredDraft,
98
+ } from "./draft-store.js";
99
+ import type { MarkupShape } from "./markup.js";
100
+ import { ShotSurface, flattenCapture } from "./shot.js";
101
+ import { uiUnit, useScreenTier } from "./ui-scale.js";
102
+ import { RecordChrome, useTabRecording } from "./capture-ui.js";
103
+ import { CLARITY_WORDS, readClarityMode, type ClarityMode } from "./clarity-model.js";
104
+ import type { ReportKind } from "./kind-model.js";
105
+ import { ClarityQuestions, useClarity } from "./clarity-ui.js";
106
+ import { DictateButton, isDictationKey, useDictation } from "./dictation-ui.js";
107
+ import { ResizeGrip, readComposerSize, type ComposerSize } from "./composer-size.js";
108
+ import { RECORD_MAX_MS } from "./record.js";
109
+ import { ReplayBuffer, redactSecretSearchParams, watchActivity } from "./replay.js";
110
+ import type { DebugContextConfig } from "./debug-context-model.js";
111
+ import { uploadDebugContext, useDebugContext } from "./use-debug-context.js";
112
+ import { MentionTextarea, MessageText, mentionsIn } from "./mentions.js";
113
+ import { useMembers } from "./members.js";
114
+ import {
115
+ clampBar,
116
+ dockMargin,
117
+ dockSlop,
118
+ placeDock,
119
+ readDock,
120
+ snapDock,
121
+ type Dock,
122
+ type DockPoint,
123
+ } from "./dock.js";
124
+ import {
125
+ overlayBounds,
126
+ placeAgainst,
127
+ useOverlayHost,
128
+ usePlacement,
129
+ type Box,
130
+ type Placed,
131
+ } from "./placement.js";
132
+ import {
133
+ captureAround,
134
+ describeCapture,
135
+ shotAround,
136
+ shotPage,
137
+ takeShot,
138
+ type PageShot,
139
+ } from "./screenshot.js";
140
+ import { prewarmPageImage } from "./page-image.js";
141
+ import {
142
+ INK,
143
+ INK_HALO,
144
+ elementUnder,
145
+ inkBox,
146
+ isTap,
147
+ pathData,
148
+ simplify,
149
+ sketchFor,
150
+ type Point,
151
+ } from "./sketch.js";
152
+ import { whenIdle } from "./idle.js";
153
+ import { CHECK_EVERY_MS, needsSwap, onBuildAnnounced, servedBuild, swapRuntime } from "./update.js";
154
+ import { announceNavigation, NAVIGATED, watchHost } from "./host-watch.js";
155
+ import { isBarePath, screenAt, type Here, type ScreenRule } from "./screen.js";
156
+ import {
157
+ askHost,
158
+ forgetSignIns,
159
+ readJwt,
160
+ sessionFor,
161
+ type GetSession,
162
+ type Session,
163
+ } from "./session.js";
164
+ import { shieldFromPage } from "./shield.js";
165
+ import { useThreadsSync, type ThreadsPayload } from "./sync.js";
166
+ import { Dashboard, type DashboardFocus, type DashboardScreen } from "./dashboard.js";
167
+ import { MessageEditor, MessageHistory, MessageTools, isMine } from "./edits.js";
168
+ import {
169
+ captureHandoff,
170
+ dropHandoff,
171
+ linkWith,
172
+ readHandoff,
173
+ stripHandoffFromUrl,
174
+ } from "./handoff.js";
175
+ import { loadFont } from "./font.js";
176
+ import { injectStyles } from "./styles.js";
177
+
178
+ /** The page screenshot for a new comment or reply starts when the browser is idle, or this long after at the latest. */
179
+ const SNAPSHOT_DELAY_MS = 500;
180
+ /** Files the service keeps on one message (`MAX_PER_MESSAGE`). */
181
+ const MAX_FILES_PER_MESSAGE = 5;
182
+ /** How far back the step trail reaches: the debug context's steps. */
183
+ const DEBUG_STEPS_MS = 180_000;
184
+ /** What "Replay" attaches: the last minute. */
185
+ const INSTANT_REPLAY_MS = 60_000;
186
+ /** Fonts and stylesheet assets are embedded ahead of the first screenshot, once the toolbar has settled. */
187
+ const PREWARM_DELAY_MS = 4000;
188
+
189
+ export type ToolbarPosition = "bottom-left" | "bottom-center" | "bottom-right";
190
+ export type ToolbarTheme = "light" | "dark" | "system";
191
+ type Mode = "browse" | "select" | "draw" | "reattach";
192
+ type Phase = "boot" | "ready" | "off";
193
+ type Surface = "none" | "unlock" | "dashboard";
194
+ type Prefs = {
195
+ pinsHidden?: boolean;
196
+ position?: ToolbarPosition;
197
+ /** Edge the bar snapped to after a drag. Overrides `position` until a preset is chosen. */
198
+ dock?: Dock;
199
+ /** The composer's size, once someone resized it. */
200
+ composerSize?: ComposerSize;
201
+ /** Recordings carry the mic unless this is false. */
202
+ recordSound?: boolean;
203
+ /** Dictation also attaches the take as a voice file unless this is false. */
204
+ keepVoice?: boolean;
205
+ };
206
+ type BarFrame = {
207
+ edge: Dock["edge"];
208
+ side: "start" | "end";
209
+ x: number;
210
+ y: number;
211
+ width: number;
212
+ height: number;
213
+ };
214
+ captureHandoff();
215
+
216
+ const prefsKey = "devaccel.comment.prefs";
217
+ const positions: Array<{ value: ToolbarPosition; label: string }> = [
218
+ { value: "bottom-left", label: "Left" },
219
+ { value: "bottom-center", label: "Center" },
220
+ { value: "bottom-right", label: "Right" },
221
+ ];
222
+
223
+ function readPrefs(): Prefs {
224
+ try {
225
+ const parsed = JSON.parse(localStorage.getItem(prefsKey) ?? "{}") as Prefs & {
226
+ sheet?: unknown;
227
+ sheetMinH?: unknown;
228
+ };
229
+ // The comments side panel's saved box, from before the panel was replaced by the dashboard.
230
+ delete parsed.sheet;
231
+ delete parsed.sheetMinH;
232
+ const dock = readDock(parsed.dock);
233
+ if (dock) parsed.dock = dock;
234
+ else delete parsed.dock;
235
+ const composerSize = readComposerSize(parsed.composerSize);
236
+ if (composerSize) parsed.composerSize = composerSize;
237
+ else delete parsed.composerSize;
238
+ return parsed;
239
+ } catch {
240
+ return {};
241
+ }
242
+ }
243
+
244
+ const pinRadius = 10;
245
+ const peekGap = 5;
246
+ const edge = 3;
247
+
248
+ function isUnset(value: unknown): value is null | undefined {
249
+ return value === null || value === undefined;
250
+ }
251
+
252
+ function subscribeMounted() {
253
+ return () => {};
254
+ }
255
+
256
+ type Spot = { left: number; top: number };
257
+
258
+ function useFitted<T extends HTMLElement>(place: (width: number, height: number) => Spot) {
259
+ const node = useRef<T | null>(null);
260
+ const observer = useRef<ResizeObserver | null>(null);
261
+ const placeRef = useRef(place);
262
+ useLayoutEffect(() => {
263
+ placeRef.current = place;
264
+ }, [place]);
265
+ const [spot, setSpot] = useState<Spot | null>(null);
266
+ const measure = useCallback(() => {
267
+ const card = node.current;
268
+ if (!card) return;
269
+ const next = placeRef.current(card.offsetWidth, card.offsetHeight);
270
+ setSpot((prev) => (prev && prev.left === next.left && prev.top === next.top ? prev : next));
271
+ }, []);
272
+ const ref = useCallback(
273
+ (card: T | null) => {
274
+ observer.current?.disconnect();
275
+ observer.current = null;
276
+ node.current = card;
277
+ if (!card) return setSpot(null);
278
+ if (typeof ResizeObserver !== "undefined") {
279
+ observer.current = new ResizeObserver(measure);
280
+ observer.current.observe(card);
281
+ }
282
+ },
283
+ [measure],
284
+ );
285
+ useLayoutEffect(measure);
286
+ const { left, top } = spot ?? place(0, 0);
287
+ return [ref, { left, top }] as const;
288
+ }
289
+
290
+ function peekSpot(
291
+ x: number,
292
+ y: number,
293
+ bounds: { width: number; height: number },
294
+ width: number,
295
+ height: number,
296
+ ): Spot {
297
+ const left = Math.max(edge, Math.min(x - width / 2, bounds.width - width - edge));
298
+ const below = y + pinRadius + peekGap;
299
+ const above = y - pinRadius - peekGap - height;
300
+ if (below + height <= bounds.height - edge) return { left, top: below };
301
+ if (above >= edge) return { left, top: above };
302
+ return { left, top: Math.max(edge, bounds.height - height - edge) };
303
+ }
304
+
305
+ /** The open thread sits beside its pin, clear of the toolbar. */
306
+ function threadSpot(
307
+ x: number,
308
+ y: number,
309
+ bounds: { width: number; height: number },
310
+ width: number,
311
+ height: number,
312
+ ): Spot {
313
+ const gap = 11;
314
+ const reserve = 57;
315
+ const right = x + pinRadius + gap;
316
+ const leftOf = x - pinRadius - gap - width;
317
+ const left =
318
+ right + width <= bounds.width - edge
319
+ ? right
320
+ : leftOf >= edge
321
+ ? leftOf
322
+ : Math.max(edge, Math.min(right, bounds.width - width - edge));
323
+ const top = Math.max(edge, Math.min(y - 17, bounds.height - height - reserve));
324
+ return { left, top };
325
+ }
326
+
327
+ function Peek({
328
+ x,
329
+ y,
330
+ bounds,
331
+ children,
332
+ }: {
333
+ x: number;
334
+ y: number;
335
+ bounds: { width: number; height: number };
336
+ children: ReactNode;
337
+ }) {
338
+ const [ref, style] = useFitted<HTMLDivElement>((width, height) =>
339
+ peekSpot(x, y, bounds, width, height),
340
+ );
341
+ return (
342
+ <div ref={ref} className="cs-peek" data-cs-ui="peek" style={style}>
343
+ {children}
344
+ </div>
345
+ );
346
+ }
347
+
348
+ /** Puts the open image or video beside a card, on whichever side has more room. */
349
+ function MediaPop({
350
+ bounds,
351
+ left,
352
+ top,
353
+ children,
354
+ }: {
355
+ bounds: { width: number; height: number };
356
+ left: number;
357
+ top: number;
358
+ children: ReactNode;
359
+ }) {
360
+ const probe = useRef<HTMLDivElement>(null);
361
+ const [slot, setSlot] = useState<{
362
+ side: "right" | "left";
363
+ width: number;
364
+ height: number;
365
+ } | null>(null);
366
+ useLayoutEffect(() => {
367
+ const card = probe.current?.parentElement;
368
+ if (!card) return;
369
+ const roomRight = bounds.width - (left + card.offsetWidth) - 9;
370
+ const roomLeft = left - 9;
371
+ // Both sides get the same height, so the wider one always shows more of the image or video.
372
+ const side = roomRight >= roomLeft ? "right" : "left";
373
+ const room = side === "right" ? roomRight : roomLeft;
374
+ const width = Math.round(Math.max(165, room));
375
+ const height = Math.round(Math.max(210, bounds.height - top - 12));
376
+ setSlot((prev) =>
377
+ prev && prev.side === side && prev.width === width && prev.height === height
378
+ ? prev
379
+ : { side, width, height },
380
+ );
381
+ }, [bounds.height, bounds.width, left, top]);
382
+ return (
383
+ <div
384
+ ref={probe}
385
+ className={`cs-media-pop is-${slot?.side ?? "right"}`}
386
+ style={slot ? { width: slot.width, height: slot.height } : undefined}
387
+ >
388
+ {children}
389
+ </div>
390
+ );
391
+ }
392
+
393
+ function PinCard({
394
+ x,
395
+ y,
396
+ bounds,
397
+ label,
398
+ children,
399
+ preview,
400
+ }: {
401
+ x: number;
402
+ y: number;
403
+ bounds: { width: number; height: number };
404
+ label: string;
405
+ children: ReactNode;
406
+ preview?: ReactNode;
407
+ }) {
408
+ const [ref, style] = useFitted<HTMLElement>((width, height) =>
409
+ threadSpot(x, y, bounds, width, height),
410
+ );
411
+ return (
412
+ <section
413
+ ref={ref}
414
+ className="cs-card cs-focus cs-hit"
415
+ data-cs-ui="focus"
416
+ aria-label={label}
417
+ style={style}
418
+ >
419
+ {children}
420
+ {preview ? (
421
+ <MediaPop bounds={bounds} left={style.left} top={style.top}>
422
+ {preview}
423
+ </MediaPop>
424
+ ) : null}
425
+ </section>
426
+ );
427
+ }
428
+
429
+ type Highlight = Box & { label: string };
430
+
431
+ function sameHighlight(a: Highlight | null, b: Highlight | null) {
432
+ if (a === b) return true;
433
+ if (!a || !b) return false;
434
+ return (
435
+ a.top === b.top &&
436
+ a.left === b.left &&
437
+ a.width === b.width &&
438
+ a.height === b.height &&
439
+ a.label === b.label
440
+ );
441
+ }
442
+
443
+ /**
444
+ * The outline under the pointer while picking an element. It lives outside React state so a pointer move
445
+ * re-renders the two outline nodes, not the whole toolbar with its pins, cards and composer.
446
+ */
447
+ function createHighlightStore() {
448
+ let value: Highlight | null = null;
449
+ const listeners = new Set<() => void>();
450
+ return {
451
+ get: () => value,
452
+ set(next: Highlight | null) {
453
+ if (sameHighlight(value, next)) return;
454
+ value = next;
455
+ for (const listener of listeners) listener();
456
+ },
457
+ subscribe(listener: () => void) {
458
+ listeners.add(listener);
459
+ return () => {
460
+ listeners.delete(listener);
461
+ };
462
+ },
463
+ };
464
+ }
465
+ type HighlightStore = ReturnType<typeof createHighlightStore>;
466
+
467
+ function PickOutline({ store }: { store: HighlightStore }) {
468
+ const highlight = useSyncExternalStore(store.subscribe, store.get, store.get);
469
+ if (!highlight) return null;
470
+ return (
471
+ <>
472
+ <div
473
+ className="cs-outline is-hover"
474
+ style={{
475
+ transform: `translate(${highlight.left}px, ${highlight.top}px)`,
476
+ width: highlight.width,
477
+ height: highlight.height,
478
+ }}
479
+ />
480
+ <div
481
+ className="cs-outline-label is-hover"
482
+ style={{
483
+ transform: `translate(${highlight.left}px, ${Math.max(3, highlight.top - 20)}px)`,
484
+ }}
485
+ >
486
+ {highlight.label}
487
+ </div>
488
+ </>
489
+ );
490
+ }
491
+
492
+ /**
493
+ * A full-screen layer that takes one freehand stroke and hands it over, in viewport coordinates. The toolbar's
494
+ * shield stops pointerdown at the window, so the stroke is followed from window listeners on the same phase.
495
+ */
496
+ function DrawLayer({ onStroke }: { onStroke: (points: Point[]) => void }) {
497
+ const [points, setPoints] = useState<Point[]>([]);
498
+ const layer = useRef<HTMLDivElement>(null);
499
+ const strokeDone = useRef(onStroke);
500
+ useEffect(() => {
501
+ strokeDone.current = onStroke;
502
+ }, [onStroke]);
503
+ useEffect(() => {
504
+ let drawing: Point[] | null = null;
505
+ const onDown = (event: PointerEvent) => {
506
+ if (event.button !== 0 || event.target !== layer.current) return;
507
+ event.preventDefault();
508
+ drawing = [{ x: event.clientX, y: event.clientY }];
509
+ setPoints(drawing);
510
+ };
511
+ const onMove = (event: PointerEvent) => {
512
+ if (!drawing) return;
513
+ const events = event.getCoalescedEvents?.() ?? [];
514
+ for (const each of events.length ? events : [event])
515
+ drawing.push({ x: each.clientX, y: each.clientY });
516
+ setPoints([...drawing]);
517
+ };
518
+ const onUp = (event: PointerEvent) => {
519
+ const stroke = drawing;
520
+ drawing = null;
521
+ setPoints([]);
522
+ if (stroke && event.type === "pointerup")
523
+ strokeDone.current([...stroke, { x: event.clientX, y: event.clientY }]);
524
+ };
525
+ window.addEventListener("pointerdown", onDown, true);
526
+ window.addEventListener("pointermove", onMove, true);
527
+ window.addEventListener("pointerup", onUp, true);
528
+ window.addEventListener("pointercancel", onUp, true);
529
+ return () => {
530
+ window.removeEventListener("pointerdown", onDown, true);
531
+ window.removeEventListener("pointermove", onMove, true);
532
+ window.removeEventListener("pointerup", onUp, true);
533
+ window.removeEventListener("pointercancel", onUp, true);
534
+ };
535
+ }, []);
536
+ const line = points.length > 1 ? points.map(({ x, y }) => `${x},${y}`).join(" ") : "";
537
+ return (
538
+ <div className="cs-draw cs-hit" data-cs-ui="draw-layer" ref={layer}>
539
+ {line ? (
540
+ <svg className="cs-ink" width="100%" height="100%">
541
+ <polyline points={line} stroke={INK_HALO} strokeWidth={6} />
542
+ <polyline points={line} stroke={INK} strokeWidth={3} />
543
+ </svg>
544
+ ) : null}
545
+ </div>
546
+ );
547
+ }
548
+
549
+ /** A saved drawing, laid over the element it was stored against. */
550
+ function SketchInk({ sketch, box, active }: { sketch: Sketch; box: Box; active?: boolean }) {
551
+ const d = sketch.paths.map((path) => pathData(path)).join(" ");
552
+ return (
553
+ <svg
554
+ className={active ? "cs-ink is-placed is-active" : "cs-ink is-placed"}
555
+ data-cs-ui="sketch"
556
+ style={{ top: box.top, left: box.left }}
557
+ width={1}
558
+ height={1}
559
+ >
560
+ <path d={d} stroke={INK_HALO} strokeWidth={6} />
561
+ <path d={d} stroke={INK} strokeWidth={3} />
562
+ </svg>
563
+ );
564
+ }
565
+
566
+ /** Where a new comment's composer opens: right of `box`, or left of it when there's no room. */
567
+ function composerSpot(box: Box, host: HTMLElement) {
568
+ const unit = uiUnit();
569
+ const width = host === document.body ? window.innerWidth : host.clientWidth;
570
+ const composerWidth = Math.min(240 * unit, width - 12 * unit);
571
+ let left = box.left + box.width + 9 * unit;
572
+ if (left + composerWidth > width - 6 * unit) left = box.left - composerWidth - 9 * unit;
573
+ left = Math.max(6 * unit, Math.min(left, width - composerWidth - 6 * unit));
574
+ return { top: Math.max(6 * unit, box.top - 6 * unit), left };
575
+ }
576
+
577
+ /** What is wrong when the session route had the host token issued for another origin, in the service's words. */
578
+ function tokenForOtherOrigin(issuedFor: string) {
579
+ return `The session was issued for ${issuedFor}, but this page is ${window.location.origin}. Update the app's session route.`;
580
+ }
581
+
582
+ /** The current page as a comment should remember it, without the toolbar's own query params. */
583
+ function pageHref() {
584
+ const url = new URL(window.location.href);
585
+ url.searchParams.delete("cs-thread");
586
+ url.searchParams.delete("cs-invite");
587
+ redactSecretSearchParams(url);
588
+ const hash = url.hash.includes("cs-thread=") || url.hash.includes("cs-invite=") ? "" : url.hash;
589
+ return `${url.pathname}${url.search}${hash}`;
590
+ }
591
+
592
+ function isTyping(target: EventTarget | null) {
593
+ if (!(target instanceof HTMLElement)) return false;
594
+ return target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
595
+ }
596
+
597
+ /** The open composer's writing box, if there is one. */
598
+ function composerField() {
599
+ return document.querySelector<HTMLTextAreaElement>('#cs-root [data-cs-ui="composer-body"]');
600
+ }
601
+
602
+ /** The open composer itself, if there is one. */
603
+ function composerForm() {
604
+ return document.querySelector<HTMLFormElement>('#cs-root form[data-cs-ui="composer"]');
605
+ }
606
+
607
+ /**
608
+ * What a comment on the whole page is anchored to: the page's main landmark, else its first laid-out
609
+ * top-level element. A thread needs an element to resolve against; the page itself is not one.
610
+ */
611
+ function pageAnchorElement(): Element {
612
+ const main = document.querySelector("main, [role='main']");
613
+ if (main && !main.closest("#cs-root") && main.getClientRects().length) return main;
614
+ for (const child of document.body.children) {
615
+ if (child.id === "cs-root" || child.tagName === "SCRIPT" || child.tagName === "STYLE") continue;
616
+ if (child.getClientRects().length) return child;
617
+ }
618
+ return document.body;
619
+ }
620
+
621
+ function fileStamp() {
622
+ return new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
623
+ }
624
+
625
+ function submitOnCmdEnter(event: ReactKeyboardEvent<HTMLTextAreaElement>) {
626
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
627
+ event.preventDefault();
628
+ event.currentTarget.form?.requestSubmit();
629
+ }
630
+ }
631
+
632
+ function Avatar({ name, size = 24 }: { name: string; size?: number }) {
633
+ return (
634
+ <span
635
+ className="cs-avatar"
636
+ style={{ width: size, height: size, fontSize: size * 0.4, background: colorFor(name) }}
637
+ >
638
+ {initials(name)}
639
+ </span>
640
+ );
641
+ }
642
+
643
+ const ease = "cubic-bezier(0.2, 0.8, 0.2, 1)";
644
+ /** Runs a one-shot animation on `node`, unless the person asked for less motion. */
645
+ function play(
646
+ node: Element | null | undefined,
647
+ keyframes: Keyframe[],
648
+ options: KeyframeAnimationOptions,
649
+ ) {
650
+ if (!node || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
651
+ node.animate(keyframes, options);
652
+ }
653
+
654
+ /** What a bar tool's icon does as it turns on. Turning off only fades, through CSS. */
655
+ const moments = {
656
+ pop: (tool: Element) =>
657
+ play(
658
+ tool.querySelector(".cs-icon"),
659
+ [
660
+ { transform: "scale(1)" },
661
+ { transform: "scale(0.82)", offset: 0.35 },
662
+ { transform: "scale(1.1)", offset: 0.7 },
663
+ { transform: "scale(1)" },
664
+ ],
665
+ { duration: 320, easing: ease },
666
+ ),
667
+ };
668
+
669
+ /** The toolbar's single-key shortcuts, and the tool each one presses. Comment, draw, comments, and dashboard are hidden. */
670
+ const SHORTCUTS: Record<string, string> = {
671
+ s: "shot-mode",
672
+ h: "pins-toggle",
673
+ };
674
+
675
+ /**
676
+ * Whether a shortcut may act on this press. `s` closes the open composer, so it stands down
677
+ * while it holds unsent work: a press can arrive because the box lost focus (an overlay took it back, the page was
678
+ * clicked), and a letter must never throw away writing. `h` only toggles pins.
679
+ */
680
+ export function shortcutAllowed(key: string, draftHasWriting: boolean) {
681
+ if (draftHasWriting && key !== "h") return false;
682
+ return true;
683
+ }
684
+
685
+ /** The press a shortcut key gives its tool, so the bar shows which key did what. */
686
+ function pressTool(bar: HTMLElement | null, ui: string) {
687
+ play(
688
+ bar?.querySelector(`.cs-tool[data-cs-ui="${ui}"]`),
689
+ [
690
+ { transform: "scale(1)" },
691
+ { transform: "scale(0.88)", offset: 0.35 },
692
+ { transform: "scale(1)" },
693
+ ],
694
+ { duration: 240, easing: ease },
695
+ );
696
+ }
697
+
698
+ type ToolProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> & {
699
+ on: boolean;
700
+ moment?: keyof typeof moments;
701
+ };
702
+
703
+ /** A bar button. Its icon plays `moment` each time it turns on, and its tip stays down after a press until the pointer leaves. */
704
+ function Tool({
705
+ on,
706
+ moment = "pop",
707
+ className,
708
+ onPointerDown,
709
+ onPointerLeave,
710
+ ...props
711
+ }: ToolProps) {
712
+ const ref = useRef<HTMLButtonElement>(null);
713
+ const was = useRef(on);
714
+ useEffect(() => {
715
+ if (on && !was.current && ref.current) moments[moment](ref.current);
716
+ was.current = on;
717
+ }, [on, moment]);
718
+ return (
719
+ <button
720
+ {...props}
721
+ ref={ref}
722
+ type="button"
723
+ className={["cs-tool", className, on ? "is-on" : ""].filter(Boolean).join(" ")}
724
+ onPointerDown={(event) => {
725
+ event.currentTarget.setAttribute("data-tipless", "");
726
+ onPointerDown?.(event);
727
+ }}
728
+ onPointerLeave={(event) => {
729
+ event.currentTarget.removeAttribute("data-tipless");
730
+ onPointerLeave?.(event);
731
+ }}
732
+ />
733
+ );
734
+ }
735
+
736
+ /** Tips wait a beat the first time, then show at once while the pointer moves along the bar. */
737
+ function useWarmTips() {
738
+ const timer = useRef(0);
739
+ // allow-react-loops: spread onto one element as event handlers; nothing depends on their identity
740
+ return {
741
+ onPointerOver: (event: ReactPointerEvent<HTMLDivElement>) => {
742
+ const bar = event.currentTarget;
743
+ window.clearTimeout(timer.current);
744
+ if (
745
+ !(event.target instanceof Element) ||
746
+ !event.target.closest(".cs-tool") ||
747
+ bar.hasAttribute("data-warm")
748
+ )
749
+ return;
750
+ timer.current = window.setTimeout(() => bar.setAttribute("data-warm", ""), 350);
751
+ },
752
+ onPointerLeave: (event: ReactPointerEvent<HTMLDivElement>) => {
753
+ const bar = event.currentTarget;
754
+ window.clearTimeout(timer.current);
755
+ timer.current = window.setTimeout(() => bar.removeAttribute("data-warm"), 600);
756
+ },
757
+ };
758
+ }
759
+
760
+ function dockInset(root: Element | null) {
761
+ return root instanceof HTMLElement && root.classList.contains("cs-inside") ? 6 : dockMargin;
762
+ }
763
+
764
+ /** The pill. A short press still clicks a tool; a longer one drags, then snaps to the nearest edge. */
765
+ function CommentBar({
766
+ dock,
767
+ barRef,
768
+ onCommit,
769
+ onPlace,
770
+ children,
771
+ ...tips
772
+ }: {
773
+ dock?: Dock;
774
+ barRef: { current: HTMLDivElement | null };
775
+ onCommit: (dock: Dock) => void;
776
+ onPlace: (frame: BarFrame | null) => void;
777
+ children: ReactNode;
778
+ } & Pick<HTMLAttributes<HTMLDivElement>, "onPointerOver" | "onPointerLeave">) {
779
+ const [drag, setDrag] = useState<DockPoint | null>(null);
780
+ const [override, setOverride] = useState<Dock | null>(null);
781
+ const [size, setSize] = useState({ width: 0, height: 0 });
782
+ const [bounds, setBounds] = useState({ width: 0, height: 0 });
783
+ const [margin, setMargin] = useState(dockMargin);
784
+ const live = useRef<DockPoint | null>(null);
785
+ const suppressClick = useRef(false);
786
+ const placeNow = useRef({
787
+ active: undefined as Dock | undefined,
788
+ drag: null as DockPoint | null,
789
+ });
790
+ const onCommitRef = useRef(onCommit);
791
+ const onPlaceRef = useRef(onPlace);
792
+ useLayoutEffect(() => {
793
+ onCommitRef.current = onCommit;
794
+ onPlaceRef.current = onPlace;
795
+ }, [onCommit, onPlace]);
796
+ const active =
797
+ override && dock && (override.edge !== dock.edge || override.ratio !== dock.ratio)
798
+ ? override
799
+ : dock;
800
+
801
+ if (override && (!dock || (override.edge === dock.edge && override.ratio === dock.ratio))) {
802
+ setOverride(null);
803
+ }
804
+
805
+ useLayoutEffect(() => {
806
+ placeNow.current = { active, drag };
807
+ }, [active, drag]);
808
+
809
+ useLayoutEffect(() => {
810
+ const node = barRef.current;
811
+ const root = node?.closest("#cs-root");
812
+ if (!node || !(root instanceof HTMLElement)) return;
813
+ const publish = (
814
+ width: number,
815
+ height: number,
816
+ nextBounds: { width: number; height: number },
817
+ inset: number,
818
+ ) => {
819
+ const current = placeNow.current;
820
+ if (!current.active) {
821
+ onPlaceRef.current(null);
822
+ return;
823
+ }
824
+ if (current.drag || width < 1 || nextBounds.width < 1) return;
825
+ const point = placeDock(current.active, nextBounds, { width, height }, inset);
826
+ onPlaceRef.current({
827
+ edge: current.active.edge,
828
+ side: point.x + width / 2 <= nextBounds.width / 2 ? "start" : "end",
829
+ x: point.x,
830
+ y: point.y,
831
+ width,
832
+ height,
833
+ });
834
+ };
835
+ const measure = () => {
836
+ const width = node.offsetWidth;
837
+ const height = node.offsetHeight;
838
+ setSize((current) =>
839
+ current.width === width && current.height === height ? current : { width, height },
840
+ );
841
+ const nextBounds = { width: root.clientWidth, height: root.clientHeight };
842
+ setBounds((current) =>
843
+ current.width === nextBounds.width && current.height === nextBounds.height
844
+ ? current
845
+ : nextBounds,
846
+ );
847
+ const nextMargin = dockInset(root);
848
+ setMargin((current) => (current === nextMargin ? current : nextMargin));
849
+ publish(width, height, nextBounds, nextMargin);
850
+ };
851
+ measure();
852
+ const observer = new ResizeObserver(measure);
853
+ observer.observe(node);
854
+ observer.observe(root);
855
+ return () => observer.disconnect();
856
+ }, [barRef]);
857
+
858
+ const placed =
859
+ drag ??
860
+ (active && size.width > 0 && bounds.width > 0 ? placeDock(active, bounds, size, margin) : null);
861
+
862
+ useLayoutEffect(() => {
863
+ placeNow.current = { active, drag };
864
+ if (!active) {
865
+ onPlaceRef.current(null);
866
+ return;
867
+ }
868
+ if (drag || size.width < 1 || bounds.width < 1) return;
869
+ const point = placeDock(active, bounds, size, margin);
870
+ onPlaceRef.current({
871
+ edge: active.edge,
872
+ side: point.x + size.width / 2 <= bounds.width / 2 ? "start" : "end",
873
+ x: point.x,
874
+ y: point.y,
875
+ width: size.width,
876
+ height: size.height,
877
+ });
878
+ }, [active, bounds, drag, margin, size]);
879
+
880
+ useEffect(() => {
881
+ const onClick = (event: MouseEvent) => {
882
+ if (!suppressClick.current) return;
883
+ suppressClick.current = false;
884
+ event.preventDefault();
885
+ event.stopPropagation();
886
+ event.stopImmediatePropagation();
887
+ };
888
+ window.addEventListener("click", onClick, true);
889
+ return () => window.removeEventListener("click", onClick, true);
890
+ }, []);
891
+
892
+ useEffect(() => {
893
+ let drag: {
894
+ start: { x: number; y: number };
895
+ grab: { x: number; y: number };
896
+ barSize: { width: number; height: number };
897
+ moved: boolean;
898
+ last: { x: number; y: number; t: number };
899
+ velocity: { x: number; y: number };
900
+ root: HTMLElement;
901
+ } | null = null;
902
+ const onDown = (event: PointerEvent) => {
903
+ if (event.button !== 0) return;
904
+ const target = event.target;
905
+ if (!(target instanceof Element) || !target.closest(".cs-bar")) return;
906
+ const node = barRef.current;
907
+ const root = node?.closest("#cs-root");
908
+ if (!node || !(root instanceof HTMLElement) || !node.contains(target)) return;
909
+ suppressClick.current = false;
910
+ const box = node.getBoundingClientRect();
911
+ drag = {
912
+ start: { x: event.clientX, y: event.clientY },
913
+ grab: { x: event.clientX - box.left, y: event.clientY - box.top },
914
+ barSize: { width: box.width, height: box.height },
915
+ moved: false,
916
+ last: { x: event.clientX, y: event.clientY, t: performance.now() },
917
+ velocity: { x: 0, y: 0 },
918
+ root,
919
+ };
920
+ };
921
+ const onMove = (event: PointerEvent) => {
922
+ if (!drag) return;
923
+ const now = performance.now();
924
+ const dt = now - drag.last.t;
925
+ if (dt > 0 && dt < 80) {
926
+ drag.velocity = {
927
+ x: (event.clientX - drag.last.x) / dt,
928
+ y: (event.clientY - drag.last.y) / dt,
929
+ };
930
+ }
931
+ drag.last = { x: event.clientX, y: event.clientY, t: now };
932
+ if (
933
+ !drag.moved &&
934
+ Math.hypot(event.clientX - drag.start.x, event.clientY - drag.start.y) <= dockSlop
935
+ )
936
+ return;
937
+ if (!drag.moved) drag.moved = true;
938
+ event.preventDefault();
939
+ const rootRect = drag.root.getBoundingClientRect();
940
+ const next = clampBar(
941
+ {
942
+ x: event.clientX - rootRect.left - drag.grab.x,
943
+ y: event.clientY - rootRect.top - drag.grab.y,
944
+ },
945
+ { width: drag.root.clientWidth, height: drag.root.clientHeight },
946
+ drag.barSize,
947
+ );
948
+ live.current = next;
949
+ setDrag(next);
950
+ };
951
+ const onUp = () => {
952
+ const activeDrag = drag;
953
+ drag = null;
954
+ if (!activeDrag?.moved || !live.current) return;
955
+ suppressClick.current = true;
956
+ const snapped = snapDock(
957
+ live.current,
958
+ activeDrag.velocity,
959
+ { width: activeDrag.root.clientWidth, height: activeDrag.root.clientHeight },
960
+ activeDrag.barSize,
961
+ dockInset(activeDrag.root),
962
+ );
963
+ live.current = null;
964
+ setDrag(null);
965
+ setOverride({ edge: snapped.edge, ratio: snapped.ratio });
966
+ onCommitRef.current({ edge: snapped.edge, ratio: snapped.ratio });
967
+ };
968
+ window.addEventListener("pointerdown", onDown, true);
969
+ window.addEventListener("pointermove", onMove, true);
970
+ window.addEventListener("pointerup", onUp, true);
971
+ window.addEventListener("pointercancel", onUp, true);
972
+ return () => {
973
+ window.removeEventListener("pointerdown", onDown, true);
974
+ window.removeEventListener("pointermove", onMove, true);
975
+ window.removeEventListener("pointerup", onUp, true);
976
+ window.removeEventListener("pointercancel", onUp, true);
977
+ };
978
+ }, [barRef]);
979
+
980
+ return (
981
+ <div
982
+ {...tips}
983
+ ref={barRef}
984
+ className={["cs-bar", "cs-hit", placed ? "is-docked" : "", drag ? "is-dragging" : ""]
985
+ .filter(Boolean)
986
+ .join(" ")}
987
+ data-cs-ui="bar"
988
+ role="toolbar"
989
+ aria-label="Comments toolbar"
990
+ style={
991
+ placed
992
+ ? { left: placed.x, top: placed.y, right: "auto", bottom: "auto", transform: "none" }
993
+ : undefined
994
+ }
995
+ >
996
+ {children}
997
+ </div>
998
+ );
999
+ }
1000
+
1001
+ export type ToolbarProps = {
1002
+ sessionPath?: string;
1003
+ pollMs?: number;
1004
+ /** Default bar position. Dragging the bar snaps it to an edge and overrides this until a preset is chosen. */
1005
+ position?: ToolbarPosition;
1006
+ /** "system" follows the OS appearance. Light by default so it matches most host pages. */
1007
+ theme?: ToolbarTheme;
1008
+ /**
1009
+ * Opens another page of the app, for example `(href) => router.navigate({ href })`.
1010
+ * Without it, selecting a comment from another page does a full page load.
1011
+ */
1012
+ navigate?: (href: string) => void;
1013
+ /**
1014
+ * What makes one screen of the app different from another, when the path alone does not: the query
1015
+ * parameters that select a screen (`{ params: ["view"] }`), or a function that names the screen an address
1016
+ * is on. Without it, every query parameter but the usual tracking, paging, sorting and filtering ones counts
1017
+ * (`./screen.ts`). Pass the same function every render.
1018
+ */
1019
+ screen?: ScreenRule;
1020
+ /**
1021
+ * Debug context for each new comment: errors with stacks, failed and slow requests, the steps, the build,
1022
+ * the environment and what the host knows (`getContext`), redacted in the browser and again by the service,
1023
+ * attached as `context-<stamp>.json`. Off unless given. `true` takes the default signals; an object turns
1024
+ * single signals on or off. Pass the same object every render or a new one: only a change of what is
1025
+ * captured restarts it.
1026
+ */
1027
+ debugContext?: DebugContextConfig;
1028
+ };
1029
+
1030
+ /** What the npm loader passes to `mount`: the host's props plus the session it already fetched. */
1031
+ export type RuntimeProps = ToolbarProps & {
1032
+ /** Older loaders don't pass this; the toolbar then reads the session route itself. */
1033
+ getSession?: GetSession;
1034
+ /**
1035
+ * Raised by the loader each time the host says its sign-in changed (`CommentTool.refresh()`): the toolbar
1036
+ * asks the session route at once. Older loaders don't pass it; the toolbar still asks on its own.
1037
+ */
1038
+ hostCheck?: number;
1039
+ };
1040
+
1041
+ /**
1042
+ * Swaps this toolbar for the build the worker serves now, as soon as nobody is in the middle of something. The
1043
+ * worker announces its build over the live socket when its project object starts after a deploy; the served
1044
+ * build is also checked when the socket (re)connects, when the tab becomes visible, and every CHECK_EVERY_MS.
1045
+ * `quiet` says the swap won't lose a draft, a reply, a recording or an edit.
1046
+ */
1047
+ function useRuntimeUpdates(apiUrl: string | undefined, live: boolean, quiet: boolean) {
1048
+ const [pending, setPending] = useState<string | null>(null);
1049
+ useEffect(() => {
1050
+ if (!apiUrl) return;
1051
+ let stopped = false;
1052
+ const check = async () => {
1053
+ const served = await servedBuild(apiUrl);
1054
+ if (!stopped && needsSwap(served)) setPending(served);
1055
+ };
1056
+ void check();
1057
+ const stopListening = onBuildAnnounced((build) => {
1058
+ if (!stopped && needsSwap(build)) setPending(build);
1059
+ });
1060
+ const onVisible = () => {
1061
+ if (document.visibilityState === "visible") void check();
1062
+ };
1063
+ document.addEventListener("visibilitychange", onVisible);
1064
+ const timer = window.setInterval(() => void check(), CHECK_EVERY_MS);
1065
+ return () => {
1066
+ stopped = true;
1067
+ stopListening();
1068
+ window.clearInterval(timer);
1069
+ document.removeEventListener("visibilitychange", onVisible);
1070
+ };
1071
+ }, [apiUrl]);
1072
+ useEffect(() => {
1073
+ if (!apiUrl || !live) return;
1074
+ let stopped = false;
1075
+ void servedBuild(apiUrl).then((served) => {
1076
+ if (!stopped && needsSwap(served)) setPending(served);
1077
+ });
1078
+ return () => {
1079
+ stopped = true;
1080
+ };
1081
+ }, [apiUrl, live]);
1082
+ useEffect(() => {
1083
+ if (!pending || !apiUrl || !quiet) return;
1084
+ let stopped = false;
1085
+ // A swap that didn't happen is retried when the next check (or announcement) sets `pending` again.
1086
+ const retryLater = () => {
1087
+ if (!stopped) setPending(null);
1088
+ };
1089
+ swapRuntime(apiUrl, pending).then(
1090
+ (swapped) => {
1091
+ if (!swapped) retryLater();
1092
+ },
1093
+ (error) => {
1094
+ // eslint-disable-next-line no-console -- the browser console is where a host page developer sees a failed toolbar swap.
1095
+ console.warn("[comment-service] Could not switch to the newer toolbar.", error);
1096
+ retryLater();
1097
+ },
1098
+ );
1099
+ return () => {
1100
+ stopped = true;
1101
+ };
1102
+ }, [apiUrl, pending, quiet]);
1103
+ }
1104
+
1105
+ /**
1106
+ * The toolbar for as long as the host app has someone signed in. The host's session route is asked again at the
1107
+ * moments `watchHost` names, and the loader's answer is taken at once when it passes a new `hostCheck`. When it
1108
+ * says nobody is signed in, every toolbar sign-in on this origin is forgotten and the toolbar leaves the page
1109
+ * with its socket, its threads and its open composer. When someone signs in again, or the host says its
1110
+ * sign-in changed (`hostCheck`) while comments stay on, a new toolbar starts that holds nothing of the last one.
1111
+ * A route that fails changes nothing.
1112
+ */
1113
+ export function ToolbarApp(props: RuntimeProps) {
1114
+ const { getSession, sessionPath = "/api/comment-service/session", hostCheck } = props;
1115
+ // This build's own reading of the route, whatever loader mounted it: an older loader's cannot tell a
1116
+ // signed-out answer from a failed request.
1117
+ const own = sessionFor(sessionPath);
1118
+ const loaders = getSession ?? own;
1119
+ const [on, setOn] = useState(true);
1120
+ const [generation, setGeneration] = useState(0);
1121
+ const checkNow = useRef<(() => void) | null>(null);
1122
+ useEffect(() => {
1123
+ let stopped = false;
1124
+ let wasOn = true;
1125
+ let serving: string | null = null;
1126
+ /** `told`: the host said its sign-in changed, so whoever is signed in now may be someone else. */
1127
+ const check = (told = false) => {
1128
+ void askHost(told ? loaders : own, !told).then((answer) => {
1129
+ if (stopped) return;
1130
+ switch (answer.state) {
1131
+ case "on": {
1132
+ const { apiUrl, project, environment } = answer.session;
1133
+ const next = `${apiUrl}\n${project}\n${environment}`;
1134
+ // The host token does not say who is signed in: a change of person is only known when told.
1135
+ if (told) forgetSignIns();
1136
+ if (told || !wasOn || (serving !== null && serving !== next))
1137
+ setGeneration((count) => count + 1);
1138
+ serving = next;
1139
+ wasOn = true;
1140
+ setOn(true);
1141
+ return;
1142
+ }
1143
+ case "off":
1144
+ forgetSignIns();
1145
+ wasOn = false;
1146
+ setOn(false);
1147
+ return;
1148
+ case "unknown":
1149
+ return;
1150
+ default: {
1151
+ const never: never = answer;
1152
+ return never;
1153
+ }
1154
+ }
1155
+ });
1156
+ };
1157
+ const watch = watchHost(() => check());
1158
+ // The loader has just read the session route for its `refresh()`: its answer is the one kept.
1159
+ checkNow.current = () => check(true);
1160
+ return () => {
1161
+ stopped = true;
1162
+ watch.stop();
1163
+ checkNow.current = null;
1164
+ };
1165
+ }, [loaders, own]);
1166
+ const asked = useRef(hostCheck);
1167
+ useEffect(() => {
1168
+ if (hostCheck === asked.current) return;
1169
+ asked.current = hostCheck;
1170
+ checkNow.current?.();
1171
+ }, [hostCheck]);
1172
+ return on ? <Toolbar key={generation} {...props} /> : null;
1173
+ }
1174
+
1175
+ function Toolbar({
1176
+ sessionPath = "/api/comment-service/session",
1177
+ getSession,
1178
+ pollMs = 4000,
1179
+ position = "bottom-center",
1180
+ theme = "light",
1181
+ navigate,
1182
+ screen: screenRule,
1183
+ debugContext,
1184
+ }: RuntimeProps) {
1185
+ const sessions = getSession ?? sessionFor(sessionPath);
1186
+ const [phase, setPhase] = useState<Phase>("boot");
1187
+ const [session, setSession] = useState<Session | null>(null);
1188
+ const [member, setMember] = useState<string | null>(null);
1189
+ const [threads, setThreads] = useState<Thread[]>([]);
1190
+ const [href, setHref] = useState(() => window.location.href);
1191
+ const screenFunction = typeof screenRule === "function" ? screenRule : null;
1192
+ const screenParams =
1193
+ screenRule && typeof screenRule !== "function" ? JSON.stringify(screenRule.params) : null;
1194
+ const rule = useMemo<ScreenRule | null>(
1195
+ () =>
1196
+ screenFunction ??
1197
+ (screenParams === null ? null : { params: JSON.parse(screenParams) as string[] }),
1198
+ [screenFunction, screenParams],
1199
+ );
1200
+ // The same object for as long as the page stays on one screen: a query that is noise (a search box writing
1201
+ // `?q=`) does not make the pins be found again.
1202
+ const [screenNow, setScreenNow] = useState(() => ({ rule, here: screenAt(href, rule) }));
1203
+ useEffect(() => {
1204
+ setScreenNow((current) => {
1205
+ const next = screenAt(href, rule);
1206
+ return current.rule === rule &&
1207
+ current.here.key === next.key &&
1208
+ current.here.route === next.route
1209
+ ? current
1210
+ : { rule, here: next };
1211
+ });
1212
+ }, [href, rule]);
1213
+ const here: Here = screenNow.here;
1214
+ const route = here.route;
1215
+ const [surface, setSurface] = useState<Surface>("none");
1216
+ const [dashboardScreen, setDashboardScreen] = useState<DashboardScreen>("insights");
1217
+ const [dashboardFocus, setDashboardFocus] = useState<DashboardFocus | null>(null);
1218
+ const [mode, setMode] = useState<Mode>("browse");
1219
+ const [reattachId, setReattachId] = useState<string | null>(null);
1220
+ const [peekId, setPeekId] = useState<string | null>(null);
1221
+ const [focusId, setFocusId] = useState<string | null>(null);
1222
+ const [focusMenu, setFocusMenu] = useState(false);
1223
+ const [preview, setPreview] = useState<{ threadId: string; fileId: string } | null>(null);
1224
+ const [trayKey, setTrayKey] = useState<string | null>(null);
1225
+ /**
1226
+ * The markup editor owns the keyboard while it is open: Escape, arrows, and letters are its own. Posting waits
1227
+ * for it too, so a message never goes out with the image the user is still drawing on.
1228
+ */
1229
+ const markingUp = useRef(false);
1230
+ const [marking, setMarking] = useState(false);
1231
+ const [focusSpot, setFocusSpot] = useState<{ id: string; x: number; y: number } | null>(null);
1232
+ /** Status painted on click, kept until a thread list already shows it or the server rejects it. */
1233
+ const pendingStatus = useRef(new Map<string, "open" | "resolved">());
1234
+ const [draft, setDraft] = useState<{
1235
+ anchor: Anchor;
1236
+ top: number;
1237
+ left: number;
1238
+ element: Element;
1239
+ /** The page the comment is filed against, when it was started elsewhere (a draft that followed the person). */
1240
+ href?: string;
1241
+ } | null>(null);
1242
+ /** While a resize drags: the size it's at. Saved to prefs when the drag ends. */
1243
+ const [resizingComposer, setResizingComposer] = useState<ComposerSize | null>(null);
1244
+ /**
1245
+ * A comment started from the bar's screenshot tool: the draft's page picture, by its key in the draft's files.
1246
+ * The composer then sits beside the picture in `ShotSurface` instead of next to an element.
1247
+ */
1248
+ const [shot, setShot] = useState<{ key: string } | null>(null);
1249
+ /** The page draft is minimised to the dock while it collects captures from other pages. */
1250
+ const [collecting, setCollecting] = useState(false);
1251
+ /** A draft found in storage (after a reload, or stashed when an element comment took over), offered in the dock. */
1252
+ const [stored, setStored] = useState<StoredDraft | null>(null);
1253
+ const [savedAt, setSavedAt] = useState<number | null>(null);
1254
+ const [confirmDiscard, setConfirmDiscard] = useState(false);
1255
+ /** Shapes drawn on each capture so far, by file key; flattened into the files when the comment is posted. */
1256
+ const [captureShapes, setCaptureShapes] = useState<ReadonlyMap<string, MarkupShape[]>>(
1257
+ () => new Map(),
1258
+ );
1259
+ const withShapes = useCallback(
1260
+ (change: (next: Map<string, MarkupShape[]>) => void) =>
1261
+ setCaptureShapes((current) => {
1262
+ const next = new Map(current);
1263
+ change(next);
1264
+ return next;
1265
+ }),
1266
+ [],
1267
+ );
1268
+ const draftId = useRef<string | null>(null);
1269
+ const draftCreated = useRef(0);
1270
+ const draftScope = useMemo(() => {
1271
+ const id = member ? readJwt(member)?.memberId : undefined;
1272
+ if (!session || !id) return null;
1273
+ return `${session.apiUrl}|${session.environment}|${session.project}|${id}`;
1274
+ }, [session, member]);
1275
+ const draftStore = useMemo(
1276
+ () => (draftScope ? createDraftStore(draftScope) : null),
1277
+ [draftScope],
1278
+ );
1279
+ const previousDraftScope = useRef<string | null>(null);
1280
+ useEffect(() => {
1281
+ const previous = previousDraftScope.current;
1282
+ if (previous && previous !== draftScope) {
1283
+ const left = createDraftStore(previous);
1284
+ void left.clear();
1285
+ void left.clearRecording();
1286
+ }
1287
+ previousDraftScope.current = draftScope;
1288
+ }, [draftScope]);
1289
+ useEffect(() => {
1290
+ if (!draftStore || !member) return;
1291
+ let stale = false;
1292
+ // A recording that was running when the page went away is put back into its draft as a clip.
1293
+ // The recording row stays until that merged draft is saved, so a crash here still has the take.
1294
+ void Promise.all([draftStore.load(), draftStore.loadRecording()]).then(([found, running]) => {
1295
+ if (stale) return;
1296
+ if (!running || !found || running.draftId !== found.id) {
1297
+ setStored(found);
1298
+ return;
1299
+ }
1300
+ const recovered = recoverRecording(found, running);
1301
+ void draftStore.save(recovered).then(
1302
+ () => {
1303
+ if (stale) return;
1304
+ void draftStore.clearRecording();
1305
+ setStored(recovered);
1306
+ },
1307
+ () => {
1308
+ if (stale) return;
1309
+ setStored(found);
1310
+ },
1311
+ );
1312
+ });
1313
+ return () => {
1314
+ stale = true;
1315
+ };
1316
+ }, [draftStore, member]);
1317
+ /** What happens on the page while a clip is recorded into the draft, for the replay kept beside it. */
1318
+ const clipBuffer = useRef<ReplayBuffer | null>(null);
1319
+ const clipWatch = useRef<(() => void) | null>(null);
1320
+ const clipChunks = useRef<Blob[]>([]);
1321
+ const clipStartedAt = useRef(0);
1322
+ /** The screenshot tool is busy copying and drawing the page. */
1323
+ const [shooting, setShooting] = useState(false);
1324
+ const [body, setBody] = useState("");
1325
+ const [reply, setReply] = useState("");
1326
+ const [replyId, setReplyId] = useState<string | null>(null);
1327
+ const [name, setName] = useState("");
1328
+ const [profile, setProfile] = useState<Profile | null>(null);
1329
+ const [deliveries, setDeliveries] = useState<Record<string, Delivery>>({});
1330
+ const [bootHandoff] = useState(() => readHandoff());
1331
+ const [invite, setInvite] = useState<string | null>(bootHandoff?.invite ?? null);
1332
+ const [capturing, setCapturing] = useState(false);
1333
+ const [renaming, setRenaming] = useState<string | null>(null);
1334
+ const [confirmReset, setConfirmReset] = useState(false);
1335
+ const [error, setError] = useState("");
1336
+ const [toast, setToast] = useState("");
1337
+ const [busy, setBusy] = useState(false);
1338
+ const [prefs, setPrefs] = useState<Prefs>(readPrefs);
1339
+ const [barFrame, setBarFrame] = useState<BarFrame | null>(null);
1340
+ const mounted = useSyncExternalStore(
1341
+ subscribeMounted,
1342
+ () => true,
1343
+ () => false,
1344
+ );
1345
+ const [highlightStore] = useState(createHighlightStore);
1346
+ const [flash, setFlash] = useState<Box | null>(null);
1347
+ const [systemDark, setSystemDark] = useState(false);
1348
+ const screen = useScreenTier();
1349
+ const [settings, setSettings] = useState<Settings | null>(null);
1350
+ const [draftDetails, setDraftDetails] = useState<Details>(noDetails);
1351
+ const [draftKind, setDraftKind] = useState<ReportKind | null>(null);
1352
+ // Testers (tagged in the project's people settings) fill the tracker's ticket form as they comment.
1353
+ const [memberType, setMemberType] = useState<"client" | "tester" | null>(null);
1354
+ const [ticketForm, setTicketForm] = useState<TicketForm | null>(null);
1355
+ const [ticketFormError, setTicketFormError] = useState("");
1356
+ const [ticketValues, setTicketValues] = useState<TicketValues>({});
1357
+ const ticketFormLoading = useRef(false);
1358
+ useEffect(() => {
1359
+ if (memberType !== "tester" || !session || !member || !draft) return;
1360
+ if (ticketForm || ticketFormLoading.current) return;
1361
+ ticketFormLoading.current = true;
1362
+ fetchTicketForm(session, member)
1363
+ .then(
1364
+ (form) => {
1365
+ setTicketForm(form);
1366
+ setTicketFormError("");
1367
+ },
1368
+ (caught: unknown) =>
1369
+ setTicketFormError(
1370
+ caught instanceof Error ? caught.message : "Couldn’t load the ticket form.",
1371
+ ),
1372
+ )
1373
+ .finally(() => {
1374
+ ticketFormLoading.current = false;
1375
+ });
1376
+ }, [memberType, session, member, draft, ticketForm]);
1377
+ const [editingId, setEditingId] = useState<string | null>(null);
1378
+ const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
1379
+ const [historyId, setHistoryId] = useState<string | null>(null);
1380
+ const [linked, setLinked] = useState<string[]>([]);
1381
+ const [envFilter, setEnvFilter] = useState("all");
1382
+ const [access, setAccess] = useState<Access>("edit");
1383
+ const [disabled, setDisabled] = useState(false);
1384
+ const [signIn, setSignIn] = useState<SignInOptions | null>(null);
1385
+ const [msfWaiting, setMsfWaiting] = useState(false);
1386
+ const msfAttempt = useRef<{ state: string; verifier: string; stop: () => void } | null>(null);
1387
+ const readOnly = access === "view";
1388
+ const readOnlyRef = useRef(readOnly);
1389
+ const threadsRef = useRef(threads);
1390
+ const modeRef = useRef(mode);
1391
+ const draftPicked = useRef(new Map<string, Member>());
1392
+ const replyPicked = useRef(new Map<string, Member>());
1393
+ const draftShot = useRef<Promise<string | null> | null>(null);
1394
+ const replyShot = useRef<Promise<string | null> | null>(null);
1395
+ /** The page as it was when the draft (or reply) opened: its screenshot and any @mention crop come from it. */
1396
+ const draftPage = useRef<PageShot | null>(null);
1397
+ const replyPage = useRef<PageShot | null>(null);
1398
+ const replySnapped = useRef(false);
1399
+ const pendingShot = useRef<{ kind: "draft" | "reply"; cancel: () => void } | null>(null);
1400
+ const cancelScheduledSnapshot = useCallback((kind: "draft" | "reply") => {
1401
+ if (pendingShot.current?.kind !== kind) return;
1402
+ pendingShot.current.cancel();
1403
+ pendingShot.current = null;
1404
+ setCapturing(false);
1405
+ }, []);
1406
+ const replyDrafts = useRef(new Map<string, string>());
1407
+ const [hasParkedReply, setHasParkedReply] = useState(false);
1408
+ const [pendingId, setPendingId] = useState<string | null>(bootHandoff?.thread ?? null);
1409
+ const [revealId, setRevealId] = useState<string | null>(null);
1410
+ /** A thread to show once its page has loaded. `focus` also opens its card at the pin. */
1411
+ const arrival = useRef<{ id: string; until: number; focus?: boolean } | null>(null);
1412
+ const prefsRef = useRef(prefs);
1413
+
1414
+ const linkedEnv = useCallback(
1415
+ (thread: Thread | undefined) =>
1416
+ thread?.environment && session && thread.environment !== session.environment
1417
+ ? thread.environment
1418
+ : undefined,
1419
+ [session],
1420
+ );
1421
+ /**
1422
+ * Whether this environment checks a new comment before it is posted, from the thread list. Null until the
1423
+ * list says so, and for good on a service that predates the check: then nothing is ever asked.
1424
+ */
1425
+ const [clarityMode, setClarityMode] = useState<ClarityMode | null>(null);
1426
+ const clarity = useClarity(session, member, clarityMode);
1427
+ const { reset: resetClarity, restore: restoreClarity } = clarity;
1428
+ const draftFiles = useAttachments(session, member, setError);
1429
+ const draftFileActions = useRef(draftFiles);
1430
+ useEffect(() => {
1431
+ draftFileActions.current = draftFiles;
1432
+ }, [draftFiles]);
1433
+ const replyFiles = useAttachments(
1434
+ session,
1435
+ member,
1436
+ setError,
1437
+ linkedEnv(threads.find((thread) => thread.id === replyId)),
1438
+ );
1439
+ const savePrefs = useCallback((next: Prefs) => {
1440
+ setPrefs(next);
1441
+ localStorage.setItem(prefsKey, JSON.stringify(next));
1442
+ }, []);
1443
+ const recording = useTabRecording(setError, prefs.recordSound !== false, (recordSound) =>
1444
+ savePrefs({ ...prefsRef.current, recordSound }),
1445
+ );
1446
+ /** The box a recording was started from: "Type what I say" on the record bar writes into it. */
1447
+ const [recordInto, setRecordInto] = useState<"draft" | "reply">("draft");
1448
+ const recordingPhase = useRef(recording.phase);
1449
+ useEffect(() => {
1450
+ readOnlyRef.current = readOnly;
1451
+ threadsRef.current = threads;
1452
+ modeRef.current = mode;
1453
+ prefsRef.current = prefs;
1454
+ recordingPhase.current = recording.phase;
1455
+ }, [mode, prefs, readOnly, recording.phase, threads]);
1456
+ // Three minutes of steps: the debug context reads them all, the instant replay its last minute.
1457
+ const replayBuffer = useRef(new ReplayBuffer({ windowMs: DEBUG_STEPS_MS, maxEvents: 150 }));
1458
+ const replayOn = Boolean(member);
1459
+ const ownApi = session?.apiUrl.replace(/\/+$/, "") ?? null;
1460
+ const debug = useDebugContext(debugContext);
1461
+ const debugCapture = debug.capture;
1462
+ const startDebug = debug.start;
1463
+ const debugKey = debug.key;
1464
+ useEffect(() => {
1465
+ if (!replayOn) return;
1466
+ const stopActivity = watchActivity(
1467
+ replayBuffer.current,
1468
+ (url) => ownApi !== null && url.startsWith(ownApi),
1469
+ );
1470
+ // Started last and stopped first, so each puts back exactly the patches it made.
1471
+ const stopDebug = ownApi !== null && debugKey !== "off" ? startDebug(ownApi) : () => {};
1472
+ return () => {
1473
+ stopDebug();
1474
+ stopActivity();
1475
+ };
1476
+ }, [replayOn, ownApi, debugKey, startDebug]);
1477
+ const drafting = draft !== null;
1478
+ useEffect(() => {
1479
+ if (drafting) debugCapture.current?.mark();
1480
+ }, [debugCapture, drafting]);
1481
+ const resetReplyFiles = replyFiles.reset;
1482
+ useEffect(() => {
1483
+ const switching = replyId;
1484
+ cancelScheduledSnapshot("reply");
1485
+ resetReplyFiles();
1486
+ if (switching === replyId) {
1487
+ replySnapped.current = false;
1488
+ replyPicked.current = new Map();
1489
+ replyShot.current = null;
1490
+ replyPage.current = null;
1491
+ }
1492
+ }, [cancelScheduledSnapshot, replyId, resetReplyFiles]);
1493
+ const trayItems = draft ? draftFiles.items : replyFiles.items;
1494
+ if (trayKey && !pendingPreviews(trayItems).some((file) => file.id === trayKey)) setTrayKey(null);
1495
+
1496
+ /** Each thread's card has a reply box; the one being typed in holds the draft, the others keep theirs aside. */
1497
+ const claimReply = (threadId: string) => {
1498
+ if (replyId === threadId) return;
1499
+ if (replyId && reply.trim()) replyDrafts.current.set(replyId, reply);
1500
+ setReply(replyDrafts.current.get(threadId) ?? "");
1501
+ replyDrafts.current.delete(threadId);
1502
+ setReplyId(threadId);
1503
+ setHasParkedReply([...replyDrafts.current.values()].some((text) => text.trim()));
1504
+ };
1505
+
1506
+ const snapshot = useCallback(
1507
+ async (
1508
+ page: PageShot,
1509
+ add: (files: Array<{ blob: Blob; name: string; capture?: Capture }>) => void,
1510
+ sketch?: Sketch,
1511
+ ) => {
1512
+ setCapturing(true);
1513
+ const capture = describeCapture(`${window.location.origin}${pageHref()}`);
1514
+ const blob = await shotPage(page, sketch).catch(() => null);
1515
+ setCapturing(false);
1516
+ if (!blob) {
1517
+ setError("Couldn’t capture the page.");
1518
+ return;
1519
+ }
1520
+ add([{ blob, name: `screenshot-${fileStamp()}.jpg`, capture }]);
1521
+ },
1522
+ [setError],
1523
+ );
1524
+ /**
1525
+ * Copies the page on the frame after the composer paints, so the picture and the outline around `target` come
1526
+ * from the moment it was picked, and draws it once the browser is idle (within SNAPSHOT_DELAY_MS), so typing
1527
+ * the first letters never waits on it. Posting is held until it's done, as before.
1528
+ */
1529
+ const scheduleSnapshot = useCallback(
1530
+ (
1531
+ kind: "draft" | "reply",
1532
+ target: Element | null,
1533
+ add: (files: Array<{ blob: Blob; name: string; capture?: Capture }>) => void,
1534
+ sketch?: Sketch,
1535
+ ) => {
1536
+ pendingShot.current?.cancel();
1537
+ setCapturing(true);
1538
+ let cancelIdle: (() => void) | null = null;
1539
+ let timer = 0;
1540
+ const frame = requestAnimationFrame(() => {
1541
+ timer = window.setTimeout(() => {
1542
+ const page = takeShot(target);
1543
+ (kind === "draft" ? draftPage : replyPage).current = page;
1544
+ cancelIdle = whenIdle(() => {
1545
+ pendingShot.current = null;
1546
+ void snapshot(page, add, sketch);
1547
+ }, SNAPSHOT_DELAY_MS);
1548
+ }, 0);
1549
+ });
1550
+ pendingShot.current = {
1551
+ kind,
1552
+ cancel: () => {
1553
+ cancelAnimationFrame(frame);
1554
+ window.clearTimeout(timer);
1555
+ cancelIdle?.();
1556
+ },
1557
+ };
1558
+ },
1559
+ [snapshot],
1560
+ );
1561
+ /** The @mention crop of the draft's element, from the page as the draft saw it. */
1562
+ const cropDraft = useCallback((element: Element, sketch?: Sketch) => {
1563
+ const page = draftPage.current ?? takeShot(element);
1564
+ return page.frame ? shotAround(page, sketch) : captureAround(element, sketch);
1565
+ }, []);
1566
+ const cropReply = useCallback((element: Element, sketch?: Sketch) => {
1567
+ const page = replyPage.current ?? takeShot(element);
1568
+ return page.frame ? shotAround(page, sketch) : captureAround(element, sketch);
1569
+ }, []);
1570
+ useEffect(() => {
1571
+ if (!draft) cancelScheduledSnapshot("draft");
1572
+ }, [cancelScheduledSnapshot, draft]);
1573
+ useEffect(() => () => pendingShot.current?.cancel(), []);
1574
+ useEffect(() => {
1575
+ if (!member) return;
1576
+ return whenIdle(() => prewarmPageImage(), PREWARM_DELAY_MS);
1577
+ }, [member]);
1578
+
1579
+ const pinsHidden = Boolean(prefs.pinsHidden);
1580
+ const barPosition = prefs.position ?? position;
1581
+
1582
+ const commitDock = useCallback(
1583
+ (dock: Dock) => savePrefs({ ...prefsRef.current, dock }),
1584
+ [savePrefs],
1585
+ );
1586
+ const placeBar = useCallback((frame: BarFrame | null) => {
1587
+ setBarFrame((current) => {
1588
+ if (!frame && !current) return current;
1589
+ if (
1590
+ frame &&
1591
+ current &&
1592
+ frame.edge === current.edge &&
1593
+ frame.side === current.side &&
1594
+ frame.x === current.x &&
1595
+ frame.y === current.y &&
1596
+ frame.width === current.width &&
1597
+ frame.height === current.height
1598
+ ) {
1599
+ return current;
1600
+ }
1601
+ return frame;
1602
+ });
1603
+ }, []);
1604
+
1605
+ const say = useCallback((message: string) => setToast(message), []);
1606
+ const barRef = useRef<HTMLDivElement>(null);
1607
+ const warmTips = useWarmTips();
1608
+
1609
+ useEffect(() => {
1610
+ if (theme !== "system") return;
1611
+ const query = window.matchMedia("(prefers-color-scheme: dark)");
1612
+ const sync = () => setSystemDark(query.matches);
1613
+ sync();
1614
+ query.addEventListener("change", sync);
1615
+ return () => query.removeEventListener("change", sync);
1616
+ }, [theme]);
1617
+
1618
+ useEffect(() => {
1619
+ if (!toast) return;
1620
+ const id = window.setTimeout(() => setToast(""), 1800);
1621
+ return () => window.clearTimeout(id);
1622
+ }, [toast]);
1623
+
1624
+ useEffect(() => {
1625
+ injectStyles("bar");
1626
+ injectStyles("overlay");
1627
+ }, []);
1628
+
1629
+ const apiUrl = session?.apiUrl;
1630
+ useEffect(() => {
1631
+ if (apiUrl) loadFont(apiUrl);
1632
+ }, [apiUrl]);
1633
+
1634
+ /**
1635
+ * The draft dock stands in for the bar while a draft is waiting: a page draft minimised to collect captures,
1636
+ * or one found in storage to resume. Its styles live in the panel sheet, so it needs that sheet too.
1637
+ */
1638
+ const draftDock = draftDockMode({
1639
+ signedIn: member !== null && !disabled,
1640
+ pageDraft: draft !== null && shot !== null,
1641
+ collecting,
1642
+ stored: stored !== null,
1643
+ });
1644
+ const panelStyles =
1645
+ surface !== "none" ||
1646
+ draft !== null ||
1647
+ peekId !== null ||
1648
+ focusId !== null ||
1649
+ draftDock !== null;
1650
+ useLayoutEffect(() => {
1651
+ if (panelStyles) injectStyles("panel");
1652
+ }, [panelStyles]);
1653
+
1654
+ useEffect(() => {
1655
+ let cancelled = false;
1656
+ sessions()
1657
+ .then((next) => {
1658
+ if (cancelled) return;
1659
+ if (!next) {
1660
+ setPhase("off");
1661
+ return;
1662
+ }
1663
+ setSession(next);
1664
+ adoptStoredMember(next);
1665
+ const stored = localStorage.getItem(storageKey(next));
1666
+ if (stored && memberIsCurrent(stored)) {
1667
+ setMember(stored);
1668
+ setName(readJwt(stored)?.name ?? "");
1669
+ }
1670
+ setPhase("ready");
1671
+ })
1672
+ .catch(() => {
1673
+ if (!cancelled) setPhase("off");
1674
+ });
1675
+ return () => {
1676
+ cancelled = true;
1677
+ };
1678
+ }, [sessions]);
1679
+
1680
+ useEffect(() => {
1681
+ const sync = () => setHref(window.location.href);
1682
+ sync();
1683
+ announceNavigation();
1684
+ window.addEventListener("popstate", sync);
1685
+ window.addEventListener("hashchange", sync);
1686
+ window.addEventListener(NAVIGATED, sync);
1687
+ return () => {
1688
+ window.removeEventListener("popstate", sync);
1689
+ window.removeEventListener("hashchange", sync);
1690
+ window.removeEventListener(NAVIGATED, sync);
1691
+ };
1692
+ }, []);
1693
+
1694
+ const onThreads = useCallback((next: ThreadsPayload) => {
1695
+ setDisabled(false);
1696
+ setAccess(next.access === "view" ? "view" : "edit");
1697
+ setMemberType(next.memberType ?? null);
1698
+ setClarityMode(readClarityMode(next.clarity));
1699
+ const painted = paintThreadStatuses(next.threads, pendingStatus.current);
1700
+ pendingStatus.current = painted.pending;
1701
+ setThreads(painted.threads);
1702
+ setSettings(next.settings);
1703
+ const names = next.linked ?? [];
1704
+ setLinked((current) => (current.join("\n") === names.join("\n") ? current : names));
1705
+ }, []);
1706
+ const onUnauthorized = useCallback(() => {
1707
+ if (session) localStorage.removeItem(storageKey(session));
1708
+ void draftStore?.clear();
1709
+ void draftStore?.clearRecording();
1710
+ setMember(null);
1711
+ }, [draftStore, session]);
1712
+ const onDisabled = useCallback(() => {
1713
+ setDisabled(true);
1714
+ setThreads([]);
1715
+ setDraft(null);
1716
+ setMode("browse");
1717
+ }, [setDisabled, setDraft, setMode, setThreads]);
1718
+
1719
+ const panelOpen = surface === "dashboard";
1720
+
1721
+ /** Opens the dashboard on Comments, with `threadId` selected when given. */
1722
+ const openComments = useCallback(
1723
+ (threadId: string | null = null) => {
1724
+ if (threadId) {
1725
+ const thread = threadsRef.current.find((each) => each.id === threadId);
1726
+ const environment = thread?.environment || session?.environment;
1727
+ setEnvFilter((current) => (current === "all" || current === environment ? current : "all"));
1728
+ }
1729
+ setDashboardFocus((current) => (threadId ? { threadId, at: (current?.at ?? 0) + 1 } : null));
1730
+ setDashboardScreen("inbox");
1731
+ setFocusId(null);
1732
+ setFocusMenu(false);
1733
+ setSurface("dashboard");
1734
+ },
1735
+ [session],
1736
+ );
1737
+ useEffect(() => {
1738
+ if (surface !== "unlock" || !session || member) return;
1739
+ let cancelled = false;
1740
+ // A 401 is most often a host token that expired while the page sat open: a fresh one is asked for once.
1741
+ void signInOptions(session)
1742
+ .then(async (first) => {
1743
+ if (first.status !== 401) return first;
1744
+ const fresh = await sessions({ refresh: true }).catch(() => null);
1745
+ return fresh ? signInOptions(fresh) : first;
1746
+ })
1747
+ .then((next) => {
1748
+ if (!cancelled) setSignIn(next);
1749
+ });
1750
+ return () => {
1751
+ cancelled = true;
1752
+ };
1753
+ }, [member, session, sessions, surface]);
1754
+
1755
+ /** The origin the session route had the host token issued for, when it is not this page's. */
1756
+ const wrongOrigin = useMemo(() => {
1757
+ const issuedFor = session ? readJwt(session.hostToken)?.origin : undefined;
1758
+ return issuedFor && issuedFor !== window.location.origin ? issuedFor : null;
1759
+ }, [session]);
1760
+ useEffect(() => {
1761
+ if (!wrongOrigin) return;
1762
+ console.warn(`[comment-service] ${tokenForOtherOrigin(wrongOrigin)}`);
1763
+ }, [wrongOrigin]);
1764
+
1765
+ // Another tab signed out of comments, or saw the host sign out: this one follows.
1766
+ useEffect(() => {
1767
+ if (!session) return;
1768
+ const key = storageKey(session);
1769
+ const onStorage = (event: StorageEvent) => {
1770
+ if ((event.key === key || event.key === null) && localStorage.getItem(key) === null)
1771
+ setMember(null);
1772
+ };
1773
+ window.addEventListener("storage", onStorage);
1774
+ return () => window.removeEventListener("storage", onStorage);
1775
+ }, [session]);
1776
+
1777
+ useEffect(() => () => msfAttempt.current?.stop(), []);
1778
+
1779
+ useEffect(() => {
1780
+ if (surface !== "dashboard" || !session || !member) return;
1781
+ let cancelled = false;
1782
+ getProfile(session, member)
1783
+ .then((next) => {
1784
+ if (!cancelled) setProfile(next);
1785
+ })
1786
+ .catch(() => undefined);
1787
+ return () => {
1788
+ cancelled = true;
1789
+ };
1790
+ }, [member, session, surface]);
1791
+
1792
+ const [leftDashboard, setLeftDashboard] = useState(surface !== "dashboard");
1793
+ if ((surface !== "dashboard") !== leftDashboard) {
1794
+ setLeftDashboard(surface !== "dashboard");
1795
+ if (surface !== "dashboard") {
1796
+ setConfirmReset(false);
1797
+ setRenaming(null);
1798
+ setDashboardFocus(null);
1799
+ }
1800
+ }
1801
+ const openOnRoute = threads.some((thread) => thread.status === "open" && here.has(thread));
1802
+ const { live, refresh, visible } = useThreadsSync({
1803
+ session,
1804
+ member,
1805
+ pollMs,
1806
+ href,
1807
+ wanted: panelOpen || draft !== null || openOnRoute,
1808
+ onThreads,
1809
+ onUnauthorized,
1810
+ onDisabled,
1811
+ });
1812
+ /** The dashboard, a new comment, or an open pin — each has a box you can type @ in. */
1813
+ const canMention = panelOpen || draft !== null || focusId !== null;
1814
+ const ownMembers = useMembers(session, member, undefined, canMention);
1815
+ const transcribe = (wav: Blob, prompt: string, signal: AbortSignal) => {
1816
+ if (!session || !member) return Promise.reject(new Error("Sign in to dictate."));
1817
+ return transcribeAudio(session, member, wav, prompt, signal);
1818
+ };
1819
+ const memberNames = ownMembers.map((person) => person.name);
1820
+ const keepVoice = prefs.keepVoice !== false;
1821
+ const setKeepVoice = (next: boolean) => savePrefs({ ...prefsRef.current, keepVoice: next });
1822
+ /** Narration while a clip is recording goes on the clip's clock, at the moment the piece was spoken. */
1823
+ const sayIntoClip = useCallback((text: string, spokenAt: number) => {
1824
+ clipBuffer.current?.pushAt({ type: "say", text }, spokenAt);
1825
+ }, []);
1826
+ const draftDictation = useDictation({
1827
+ transcribe,
1828
+ value: body,
1829
+ onChange: setBody,
1830
+ names: memberNames,
1831
+ onNote: setError,
1832
+ onAudio: (file) => draftFiles.add([{ blob: file, name: file.name }]),
1833
+ keepVoice,
1834
+ onKeepVoice: setKeepVoice,
1835
+ onSaid: sayIntoClip,
1836
+ });
1837
+ const replyDictation = useDictation({
1838
+ transcribe,
1839
+ value: reply,
1840
+ onChange: (value) => {
1841
+ setReply(value);
1842
+ if (!value.trim() || replySnapped.current) return;
1843
+ const thread = threads.find((item) => item.id === replyId);
1844
+ const element = thread ? elementOf(thread) : null;
1845
+ if (!thread || !element) return;
1846
+ replySnapped.current = true;
1847
+ scheduleSnapshot("reply", element, replyFiles.add, thread.anchor.sketch);
1848
+ },
1849
+ names: memberNames,
1850
+ onNote: setError,
1851
+ onAudio: (file) => replyFiles.add([{ blob: file, name: file.name }]),
1852
+ keepVoice,
1853
+ onKeepVoice: setKeepVoice,
1854
+ onSaid: sayIntoClip,
1855
+ });
1856
+ const cancelDraftDictation = draftDictation.cancel;
1857
+ const cancelReplyDictation = replyDictation.cancel;
1858
+ const recordDictation = recordInto === "reply" ? replyDictation : draftDictation;
1859
+ const stopRecordDictation = recordDictation.stop;
1860
+ /** Dictation was started from the record bar, so it ends with the take. */
1861
+ const dictatingWithRecording = useRef(false);
1862
+ useEffect(() => {
1863
+ if (recording.phase === "recording" || !dictatingWithRecording.current) return;
1864
+ dictatingWithRecording.current = false;
1865
+ // Words still on their way are written.
1866
+ stopRecordDictation();
1867
+ }, [recording.phase, stopRecordDictation]);
1868
+ // Words belong to the box they started in: a discarded or replaced draft, or another thread's reply box,
1869
+ // gets none of them.
1870
+ useEffect(() => {
1871
+ if (!draft) return;
1872
+ return () => cancelDraftDictation();
1873
+ }, [cancelDraftDictation, draft]);
1874
+ useEffect(() => {
1875
+ if (!replyId) return;
1876
+ return () => cancelReplyDictation();
1877
+ }, [cancelReplyDictation, replyId]);
1878
+ const noDraftWork =
1879
+ mode === "browse" &&
1880
+ !draft &&
1881
+ !body.trim() &&
1882
+ !reply.trim() &&
1883
+ !hasParkedReply &&
1884
+ replyFiles.ids.length === 0 &&
1885
+ recording.phase === "idle" &&
1886
+ !draftDictation.busy &&
1887
+ !replyDictation.busy &&
1888
+ !capturing &&
1889
+ !busy &&
1890
+ !editingId &&
1891
+ !editingMessageId;
1892
+ useRuntimeUpdates(
1893
+ session?.apiUrl,
1894
+ live,
1895
+ noDraftWork && (surface === "none" || document.visibilityState === "hidden"),
1896
+ );
1897
+ const replyEnv = linkedEnv(threads.find((thread) => thread.id === (focusId ?? replyId)));
1898
+ const replyEnvMembers = useMembers(
1899
+ session,
1900
+ member,
1901
+ replyEnv,
1902
+ Boolean(replyEnv) && (panelOpen || focusId !== null),
1903
+ );
1904
+
1905
+ useEffect(() => {
1906
+ // This tab may be a sign-in window that came back here: hand the result to the tab that started it.
1907
+ if (relayMsfHandoff()) {
1908
+ window.close();
1909
+ window.setTimeout(() => setToast("Signed in. You can close this tab."), 300);
1910
+ }
1911
+ }, []);
1912
+
1913
+ useEffect(() => {
1914
+ stripHandoffFromUrl();
1915
+ }, []);
1916
+
1917
+ const dropInvite = useCallback(() => {
1918
+ setInvite(null);
1919
+ dropHandoff("invite");
1920
+ }, []);
1921
+
1922
+ if (phase === "ready" && !member && (pendingId || invite) && surface !== "unlock") {
1923
+ setSurface("unlock");
1924
+ }
1925
+ if (member && invite) {
1926
+ setInvite(null);
1927
+ dropHandoff("invite");
1928
+ }
1929
+
1930
+ const portal = useOverlayHost(mounted);
1931
+ const { placed, elementFor } = usePlacement({
1932
+ threads,
1933
+ here,
1934
+ host: portal ?? document.body,
1935
+ active: Boolean(member) && (panelOpen || (!pinsHidden && openOnRoute)),
1936
+ panelOpen,
1937
+ visible,
1938
+ });
1939
+ /** The element a thread on this page is attached to, for screenshots and "Show". */
1940
+ const elementOf = (thread: Thread) =>
1941
+ here.has(thread)
1942
+ ? (elementFor(thread.id) ?? resolveInDocument(thread.anchor, route).element)
1943
+ : null;
1944
+
1945
+ useEffect(() => {
1946
+ if (!flash) return;
1947
+ const id = window.setTimeout(() => setFlash(null), 1400);
1948
+ return () => window.clearTimeout(id);
1949
+ }, [flash]);
1950
+
1951
+ /** Closes an element comment. A page draft is minimised to the dock instead; nothing throws it away by accident. */
1952
+ const closeOrPark = useCallback(() => {
1953
+ if (draft && shot) {
1954
+ setCollecting(true);
1955
+ return;
1956
+ }
1957
+ setDraft(null);
1958
+ }, [draft, shot]);
1959
+
1960
+ /**
1961
+ * The bar's screenshot tool: copies and draws the whole page, then opens it in `ShotSurface` with a composer
1962
+ * beside it. Pressed again while one is open, it closes it.
1963
+ */
1964
+ /** The draft as it stands, ready for storage; null when there is no page draft. */
1965
+ const storedDraftNow = useCallback((): StoredDraft | null => {
1966
+ if (!draft || !shot || !draftId.current) return null;
1967
+ return {
1968
+ v: 1,
1969
+ id: draftId.current,
1970
+ createdAt: draftCreated.current,
1971
+ updatedAt: Date.now(),
1972
+ href: draft.href ?? pageHref(),
1973
+ route: draft.anchor.route,
1974
+ anchor: draft.anchor,
1975
+ body,
1976
+ details: draftDetails,
1977
+ ...(draftKind ? { kind: draftKind } : {}),
1978
+ ticket: ticketValues,
1979
+ captures: draftFiles.items.map((item) => ({
1980
+ key: item.key,
1981
+ name: item.name,
1982
+ type: item.contentType,
1983
+ blob: item.blob,
1984
+ capture: item.capture,
1985
+ markup: item.markup
1986
+ ? { blob: item.markup.blob, name: item.markup.name, shapes: item.markup.shapes }
1987
+ : undefined,
1988
+ shapes: captureShapes.get(item.key) ?? item.markup?.shapes ?? [],
1989
+ })),
1990
+ selected: shot.key,
1991
+ // Open questions and the answers typed so far follow the draft through a minimise or a reload.
1992
+ ...(clarity.hold ? { clarity: clarity.hold } : {}),
1993
+ };
1994
+ }, [
1995
+ body,
1996
+ captureShapes,
1997
+ clarity.hold,
1998
+ draft,
1999
+ draftDetails,
2000
+ draftKind,
2001
+ draftFiles.items,
2002
+ shot,
2003
+ ticketValues,
2004
+ ]);
2005
+ const storedDraftNowRef = useRef(storedDraftNow);
2006
+ useEffect(() => {
2007
+ storedDraftNowRef.current = storedDraftNow;
2008
+ });
2009
+ // Every change to a page draft is written to the browser's storage within a moment, pictures and clips
2010
+ // included, so a reload, a crash or another tab can offer to pick it up. The title's "Saved" follows this.
2011
+ useEffect(() => {
2012
+ if (!draftStore) return;
2013
+ const timer = window.setTimeout(() => {
2014
+ const payload = storedDraftNow();
2015
+ if (!payload) return;
2016
+ void draftStore.save(payload).then(
2017
+ () => {
2018
+ if (draftStore.durable) setSavedAt(Date.now());
2019
+ },
2020
+ () => {},
2021
+ );
2022
+ }, 300);
2023
+ return () => window.clearTimeout(timer);
2024
+ }, [draftStore, storedDraftNow]);
2025
+
2026
+ /** Keeps the page draft in storage and offers it in the dock, so an element comment can take the composer. */
2027
+ const stashDraft = useCallback(() => {
2028
+ const payload = storedDraftNowRef.current();
2029
+ if (!payload || !draftStore) return;
2030
+ void draftStore.save(payload).catch(() => {});
2031
+ setStored(payload);
2032
+ setCollecting(false);
2033
+ }, [draftStore]);
2034
+ const pageDraftRef = useRef(false);
2035
+ const stashDraftRef = useRef(stashDraft);
2036
+ useEffect(() => {
2037
+ pageDraftRef.current = Boolean(draft && shot);
2038
+ stashDraftRef.current = stashDraft;
2039
+ });
2040
+
2041
+ const resetPageDraft = useCallback(() => {
2042
+ draftFileActions.current.reset();
2043
+ setCaptureShapes(new Map());
2044
+ draftId.current = null;
2045
+ setShot(null);
2046
+ setCollecting(false);
2047
+ setSavedAt(null);
2048
+ setBody("");
2049
+ setDraftDetails(noDetails);
2050
+ setDraftKind(null);
2051
+ setTicketValues({});
2052
+ resetClarity();
2053
+ }, [resetClarity]);
2054
+
2055
+ /** Throws the draft away. The first press asks; the second, within a few seconds, does it. */
2056
+ const discardDraft = useCallback(() => {
2057
+ if (!confirmDiscard) {
2058
+ setConfirmDiscard(true);
2059
+ window.setTimeout(() => setConfirmDiscard(false), 4000);
2060
+ return;
2061
+ }
2062
+ setConfirmDiscard(false);
2063
+ void draftStore?.clear();
2064
+ setStored(null);
2065
+ setDraft(null);
2066
+ resetPageDraft();
2067
+ }, [confirmDiscard, draftStore, resetPageDraft]);
2068
+
2069
+ /** Puts a stored draft back: its captures (uploaded again), its words, its drawings, open on this page. */
2070
+ const resumeDraft = useCallback(
2071
+ (found: StoredDraft) => {
2072
+ if (!session || !member) return;
2073
+ const element = pageAnchorElement();
2074
+ const target = portal ?? document.body;
2075
+ const box = placeAgainst(element, target) ?? { top: 0, left: 0, width: 0, height: 0 };
2076
+ setMode("browse");
2077
+ setReattachId(null);
2078
+ setSurface("none");
2079
+ setFocusId(null);
2080
+ setFocusMenu(false);
2081
+ draftPicked.current = new Map();
2082
+ draftShot.current = null;
2083
+ draftPage.current = null;
2084
+ draftFileActions.current.reset();
2085
+ setCaptureShapes(new Map(found.captures.map((item) => [item.key, item.shapes])));
2086
+ draftFileActions.current.restore(
2087
+ found.captures.map((item) => ({
2088
+ key: item.key,
2089
+ blob: item.blob,
2090
+ name: item.name,
2091
+ capture: item.capture,
2092
+ markup: item.markup,
2093
+ })),
2094
+ );
2095
+ draftId.current = found.id;
2096
+ draftCreated.current = found.createdAt;
2097
+ setDraft({ anchor: found.anchor, href: found.href, ...composerSpot(box, target), element });
2098
+ setShot({ key: found.selected ?? found.captures[0]?.key ?? "" });
2099
+ setBody(found.body);
2100
+ setDraftDetails(found.details);
2101
+ setDraftKind(found.kind ?? null);
2102
+ setTicketValues(found.ticket);
2103
+ restoreClarity(found.clarity ?? null);
2104
+ setStored(null);
2105
+ setCollecting(false);
2106
+ setSavedAt(found.updatedAt);
2107
+ setError("");
2108
+ },
2109
+ [member, portal, restoreClarity, session],
2110
+ );
2111
+
2112
+ /** Takes a picture of this page into the open draft (from the dock, or S while collecting). */
2113
+ const addCapture = useCallback(async () => {
2114
+ if (!draft || !shot || shooting) return;
2115
+ setShooting(true);
2116
+ await new Promise<void>((resolve) =>
2117
+ requestAnimationFrame(() => window.setTimeout(resolve, 0)),
2118
+ );
2119
+ const page = takeShot(null);
2120
+ const capture = describeCapture(`${window.location.origin}${pageHref()}`);
2121
+ const blob = await shotPage(page).catch(() => null);
2122
+ setShooting(false);
2123
+ if (!blob) {
2124
+ say("Couldn’t capture the page.");
2125
+ return;
2126
+ }
2127
+ const [key] = draftFileActions.current.add([
2128
+ { blob, name: `screenshot-${fileStamp()}.jpg`, capture },
2129
+ ]);
2130
+ if (!key) return;
2131
+ setShot({ key });
2132
+ say("Added to the draft");
2133
+ }, [draft, say, shooting, shot]);
2134
+
2135
+ /**
2136
+ * Records this tab into the open draft; the surface gets out of the way while it runs. Every second of the
2137
+ * take and everything that happens on the page meanwhile are kept in storage as they land, so a reload or a
2138
+ * crash mid-take keeps what was filmed; the replay goes with the clip when it is kept.
2139
+ */
2140
+ const recordIntoDraft = useCallback(() => {
2141
+ if (recording.phase !== "idle" || !draftId.current) return;
2142
+ setCollecting(true);
2143
+ setRecordInto("draft");
2144
+ clipWatch.current?.();
2145
+ const buffer = new ReplayBuffer({ windowMs: RECORD_MAX_MS + 10_000, maxEvents: 400 });
2146
+ clipBuffer.current = buffer;
2147
+ clipWatch.current = watchActivity(buffer);
2148
+ clipChunks.current = [];
2149
+ clipStartedAt.current = Date.now();
2150
+ const id = draftId.current;
2151
+ const url = `${window.location.origin}${pageHref()}`;
2152
+ void recording.begin(draftFileActions.current.add, {
2153
+ onChunk: (chunk, _index, mime) => {
2154
+ clipChunks.current.push(chunk);
2155
+ void draftStore?.saveRecording({
2156
+ v: 1,
2157
+ draftId: id,
2158
+ mime,
2159
+ startedAt: clipStartedAt.current,
2160
+ url,
2161
+ chunks: clipChunks.current.slice(),
2162
+ events: buffer.snapshot(url)?.events ?? [],
2163
+ });
2164
+ },
2165
+ companions: (info) => {
2166
+ const file = buffer.snapshot(url, {
2167
+ name: info.name,
2168
+ startedAt: info.startedAt,
2169
+ durationMs: info.durationMs,
2170
+ });
2171
+ return file
2172
+ ? [
2173
+ {
2174
+ blob: new Blob([JSON.stringify(file)], { type: "application/json" }),
2175
+ name: `replay-${info.stamp}.json`,
2176
+ },
2177
+ ]
2178
+ : [];
2179
+ },
2180
+ });
2181
+ }, [draftStore, recording]);
2182
+ useEffect(() => {
2183
+ if (recording.phase !== "idle") return;
2184
+ // The take ended (kept, discarded or cancelled): nothing is running that a reload could cut short.
2185
+ clipWatch.current?.();
2186
+ clipWatch.current = null;
2187
+ clipBuffer.current = null;
2188
+ clipChunks.current = [];
2189
+ void draftStore?.clearRecording();
2190
+ }, [draftStore, recording.phase]);
2191
+
2192
+ /**
2193
+ * The bar's screenshot tool. With no draft: copies and draws the whole page and opens it in `ShotSurface` with
2194
+ * a composer beside it. With a draft open: minimises it to the dock, and from the dock, opens it again. With a
2195
+ * stored draft waiting: resumes it.
2196
+ */
2197
+ const startShot = useCallback(async () => {
2198
+ if (readOnlyRef.current) {
2199
+ setToast("You can read comments here, not add them.");
2200
+ return;
2201
+ }
2202
+ if (!session || !member || shooting) return;
2203
+ if (draft && shot) {
2204
+ setCollecting((current) => !current);
2205
+ return;
2206
+ }
2207
+ if (stored) {
2208
+ resumeDraft(stored);
2209
+ return;
2210
+ }
2211
+ setMode("browse");
2212
+ setReattachId(null);
2213
+ setSurface("none");
2214
+ setFocusId(null);
2215
+ setFocusMenu(false);
2216
+ setDraft(null);
2217
+ setShooting(true);
2218
+ // The page is copied after this frame paints, so an open card or menu just closed is not in the picture.
2219
+ await new Promise<void>((resolve) =>
2220
+ requestAnimationFrame(() => window.setTimeout(resolve, 0)),
2221
+ );
2222
+ const element = pageAnchorElement();
2223
+ const page = takeShot(null);
2224
+ const capture = describeCapture(`${window.location.origin}${pageHref()}`);
2225
+ const blob = await shotPage(page).catch(() => null);
2226
+ setShooting(false);
2227
+ if (!blob) {
2228
+ setError("Couldn’t capture the page.");
2229
+ return;
2230
+ }
2231
+ const anchor = captureAnchor(element, window.location.pathname);
2232
+ const target = portal ?? document.body;
2233
+ const box = placeAgainst(element, target) ?? { top: 0, left: 0, width: 0, height: 0 };
2234
+ draftPicked.current = new Map();
2235
+ draftShot.current = null;
2236
+ draftPage.current = page;
2237
+ resetPageDraft();
2238
+ draftId.current = crypto.randomUUID();
2239
+ draftCreated.current = Date.now();
2240
+ const [key] = draftFileActions.current.add([
2241
+ { blob, name: `screenshot-${fileStamp()}.jpg`, capture },
2242
+ ]);
2243
+ if (!key) return;
2244
+ setDraft({ anchor, href: pageHref(), ...composerSpot(box, target), element });
2245
+ setShot({ key });
2246
+ setError("");
2247
+ }, [draft, member, portal, resetPageDraft, resumeDraft, session, shooting, shot, stored]);
2248
+
2249
+ const togglePins = useCallback(() => {
2250
+ const next = !pinsHidden;
2251
+ savePrefs({ ...prefs, pinsHidden: next });
2252
+ say(next ? "Pins hidden · H to show" : "Pins shown");
2253
+ }, [pinsHidden, prefs, savePrefs, say]);
2254
+
2255
+ /**
2256
+ * Unsent work in the open composer: the same test Post applies. A letter pressed while the box has lost focus
2257
+ * (an overlay taking focus back, a click on the page) reads as a shortcut, and a shortcut closing the composer
2258
+ * over writing is not something a letter may do.
2259
+ */
2260
+ const draftHasWriting = Boolean(
2261
+ draft &&
2262
+ (body.trim() ||
2263
+ draftDictation.busy ||
2264
+ draftFiles.ids.length ||
2265
+ shot ||
2266
+ (settings?.fields.enabled && hasFieldValues(draftDetails))),
2267
+ );
2268
+
2269
+ const onKey = useRef<(event: KeyboardEvent) => void>(() => {});
2270
+ const handleKey = (event: KeyboardEvent) => {
2271
+ if (event.defaultPrevented || markingUp.current) return;
2272
+ if (event.key === "Escape" && (draftDictation.listening || replyDictation.listening)) {
2273
+ event.stopImmediatePropagation();
2274
+ draftDictation.stop();
2275
+ replyDictation.stop();
2276
+ return;
2277
+ }
2278
+ if (recordingPhase.current !== "idle") {
2279
+ if (event.key === "Escape") {
2280
+ event.stopImmediatePropagation();
2281
+ recording.cancel();
2282
+ }
2283
+ return;
2284
+ }
2285
+ const trayFiles = pendingPreviews(draft ? draftFiles.items : replyFiles.items);
2286
+ if (trayKey && trayFiles.some((file) => file.id === trayKey)) {
2287
+ if (event.key === "Escape") {
2288
+ event.stopImmediatePropagation();
2289
+ setTrayKey(null);
2290
+ return;
2291
+ }
2292
+ if (
2293
+ !isTyping(event.target) &&
2294
+ trayFiles.length > 1 &&
2295
+ (event.key === "ArrowLeft" || event.key === "ArrowRight")
2296
+ ) {
2297
+ event.preventDefault();
2298
+ event.stopImmediatePropagation();
2299
+ const delta = event.key === "ArrowRight" ? 1 : -1;
2300
+ const index = trayFiles.findIndex((file) => file.id === trayKey);
2301
+ const next = trayFiles[(Math.max(0, index) + delta + trayFiles.length) % trayFiles.length];
2302
+ if (next) setTrayKey(next.id);
2303
+ return;
2304
+ }
2305
+ }
2306
+ if (preview) {
2307
+ const files = visualAttachments(
2308
+ threads.find((thread) => thread.id === preview.threadId)?.messages,
2309
+ );
2310
+ if (event.key === "Escape") {
2311
+ event.stopImmediatePropagation();
2312
+ setPreview(null);
2313
+ return;
2314
+ }
2315
+ if (
2316
+ !isTyping(event.target) &&
2317
+ files.length > 1 &&
2318
+ (event.key === "ArrowLeft" || event.key === "ArrowRight")
2319
+ ) {
2320
+ event.preventDefault();
2321
+ event.stopImmediatePropagation();
2322
+ const delta = event.key === "ArrowRight" ? 1 : -1;
2323
+ const index = files.findIndex((file) => file.id === preview.fileId);
2324
+ const next = files[(Math.max(0, index) + delta + files.length) % files.length];
2325
+ if (next) setPreview({ threadId: preview.threadId, fileId: next.id });
2326
+ return;
2327
+ }
2328
+ }
2329
+ if (event.key === "Escape") {
2330
+ if (mode !== "browse" || draft || surface !== "none" || focusId) {
2331
+ event.stopImmediatePropagation();
2332
+ setMode("browse");
2333
+ closeOrPark();
2334
+ setReattachId(null);
2335
+ setSurface("none");
2336
+ setFocusId(null);
2337
+ setFocusMenu(false);
2338
+ setConfirmReset(false);
2339
+ setRenaming(null);
2340
+ }
2341
+ return;
2342
+ }
2343
+ if (!member || event.metaKey || event.ctrlKey || event.altKey || isTyping(event.target)) return;
2344
+ const key = event.key.toLowerCase();
2345
+ // A draft minimised to the dock takes more captures from the keyboard; its writing is safe in storage.
2346
+ const parked = collecting && Boolean(draft && shot);
2347
+ if (parked && (key === "s" || key === "r")) {
2348
+ event.preventDefault();
2349
+ event.stopImmediatePropagation();
2350
+ if (key === "s") void addCapture();
2351
+ else recordIntoDraft();
2352
+ return;
2353
+ }
2354
+ const ui = SHORTCUTS[key];
2355
+ if (!ui) return;
2356
+ if (!shortcutAllowed(key, draftHasWriting && !parked)) {
2357
+ event.preventDefault();
2358
+ event.stopImmediatePropagation();
2359
+ composerField()?.focus();
2360
+ return;
2361
+ }
2362
+ if (key === "s") void startShot();
2363
+ else if (key === "h") togglePins();
2364
+ else return;
2365
+ pressTool(barRef.current, ui);
2366
+ event.preventDefault();
2367
+ event.stopImmediatePropagation();
2368
+ };
2369
+ useEffect(() => {
2370
+ onKey.current = handleKey;
2371
+ });
2372
+
2373
+ useEffect(() => {
2374
+ const inToolbar = (event: KeyboardEvent) =>
2375
+ event.target instanceof Element && Boolean(event.target.closest("#cs-root"));
2376
+ // Page keys in the capture phase: an open menu or dialog owns focus and swallows letters and Escape.
2377
+ const fromPage = (event: KeyboardEvent) => {
2378
+ if (!inToolbar(event)) onKey.current(event);
2379
+ };
2380
+ // Toolbar keys after its own fields had their turn. Registered once, before any app dialog opens, so
2381
+ // stopImmediatePropagation keeps a dialog's document-level Escape handler from also closing it.
2382
+ const fromToolbar = (event: KeyboardEvent) => {
2383
+ if (inToolbar(event)) onKey.current(event);
2384
+ };
2385
+ window.addEventListener("keydown", fromPage, true);
2386
+ document.addEventListener("keydown", fromToolbar);
2387
+ return () => {
2388
+ window.removeEventListener("keydown", fromPage, true);
2389
+ document.removeEventListener("keydown", fromToolbar);
2390
+ };
2391
+ }, []);
2392
+
2393
+ useEffect(() => shieldFromPage(), []);
2394
+
2395
+ useEffect(() => {
2396
+ const outline = highlightStore;
2397
+ if (mode !== "select" && mode !== "reattach") {
2398
+ outline.set(null);
2399
+ document.documentElement.style.cursor = "";
2400
+ return;
2401
+ }
2402
+ document.documentElement.style.cursor = "crosshair";
2403
+ const host = () => portal ?? document.body;
2404
+ // One hit-test per frame, however many moves arrive; the label (a walk up the DOM and React trees) is
2405
+ // computed only when the pointer reaches a different element.
2406
+ let frame = 0;
2407
+ let pending: MouseEvent | null = null;
2408
+ let under: Element | null = null;
2409
+ let label = "";
2410
+ const show = () => {
2411
+ frame = 0;
2412
+ const event = pending;
2413
+ pending = null;
2414
+ if (!event) return;
2415
+ if (event.target instanceof Element && event.target.closest("#cs-root")) {
2416
+ under = null;
2417
+ outline.set(null);
2418
+ return;
2419
+ }
2420
+ const element = elementAt(event.clientX, event.clientY);
2421
+ const box = element ? placeAgainst(element, host()) : null;
2422
+ if (!element || !box) {
2423
+ under = null;
2424
+ outline.set(null);
2425
+ return;
2426
+ }
2427
+ if (element !== under) {
2428
+ under = element;
2429
+ label = describeElement(element);
2430
+ }
2431
+ outline.set({ ...box, label });
2432
+ };
2433
+ const onMove = (event: MouseEvent) => {
2434
+ pending = event;
2435
+ if (!frame) frame = requestAnimationFrame(show);
2436
+ };
2437
+ const onClick = (event: MouseEvent) => {
2438
+ if (
2439
+ event.target instanceof Element &&
2440
+ event.target.closest("#cs-root button, #cs-root input, #cs-root textarea, #cs-root form")
2441
+ ) {
2442
+ return;
2443
+ }
2444
+ const element = elementAt(event.clientX, event.clientY);
2445
+ if (!element || !session) return;
2446
+ event.preventDefault();
2447
+ event.stopPropagation();
2448
+ const anchor = captureAnchor(element, window.location.pathname);
2449
+ const target = host();
2450
+ const box = placeAgainst(element, target);
2451
+ if (modeRef.current === "reattach" && reattachId && member) {
2452
+ setBusy(true);
2453
+ updateThread(session, member, reattachId, { anchor, url: pageHref() })
2454
+ .then(() => refresh())
2455
+ .then(() => {
2456
+ setMode("browse");
2457
+ setReattachId(null);
2458
+ openComments(reattachId);
2459
+ setError("");
2460
+ say("Reattached");
2461
+ })
2462
+ .catch((caught) =>
2463
+ setError(caught instanceof Error ? caught.message : "Could not reattach."),
2464
+ )
2465
+ .finally(() => setBusy(false));
2466
+ return;
2467
+ }
2468
+ const spot = composerSpot(
2469
+ box ?? { top: event.clientY, left: event.clientX, width: 0, height: 0 },
2470
+ target,
2471
+ );
2472
+ if (pageDraftRef.current) stashDraftRef.current();
2473
+ setDraft({ anchor, ...spot, element });
2474
+ setShot(null);
2475
+ draftPicked.current = new Map();
2476
+ draftShot.current = null;
2477
+ draftPage.current = null;
2478
+ draftFileActions.current.reset();
2479
+ scheduleSnapshot("draft", element, draftFileActions.current.add);
2480
+ setBody("");
2481
+ setDraftDetails(noDetails);
2482
+ setDraftKind(null);
2483
+ resetClarity();
2484
+ setMode("browse");
2485
+ setError("");
2486
+ };
2487
+ const swallow = (event: Event) => {
2488
+ if (event.target instanceof Element && event.target.closest("#cs-root")) return;
2489
+ if (!event.type.startsWith("touch")) event.preventDefault();
2490
+ event.stopPropagation();
2491
+ };
2492
+ const presses = ["pointerdown", "mousedown", "pointerup", "mouseup", "touchstart", "touchend"];
2493
+ document.addEventListener("mousemove", onMove, true);
2494
+ window.addEventListener("click", onClick, true);
2495
+ for (const type of presses) window.addEventListener(type, swallow, true);
2496
+ return () => {
2497
+ cancelAnimationFrame(frame);
2498
+ outline.set(null);
2499
+ document.documentElement.style.cursor = "";
2500
+ document.removeEventListener("mousemove", onMove, true);
2501
+ window.removeEventListener("click", onClick, true);
2502
+ for (const type of presses) window.removeEventListener(type, swallow, true);
2503
+ };
2504
+ }, [
2505
+ highlightStore,
2506
+ member,
2507
+ mode,
2508
+ openComments,
2509
+ portal,
2510
+ reattachId,
2511
+ refresh,
2512
+ resetClarity,
2513
+ say,
2514
+ scheduleSnapshot,
2515
+ session,
2516
+ ]);
2517
+
2518
+ /** A finished freehand stroke opens a composer on the element it was drawn over. */
2519
+ const onStroke = useCallback(
2520
+ (points: Point[]) => {
2521
+ const drawn = inkBox(points);
2522
+ if (isTap(drawn) || !session) return;
2523
+ const element = elementUnder(drawn);
2524
+ if (!element) {
2525
+ say("Draw over something on the page");
2526
+ return;
2527
+ }
2528
+ const sketch = sketchFor(element, [simplify(points)]);
2529
+ const anchor = { ...captureAnchor(element, window.location.pathname), sketch };
2530
+ const target = portal ?? document.body;
2531
+ const box = placeAgainst(element, target);
2532
+ const rect = element.getBoundingClientRect();
2533
+ const spot = composerSpot(
2534
+ {
2535
+ top: (box?.top ?? rect.top) + drawn.top - rect.top,
2536
+ left: (box?.left ?? rect.left) + drawn.left - rect.left,
2537
+ width: drawn.right - drawn.left,
2538
+ height: drawn.bottom - drawn.top,
2539
+ },
2540
+ target,
2541
+ );
2542
+ if (pageDraftRef.current) stashDraftRef.current();
2543
+ setDraft({ anchor, ...spot, element });
2544
+ setShot(null);
2545
+ draftPicked.current = new Map();
2546
+ draftShot.current = null;
2547
+ draftPage.current = null;
2548
+ draftFileActions.current.reset();
2549
+ scheduleSnapshot("draft", element, draftFileActions.current.add, sketch);
2550
+ setBody("");
2551
+ setDraftDetails(noDetails);
2552
+ setDraftKind(null);
2553
+ resetClarity();
2554
+ setMode("browse");
2555
+ setError("");
2556
+ },
2557
+ [portal, resetClarity, say, scheduleSnapshot, session],
2558
+ );
2559
+
2560
+ useEffect(() => {
2561
+ const api = {
2562
+ version: 1 as const,
2563
+ project: session?.project ?? null,
2564
+ environment: session?.environment ?? null,
2565
+ list: () => threadsRef.current,
2566
+ capture: (selector: string) => {
2567
+ const element = document.querySelector(selector);
2568
+ return element ? captureAnchor(element, window.location.pathname) : null;
2569
+ },
2570
+ resolve: (anchor: Anchor) => resolveInDocument(anchor, window.location.pathname).resolution,
2571
+ };
2572
+ Object.assign(window, { __COMMENT_SERVICE__: api });
2573
+ }, [session]);
2574
+
2575
+ const envScope =
2576
+ envFilter !== "all" && (envFilter === session?.environment || linked.includes(envFilter))
2577
+ ? envFilter
2578
+ : "all";
2579
+ const scoped = useMemo(
2580
+ () =>
2581
+ envScope === "all"
2582
+ ? placed
2583
+ : placed.filter((item) => (item.thread.environment || session?.environment) === envScope),
2584
+ [envScope, placed, session],
2585
+ );
2586
+ if (preview) {
2587
+ const files = visualAttachments(
2588
+ threads.find((thread) => thread.id === preview.threadId)?.messages,
2589
+ );
2590
+ if (!files.some((file) => file.id === preview.fileId) || focusId !== preview.threadId)
2591
+ setPreview(null);
2592
+ }
2593
+ const showOnPage = useCallback(
2594
+ (thread: Thread) => {
2595
+ const element =
2596
+ elementFor(thread.id) ?? resolveInDocument(thread.anchor, window.location.pathname).element;
2597
+ if (!element) return;
2598
+ element.scrollIntoView({ block: "center", behavior: "smooth" });
2599
+ window.setTimeout(() => {
2600
+ const box = placeAgainst(element, portal ?? document.body);
2601
+ if (box) setFlash(box);
2602
+ }, 350);
2603
+ },
2604
+ [elementFor, portal],
2605
+ );
2606
+
2607
+ const pendingItem =
2608
+ pendingId && member ? placed.find((candidate) => candidate.thread.id === pendingId) : undefined;
2609
+ if (pendingItem && pendingId) {
2610
+ const id = pendingId;
2611
+ const onPage = pendingItem.status === "pinned" || pendingItem.status === "drifted";
2612
+ setPendingId(null);
2613
+ setRevealId(id);
2614
+ if (onPage) {
2615
+ setFocusId(id);
2616
+ setFocusMenu(false);
2617
+ setSurface("none");
2618
+ } else {
2619
+ const thread = threads.find((each) => each.id === id);
2620
+ const environment = thread?.environment || session?.environment;
2621
+ setEnvFilter((current) => (current === "all" || current === environment ? current : "all"));
2622
+ setDashboardFocus((current) => ({ threadId: id, at: (current?.at ?? 0) + 1 }));
2623
+ setDashboardScreen("inbox");
2624
+ setFocusId(null);
2625
+ setFocusMenu(false);
2626
+ setSurface("dashboard");
2627
+ }
2628
+ }
2629
+ const revealedHandoff = useRef<string | null>(null);
2630
+ useEffect(() => {
2631
+ if (!revealId || revealedHandoff.current === revealId) return;
2632
+ const item = placed.find((candidate) => candidate.thread.id === revealId);
2633
+ if (!item) return;
2634
+ revealedHandoff.current = revealId;
2635
+ dropHandoff("thread");
2636
+ if (item.status === "pinned" || item.status === "drifted") showOnPage(item.thread);
2637
+ else arrival.current = { id: revealId, until: Date.now() + 6000 };
2638
+ }, [placed, revealId, showOnPage]);
2639
+
2640
+ useEffect(() => {
2641
+ if (!focusId) return;
2642
+ const close = (event: PointerEvent) => {
2643
+ const target = event.target;
2644
+ if (target instanceof Element && target.closest("#cs-root")) return;
2645
+ setFocusId(null);
2646
+ setFocusMenu(false);
2647
+ };
2648
+ window.addEventListener("pointerdown", close, true);
2649
+ return () => window.removeEventListener("pointerdown", close, true);
2650
+ }, [focusId]);
2651
+
2652
+ useEffect(() => {
2653
+ const pending = arrival.current;
2654
+ if (!pending) return;
2655
+ if (Date.now() > pending.until) {
2656
+ arrival.current = null;
2657
+ return;
2658
+ }
2659
+ const item = placed.find((candidate) => candidate.thread.id === pending.id);
2660
+ if (!item || (item.status !== "pinned" && item.status !== "drifted")) return;
2661
+ arrival.current = null;
2662
+ showOnPage(item.thread);
2663
+ if (pending.focus) {
2664
+ setFocusId(item.thread.id);
2665
+ setFocusMenu(false);
2666
+ }
2667
+ }, [placed, showOnPage]);
2668
+
2669
+ /**
2670
+ * Shows a thread at its pin: here when it's pinned on this page, else after taking you to the page it
2671
+ * was left on. Says so when it can't be found on this page.
2672
+ */
2673
+ const showThread = (item: Placed) => {
2674
+ const { thread } = item;
2675
+ setEditingId(null);
2676
+ const onPage = item.status === "pinned" || item.status === "drifted";
2677
+ const target = thread.url || thread.route;
2678
+ if (here.has(thread) && onPage) {
2679
+ setSurface("none");
2680
+ setFocusId(thread.id);
2681
+ setFocusMenu(false);
2682
+ showOnPage(thread);
2683
+ return;
2684
+ }
2685
+ // A thread from before addresses were kept shows on every screen of its path: it may still be on another.
2686
+ if (target === pageHref() || (here.has(thread) && !isBarePath(target))) {
2687
+ say("It isn’t on this page right now");
2688
+ return;
2689
+ }
2690
+ setSurface("none");
2691
+ if (navigate) {
2692
+ arrival.current = { id: thread.id, until: Date.now() + 6000, focus: true };
2693
+ navigate(target);
2694
+ return;
2695
+ }
2696
+ window.location.assign(linkWith(target, "cs-thread", thread.id));
2697
+ };
2698
+
2699
+ const [composerRef, composerStyle] = useFitted<HTMLFormElement>((width, height) => {
2700
+ if (!draft) return { left: 0, top: 0 };
2701
+ const room = overlayBounds(portal ?? document.body);
2702
+ return {
2703
+ left: Math.max(edge, Math.min(draft.left, room.width - width - edge)),
2704
+ top: Math.max(edge, Math.min(draft.top, room.height - height - edge)),
2705
+ };
2706
+ });
2707
+ const composerSize = resizingComposer ?? prefs.composerSize;
2708
+
2709
+ if (!mounted || phase !== "ready" || !session) return null;
2710
+
2711
+ const changeStatus = (thread: Thread, next: "open" | "resolved") => {
2712
+ if (!member || thread.status === next) return;
2713
+ const previous = thread.status;
2714
+ pendingStatus.current.set(thread.id, next);
2715
+ setThreads((current) =>
2716
+ current.map((item) => (item.id === thread.id ? { ...item, status: next } : item)),
2717
+ );
2718
+ if (next === "resolved") {
2719
+ if (focusId === thread.id) {
2720
+ setFocusId(null);
2721
+ setFocusMenu(false);
2722
+ }
2723
+ setPeekId((current) => (current === thread.id ? null : current));
2724
+ }
2725
+ const restore = () => {
2726
+ if (pendingStatus.current.get(thread.id) !== next) return;
2727
+ pendingStatus.current.delete(thread.id);
2728
+ setThreads((current) =>
2729
+ current.map((item) =>
2730
+ item.id === thread.id && item.status === next ? { ...item, status: previous } : item,
2731
+ ),
2732
+ );
2733
+ };
2734
+ updateThread(session, member, thread.id, { status: next })
2735
+ .then(() => refresh().catch(() => false))
2736
+ .then(() => say(next === "resolved" ? "Resolved" : "Reopened"))
2737
+ .catch((caught) => {
2738
+ restore();
2739
+ const message = caught instanceof Error ? caught.message : "Couldn’t update.";
2740
+ setError(message);
2741
+ say(message);
2742
+ });
2743
+ };
2744
+
2745
+ const memberName = member ? (readJwt(member)?.name ?? name) : "";
2746
+ const memberId = member ? readJwt(member)?.memberId : undefined;
2747
+ const saveEdit =
2748
+ (thread: Thread, message: CommentMessage) => (text: string, mentionIds: string[]) =>
2749
+ editMessage(session, member!, thread.id, message.id, text, mentionIds)
2750
+ .then(async (result) => {
2751
+ setEditingMessageId(null);
2752
+ setError("");
2753
+ if (result.delivery) {
2754
+ setDeliveries((current) => ({ ...current, [message.id]: result.delivery! }));
2755
+ const note = deliveryText(result.delivery);
2756
+ if (note) say(note);
2757
+ }
2758
+ await refresh();
2759
+ say("Comment updated");
2760
+ })
2761
+ .catch((caught) =>
2762
+ setError(caught instanceof Error ? caught.message : "Couldn’t save the edit."),
2763
+ );
2764
+ const messageTools = (thread: Thread, message: CommentMessage) => (
2765
+ <MessageTools
2766
+ message={message}
2767
+ mine={!readOnly && !linkedEnv(thread) && isMine(message, memberId, memberName)}
2768
+ historyOpen={historyId === message.id}
2769
+ onHistory={() => setHistoryId((current) => (current === message.id ? null : message.id))}
2770
+ onEdit={() => {
2771
+ setHistoryId(null);
2772
+ setEditingMessageId(message.id);
2773
+ }}
2774
+ />
2775
+ );
2776
+ const messageEditor = (thread: Thread, message: CommentMessage, index: number) => (
2777
+ <MessageEditor
2778
+ message={message}
2779
+ members={ownMembers}
2780
+ selfEmail={readJwt(member ?? "")?.email}
2781
+ allowEmpty={Boolean(message.attachments?.length) || (index === 0 && thread.fields.length > 0)}
2782
+ onSave={saveEdit(thread, message)}
2783
+ onCancel={() => setEditingMessageId(null)}
2784
+ />
2785
+ );
2786
+ const host = portal ?? document.body;
2787
+ const inside = host !== document.body;
2788
+ const peek = peekId
2789
+ ? placed.find((item) => item.thread.id === peekId && !isUnset(item.top))
2790
+ : undefined;
2791
+ const bounds = overlayBounds(host);
2792
+ const pinLeft = (left: number) => Math.min(Math.max(left, 11), bounds.width - 11);
2793
+ const focusItem = focusId ? placed.find((item) => item.thread.id === focusId) : undefined;
2794
+ const nextFocus =
2795
+ focusItem && !isUnset(focusItem.top) && !isUnset(focusItem.left)
2796
+ ? {
2797
+ id: focusItem.thread.id,
2798
+ x: pinLeft(focusItem.left),
2799
+ y: focusItem.top >= 0 && focusItem.top < 12 ? 12 : focusItem.top,
2800
+ }
2801
+ : null;
2802
+ if (
2803
+ nextFocus &&
2804
+ (focusSpot?.id !== nextFocus.id || focusSpot.x !== nextFocus.x || focusSpot.y !== nextFocus.y)
2805
+ ) {
2806
+ setFocusSpot(nextFocus);
2807
+ }
2808
+ if (focusId && focusItem?.thread.status === "resolved") {
2809
+ setFocusId(null);
2810
+ setFocusMenu(false);
2811
+ }
2812
+ const focusAt = openPinCardSpot(focusItem?.thread, nextFocus, focusSpot);
2813
+ const focusElement =
2814
+ focusItem && (focusItem.status === "pinned" || focusItem.status === "drifted")
2815
+ ? elementOf(focusItem.thread)
2816
+ : null;
2817
+ // A drawing is its own highlight; the element it is stored against is not outlined.
2818
+ const focusBox =
2819
+ focusElement && !focusItem?.thread.anchor.sketch ? placeAgainst(focusElement, host) : null;
2820
+ const draftBox = draft?.anchor.sketch ? placeAgainst(draft.element, host) : null;
2821
+ const testerForm = memberType === "tester" ? ticketForm : null;
2822
+ // Post is enabled by exactly the work that a shortcut must not discard.
2823
+ const canPost = draftHasWriting;
2824
+ /** A page draft: the composer is docked beside its captures (or minimised to the dock) instead of next to an element. */
2825
+ const pageDraft = Boolean(draft && shot);
2826
+ const captures = pageDraft ? pendingPreviews(draftFiles.items) : [];
2827
+
2828
+ const changeSettings = (next: Settings) => {
2829
+ if (!member) return;
2830
+ const previous = settings;
2831
+ setSettings(next);
2832
+ setError("");
2833
+ saveSettings(session, member, next)
2834
+ .then(setSettings)
2835
+ .catch((caught) => {
2836
+ setSettings(previous);
2837
+ setError(caught instanceof Error ? caught.message : "Couldn’t save.");
2838
+ });
2839
+ };
2840
+
2841
+ const attachReplay = (add: (files: Array<{ blob: Blob; name: string }>) => void) => {
2842
+ const file = replayBuffer.current.snapshot(
2843
+ `${window.location.origin}${pageHref()}`,
2844
+ undefined,
2845
+ INSTANT_REPLAY_MS,
2846
+ );
2847
+ if (!file) {
2848
+ setError("Nothing recorded yet. Use the page, then attach it.");
2849
+ return;
2850
+ }
2851
+ const when = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
2852
+ add([
2853
+ {
2854
+ blob: new Blob([JSON.stringify(file)], { type: "application/json" }),
2855
+ name: `replay-${when}.json`,
2856
+ },
2857
+ ]);
2858
+ };
2859
+
2860
+ const lock = () => {
2861
+ recording.cancel();
2862
+ localStorage.removeItem(storageKey(session));
2863
+ void draftStore?.clear();
2864
+ void draftStore?.clearRecording();
2865
+ setMember(null);
2866
+ setThreads([]);
2867
+ setLinked([]);
2868
+ setSurface("none");
2869
+ setFocusId(null);
2870
+ setFocusMenu(false);
2871
+ setMode("browse");
2872
+ setDraft(null);
2873
+ };
2874
+
2875
+ const reset = () => {
2876
+ recording.cancel();
2877
+ localStorage.removeItem(storageKey(session));
2878
+ localStorage.removeItem(prefsKey);
2879
+ setPrefs({});
2880
+ void draftStore?.clear();
2881
+ void draftStore?.clearRecording();
2882
+ setMember(null);
2883
+ setThreads([]);
2884
+ setLinked([]);
2885
+ setFocusId(null);
2886
+ setFocusMenu(false);
2887
+ setName("");
2888
+ setProfile(null);
2889
+ setDeliveries({});
2890
+ setConfirmReset(false);
2891
+ setMode("browse");
2892
+ setDraft(null);
2893
+ setSurface("unlock");
2894
+ };
2895
+
2896
+ const bar = (
2897
+ <CommentBar
2898
+ dock={prefs.dock}
2899
+ barRef={barRef}
2900
+ onCommit={commitDock}
2901
+ onPlace={placeBar}
2902
+ {...warmTips}
2903
+ >
2904
+ {!member || disabled ? (
2905
+ <button
2906
+ className="cs-bar-lock"
2907
+ data-cs-ui="launcher"
2908
+ type="button"
2909
+ aria-label="Comments"
2910
+ onClick={() => {
2911
+ setError("");
2912
+ setSurface(surface === "unlock" ? "none" : "unlock");
2913
+ }}
2914
+ >
2915
+ <Icon.Lock />
2916
+ <span>Comments</span>
2917
+ </button>
2918
+ ) : (
2919
+ <>
2920
+ <Tool
2921
+ on={shooting || pageDraft}
2922
+ data-cs-ui="shot-mode"
2923
+ data-tip={
2924
+ pageDraft ? (collecting ? "Open draft S" : "Minimise draft S") : "Snapshot S"
2925
+ }
2926
+ aria-label={pageDraft ? "Your draft" : "Comment on a snapshot of this page"}
2927
+ aria-pressed={pageDraft}
2928
+ aria-busy={shooting}
2929
+ onClick={() => void startShot()}
2930
+ >
2931
+ <Icon.Camera size={13} />
2932
+ </Tool>
2933
+ <Tool
2934
+ on={pinsHidden}
2935
+ data-cs-ui="pins-toggle"
2936
+ data-tip={pinsHidden ? "Show pins H" : "Hide pins H"}
2937
+ aria-label={pinsHidden ? "Show pins" : "Hide pins"}
2938
+ aria-pressed={pinsHidden}
2939
+ onClick={togglePins}
2940
+ >
2941
+ <Icon.Pins hidden={pinsHidden} />
2942
+ </Tool>
2943
+ </>
2944
+ )}
2945
+ </CommentBar>
2946
+ );
2947
+
2948
+ const signedIn = (token: string) => {
2949
+ localStorage.setItem(storageKey(session), token);
2950
+ setMember(token);
2951
+ setName(readJwt(token)?.name ?? "");
2952
+ dropInvite();
2953
+ setSurface("none");
2954
+ };
2955
+
2956
+ const stopMsf = () => {
2957
+ msfAttempt.current?.stop();
2958
+ msfAttempt.current = null;
2959
+ setMsfWaiting(false);
2960
+ };
2961
+
2962
+ /**
2963
+ * Sign in through the service's identity connection: an OAuth 2.0 authorization code request with PKCE in a small window. The code
2964
+ * comes back to this page only: by message from the window, or, when the window lost its link to this page
2965
+ * (some sign-in providers cut it), through this app's own tab the window returns to.
2966
+ */
2967
+ const startMsf = () => {
2968
+ const authorizeUrl = signIn?.msf?.authorizeUrl;
2969
+ if (!authorizeUrl) return;
2970
+ setError("");
2971
+ stopMsf();
2972
+ // Opened in the click itself so the browser allows it; the address is set once the request is ready.
2973
+ const popup = window.open("", "devaccel-msf-sign-in", "popup,width=480,height=720");
2974
+ let stopped = false;
2975
+ let attempt: { state: string; verifier: string } | null = null;
2976
+ let unlisten = () => {};
2977
+ const timer = window.setTimeout(
2978
+ () => fail(new Error("Sign-in didn’t finish. Try again.")),
2979
+ 10 * 60_000,
2980
+ );
2981
+ const stop = () => {
2982
+ stopped = true;
2983
+ window.clearTimeout(timer);
2984
+ unlisten();
2985
+ };
2986
+ const fail = (caught: unknown) => {
2987
+ if (stopped) return;
2988
+ stopMsf();
2989
+ setError(caught instanceof Error ? caught.message : "Couldn’t sign in.");
2990
+ };
2991
+ unlisten = listenForMsfHandoff(new URL(session.apiUrl).origin, (handoff) => {
2992
+ if (stopped || !attempt || handoff.state !== attempt.state) return;
2993
+ // The same result can arrive by message, broadcast and storage; only the first one is used.
2994
+ const current = attempt;
2995
+ attempt = null;
2996
+ try {
2997
+ popup?.close();
2998
+ } catch {
2999
+ // the window may already be gone
3000
+ }
3001
+ if (handoff.error || !handoff.code) {
3002
+ fail(
3003
+ new Error(
3004
+ handoff.error === "access_denied"
3005
+ ? "This account doesn’t have access to comments here. Ask a project manager."
3006
+ : "Sign-in didn’t finish. Try again.",
3007
+ ),
3008
+ );
3009
+ return;
3010
+ }
3011
+ const code = handoff.code;
3012
+ sessions()
3013
+ .then((fresh) => {
3014
+ if (!fresh) throw new Error("Comments aren’t enabled here.");
3015
+ return finishMsfSignIn(fresh, { ...current, code });
3016
+ })
3017
+ .then((token) => {
3018
+ if (stopped) return;
3019
+ stopMsf();
3020
+ signedIn(token);
3021
+ })
3022
+ .catch(fail);
3023
+ });
3024
+ msfAttempt.current = { state: "", verifier: "", stop };
3025
+ setMsfWaiting(true);
3026
+ msfAuthorization(authorizeUrl, window.location.href)
3027
+ .then((request) => {
3028
+ if (stopped) return;
3029
+ attempt = { state: request.state, verifier: request.verifier };
3030
+ msfAttempt.current = { ...attempt, stop };
3031
+ if (popup && !popup.closed) popup.location.href = request.url;
3032
+ else if (!window.open(request.url, "_blank"))
3033
+ throw new Error("Allow pop-ups for this site to sign in.");
3034
+ })
3035
+ .catch((caught) => {
3036
+ popup?.close();
3037
+ fail(caught);
3038
+ });
3039
+ };
3040
+
3041
+ const disabledCard = (
3042
+ <section
3043
+ className="cs-card cs-unlock cs-hit"
3044
+ data-cs-ui="disabled"
3045
+ aria-label="Comments are off"
3046
+ >
3047
+ <div className="cs-unlock-icon">
3048
+ <Icon.Lock size={14} />
3049
+ </div>
3050
+ <h2>Comments are off</h2>
3051
+ <p className="cs-sub">
3052
+ Comments are turned off for this app right now. They come back here as soon as they’re on
3053
+ again.
3054
+ </p>
3055
+ </section>
3056
+ );
3057
+
3058
+ const msfButton = signIn?.msf ? (
3059
+ <>
3060
+ <button
3061
+ className="cs-button is-primary is-wide"
3062
+ data-cs-ui="msf-sign-in"
3063
+ type="button"
3064
+ disabled={busy}
3065
+ onClick={msfWaiting ? stopMsf : startMsf}
3066
+ >
3067
+ {msfWaiting ? "Waiting for sign-in… Cancel" : `Sign in with ${signIn.msf.label}`}
3068
+ </button>
3069
+ </>
3070
+ ) : null;
3071
+
3072
+ const unlockCard = (
3073
+ <section className="cs-card cs-unlock cs-hit" data-cs-ui="panel" aria-label="Unlock comments">
3074
+ <div className="cs-unlock-icon">
3075
+ <Icon.Lock size={14} />
3076
+ </div>
3077
+ <h2>Comments</h2>
3078
+ <p className="cs-sub">
3079
+ {invite
3080
+ ? "Invite links don’t work here any more. Sign in to see and add comments."
3081
+ : signIn?.msf
3082
+ ? "Sign in to see and add comments."
3083
+ : signIn && !signIn.unavailable
3084
+ ? // The project has nowhere to sign in yet; naming a provider here would send people looking for one.
3085
+ "Sign-in isn’t set up for this project yet. Ask whoever added comments to this app."
3086
+ : "Sign in to see and add comments."}
3087
+ </p>
3088
+ {signIn?.msf ? <div className="cs-unlock-sso">{msfButton}</div> : null}
3089
+ {signIn?.unavailable ? (
3090
+ <p className="cs-error" data-cs-ui="error">
3091
+ {wrongOrigin
3092
+ ? tokenForOtherOrigin(wrongOrigin)
3093
+ : "Couldn’t check how to sign in. Reload and try again."}
3094
+ </p>
3095
+ ) : !signIn ? (
3096
+ <p className="cs-sub">Checking sign-in…</p>
3097
+ ) : error ? (
3098
+ <p className="cs-error" data-cs-ui="error">
3099
+ {error}
3100
+ </p>
3101
+ ) : null}
3102
+ </section>
3103
+ );
3104
+
3105
+ const resetConfirm = (
3106
+ <>
3107
+ <div className="cs-confirm" data-cs-ui="reset-confirm">
3108
+ <h2>Logout?</h2>
3109
+ <p className="cs-sub">
3110
+ You’ll be signed out and your name and preferences are cleared. Comments aren’t affected.
3111
+ </p>
3112
+ <div className="cs-row-end">
3113
+ <button className="cs-button" type="button" onClick={() => setConfirmReset(false)}>
3114
+ Cancel
3115
+ </button>
3116
+ <button
3117
+ className="cs-button is-danger"
3118
+ data-cs-ui="reset-submit"
3119
+ type="button"
3120
+ onClick={reset}
3121
+ >
3122
+ Logout
3123
+ </button>
3124
+ </div>
3125
+ </div>
3126
+ </>
3127
+ );
3128
+ const meRow = (
3129
+ <>
3130
+ <div className="cs-me-row">
3131
+ <Avatar name={renaming ?? memberName} size={27} />
3132
+ {renaming !== null ? (
3133
+ <form
3134
+ className="cs-rename"
3135
+ onSubmit={(event) => {
3136
+ event.preventDefault();
3137
+ const next = renaming.trim();
3138
+ if (!next || next === memberName || !member) {
3139
+ setRenaming(null);
3140
+ return;
3141
+ }
3142
+ setBusy(true);
3143
+ updateProfile(session, member, { displayName: next })
3144
+ .then((updated) => {
3145
+ localStorage.setItem(storageKey(session), updated.token);
3146
+ setMember(updated.token);
3147
+ setProfile(updated);
3148
+ setName(next);
3149
+ setRenaming(null);
3150
+ say("Name updated");
3151
+ })
3152
+ .catch((caught) =>
3153
+ setError(caught instanceof Error ? caught.message : "Couldn’t rename."),
3154
+ )
3155
+ .finally(() => setBusy(false));
3156
+ }}
3157
+ >
3158
+ <input
3159
+ data-cs-ui="rename-input"
3160
+ aria-label="Your name"
3161
+ value={renaming}
3162
+ maxLength={40}
3163
+ autoFocus
3164
+ onChange={(event) => setRenaming(event.target.value)}
3165
+ onKeyDown={(event) => {
3166
+ if (event.key === "Escape") {
3167
+ event.preventDefault();
3168
+ setRenaming(null);
3169
+ }
3170
+ }}
3171
+ />
3172
+ <button className="cs-icon-button" type="submit" aria-label="Save name" disabled={busy}>
3173
+ <Icon.Check />
3174
+ </button>
3175
+ </form>
3176
+ ) : (
3177
+ <div className="cs-me-name">
3178
+ <span className="cs-me-lines">
3179
+ <strong>{memberName}</strong>
3180
+ <small>{profile?.email ?? readJwt(member ?? "")?.email ?? ""}</small>
3181
+ </span>
3182
+ <button
3183
+ className="cs-icon-button"
3184
+ data-cs-ui="rename"
3185
+ type="button"
3186
+ aria-label="Change name"
3187
+ onClick={() => {
3188
+ setError("");
3189
+ setRenaming(memberName);
3190
+ }}
3191
+ >
3192
+ <Icon.Pencil />
3193
+ </button>
3194
+ </div>
3195
+ )}
3196
+ </div>
3197
+ </>
3198
+ );
3199
+ const emailSwitch = (
3200
+ <>
3201
+ <button
3202
+ className="cs-item"
3203
+ data-cs-ui="mention-emails"
3204
+ type="button"
3205
+ role="switch"
3206
+ aria-checked={profile?.mentionEmails ?? true}
3207
+ disabled={!profile}
3208
+ onClick={() => {
3209
+ if (!member || !profile) return;
3210
+ updateProfile(session, member, { mentionEmails: !profile.mentionEmails })
3211
+ .then((updated) => {
3212
+ localStorage.setItem(storageKey(session), updated.token);
3213
+ setMember(updated.token);
3214
+ setProfile(updated);
3215
+ say(updated.mentionEmails ? "Mention emails on" : "Mention emails off");
3216
+ })
3217
+ .catch((caught) =>
3218
+ setError(caught instanceof Error ? caught.message : "Couldn’t update."),
3219
+ );
3220
+ }}
3221
+ >
3222
+ <span>Email me when mentioned</span>
3223
+ <span className={(profile?.mentionEmails ?? true) ? "cs-switch is-on" : "cs-switch"} />
3224
+ </button>
3225
+ </>
3226
+ );
3227
+ const pinsSwitch = (
3228
+ <>
3229
+ <button
3230
+ className="cs-item"
3231
+ type="button"
3232
+ role="switch"
3233
+ aria-checked={!pinsHidden}
3234
+ onClick={togglePins}
3235
+ >
3236
+ <span>Show pins</span>
3237
+ <span className={pinsHidden ? "cs-switch" : "cs-switch is-on"} />
3238
+ </button>
3239
+ </>
3240
+ );
3241
+ const positionRow = (
3242
+ <>
3243
+ <div className="cs-item is-static">
3244
+ <span>Position</span>
3245
+ <span className="cs-segment" role="radiogroup" aria-label="Toolbar position">
3246
+ {positions.map((option) => (
3247
+ <button
3248
+ key={option.value}
3249
+ type="button"
3250
+ role="radio"
3251
+ aria-checked={!prefs.dock && barPosition === option.value}
3252
+ className={!prefs.dock && barPosition === option.value ? "is-on" : ""}
3253
+ onClick={() => {
3254
+ const next = { ...prefs, position: option.value };
3255
+ delete next.dock;
3256
+ savePrefs(next);
3257
+ }}
3258
+ >
3259
+ {option.label}
3260
+ </button>
3261
+ ))}
3262
+ </span>
3263
+ </div>
3264
+ <p className="cs-hint">Drag the toolbar to move it.</p>
3265
+ </>
3266
+ );
3267
+ const lockButtons = (
3268
+ <>
3269
+ <button className="cs-item" data-cs-ui="lock" type="button" onClick={lock}>
3270
+ <span>Lock</span>
3271
+ </button>
3272
+ <button
3273
+ className="cs-item is-danger"
3274
+ data-cs-ui="reset"
3275
+ type="button"
3276
+ onClick={() => setConfirmReset(true)}
3277
+ >
3278
+ <span>Logout</span>
3279
+ </button>
3280
+ </>
3281
+ );
3282
+ const profilePane = confirmReset ? (
3283
+ resetConfirm
3284
+ ) : (
3285
+ <>
3286
+ {meRow}
3287
+ {error ? <p className="cs-error">{error}</p> : null}
3288
+ <div className="cs-sep" />
3289
+ {emailSwitch}
3290
+ <div className="cs-sep" />
3291
+ {lockButtons}
3292
+ </>
3293
+ );
3294
+
3295
+ const settingsPane = (
3296
+ <>
3297
+ {pinsSwitch}
3298
+ {positionRow}
3299
+ <div className="cs-sep" />
3300
+ <SettingsPanel settings={settings} onChange={changeSettings} error={error} />
3301
+ <p className="cs-hint">S snapshot · H pins</p>
3302
+ </>
3303
+ );
3304
+
3305
+ const envChips = linked.length ? (
3306
+ <div
3307
+ className="cs-env-filter"
3308
+ data-cs-ui="env-filter"
3309
+ role="radiogroup"
3310
+ aria-label="Environment"
3311
+ >
3312
+ {[
3313
+ ["all", "Everywhere"],
3314
+ [session.environment, `${session.environment} (here)`],
3315
+ ...linked.map((name) => [name, name]),
3316
+ ].map(([value, label]) => (
3317
+ <button
3318
+ key={value}
3319
+ type="button"
3320
+ role="radio"
3321
+ aria-checked={envScope === value}
3322
+ data-env={value}
3323
+ className={envScope === value ? "is-on" : ""}
3324
+ onClick={() => setEnvFilter(value)}
3325
+ >
3326
+ {label}
3327
+ </button>
3328
+ ))}
3329
+ {live ? (
3330
+ <span className="cs-live" data-cs-ui="live" title="Updates arrive as they happen">
3331
+ Live
3332
+ </span>
3333
+ ) : null}
3334
+ </div>
3335
+ ) : null;
3336
+ const envBadge = (thread: Thread) => {
3337
+ const environment = linkedEnv(thread);
3338
+ return environment ? (
3339
+ <span className="cs-env" data-cs-ui="env-badge">
3340
+ {environment}
3341
+ </span>
3342
+ ) : null;
3343
+ };
3344
+
3345
+ const replyComposer = (thread: Thread) => {
3346
+ if (readOnly) return null;
3347
+ const foreign = linkedEnv(thread);
3348
+ const replyMembers = foreign ? (foreign === replyEnv ? replyEnvMembers : []) : ownMembers;
3349
+ const owns = replyId === thread.id;
3350
+ return (
3351
+ <>
3352
+ <form
3353
+ className="cs-reply"
3354
+ onPointerDownCapture={() => claimReply(thread.id)}
3355
+ onFocusCapture={() => claimReply(thread.id)}
3356
+ // The page shield stops presses before they reach here and keeps buttons from taking focus, so a
3357
+ // press on the paperclip (or any button of the box) would otherwise act on a box nobody claimed:
3358
+ // the file uploaded, never showed, and was dropped on the first keystroke.
3359
+ onClickCapture={() => claimReply(thread.id)}
3360
+ onSubmit={async (event) => {
3361
+ event.preventDefault();
3362
+ if (
3363
+ !member ||
3364
+ !owns ||
3365
+ (!reply.trim() && !replyFiles.ids.length) ||
3366
+ replyFiles.uploading ||
3367
+ marking ||
3368
+ capturing ||
3369
+ recording.phase !== "idle" ||
3370
+ replyDictation.busy
3371
+ )
3372
+ return;
3373
+ setBusy(true);
3374
+ const picked = mentionsIn(reply, replyPicked.current);
3375
+ const element = elementOf(thread);
3376
+ const screenshot =
3377
+ picked.length && element
3378
+ ? await (replyShot.current ?? cropReply(element, thread.anchor.sketch)).catch(
3379
+ () => null,
3380
+ )
3381
+ : null;
3382
+ replyToThread(session, member, thread.id, reply, {
3383
+ mentions: picked.map((person) => person.id),
3384
+ screenshot,
3385
+ attachments: replyFiles.ids,
3386
+ })
3387
+ .then(async (result) => {
3388
+ setReply("");
3389
+ replyFiles.reset();
3390
+ setError("");
3391
+ replyPicked.current = new Map();
3392
+ replyShot.current = null;
3393
+ replyPage.current = null;
3394
+ replySnapped.current = false;
3395
+ if (result.delivery) {
3396
+ setDeliveries((current) => ({
3397
+ ...current,
3398
+ [result.message.id]: result.delivery!,
3399
+ }));
3400
+ const note = deliveryText(result.delivery);
3401
+ if (note) say(note);
3402
+ }
3403
+ await refresh();
3404
+ })
3405
+ .catch((caught) =>
3406
+ setError(caught instanceof Error ? caught.message : "Couldn’t reply."),
3407
+ )
3408
+ .finally(() => setBusy(false));
3409
+ }}
3410
+ >
3411
+ <MentionTextarea
3412
+ data-cs-ui="reply-body"
3413
+ aria-label="Reply"
3414
+ placeholder="Reply… (@ to mention)"
3415
+ rows={1}
3416
+ value={owns ? reply : ""}
3417
+ onValueChange={(value) => {
3418
+ if (!owns) {
3419
+ claimReply(thread.id);
3420
+ setReply(value);
3421
+ return;
3422
+ }
3423
+ setReply(value);
3424
+ if (!value.trim() || replySnapped.current) return;
3425
+ replySnapped.current = true;
3426
+ const element = elementOf(thread);
3427
+ if (element) scheduleSnapshot("reply", element, replyFiles.add, thread.anchor.sketch);
3428
+ }}
3429
+ members={replyMembers}
3430
+ selfEmail={readJwt(member ?? "")?.email}
3431
+ onMention={(person) => {
3432
+ replyPicked.current.set(person.id, person);
3433
+ const element = elementOf(thread);
3434
+ if (element && !replyShot.current)
3435
+ replyShot.current = cropReply(element, thread.anchor.sketch);
3436
+ }}
3437
+ onKeyDown={(event) => {
3438
+ if (isDictationKey(event)) {
3439
+ event.preventDefault();
3440
+ replyDictation.toggle(event.currentTarget);
3441
+ return;
3442
+ }
3443
+ submitOnCmdEnter(event);
3444
+ }}
3445
+ onPaste={(event) => {
3446
+ const files = filesFromClipboard(event);
3447
+ if (files.length) {
3448
+ event.preventDefault();
3449
+ replyFiles.add(files);
3450
+ }
3451
+ }}
3452
+ />
3453
+ {replyDictation.shown && owns ? null : (
3454
+ <AttachButtons
3455
+ onFiles={replyFiles.add}
3456
+ capturing={capturing || recording.phase !== "idle"}
3457
+ onSnapshot={() =>
3458
+ snapshot(takeShot(elementOf(thread)), replyFiles.add, thread.anchor.sketch)
3459
+ }
3460
+ onRecord={() => {
3461
+ setRecordInto("reply");
3462
+ void recording.begin(replyFiles.add);
3463
+ }}
3464
+ onReplay={() => attachReplay(replyFiles.add)}
3465
+ />
3466
+ )}
3467
+ {owns || !replyDictation.shown ? (
3468
+ <DictateButton
3469
+ dictation={replyDictation}
3470
+ field="reply-body"
3471
+ disabled={busy || recording.phase !== "idle"}
3472
+ compact
3473
+ />
3474
+ ) : null}
3475
+ <button
3476
+ className="cs-send"
3477
+ data-cs-ui="reply-submit"
3478
+ type="submit"
3479
+ aria-label="Post reply"
3480
+ disabled={
3481
+ busy ||
3482
+ capturing ||
3483
+ recording.phase !== "idle" ||
3484
+ !owns ||
3485
+ replyFiles.uploading ||
3486
+ marking ||
3487
+ (owns && replyDictation.busy) ||
3488
+ (!reply.trim() && !replyFiles.ids.length)
3489
+ }
3490
+ >
3491
+ <Icon.Send />
3492
+ </button>
3493
+ </form>
3494
+ {owns ? (
3495
+ <AttachmentTray
3496
+ items={replyFiles.items}
3497
+ activeKey={trayKey}
3498
+ onPreview={(item) => {
3499
+ setPreview(null);
3500
+ setTrayKey((current) => (current === item.key ? null : item.key));
3501
+ }}
3502
+ onRemove={replyFiles.remove}
3503
+ />
3504
+ ) : null}
3505
+ </>
3506
+ );
3507
+ };
3508
+
3509
+ const openPreview = (thread: Thread, file: { id: string }) => {
3510
+ setTrayKey(null);
3511
+ setPreview((current) =>
3512
+ current?.threadId === thread.id && current.fileId === file.id
3513
+ ? null
3514
+ : { threadId: thread.id, fileId: file.id },
3515
+ );
3516
+ };
3517
+ const trayFiles = pendingPreviews(draft ? draftFiles.items : replyFiles.items);
3518
+ const trayPane =
3519
+ trayKey && trayFiles.some((file) => file.id === trayKey) ? (
3520
+ <MediaPane
3521
+ files={trayFiles}
3522
+ fileId={trayKey}
3523
+ onSelect={setTrayKey}
3524
+ onClose={() => setTrayKey(null)}
3525
+ onMarkup={(fileId, result) => {
3526
+ const next = (draft ? draftFiles : replyFiles).replace(fileId, result);
3527
+ setTrayKey((current) => (current === fileId ? next : current));
3528
+ }}
3529
+ onMarkingUp={(open) => {
3530
+ markingUp.current = open;
3531
+ setMarking(open);
3532
+ }}
3533
+ />
3534
+ ) : null;
3535
+ const previewFiles = visualAttachments(
3536
+ threads.find((thread) => thread.id === preview?.threadId)?.messages,
3537
+ );
3538
+ const previewPane =
3539
+ preview && previewFiles.some((file) => file.id === preview.fileId) ? (
3540
+ <MediaPane
3541
+ files={previewFiles}
3542
+ fileId={preview.fileId}
3543
+ onSelect={(fileId) => setPreview({ threadId: preview.threadId, fileId })}
3544
+ onClose={() => setPreview(null)}
3545
+ />
3546
+ ) : null;
3547
+ const activeFile = (threadId: string) => (preview?.threadId === threadId ? preview.fileId : null);
3548
+
3549
+ const focusThread = focusItem?.thread;
3550
+ const focusCard =
3551
+ focusAt && focusItem && focusThread ? (
3552
+ <PinCard
3553
+ x={focusAt.x}
3554
+ y={focusAt.y}
3555
+ bounds={bounds}
3556
+ label={`Comment by ${focusThread.createdBy}`}
3557
+ preview={
3558
+ trayPane && !draft ? trayPane : preview?.threadId === focusThread.id ? previewPane : null
3559
+ }
3560
+ >
3561
+ {(() => {
3562
+ const thread = focusThread;
3563
+ const first = thread.messages[0];
3564
+ const foreign = linkedEnv(thread);
3565
+ const onPage = focusItem.status === "pinned" || focusItem.status === "drifted";
3566
+ const note = statusNote(focusItem.status, thread.anchor);
3567
+ const ring =
3568
+ thread.status === "resolved"
3569
+ ? "is-done"
3570
+ : focusItem.status === "drifted"
3571
+ ? "is-drifted"
3572
+ : foreign
3573
+ ? "is-linked"
3574
+ : "is-open";
3575
+ return (
3576
+ <article
3577
+ className="cs-focus-thread"
3578
+ data-cs-ui="thread"
3579
+ data-thread-id={thread.id}
3580
+ data-cs-status={focusItem.status}
3581
+ data-cs-env={thread.environment ?? session.environment}
3582
+ >
3583
+ <header className="cs-focus-head">
3584
+ <span className={`cs-focus-ring ${ring}`}>
3585
+ <Avatar name={thread.createdBy} size={21} />
3586
+ </span>
3587
+ <div className="cs-focus-who">
3588
+ <strong>{thread.createdBy}</strong>
3589
+ <time dateTime={thread.createdAt}>{ago(thread.createdAt)}</time>
3590
+ {envBadge(thread)}
3591
+ <RoleBadge role={thread.role} />
3592
+ <TicketBadge ticket={thread.ticket} />
3593
+ </div>
3594
+ <div className="cs-focus-tools">
3595
+ <button
3596
+ className="cs-icon-button"
3597
+ type="button"
3598
+ aria-label="Thread actions"
3599
+ aria-expanded={focusMenu}
3600
+ onClick={() => setFocusMenu((open) => !open)}
3601
+ >
3602
+ <Icon.Ellipsis />
3603
+ </button>
3604
+ {foreign || readOnly ? null : (
3605
+ <button
3606
+ className={
3607
+ thread.status === "resolved" ? "cs-icon-button is-done" : "cs-icon-button"
3608
+ }
3609
+ data-cs-ui="resolve"
3610
+ type="button"
3611
+ aria-label={thread.status === "resolved" ? "Reopen" : "Resolve"}
3612
+ onClick={() =>
3613
+ changeStatus(thread, thread.status === "resolved" ? "open" : "resolved")
3614
+ }
3615
+ >
3616
+ {thread.status === "resolved" ? <Icon.Undo /> : <Icon.Check />}
3617
+ </button>
3618
+ )}
3619
+ <button
3620
+ className="cs-icon-button"
3621
+ type="button"
3622
+ aria-label="Close thread"
3623
+ onClick={() => {
3624
+ setFocusId(null);
3625
+ setFocusMenu(false);
3626
+ }}
3627
+ >
3628
+ <Icon.Close />
3629
+ </button>
3630
+ </div>
3631
+ </header>
3632
+ {focusMenu ? (
3633
+ <div className="cs-focus-menu" role="menu">
3634
+ {onPage && thread.status === "open" ? (
3635
+ <button
3636
+ className="cs-item"
3637
+ type="button"
3638
+ role="menuitem"
3639
+ onClick={() => {
3640
+ showOnPage(thread);
3641
+ setFocusMenu(false);
3642
+ }}
3643
+ >
3644
+ <span>Show on page</span>
3645
+ </button>
3646
+ ) : null}
3647
+ {!foreign && !readOnly && detailsEnabled(settings) && editingId !== thread.id ? (
3648
+ <button
3649
+ className="cs-item"
3650
+ data-cs-ui="details-edit"
3651
+ type="button"
3652
+ role="menuitem"
3653
+ onClick={() => {
3654
+ setEditingId(thread.id);
3655
+ setFocusMenu(false);
3656
+ }}
3657
+ >
3658
+ <span>Details</span>
3659
+ </button>
3660
+ ) : null}
3661
+ {!foreign &&
3662
+ !readOnly &&
3663
+ !onPage &&
3664
+ thread.status === "open" &&
3665
+ here.has(thread) ? (
3666
+ <button
3667
+ className="cs-item"
3668
+ data-cs-ui="reattach"
3669
+ type="button"
3670
+ role="menuitem"
3671
+ onClick={() => {
3672
+ setReattachId(thread.id);
3673
+ setMode("reattach");
3674
+ setFocusId(null);
3675
+ setFocusMenu(false);
3676
+ setSurface("none");
3677
+ }}
3678
+ >
3679
+ <span>Reattach</span>
3680
+ </button>
3681
+ ) : null}
3682
+ <button
3683
+ className="cs-item"
3684
+ type="button"
3685
+ role="menuitem"
3686
+ onClick={() => openComments(thread.id)}
3687
+ >
3688
+ <span>All comments</span>
3689
+ </button>
3690
+ </div>
3691
+ ) : null}
3692
+ <div className="cs-focus-scroll" onPointerDown={() => setFocusMenu(false)}>
3693
+ <div className="cs-focus-body">
3694
+ {first && editingMessageId === first.id ? (
3695
+ messageEditor(thread, first, 0)
3696
+ ) : first?.body ? (
3697
+ <p className="cs-text">
3698
+ <MessageText body={first.body} mentions={first.mentions} />
3699
+ </p>
3700
+ ) : null}
3701
+ {first && editingMessageId !== first.id ? (
3702
+ <div className="cs-first-tools">{messageTools(thread, first)}</div>
3703
+ ) : null}
3704
+ {first && historyId === first.id ? <MessageHistory message={first} /> : null}
3705
+ <FieldValues fields={thread.fields} />
3706
+ <MessageAttachments
3707
+ attachments={first?.attachments}
3708
+ activeId={activeFile(thread.id)}
3709
+ onPreview={(file) => openPreview(thread, file)}
3710
+ />
3711
+ {first && deliveryText(deliveries[first.id]) ? (
3712
+ <div className="cs-delivery" data-cs-ui="delivery">
3713
+ {deliveryText(deliveries[first.id])}
3714
+ </div>
3715
+ ) : null}
3716
+ <div className="cs-target">
3717
+ <span title={thread.anchor.component?.file ?? undefined}>
3718
+ {targetName(thread.anchor)}
3719
+ </span>
3720
+ {note ? <span className={`cs-note is-${note.tone}`}>{note.text}</span> : null}
3721
+ </div>
3722
+ </div>
3723
+ {thread.messages.length > 1 ? (
3724
+ <div className="cs-focus-replies">
3725
+ {thread.messages.slice(1).map((message, index) => (
3726
+ <div
3727
+ key={message.id}
3728
+ className="cs-message"
3729
+ data-cs-ui="message"
3730
+ data-message-id={message.id}
3731
+ >
3732
+ <Avatar name={message.author} size={17} />
3733
+ <div className="cs-message-body">
3734
+ <div className="cs-meta">
3735
+ <strong>{message.author}</strong>
3736
+ <time dateTime={message.createdAt}>{ago(message.createdAt)}</time>
3737
+ {editingMessageId !== message.id ? messageTools(thread, message) : null}
3738
+ </div>
3739
+ {editingMessageId === message.id ? (
3740
+ messageEditor(thread, message, index + 1)
3741
+ ) : message.body ? (
3742
+ <p className="cs-text">
3743
+ <MessageText body={message.body} mentions={message.mentions} />
3744
+ </p>
3745
+ ) : null}
3746
+ {historyId === message.id ? <MessageHistory message={message} /> : null}
3747
+ <MessageAttachments
3748
+ attachments={message.attachments}
3749
+ activeId={activeFile(thread.id)}
3750
+ onPreview={(file) => openPreview(thread, file)}
3751
+ />
3752
+ {deliveryText(deliveries[message.id]) ? (
3753
+ <div className="cs-delivery" data-cs-ui="delivery">
3754
+ {deliveryText(deliveries[message.id])}
3755
+ </div>
3756
+ ) : null}
3757
+ </div>
3758
+ </div>
3759
+ ))}
3760
+ </div>
3761
+ ) : null}
3762
+ {editingId === thread.id && settings ? (
3763
+ <DetailsEditor
3764
+ settings={settings}
3765
+ thread={thread}
3766
+ onClose={() => setEditingId(null)}
3767
+ onSave={(patch) =>
3768
+ updateThread(session, member!, thread.id, patch)
3769
+ .then(() => refresh())
3770
+ .then(() => say("Details saved"))
3771
+ .catch((caught) =>
3772
+ setError(caught instanceof Error ? caught.message : "Couldn’t save."),
3773
+ )
3774
+ }
3775
+ />
3776
+ ) : null}
3777
+ {foreign ? (
3778
+ <p className="cs-hint cs-linked-note" data-cs-ui="linked-note">
3779
+ {thread.status === "resolved" ? "Resolved" : "Resolve"} in {foreign}
3780
+ </p>
3781
+ ) : null}
3782
+ {error ? (
3783
+ <p className="cs-error" data-cs-ui="error">
3784
+ {error}
3785
+ </p>
3786
+ ) : null}
3787
+ </div>
3788
+ <div className="cs-focus-foot" onPointerDown={() => setFocusMenu(false)}>
3789
+ {replyComposer(thread)}
3790
+ </div>
3791
+ </article>
3792
+ );
3793
+ })()}
3794
+ </PinCard>
3795
+ ) : null;
3796
+
3797
+ const ui = (
3798
+ <div
3799
+ id="cs-root"
3800
+ data-react-grab-ignore=""
3801
+ className={[
3802
+ inside ? "cs-inside" : "cs-fixed",
3803
+ `cs-at-${barPosition.replace("bottom-", "")}`,
3804
+ theme === "dark" || (theme === "system" && systemDark) ? "cs-dark" : "",
3805
+ recording.phase !== "idle" ? "is-recording" : "",
3806
+ barFrame ? "cs-docked" : "",
3807
+ ].join(" ")}
3808
+ data-edge={barFrame?.edge}
3809
+ data-side={barFrame?.side}
3810
+ data-cs-screen={screen === "desktop" ? undefined : screen}
3811
+ style={
3812
+ barFrame
3813
+ ? ({
3814
+ "--cs-dock-x": `${barFrame.x}px`,
3815
+ "--cs-dock-y": `${barFrame.y}px`,
3816
+ "--cs-dock-w": `${barFrame.width}px`,
3817
+ "--cs-dock-h": `${barFrame.height}px`,
3818
+ } as CSSProperties)
3819
+ : undefined
3820
+ }
3821
+ >
3822
+ {recording.phase !== "idle" ? (
3823
+ <RecordChrome
3824
+ elapsed={recording.elapsed}
3825
+ sound={recording.sound}
3826
+ soundNext={recording.soundNext}
3827
+ dictation={
3828
+ (recordInto === "reply" ? replyId : draft)
3829
+ ? {
3830
+ listening: recordDictation.listening,
3831
+ queued: recordDictation.state.queued,
3832
+ toggle: () => {
3833
+ dictatingWithRecording.current = !recordDictation.listening;
3834
+ // The screen recording carries the voice already.
3835
+ recordDictation.toggle(null, false);
3836
+ },
3837
+ }
3838
+ : undefined
3839
+ }
3840
+ onToggleSound={recording.toggleSound}
3841
+ onStop={recording.stop}
3842
+ onCancel={recording.cancel}
3843
+ />
3844
+ ) : null}
3845
+ {mode === "select" || mode === "reattach" ? <PickOutline store={highlightStore} /> : null}
3846
+ {mode === "draw" ? <DrawLayer onStroke={onStroke} /> : null}
3847
+ {draft?.anchor.sketch && draftBox ? (
3848
+ <SketchInk sketch={draft.anchor.sketch} box={draftBox} active />
3849
+ ) : null}
3850
+ {flash ? (
3851
+ <div
3852
+ className="cs-outline is-flash"
3853
+ style={{ top: flash.top, left: flash.left, width: flash.width, height: flash.height }}
3854
+ />
3855
+ ) : null}
3856
+ {focusBox && mode === "browse" ? (
3857
+ <div
3858
+ className="cs-outline is-selected"
3859
+ data-cs-ui="focus-target"
3860
+ style={{
3861
+ top: focusBox.top,
3862
+ left: focusBox.left,
3863
+ width: focusBox.width,
3864
+ height: focusBox.height,
3865
+ }}
3866
+ />
3867
+ ) : null}
3868
+ {mode !== "browse" ? (
3869
+ <div className="cs-banner cs-hit" data-cs-ui="mode">
3870
+ {mode === "draw" ? <Icon.Scribble /> : <Icon.Cursor />}
3871
+ <span>
3872
+ {mode === "reattach"
3873
+ ? "Click the element this comment belongs to"
3874
+ : mode === "draw"
3875
+ ? "Circle or draw on anything to comment"
3876
+ : "Click anything to comment"}
3877
+ </span>
3878
+ <button
3879
+ type="button"
3880
+ onClick={() => {
3881
+ setMode("browse");
3882
+ setReattachId(null);
3883
+ }}
3884
+ >
3885
+ Cancel
3886
+ </button>
3887
+ </div>
3888
+ ) : null}
3889
+ {pinsHidden
3890
+ ? null
3891
+ : scoped.map((item) =>
3892
+ item.thread.anchor.sketch &&
3893
+ item.box &&
3894
+ (item.status === "pinned" || item.status === "drifted") ? (
3895
+ <SketchInk
3896
+ key={`ink-${item.thread.id}`}
3897
+ sketch={item.thread.anchor.sketch}
3898
+ box={item.box}
3899
+ active={focusId === item.thread.id || peekId === item.thread.id}
3900
+ />
3901
+ ) : null,
3902
+ )}
3903
+ {pinsHidden
3904
+ ? null
3905
+ : scoped.map((item) =>
3906
+ isUnset(item.top) ||
3907
+ isUnset(item.left) ||
3908
+ (item.status !== "pinned" && item.status !== "drifted") ? null : (
3909
+ <button
3910
+ key={item.thread.id}
3911
+ type="button"
3912
+ className={[
3913
+ "cs-pin",
3914
+ item.status === "drifted" ? "is-drifted" : "",
3915
+ linkedEnv(item.thread) ? "is-linked" : "",
3916
+ focusId === item.thread.id ? "is-active" : "",
3917
+ ].join(" ")}
3918
+ data-cs-ui="pin"
3919
+ data-thread-id={item.thread.id}
3920
+ data-cs-status={item.status}
3921
+ data-cs-env={item.thread.environment ?? session.environment}
3922
+ aria-label={`Comment by ${item.thread.createdBy}${linkedEnv(item.thread) ? ` in ${linkedEnv(item.thread)}` : ""}`}
3923
+ aria-expanded={focusId === item.thread.id}
3924
+ style={{
3925
+ top: item.top >= 0 && item.top < 12 ? 12 : item.top,
3926
+ left: pinLeft(item.left),
3927
+ }}
3928
+ onMouseEnter={() => setPeekId(item.thread.id)}
3929
+ onMouseLeave={() => setPeekId(null)}
3930
+ onClick={() => {
3931
+ setPeekId(null);
3932
+ setFocusMenu(false);
3933
+ if (focusId === item.thread.id) {
3934
+ setFocusId(null);
3935
+ return;
3936
+ }
3937
+ setSurface("none");
3938
+ setFocusId(item.thread.id);
3939
+ }}
3940
+ >
3941
+ <Avatar name={item.thread.createdBy} size={20} />
3942
+ {item.thread.messages.length > 1 ? (
3943
+ <span className="cs-pin-count">{item.thread.messages.length}</span>
3944
+ ) : null}
3945
+ {linkedEnv(item.thread) ? (
3946
+ <span className="cs-pin-env">{linkedEnv(item.thread)}</span>
3947
+ ) : null}
3948
+ </button>
3949
+ ),
3950
+ )}
3951
+ {peek &&
3952
+ !pinsHidden &&
3953
+ peek.thread.id !== focusId &&
3954
+ !isUnset(peek.top) &&
3955
+ !isUnset(peek.left) ? (
3956
+ <Peek x={pinLeft(peek.left)} y={peek.top} bounds={bounds}>
3957
+ <div className="cs-meta">
3958
+ <Avatar name={peek.thread.createdBy} size={14} />
3959
+ <strong>{peek.thread.createdBy}</strong>
3960
+ <time>{ago(peek.thread.createdAt)}</time>
3961
+ {envBadge(peek.thread)}
3962
+ </div>
3963
+ <p className="cs-text">
3964
+ <MessageText
3965
+ body={peek.thread.messages[0]?.body ?? ""}
3966
+ mentions={peek.thread.messages[0]?.mentions}
3967
+ />
3968
+ </p>
3969
+ {peek.thread.messages.length > 1 ? (
3970
+ <div className="cs-replies">
3971
+ {peek.thread.messages.length - 1}{" "}
3972
+ {peek.thread.messages.length === 2 ? "reply" : "replies"}
3973
+ </div>
3974
+ ) : null}
3975
+ </Peek>
3976
+ ) : null}
3977
+ {focusCard}
3978
+ {(() => {
3979
+ if (!draft) return null;
3980
+ // Beside a page screenshot the composer is a column of the surface, not a card next to an element.
3981
+ const docked = pageDraft;
3982
+ const composer = (
3983
+ <form
3984
+ className={`cs-card cs-composer cs-hit${docked ? " is-docked" : ""}${composerSize && !docked ? " is-sized" : ""}${resizingComposer ? " is-resizing" : ""}`}
3985
+ ref={composerRef}
3986
+ data-cs-ui="composer"
3987
+ data-cs-target={[draft.anchor.component?.file, targetName(draft.anchor)]
3988
+ .filter(Boolean)
3989
+ .join(" · ")}
3990
+ style={
3991
+ docked
3992
+ ? undefined
3993
+ : composerSize
3994
+ ? { ...composerStyle, width: composerSize.width, minHeight: composerSize.height }
3995
+ : composerStyle
3996
+ }
3997
+ onSubmit={async (event) => {
3998
+ event.preventDefault();
3999
+ // Read first: a submit that stops below must not leave "Send as is" set for the next one.
4000
+ const how = clarity.takeSend();
4001
+ if (
4002
+ !member ||
4003
+ !canPost ||
4004
+ (draftFiles.uploading && !docked) ||
4005
+ marking ||
4006
+ capturing ||
4007
+ recording.phase !== "idle" ||
4008
+ draftDictation.busy ||
4009
+ clarity.dictating
4010
+ )
4011
+ return;
4012
+ const missing = testerForm ? missingTicketFields(testerForm, ticketValues) : [];
4013
+ if (missing.length) {
4014
+ setError(`Fill in ${missing.join(", ")}.`);
4015
+ return;
4016
+ }
4017
+ const ticket = testerForm ? ticketDraftPayload(testerForm, ticketValues) : null;
4018
+ setBusy(true);
4019
+ let attachments = draftFiles.ids;
4020
+ let fileKeys = draftFiles.items.map((item) => item.key);
4021
+ if (docked) {
4022
+ // What was drawn on each picture goes into it now, and the uploads are waited for, not asked for.
4023
+ try {
4024
+ for (const item of draftFiles.items) {
4025
+ const drawn = captureShapes.get(item.key);
4026
+ if (!drawn) continue;
4027
+ const flattened = await flattenCapture(item, drawn);
4028
+ if (!flattened) continue;
4029
+ const next = draftFiles.replace(item.key, flattened);
4030
+ if (!next) {
4031
+ setBusy(false);
4032
+ return;
4033
+ }
4034
+ withShapes((shapes) => {
4035
+ shapes.delete(item.key);
4036
+ shapes.set(next, drawn);
4037
+ });
4038
+ if (shot?.key === item.key) setShot({ key: next });
4039
+ }
4040
+ } catch (caught) {
4041
+ setError(caught instanceof Error ? caught.message : "Couldn’t save a picture.");
4042
+ setBusy(false);
4043
+ return;
4044
+ }
4045
+ const files = await draftFiles.settled();
4046
+ const failed = files.find((item) => item.status === "failed");
4047
+ if (failed) {
4048
+ setError(
4049
+ failed.error
4050
+ ? `${failed.name}: ${failed.error}`
4051
+ : `${failed.name} didn’t upload.`,
4052
+ );
4053
+ setBusy(false);
4054
+ return;
4055
+ }
4056
+ attachments = files
4057
+ .filter((item) => item.status === "ready" && item.id)
4058
+ .map((item) => item.id!);
4059
+ fileKeys = files.map((item) => item.key);
4060
+ }
4061
+ // Where the environment checks new comments, a reader's questions come before the post. This
4062
+ // always answers, and where it has nothing to ask (or could not ask) the text is `body`, untouched.
4063
+ const sent = await clarity.send(
4064
+ {
4065
+ body,
4066
+ url: new URL(draft.href ?? pageHref(), window.location.origin).href,
4067
+ route: draft.anchor.route,
4068
+ target: docked ? "the whole page" : targetName(draft.anchor),
4069
+ attachmentIds: attachments,
4070
+ fileKeys,
4071
+ posted: {
4072
+ ...detailsPayload(settings, draftDetails),
4073
+ ...(ticket ? { ticket } : {}),
4074
+ },
4075
+ },
4076
+ how,
4077
+ );
4078
+ if (!sent.post) {
4079
+ setBusy(false);
4080
+ return;
4081
+ }
4082
+ const picked = mentionsIn(body, draftPicked.current);
4083
+ const screenshot = picked.length
4084
+ ? await (draftShot.current ?? cropDraft(draft.element, draft.anchor.sketch)).catch(
4085
+ () => null,
4086
+ )
4087
+ : null;
4088
+ // After the clarity check, so the context never reaches its model. A message holds five files;
4089
+ // the person's own come first.
4090
+ const contextId =
4091
+ attachments.length < MAX_FILES_PER_MESSAGE
4092
+ ? await uploadDebugContext(
4093
+ debugCapture.current,
4094
+ {
4095
+ element: docked ? null : draft.element,
4096
+ anchor: draft.anchor,
4097
+ steps: replayBuffer.current.events(),
4098
+ },
4099
+ (blob, name) => uploadAttachment(session, member, blob, name),
4100
+ )
4101
+ : null;
4102
+ createThread(session, member, draft.anchor, sent.body, {
4103
+ mentions: picked.map((person) => person.id),
4104
+ screenshot,
4105
+ attachments: contextId ? [...attachments, contextId] : attachments,
4106
+ url: draft.href ?? pageHref(),
4107
+ ...detailsPayload(settings, draftDetails),
4108
+ ...(ticket ? { ticket } : {}),
4109
+ ...(draftKind ? { kind: draftKind } : {}),
4110
+ })
4111
+ .then(async ({ thread, delivery }) => {
4112
+ setDraft(null);
4113
+ setBody("");
4114
+ setDraftDetails(noDetails);
4115
+ setDraftKind(null);
4116
+ setTicketValues({});
4117
+ draftFiles.reset();
4118
+ setError("");
4119
+ draftPicked.current = new Map();
4120
+ draftShot.current = null;
4121
+ draftPage.current = null;
4122
+ void draftStore?.clear();
4123
+ setStored(null);
4124
+ resetPageDraft();
4125
+ const firstId = thread.messages[0]?.id;
4126
+ const note = delivery ? deliveryText(delivery) : "";
4127
+ if (delivery && firstId)
4128
+ setDeliveries((current) => ({ ...current, [firstId]: delivery }));
4129
+ // Posting opens nothing: the composer closes, the pin appears and the person carries on.
4130
+ say(note || "Comment posted");
4131
+ await refresh();
4132
+ })
4133
+ .catch((caught) =>
4134
+ setError(caught instanceof Error ? caught.message : "Couldn’t post."),
4135
+ )
4136
+ .finally(() => setBusy(false));
4137
+ }}
4138
+ >
4139
+ {docked ? null : (
4140
+ <ResizeGrip
4141
+ room={() => overlayBounds(portal ?? document.body)}
4142
+ onResize={setResizingComposer}
4143
+ onEnd={(size) => {
4144
+ setResizingComposer(null);
4145
+ savePrefs({ ...prefsRef.current, composerSize: size });
4146
+ }}
4147
+ onReset={() => {
4148
+ setResizingComposer(null);
4149
+ const { composerSize: _reset, ...rest } = prefsRef.current;
4150
+ savePrefs(rest);
4151
+ }}
4152
+ />
4153
+ )}
4154
+ <button
4155
+ className="cs-icon-button cs-composer-close"
4156
+ type="button"
4157
+ aria-label={docked ? "Minimise" : "Discard"}
4158
+ onClick={() => (docked ? setCollecting(true) : setDraft(null))}
4159
+ >
4160
+ <Icon.Close />
4161
+ </button>
4162
+ <div
4163
+ className="cs-composer-body"
4164
+ onClick={(event) => {
4165
+ // A resized box has room below the text: a click there writes, like in the text itself.
4166
+ // (Docked, the body hugs the text, so this never fires on the column's space below.)
4167
+ // (A click, because the toolbar's shield stops mousedown before React sees it.)
4168
+ if (event.target !== event.currentTarget) return;
4169
+ const field = composerField();
4170
+ if (!field) return;
4171
+ field.focus();
4172
+ field.setSelectionRange(field.value.length, field.value.length);
4173
+ }}
4174
+ >
4175
+ <MentionTextarea
4176
+ data-cs-ui="composer-body"
4177
+ aria-label="Comment"
4178
+ placeholder="Add a comment… (@ to mention)"
4179
+ value={body}
4180
+ autoFocus
4181
+ grow
4182
+ onValueChange={setBody}
4183
+ members={ownMembers}
4184
+ selfEmail={readJwt(member ?? "")?.email}
4185
+ onMention={(person) => {
4186
+ draftPicked.current.set(person.id, person);
4187
+ if (!draftShot.current)
4188
+ draftShot.current = cropDraft(draft.element, draft.anchor.sketch);
4189
+ }}
4190
+ onKeyDown={(event) => {
4191
+ if (isDictationKey(event)) {
4192
+ event.preventDefault();
4193
+ draftDictation.toggle(event.currentTarget);
4194
+ return;
4195
+ }
4196
+ submitOnCmdEnter(event);
4197
+ }}
4198
+ onPaste={(event) => {
4199
+ const files = filesFromClipboard(event);
4200
+ if (files.length) {
4201
+ event.preventDefault();
4202
+ draftFiles.add(files);
4203
+ }
4204
+ }}
4205
+ />
4206
+ </div>
4207
+ <DetailsInput
4208
+ settings={settings}
4209
+ value={draftDetails}
4210
+ onChange={setDraftDetails}
4211
+ onKeyDown={submitOnCmdEnter}
4212
+ />
4213
+ <KindInput value={draftKind} onChange={setDraftKind} />
4214
+ {memberType === "tester" ? (
4215
+ testerForm ? (
4216
+ <TicketFormInput
4217
+ form={testerForm}
4218
+ values={ticketValues}
4219
+ onChange={setTicketValues}
4220
+ onKeyDown={submitOnCmdEnter}
4221
+ />
4222
+ ) : (
4223
+ <p className="cs-hint cs-pad" data-cs-ui="ticket-form-status">
4224
+ {ticketFormError || "Loading the ticket form…"}
4225
+ </p>
4226
+ )
4227
+ ) : null}
4228
+ <AttachmentTray
4229
+ // The page screenshot is the surface itself, not a tray item, while the composer is docked beside it.
4230
+ items={
4231
+ docked
4232
+ ? draftFiles.items.filter(
4233
+ (item) => !captures.some((file) => file.id === item.key),
4234
+ )
4235
+ : draftFiles.items
4236
+ }
4237
+ activeKey={docked ? null : trayKey}
4238
+ onPreview={
4239
+ docked
4240
+ ? undefined
4241
+ : (item) => {
4242
+ setPreview(null);
4243
+ setTrayKey((current) => (current === item.key ? null : item.key));
4244
+ }
4245
+ }
4246
+ onRemove={draftFiles.remove}
4247
+ />
4248
+ <ClarityQuestions
4249
+ clarity={clarity}
4250
+ mode={clarityMode}
4251
+ body={body}
4252
+ busy={busy}
4253
+ voice={{
4254
+ transcribe,
4255
+ names: memberNames,
4256
+ onNote: setError,
4257
+ off: busy || recording.phase !== "idle",
4258
+ }}
4259
+ onKeyDown={submitOnCmdEnter}
4260
+ />
4261
+ {error ? <p className="cs-error cs-pad">{error}</p> : null}
4262
+ <div className="cs-composer-foot">
4263
+ {draftDictation.shown ? null : (
4264
+ <AttachButtons
4265
+ onFiles={draftFiles.add}
4266
+ capturing={capturing || recording.phase !== "idle"}
4267
+ onSnapshot={() =>
4268
+ snapshot(takeShot(draft.element), draftFiles.add, draft.anchor.sketch)
4269
+ }
4270
+ onRecord={() => {
4271
+ setRecordInto("draft");
4272
+ void recording.begin(draftFiles.add);
4273
+ }}
4274
+ onReplay={() => attachReplay(draftFiles.add)}
4275
+ />
4276
+ )}
4277
+ <DictateButton
4278
+ dictation={draftDictation}
4279
+ field="composer-body"
4280
+ disabled={busy || recording.phase !== "idle"}
4281
+ />
4282
+ {capturing || (draftFiles.uploading && !docked) || busy || marking ? (
4283
+ <span className="cs-hint">
4284
+ {capturing
4285
+ ? "Capturing…"
4286
+ : draftFiles.uploading && !docked
4287
+ ? "Uploading…"
4288
+ : clarity.checking
4289
+ ? CLARITY_WORDS.working
4290
+ : busy
4291
+ ? "Posting…"
4292
+ : "Marking up…"}
4293
+ </span>
4294
+ ) : null}
4295
+ <button
4296
+ className="cs-send"
4297
+ data-cs-ui="composer-submit"
4298
+ type="submit"
4299
+ aria-label="Post comment"
4300
+ aria-busy={clarity.checking || undefined}
4301
+ disabled={
4302
+ busy ||
4303
+ capturing ||
4304
+ recording.phase !== "idle" ||
4305
+ (draftFiles.uploading && !docked) ||
4306
+ marking ||
4307
+ draftDictation.busy ||
4308
+ clarity.dictating ||
4309
+ !canPost
4310
+ }
4311
+ >
4312
+ <Icon.Send />
4313
+ </button>
4314
+ </div>
4315
+ {trayPane && !docked ? (
4316
+ <MediaPop bounds={bounds} left={composerStyle.left} top={composerStyle.top}>
4317
+ {trayPane}
4318
+ </MediaPop>
4319
+ ) : null}
4320
+ </form>
4321
+ );
4322
+ if (!docked) return composer;
4323
+ if (collecting) return null;
4324
+ return (
4325
+ <ShotSurface
4326
+ captures={captures}
4327
+ selectedId={shot?.key ?? null}
4328
+ shapes={captureShapes}
4329
+ savedAt={savedAt}
4330
+ onSelect={(id) => setShot({ key: id })}
4331
+ onRemove={(id) => {
4332
+ draftFiles.remove(id);
4333
+ withShapes((shapes) => shapes.delete(id));
4334
+ }}
4335
+ onShapes={(id, next) => withShapes((shapes) => shapes.set(id, next))}
4336
+ onAnother={() => setCollecting(true)}
4337
+ onRecord={recordIntoDraft}
4338
+ onDictate={() => {
4339
+ const field = composerField();
4340
+ if (field) draftDictation.toggle(field);
4341
+ }}
4342
+ dictating={draftDictation.listening}
4343
+ onMinimise={() => setCollecting(true)}
4344
+ onDiscard={discardDraft}
4345
+ confirmDiscard={confirmDiscard}
4346
+ onSubmit={() => composerForm()?.requestSubmit()}
4347
+ >
4348
+ {composer}
4349
+ </ShotSurface>
4350
+ );
4351
+ })()}
4352
+ {draftDock
4353
+ ? (() => {
4354
+ const live = draftDock === "collecting";
4355
+ const about = live
4356
+ ? describeDraft({
4357
+ body,
4358
+ captures: draftFiles.items.map((item) => ({ type: item.contentType })),
4359
+ })
4360
+ : describeDraft(stored!);
4361
+ return (
4362
+ <DraftDock
4363
+ title={about.title}
4364
+ pictures={about.pictures}
4365
+ clips={about.clips}
4366
+ savedAt={live ? savedAt : stored!.updatedAt}
4367
+ mode={draftDock}
4368
+ note={live ? undefined : stored!.note}
4369
+ recording={recording.phase !== "idle"}
4370
+ confirmDiscard={confirmDiscard}
4371
+ onCapture={() => void addCapture()}
4372
+ onRecord={recordIntoDraft}
4373
+ onOpen={() => (live ? setCollecting(false) : resumeDraft(stored!))}
4374
+ onDiscard={discardDraft}
4375
+ />
4376
+ );
4377
+ })()
4378
+ : null}
4379
+ {surface === "unlock" && disabled
4380
+ ? disabledCard
4381
+ : surface === "unlock" && !member
4382
+ ? unlockCard
4383
+ : null}
4384
+ {surface === "dashboard" && member ? (
4385
+ <Dashboard
4386
+ rows={scoped}
4387
+ here={here}
4388
+ environment={session.environment}
4389
+ memberName={memberName}
4390
+ memberId={memberId}
4391
+ roles={settings?.roles.enabled ? settings.roles.options : []}
4392
+ members={ownMembers}
4393
+ session={session}
4394
+ memberToken={member}
4395
+ envFilter={envChips}
4396
+ profile={profilePane}
4397
+ settingsPane={settingsPane}
4398
+ onClose={() => setSurface("none")}
4399
+ onRefresh={refresh}
4400
+ readOnly={readOnly}
4401
+ screen={dashboardScreen}
4402
+ onScreen={setDashboardScreen}
4403
+ focus={dashboardFocus}
4404
+ onOpen={(row) => {
4405
+ const item = placed.find((candidate) => candidate.thread.id === row.thread.id);
4406
+ if (item) showThread(item);
4407
+ }}
4408
+ onReattach={(row) => {
4409
+ setReattachId(row.thread.id);
4410
+ setMode("reattach");
4411
+ setFocusId(null);
4412
+ setFocusMenu(false);
4413
+ setSurface("none");
4414
+ }}
4415
+ />
4416
+ ) : null}
4417
+ {toast ? (
4418
+ <div key={toast} className="cs-toast" role="status">
4419
+ {toast}
4420
+ </div>
4421
+ ) : null}
4422
+ {draftDock ? null : bar}
4423
+ </div>
4424
+ );
4425
+
4426
+ return createPortal(ui, host);
4427
+ }