@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,402 @@
1
+ import { pageBackground } from "./page-background.js";
2
+
3
+ /** A recording stops itself after this long. */
4
+ export const RECORD_MAX_MS = 75_000;
5
+ const MAX_BYTES = 9 * 1024 * 1024;
6
+ const BITRATE = 4_000_000;
7
+ const AUDIO_BITRATE = 64_000;
8
+ const FRAME_RATE = 30;
9
+
10
+ export const FRAME_WIDTH = 1920;
11
+ export const FRAME_HEIGHT = 1080;
12
+
13
+ const MIME_TYPES = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm", "video/mp4"];
14
+ /** With the mic: Opus in WebM, or whatever audio Safari's MP4 recorder picks. */
15
+ const MIME_TYPES_WITH_SOUND = [
16
+ "video/webm;codecs=vp9,opus",
17
+ "video/webm;codecs=vp8,opus",
18
+ "video/webm",
19
+ "video/mp4",
20
+ ];
21
+
22
+ export function recordingMime(sound = false) {
23
+ if (typeof MediaRecorder === "undefined") return null;
24
+ return (
25
+ (sound ? MIME_TYPES_WITH_SOUND : MIME_TYPES).find((type) =>
26
+ MediaRecorder.isTypeSupported(type),
27
+ ) ?? null
28
+ );
29
+ }
30
+
31
+ /**
32
+ * The mic for a recording's sound, so someone can explain what they show. Null when the person, the browser
33
+ * or the host page's policy refuses it: the recording then carries on without sound.
34
+ */
35
+ async function openMicrophone(): Promise<MediaStreamTrack | null> {
36
+ if (!navigator.mediaDevices?.getUserMedia) return null;
37
+ try {
38
+ const stream = await navigator.mediaDevices.getUserMedia({
39
+ audio: {
40
+ channelCount: 1,
41
+ echoCancellation: true,
42
+ noiseSuppression: true,
43
+ autoGainControl: true,
44
+ },
45
+ });
46
+ return stream.getAudioTracks()[0] ?? null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export function formatClock(ms: number) {
53
+ const total = Math.max(0, Math.round(ms / 1000));
54
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
55
+ }
56
+
57
+ type FeaturePolicy = { allowsFeature(feature: string): boolean };
58
+
59
+ /** The document's capture policy. Chrome still exposes this as `featurePolicy`. */
60
+ export function pageCapturePolicy(
61
+ doc:
62
+ | { permissionsPolicy?: FeaturePolicy | null; featurePolicy?: FeaturePolicy | null }
63
+ | null
64
+ | undefined,
65
+ ) {
66
+ return doc?.permissionsPolicy ?? doc?.featurePolicy ?? null;
67
+ }
68
+
69
+ /** True when this document may call getDisplayMedia. A missing policy counts as allowed. */
70
+ export function displayCaptureAllowed(policy: FeaturePolicy | null | undefined) {
71
+ if (!policy) return true;
72
+ try {
73
+ return policy.allowsFeature("display-capture");
74
+ } catch {
75
+ return true;
76
+ }
77
+ }
78
+
79
+ /** Fit a source rectangle inside a 1920×1080 frame, scaling by at most 2. */
80
+ export function fitFrame(sourceWidth: number, sourceHeight: number) {
81
+ const width = Math.max(1, sourceWidth);
82
+ const height = Math.max(1, sourceHeight);
83
+ const scale = Math.min(2, FRAME_WIDTH / width, FRAME_HEIGHT / height);
84
+ const drawnWidth = Math.round(width * scale);
85
+ const drawnHeight = Math.round(height * scale);
86
+ return {
87
+ scale,
88
+ x: Math.round((FRAME_WIDTH - drawnWidth) / 2),
89
+ y: Math.round((FRAME_HEIGHT - drawnHeight) / 2),
90
+ width: drawnWidth,
91
+ height: drawnHeight,
92
+ };
93
+ }
94
+
95
+ export type TabTake = {
96
+ stop: () => void;
97
+ cancel: () => void;
98
+ finished: Promise<Blob | null>;
99
+ /** The mic in the take; mute it with `enabled`. Null when sound was off or the mic was refused. */
100
+ microphone: MediaStreamTrack | null;
101
+ };
102
+
103
+ /** Mute or unmute the mic in this take. Returns whether it is now on. */
104
+ export function toggleMicrophone(microphone: MediaStreamTrack): boolean {
105
+ microphone.enabled = !microphone.enabled;
106
+ return microphone.enabled;
107
+ }
108
+
109
+ export type RecordOptions = {
110
+ /** Record the mic too. */
111
+ sound: boolean;
112
+ /**
113
+ * Every second of the recording as it lands, in order; the first chunk carries the file's header, so the
114
+ * chunks so far always make a playable file. Lets a take be kept somewhere safe while it runs.
115
+ */
116
+ onChunk?: (chunk: Blob, index: number, mime: string) => void;
117
+ };
118
+
119
+ function recordStream(
120
+ video: MediaStream,
121
+ mime: string,
122
+ microphone: MediaStreamTrack | null,
123
+ onEnd?: () => void,
124
+ onChunk?: RecordOptions["onChunk"],
125
+ ): TabTake {
126
+ const stream = microphone ? new MediaStream([...video.getVideoTracks(), microphone]) : video;
127
+ const recorder = new MediaRecorder(stream, {
128
+ mimeType: mime,
129
+ videoBitsPerSecond: BITRATE,
130
+ ...(microphone ? { audioBitsPerSecond: AUDIO_BITRATE } : {}),
131
+ });
132
+ const chunks: Blob[] = [];
133
+ let bytes = 0;
134
+ let settled = false;
135
+ let mode: "stop" | "cancel" = "cancel";
136
+ let resolve: (blob: Blob | null) => void = () => {};
137
+ const finished = new Promise<Blob | null>((done) => {
138
+ resolve = done;
139
+ });
140
+ const finish = () => {
141
+ if (settled) return;
142
+ settled = true;
143
+ onEnd?.();
144
+ window.clearTimeout(limit);
145
+ for (const track of stream.getTracks()) track.stop();
146
+ if (mode === "cancel" || bytes === 0) {
147
+ resolve(null);
148
+ return;
149
+ }
150
+ resolve(new Blob(chunks, { type: mime.split(";")[0] }));
151
+ };
152
+ recorder.ondataavailable = (event) => {
153
+ if (!event.data.size || settled) return;
154
+ chunks.push(event.data);
155
+ bytes += event.data.size;
156
+ onChunk?.(event.data, chunks.length - 1, mime.split(";")[0]!);
157
+ if (bytes >= MAX_BYTES && recorder.state === "recording") {
158
+ mode = "stop";
159
+ recorder.stop();
160
+ }
161
+ };
162
+ recorder.onstop = finish;
163
+ stream.getVideoTracks()[0]?.addEventListener("ended", () => {
164
+ if (settled) return;
165
+ mode = "stop";
166
+ if (recorder.state === "recording") recorder.stop();
167
+ else finish();
168
+ });
169
+ const limit = window.setTimeout(() => {
170
+ if (recorder.state !== "recording") return;
171
+ mode = "stop";
172
+ recorder.stop();
173
+ }, RECORD_MAX_MS);
174
+ recorder.start(1000);
175
+ return {
176
+ stop: () => {
177
+ mode = "stop";
178
+ if (recorder.state === "recording") recorder.stop();
179
+ else finish();
180
+ },
181
+ cancel: () => {
182
+ mode = "cancel";
183
+ if (recorder.state === "recording") recorder.stop();
184
+ else finish();
185
+ },
186
+ finished,
187
+ microphone,
188
+ };
189
+ }
190
+
191
+ function frameCanvas() {
192
+ const canvas = document.createElement("canvas");
193
+ canvas.width = FRAME_WIDTH;
194
+ canvas.height = FRAME_HEIGHT;
195
+ const ctx = canvas.getContext("2d", { alpha: false });
196
+ if (!ctx || typeof canvas.captureStream !== "function")
197
+ throw new Error("Recording isn’t available in this browser.");
198
+ ctx.imageSmoothingEnabled = true;
199
+ ctx.imageSmoothingQuality = "high";
200
+ return { canvas, ctx };
201
+ }
202
+
203
+ function paintFrame(
204
+ ctx: CanvasRenderingContext2D,
205
+ source: CanvasImageSource,
206
+ sourceWidth: number,
207
+ sourceHeight: number,
208
+ background: string,
209
+ ) {
210
+ const fit = fitFrame(sourceWidth, sourceHeight);
211
+ ctx.fillStyle = background;
212
+ ctx.fillRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT);
213
+ ctx.drawImage(source, fit.x, fit.y, fit.width, fit.height);
214
+ return fit;
215
+ }
216
+
217
+ /** Films the visible page when the host forbids tab capture. The toolbar is left out of the frames. */
218
+ async function startViewportRecording(
219
+ mime: string,
220
+ microphone: MediaStreamTrack | null,
221
+ onReady?: (take: TabTake) => void,
222
+ onChunk?: RecordOptions["onChunk"],
223
+ ): Promise<TabTake> {
224
+ const { canvas, ctx } = frameCanvas();
225
+ const background = pageBackground();
226
+ ctx.fillStyle = background;
227
+ ctx.fillRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT);
228
+ const stream = canvas.captureStream(FRAME_RATE);
229
+ let stopped = false;
230
+ let fit = fitFrame(document.documentElement.clientWidth, window.innerHeight);
231
+ const clicks: Array<{ x: number; y: number; at: number }> = [];
232
+ const onPointer = (event: PointerEvent) => {
233
+ const target = event.target;
234
+ if (target instanceof Element && target.closest("#cs-root")) return;
235
+ clicks.push({
236
+ x: fit.x + event.clientX * fit.scale,
237
+ y: fit.y + event.clientY * fit.scale,
238
+ at: performance.now(),
239
+ });
240
+ if (clicks.length > 12) clicks.shift();
241
+ };
242
+ document.addEventListener("pointerdown", onPointer, true);
243
+ const take = recordStream(
244
+ stream,
245
+ mime,
246
+ microphone,
247
+ () => {
248
+ stopped = true;
249
+ document.removeEventListener("pointerdown", onPointer, true);
250
+ },
251
+ onChunk,
252
+ );
253
+ onReady?.(take);
254
+ const paint = async (timeout: number) => {
255
+ if (stopped) return;
256
+ const { renderViewport } = await import("./screenshot.js");
257
+ const cssWidth = document.documentElement.clientWidth;
258
+ const cssHeight = window.innerHeight;
259
+ fit = fitFrame(cssWidth, cssHeight);
260
+ const page = await renderViewport(fit.scale, timeout);
261
+ if (stopped) return;
262
+ paintFrame(ctx, page, cssWidth, cssHeight, background);
263
+ const now = performance.now();
264
+ for (const click of clicks) {
265
+ const age = now - click.at;
266
+ if (age > 1200) continue;
267
+ ctx.beginPath();
268
+ ctx.arc(click.x, click.y, 22, 0, Math.PI * 2);
269
+ ctx.strokeStyle = `rgba(0, 122, 255, ${1 - age / 1200})`;
270
+ ctx.lineWidth = 4;
271
+ ctx.stroke();
272
+ }
273
+ };
274
+ try {
275
+ await paint(4000);
276
+ } catch {
277
+ take.cancel();
278
+ if (stopped) return take;
279
+ throw new Error("Couldn’t start a recording.");
280
+ }
281
+ if (stopped) return take;
282
+ const pump = async () => {
283
+ while (!stopped) {
284
+ try {
285
+ await paint(6000);
286
+ } catch {
287
+ // Keep the last good frame and try again.
288
+ }
289
+ if (stopped) return;
290
+ await new Promise((resolve) => window.setTimeout(resolve, 32));
291
+ }
292
+ };
293
+ void pump();
294
+ return take;
295
+ }
296
+
297
+ /**
298
+ * Films this tab. When the page allows screen capture, the browser asks first.
299
+ * When the page forbids it, the visible page is filmed instead, so the record button still works.
300
+ * With `sound`, the mic is asked for after the screen (the screen prompt needs the click's activation).
301
+ */
302
+ export async function startTabRecording(
303
+ options: RecordOptions,
304
+ onReady?: (take: TabTake) => void,
305
+ ): Promise<TabTake> {
306
+ if (!recordingMime()) throw new Error("Recording isn’t available in this browser.");
307
+ const withSound = async () => {
308
+ const microphone = options.sound ? await openMicrophone() : null;
309
+ const mime = recordingMime(Boolean(microphone)) ?? recordingMime()!;
310
+ return { microphone, mime };
311
+ };
312
+ const policy =
313
+ typeof document === "undefined"
314
+ ? null
315
+ : pageCapturePolicy(
316
+ document as Document & {
317
+ permissionsPolicy?: FeaturePolicy;
318
+ featurePolicy?: FeaturePolicy;
319
+ },
320
+ );
321
+ if (displayCaptureAllowed(policy) && navigator.mediaDevices?.getDisplayMedia) {
322
+ let stream: MediaStream;
323
+ try {
324
+ stream = await navigator.mediaDevices.getDisplayMedia({
325
+ video: {
326
+ width: { ideal: FRAME_WIDTH },
327
+ height: { ideal: FRAME_HEIGHT },
328
+ frameRate: { ideal: FRAME_RATE, max: FRAME_RATE },
329
+ },
330
+ audio: false,
331
+ preferCurrentTab: true,
332
+ selfBrowserSurface: "include",
333
+ monitorTypeSurfaces: "exclude",
334
+ surfaceSwitching: "exclude",
335
+ systemAudio: "exclude",
336
+ } as DisplayMediaStreamOptions);
337
+ } catch (caught) {
338
+ if (
339
+ caught instanceof DOMException &&
340
+ (caught.name === "NotAllowedError" || caught.name === "AbortError")
341
+ ) {
342
+ throw new Error("Recording needs permission to capture this tab.");
343
+ }
344
+ throw new Error("Couldn’t start a recording.");
345
+ }
346
+ const videoTrack = stream.getVideoTracks()[0];
347
+ const surface = videoTrack
348
+ ? (videoTrack.getSettings() as MediaTrackSettings & { displaySurface?: string })
349
+ .displaySurface
350
+ : undefined;
351
+ if (surface && surface !== "browser") {
352
+ for (const track of stream.getTracks()) track.stop();
353
+ throw new Error("Recording stays on this tab. Choose the current tab.");
354
+ }
355
+ const { microphone, mime } = await withSound();
356
+ return recordDisplayStream(stream, mime, microphone, onReady, options.onChunk);
357
+ }
358
+ const { microphone, mime } = await withSound();
359
+ return startViewportRecording(mime, microphone, onReady, options.onChunk);
360
+ }
361
+
362
+ /** Scales a tab capture down to 1920×1080. Drawing a video frame is cheap, so this stays at 30 fps. */
363
+ async function recordDisplayStream(
364
+ stream: MediaStream,
365
+ mime: string,
366
+ microphone: MediaStreamTrack | null,
367
+ onReady?: (take: TabTake) => void,
368
+ onChunk?: RecordOptions["onChunk"],
369
+ ): Promise<TabTake> {
370
+ const video = document.createElement("video");
371
+ video.srcObject = stream;
372
+ video.muted = true;
373
+ video.playsInline = true;
374
+ await video.play();
375
+ const { canvas, ctx } = frameCanvas();
376
+ const background = pageBackground();
377
+ const output = canvas.captureStream(FRAME_RATE);
378
+ let stopped = false;
379
+ let frame = 0;
380
+ const draw = () => {
381
+ if (stopped) return;
382
+ frame = requestAnimationFrame(draw);
383
+ if (video.readyState < 2 || video.videoWidth < 2) return;
384
+ paintFrame(ctx, video, video.videoWidth, video.videoHeight, background);
385
+ };
386
+ draw();
387
+ const take = recordStream(
388
+ output,
389
+ mime,
390
+ microphone,
391
+ () => {
392
+ stopped = true;
393
+ cancelAnimationFrame(frame);
394
+ for (const track of stream.getTracks()) track.stop();
395
+ video.srcObject = null;
396
+ },
397
+ onChunk,
398
+ );
399
+ stream.getVideoTracks()[0]?.addEventListener("ended", () => take.stop());
400
+ onReady?.(take);
401
+ return take;
402
+ }
@@ -0,0 +1,30 @@
1
+ const SECRET =
2
+ /bearer\s+[a-z0-9._~+/=-]+|\beyJ[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9_-]{8,}\b|\b(?:sk|pk|rk)_(?:live|test)_[a-zA-Z0-9]+\b/gi;
3
+
4
+ /** Strips tokens and control characters from text that may be stored with a comment. */
5
+ export function redactText(value: string) {
6
+ return value
7
+ .replace(SECRET, "[redacted]")
8
+ .replace(/[\u0000-\u001f]/g, "")
9
+ .slice(0, 240);
10
+ }
11
+
12
+ const SENSITIVE =
13
+ /pass(?:word|phrase|code)?|\bsecret\b|\btoken\b|credential|\bssn\b|social security|\bpin\b|\botp\b|\bauth\b|session|cookie|api[-_ ]?key|card number|cvv|cvc/i;
14
+
15
+ /** True for password fields and controls whose name, id, label, or autocomplete looks like a credential. */
16
+ export function fieldIsSensitive(field: {
17
+ type?: string;
18
+ name?: string;
19
+ id?: string;
20
+ autocomplete?: string;
21
+ label?: string;
22
+ }) {
23
+ const type = (field.type ?? "").toLowerCase();
24
+ if (type === "password" || type === "hidden") return true;
25
+ const autocomplete = (field.autocomplete ?? "").toLowerCase();
26
+ if (autocomplete.includes("password") || autocomplete.startsWith("cc-")) return true;
27
+ return SENSITIVE.test(
28
+ `${field.name ?? ""} ${field.id ?? ""} ${field.label ?? ""} ${autocomplete}`,
29
+ );
30
+ }
@@ -0,0 +1,34 @@
1
+ import type { Thread } from "./api.js";
2
+
3
+ export type Related = { thread: Thread; because: string };
4
+
5
+ /** The page address without a hash, so a thread link does not split two comments on the same screen. */
6
+ function pageKey(thread: Thread) {
7
+ return (thread.url || thread.route).split("#")[0];
8
+ }
9
+
10
+ /**
11
+ * Other comments that are actually about this one.
12
+ * The path alone is not enough: every comment in a single-page app shares it.
13
+ * A match is the same page address, or the same component.
14
+ */
15
+ export function relatedTo(selected: Thread, threads: Thread[]): Related[] {
16
+ const page = pageKey(selected);
17
+ const component = selected.anchor.component?.name || null;
18
+ const found: Related[] = [];
19
+ for (const thread of threads) {
20
+ if (thread.id === selected.id) continue;
21
+ const samePage = pageKey(thread) === page;
22
+ const sameComponent = Boolean(component) && thread.anchor.component?.name === component;
23
+ if (!samePage && !sameComponent) continue;
24
+ const because = [samePage ? "Same page" : null, sameComponent ? component : null]
25
+ .filter(Boolean)
26
+ .join(" · ");
27
+ found.push({ thread, because });
28
+ }
29
+ return found.sort((a, b) => {
30
+ const rank = (item: Related) =>
31
+ item.because.includes("·") ? 0 : item.because === "Same page" ? 1 : 2;
32
+ return rank(a) - rank(b) || Date.parse(b.thread.updatedAt) - Date.parse(a.thread.updatedAt);
33
+ });
34
+ }
@@ -0,0 +1,2 @@
1
+ // Loaded with import() through this module: importing the package itself that way would bundle all of it, not just domToCanvas.
2
+ export { domToCanvas } from "modern-screenshot";
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The activity replay model: event types, the rolling buffer, the `replay-*.json` file, and how each event reads.
3
+ * DOM-free, so native clients share it. Watching a web page for those events is `watchActivity` in `./replay.ts`.
4
+ */
5
+ import { redactText } from "./redact.js";
6
+
7
+ const WINDOW_MS = 60_000;
8
+ const MAX_EVENTS = 80;
9
+ /** Most events a file carries, however long the window that made it. */
10
+ const MAX_FILE_EVENTS = 400;
11
+
12
+ export type ReplayDraft =
13
+ | { type: "navigate"; path: string }
14
+ | { type: "click"; label: string }
15
+ | { type: "input"; label: string; redacted: boolean; value?: string }
16
+ | { type: "console"; level: "error" | "warn"; message: string }
17
+ /** A request the page made that failed: `status` 0 means it never got an answer. */
18
+ | { type: "network"; method: string; path: string; status: number }
19
+ /** Words the commenter said while a clip was recording, placed when that piece was spoken. */
20
+ | { type: "say"; text: string };
21
+
22
+ export type ReplayEvent = ReplayDraft & { t: number };
23
+
24
+ /** When the file goes with a recording: the clip's name and clock, so events sit on its timeline. */
25
+ export type ReplayClip = { name: string; startedAt: number; durationMs: number };
26
+
27
+ export type ReplayFile = {
28
+ v: 1;
29
+ kind: "instant-replay";
30
+ url: string;
31
+ events: ReplayEvent[];
32
+ clip?: ReplayClip;
33
+ };
34
+
35
+ export type ReplayBufferOptions = {
36
+ now?: () => number;
37
+ /** How far back events are kept. */
38
+ windowMs?: number;
39
+ maxEvents?: number;
40
+ };
41
+
42
+ /**
43
+ * What the commenter did, as a rolling window. The default minute backs the instant replay; a recording keeps a
44
+ * wider one for the clip's whole length. Stays in memory until it is attached.
45
+ */
46
+ export class ReplayBuffer {
47
+ private items: ReplayEvent[] = [];
48
+ private readonly now: () => number;
49
+ private readonly windowMs: number;
50
+ private readonly maxEvents: number;
51
+
52
+ constructor(options: ReplayBufferOptions | (() => number) = {}) {
53
+ const settings = typeof options === "function" ? { now: options } : options;
54
+ this.now = settings.now ?? (() => Date.now());
55
+ this.windowMs = settings.windowMs ?? WINDOW_MS;
56
+ this.maxEvents = Math.min(settings.maxEvents ?? MAX_EVENTS, MAX_FILE_EVENTS);
57
+ }
58
+
59
+ push(event: ReplayDraft) {
60
+ this.pushAt(event, this.now());
61
+ }
62
+
63
+ /** An event that happened at `t`, not now: narration lands when it was spoken, not when it was written. */
64
+ pushAt(event: ReplayDraft, t: number) {
65
+ const now = this.now();
66
+ this.items.push({ ...event, t });
67
+ this.items.sort((a, b) => a.t - b.t);
68
+ const cutoff = now - this.windowMs;
69
+ this.items = this.items.filter((item) => item.t >= cutoff).slice(-this.maxEvents);
70
+ }
71
+
72
+ /** `windowMs` keeps only the events of that last stretch. */
73
+ snapshot(url: string, clip?: ReplayClip, windowMs?: number): ReplayFile | null {
74
+ const cutoff = windowMs === undefined ? -Infinity : this.now() - windowMs;
75
+ const kept = this.items.filter((event) => event.t >= cutoff);
76
+ if (!kept.length) return null;
77
+ return {
78
+ v: 1,
79
+ kind: "instant-replay",
80
+ url,
81
+ events: kept.map((event) => ({ ...event })),
82
+ ...(clip ? { clip } : {}),
83
+ };
84
+ }
85
+
86
+ /** Every event still in the window, oldest first. */
87
+ events(): ReplayEvent[] {
88
+ return this.items.map((event) => ({ ...event }));
89
+ }
90
+
91
+ clear() {
92
+ this.items = [];
93
+ }
94
+
95
+ get size() {
96
+ return this.items.length;
97
+ }
98
+ }
99
+
100
+ function text(value: unknown, max: number) {
101
+ return typeof value === "string" ? value.replace(/[\u0000-\u001f]/g, "").slice(0, max) : "";
102
+ }
103
+
104
+ /** Accepts only the replay shape the toolbar writes. Anything else is dropped. */
105
+ export function parseReplay(value: unknown): ReplayFile | null {
106
+ if (!value || typeof value !== "object") return null;
107
+ // allow-banned-record-unknown: untrusted JSON from an attachment, each field is checked below
108
+ const input = value as Record<string, unknown>;
109
+ if (input["v"] !== 1 || input["kind"] !== "instant-replay" || !Array.isArray(input["events"]))
110
+ return null;
111
+ const url = text(input["url"], 2048);
112
+ if (!url) return null;
113
+ const events: ReplayEvent[] = [];
114
+ for (const item of input["events"].slice(0, MAX_FILE_EVENTS)) {
115
+ if (!item || typeof item !== "object") continue;
116
+ // allow-banned-record-unknown: untrusted JSON from an attachment, each field is checked below
117
+ const event = item as Record<string, unknown>;
118
+ const t = typeof event["t"] === "number" && Number.isFinite(event["t"]) ? event["t"] : null;
119
+ if (t === null) continue;
120
+ if (event["type"] === "navigate") {
121
+ const path = text(event["path"], 300);
122
+ if (path) events.push({ t, type: "navigate", path });
123
+ } else if (event["type"] === "click") {
124
+ const label = text(event["label"], 120);
125
+ if (label) events.push({ t, type: "click", label });
126
+ } else if (event["type"] === "input") {
127
+ const label = text(event["label"], 120);
128
+ if (!label) continue;
129
+ const redacted = event["redacted"] === true;
130
+ const entered = text(event["value"], 80);
131
+ events.push({
132
+ t,
133
+ type: "input",
134
+ label,
135
+ redacted,
136
+ ...(redacted || !entered ? {} : { value: entered }),
137
+ });
138
+ } else if (
139
+ event["type"] === "console" &&
140
+ (event["level"] === "error" || event["level"] === "warn")
141
+ ) {
142
+ const message = text(event["message"], 240);
143
+ if (message) events.push({ t, type: "console", level: event["level"], message });
144
+ } else if (event["type"] === "say") {
145
+ const said = text(event["text"], 400);
146
+ if (said) events.push({ t, type: "say", text: said });
147
+ } else if (event["type"] === "network") {
148
+ const path = text(event["path"], 300);
149
+ const status =
150
+ typeof event["status"] === "number" && Number.isFinite(event["status"])
151
+ ? event["status"]
152
+ : 0;
153
+ if (path)
154
+ events.push({
155
+ t,
156
+ type: "network",
157
+ method: text(event["method"], 8) || "GET",
158
+ path,
159
+ status,
160
+ });
161
+ }
162
+ }
163
+ if (!events.length) return null;
164
+ let clip: ReplayClip | undefined;
165
+ if (input["clip"] && typeof input["clip"] === "object") {
166
+ // allow-banned-record-unknown: untrusted JSON from an attachment, each field is checked below
167
+ const raw = input["clip"] as Record<string, unknown>;
168
+ const name = text(raw["name"], 120);
169
+ const startedAt =
170
+ typeof raw["startedAt"] === "number" && Number.isFinite(raw["startedAt"])
171
+ ? raw["startedAt"]
172
+ : null;
173
+ const durationMs =
174
+ typeof raw["durationMs"] === "number" && Number.isFinite(raw["durationMs"])
175
+ ? raw["durationMs"]
176
+ : null;
177
+ if (name && startedAt !== null && durationMs !== null && durationMs > 0)
178
+ clip = { name, startedAt, durationMs };
179
+ }
180
+ return { v: 1, kind: "instant-replay", url, events, ...(clip ? { clip } : {}) };
181
+ }
182
+
183
+ /** The clip name a replay file goes with, by the stamp both carry: `recording-<stamp>.webm` ↔ `replay-<stamp>.json`. */
184
+ export function replayStamp(name: string) {
185
+ return /^(?:recording|replay)-(.+?)(?:\.cut)?\.(?:webm|mp4|json)$/.exec(name)?.[1] ?? null;
186
+ }
187
+
188
+ /** One line of a replay event, as the viewers show it. */
189
+ export function replayLine(event: ReplayEvent) {
190
+ switch (event.type) {
191
+ case "navigate":
192
+ return `Went to ${event.path}`;
193
+ case "click":
194
+ return `Clicked “${event.label}”`;
195
+ case "input":
196
+ return event.redacted
197
+ ? `Typed in “${event.label}”`
198
+ : `Typed “${event.value}” in “${event.label}”`;
199
+ case "console":
200
+ return `${event.level === "error" ? "Error" : "Warning"}: ${event.message}`;
201
+ case "network":
202
+ return event.status
203
+ ? `${event.method} ${event.path} answered ${event.status}`
204
+ : `${event.method} ${event.path} got no answer`;
205
+ case "say":
206
+ return `Said “${event.text}”`;
207
+ }
208
+ }
209
+
210
+ /** Query keys whose values are never written down. `s` is the comment service's own signed-file signature (`/v1/files?…&s=`). */
211
+ const SECRET_PARAM = /^s$|token|key|secret|auth|session|password|passwd|sig|code|credential/i;
212
+
213
+ /** Replaces secret query values in place. The page URL stored with a comment uses this too. */
214
+ export function redactSecretSearchParams(url: URL) {
215
+ for (const name of [...url.searchParams.keys()]) {
216
+ if (SECRET_PARAM.test(name)) url.searchParams.set(name, "[redacted]");
217
+ }
218
+ }
219
+
220
+ /** A console call's arguments as one redacted line. */
221
+ export function consoleText(args: unknown[]) {
222
+ const message = args
223
+ .map((arg) => {
224
+ if (arg instanceof Error) return arg.message;
225
+ if (typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean")
226
+ return String(arg);
227
+ return "";
228
+ })
229
+ .filter(Boolean)
230
+ .join(" ");
231
+ return redactText(message || "error");
232
+ }