@devaccel-labs/app-comments-toolbar 1.0.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 (220) hide show
  1. package/README.md +152 -18
  2. package/dist/react.js +516 -61
  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 +8 -1
  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 +7 -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 +3 -1
  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 -11
  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 +123 -0
  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/runtime.d.ts +6 -0
  82. package/dist/types/runtime/screen.d.ts +47 -0
  83. package/dist/types/runtime/screenshot.d.ts +60 -6
  84. package/dist/types/runtime/session-source.d.ts +45 -0
  85. package/dist/types/runtime/session.d.ts +20 -18
  86. package/dist/types/runtime/shield.d.ts +9 -0
  87. package/dist/types/runtime/shot.d.ts +45 -0
  88. package/dist/types/runtime/sketch.d.ts +26 -0
  89. package/dist/types/runtime/styles.d.ts +1 -1
  90. package/dist/types/runtime/sync.d.ts +1 -0
  91. package/dist/types/runtime/thread-model.d.ts +18 -0
  92. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  93. package/dist/types/runtime/ticket-form.d.ts +9 -0
  94. package/dist/types/runtime/toolbar.d.ts +37 -1
  95. package/dist/types/runtime/ui-scale.d.ts +17 -0
  96. package/dist/types/runtime/update.d.ts +37 -0
  97. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  98. package/dist/types/server/index.d.ts +22 -0
  99. package/dist/types/server/support.d.ts +20 -0
  100. package/dist/types/server.d.ts +2 -0
  101. package/dist/types/vite/plugin.d.ts +15 -0
  102. package/dist/vite.js +121 -41
  103. package/package.json +26 -13
  104. package/src/capture/api.ts +128 -0
  105. package/src/capture/editor.tsx +178 -0
  106. package/src/capture/ignore.ts +38 -0
  107. package/src/capture/index.ts +27 -0
  108. package/src/capture/picker.tsx +171 -0
  109. package/src/capture/picture.ts +90 -0
  110. package/src/capture/surface.tsx +185 -0
  111. package/src/compiler/annotate.ts +202 -0
  112. package/src/compiler/babel.d.ts +11 -0
  113. package/src/compiler/enabled.ts +55 -0
  114. package/src/compiler/index.ts +2 -0
  115. package/src/core/index.ts +8 -0
  116. package/src/core/resolve.ts +307 -0
  117. package/src/core/types.ts +58 -0
  118. package/src/debug-context.ts +47 -0
  119. package/src/dictation.ts +17 -0
  120. package/src/embed/controller.ts +215 -0
  121. package/src/embed/index.ts +68 -0
  122. package/src/embed/options.ts +100 -0
  123. package/src/help/index.ts +38 -0
  124. package/src/help/panel-index.ts +17 -0
  125. package/src/help/panel-styles.ts +123 -0
  126. package/src/help/panel.ts +840 -0
  127. package/src/help/pill-styles.ts +75 -0
  128. package/src/help/pill.ts +338 -0
  129. package/src/help/react.tsx +143 -0
  130. package/src/help/requests.ts +111 -0
  131. package/src/help/types.ts +124 -0
  132. package/src/help/wire.ts +55 -0
  133. package/src/native-wire.ts +124 -0
  134. package/src/native.ts +282 -0
  135. package/src/react.ts +27 -0
  136. package/src/replay.ts +13 -0
  137. package/src/runtime/api.ts +531 -0
  138. package/src/runtime/attachment-model.ts +42 -0
  139. package/src/runtime/attachments.tsx +1260 -0
  140. package/src/runtime/boot.ts +95 -0
  141. package/src/runtime/browser-label.ts +31 -0
  142. package/src/runtime/build-id.ts +10 -0
  143. package/src/runtime/capture-ui.tsx +306 -0
  144. package/src/runtime/capture.ts +508 -0
  145. package/src/runtime/clarity-model.ts +634 -0
  146. package/src/runtime/clarity-ui.tsx +454 -0
  147. package/src/runtime/client.ts +151 -0
  148. package/src/runtime/composer-size.tsx +146 -0
  149. package/src/runtime/dashboard.tsx +1130 -0
  150. package/src/runtime/debug-context-core.ts +681 -0
  151. package/src/runtime/debug-context-host.ts +56 -0
  152. package/src/runtime/debug-context-model.ts +1319 -0
  153. package/src/runtime/debug-context-redact.ts +259 -0
  154. package/src/runtime/debug-context.ts +613 -0
  155. package/src/runtime/details-model.ts +30 -0
  156. package/src/runtime/details.tsx +395 -0
  157. package/src/runtime/dictation-engine.ts +616 -0
  158. package/src/runtime/dictation-model.ts +79 -0
  159. package/src/runtime/dictation-ui.tsx +363 -0
  160. package/src/runtime/dictation.ts +271 -0
  161. package/src/runtime/dock.ts +108 -0
  162. package/src/runtime/draft-dock.tsx +124 -0
  163. package/src/runtime/draft-model.ts +251 -0
  164. package/src/runtime/draft-store.ts +149 -0
  165. package/src/runtime/edits.tsx +165 -0
  166. package/src/runtime/font.ts +42 -0
  167. package/src/runtime/format.ts +129 -0
  168. package/src/runtime/freshness.ts +23 -0
  169. package/src/runtime/handoff.ts +76 -0
  170. package/src/runtime/host-watch.ts +64 -0
  171. package/src/runtime/icons.tsx +295 -0
  172. package/src/runtime/idle.ts +20 -0
  173. package/src/runtime/index.ts +11 -0
  174. package/src/runtime/ink.ts +6 -0
  175. package/src/runtime/kind-model.ts +20 -0
  176. package/src/runtime/live-model.ts +32 -0
  177. package/src/runtime/loader.tsx +87 -0
  178. package/src/runtime/markup-editor.tsx +1333 -0
  179. package/src/runtime/markup-model.ts +485 -0
  180. package/src/runtime/markup.ts +382 -0
  181. package/src/runtime/members.ts +58 -0
  182. package/src/runtime/mention-parse.ts +56 -0
  183. package/src/runtime/mentions.tsx +205 -0
  184. package/src/runtime/page-background.ts +51 -0
  185. package/src/runtime/page-image.ts +1592 -0
  186. package/src/runtime/placement.ts +336 -0
  187. package/src/runtime/record.ts +402 -0
  188. package/src/runtime/redact.ts +30 -0
  189. package/src/runtime/related.ts +34 -0
  190. package/src/runtime/render-page.ts +2 -0
  191. package/src/runtime/replay-model.ts +232 -0
  192. package/src/runtime/replay.ts +173 -0
  193. package/src/runtime/runtime-version.ts +7 -0
  194. package/src/runtime/runtime.tsx +51 -0
  195. package/src/runtime/screen.ts +182 -0
  196. package/src/runtime/screenshot.ts +260 -0
  197. package/src/runtime/session-source.ts +112 -0
  198. package/src/runtime/session.ts +108 -0
  199. package/src/runtime/shield.ts +34 -0
  200. package/src/runtime/shot.tsx +422 -0
  201. package/src/runtime/sketch.ts +139 -0
  202. package/src/runtime/styles.ts +1541 -0
  203. package/src/runtime/sync.ts +300 -0
  204. package/src/runtime/thread-model.ts +71 -0
  205. package/src/runtime/ticket-form-model.ts +128 -0
  206. package/src/runtime/ticket-form.tsx +342 -0
  207. package/src/runtime/toolbar.tsx +4427 -0
  208. package/src/runtime/ui-scale.ts +75 -0
  209. package/src/runtime/update.ts +98 -0
  210. package/src/runtime/use-debug-context.ts +81 -0
  211. package/src/server/index.ts +133 -0
  212. package/src/server/support.ts +499 -0
  213. package/src/server.ts +4 -0
  214. package/src/vite/plugin.ts +102 -0
  215. package/src/vite.ts +1 -0
  216. package/dist/chunks/chunk-CSKXLF33.js +0 -153
  217. package/dist/chunks/chunk-HUM5FQII.js +0 -160
  218. package/dist/chunks/render-page-76MLIT6M.js +0 -1421
  219. package/dist/chunks/runtime-JX75TKER.js +0 -9267
  220. package/dist/chunks/screenshot-66ARTBGR.js +0 -12
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The host session (which comment service, project and environment, and the 15-minute host token) and the
3
+ * cache that reuses it until the token is about to expire. DOM-free: the web toolbar loads the session route
4
+ * with `fetch` from the page (`./session.ts`); native clients load it with the app's own bearer auth.
5
+ */
6
+ export type Session = {
7
+ apiUrl: string;
8
+ project: string;
9
+ environment: string;
10
+ hostToken: string;
11
+ /**
12
+ * Native clients only: their app origin (`<scheme>://app`), sent as `x-comment-service-origin` on every
13
+ * request. Web leaves it unset; the browser sends `Origin`.
14
+ */
15
+ clientOrigin?: string;
16
+ /**
17
+ * Web, script-tag loader only (`../embed`): the origin the session route says `embed.js` may be loaded from
18
+ * when that is not `apiUrl`'s origin. Without it the loader mounts nothing for such a session.
19
+ */
20
+ loaderOrigin?: string;
21
+ };
22
+
23
+ /** Returns this page's session, or null when comments are off. `refresh` skips the cached one. */
24
+ export type GetSession = (options?: { refresh?: boolean }) => Promise<Session | null>;
25
+
26
+ /** A cached host token is replaced this long before it expires, so a request never carries an expired one. */
27
+ const EXPIRY_MARGIN_MS = 60_000;
28
+ /** Host tokens last 15 minutes. If one carries no expiry, reuse it for less than that. */
29
+ const UNKNOWN_EXPIRY_MS = 5 * 60_000;
30
+
31
+ export function readJwt(token: string): {
32
+ exp?: number;
33
+ typ?: string;
34
+ name?: string;
35
+ email?: string;
36
+ memberId?: string;
37
+ origin?: string;
38
+ } | null {
39
+ try {
40
+ const part = token.split(".")[1];
41
+ if (!part) return null;
42
+ const pad = part.length % 4 === 0 ? "" : "=".repeat(4 - (part.length % 4));
43
+ return JSON.parse(atob(part.replaceAll("-", "+").replaceAll("_", "/") + pad)) as {
44
+ exp?: number;
45
+ typ?: string;
46
+ name?: string;
47
+ email?: string;
48
+ memberId?: string;
49
+ origin?: string;
50
+ };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export function sessionFrom(payload: unknown): Session | null {
57
+ const value = payload as Partial<Session> & { enabled?: boolean };
58
+ if (!value?.enabled || !value.apiUrl || !value.project || !value.environment || !value.hostToken)
59
+ return null;
60
+ return {
61
+ apiUrl: value.apiUrl,
62
+ project: value.project,
63
+ environment: value.environment,
64
+ hostToken: value.hostToken,
65
+ ...(typeof value.loaderOrigin === "string" ? { loaderOrigin: value.loaderOrigin } : {}),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * The session route answered, and its answer was "comments are off" with a reason (`{ enabled: false, error }`),
71
+ * most often because nobody is signed in. Not a failure to reach it.
72
+ */
73
+ export class SessionOff extends Error {}
74
+
75
+ /**
76
+ * Reads the session route at most once at a time and reuses the answer until its host token is about to expire.
77
+ * "Comments are off" is kept for the source's lifetime. A failed request is not kept, so the next call retries.
78
+ */
79
+ export function sessionSource(
80
+ path: string,
81
+ load: (path: string) => Promise<unknown>,
82
+ now = Date.now,
83
+ ): GetSession {
84
+ let kept: { session: Session | null; until: number } | null = null;
85
+ let pending: Promise<Session | null> | null = null;
86
+ return ({ refresh = false } = {}) => {
87
+ if (!refresh && kept && now() < kept.until) return Promise.resolve(kept.session);
88
+ if (pending) return pending;
89
+ pending = load(path)
90
+ .then((payload) => {
91
+ const failed = payload as { enabled?: boolean; error?: unknown };
92
+ if (failed && failed.enabled === false && failed.error) {
93
+ throw new SessionOff(
94
+ typeof failed.error === "string" ? failed.error : "Comment service is unreachable.",
95
+ );
96
+ }
97
+ const session = sessionFrom(payload);
98
+ const expires = session ? readJwt(session.hostToken)?.exp : undefined;
99
+ const until = !session
100
+ ? Infinity
101
+ : expires
102
+ ? expires * 1000 - EXPIRY_MARGIN_MS
103
+ : now() + UNKNOWN_EXPIRY_MS;
104
+ kept = { session, until };
105
+ return session;
106
+ })
107
+ .finally(() => {
108
+ pending = null;
109
+ });
110
+ return pending;
111
+ };
112
+ }
@@ -0,0 +1,108 @@
1
+ import {
2
+ sessionSource as cachedSession,
3
+ SessionOff,
4
+ type GetSession,
5
+ type Session,
6
+ } from "./session-source.js";
7
+
8
+ // Everything but `sessionSource`, which this file wraps with the page's own fetch.
9
+ export { readJwt, sessionFrom, SessionOff } from "./session-source.js";
10
+ export type { GetSession, Session } from "./session-source.js";
11
+
12
+ /** Where the toolbar keeps a person's sign-in: `devaccel.comment.<apiUrl>.<project>.<environment>`. */
13
+ const SIGN_IN_PREFIX = "devaccel.comment.http";
14
+
15
+ /**
16
+ * Forgets every toolbar sign-in this page's origin holds. Run when the host app says nobody is signed in: a
17
+ * sign-in is kept per browser, not per person of the host app, so the next person to sign in to the app must
18
+ * not find the last one's.
19
+ */
20
+ export function forgetSignIns(storage: Storage = localStorage): void {
21
+ try {
22
+ for (let index = storage.length - 1; index >= 0; index--) {
23
+ const key = storage.key(index);
24
+ if (key?.startsWith(SIGN_IN_PREFIX)) storage.removeItem(key);
25
+ }
26
+ } catch {
27
+ // Storage the page may not use holds no sign-in either.
28
+ }
29
+ }
30
+
31
+ const reported = new Set<string>();
32
+
33
+ /** One console line per distinct answer per page: what the route said when it did not turn comments on. */
34
+ function report(path: string, status: number, error: unknown, failed: boolean) {
35
+ const said = typeof error === "string" ? `: ${error}` : failed ? "." : " with comments off.";
36
+ const line = `[comment-service] ${path} answered ${status}${said}`;
37
+ if (reported.has(line)) return;
38
+ reported.add(line);
39
+ if (failed) console.warn(line);
40
+ else console.info(line);
41
+ }
42
+
43
+ /**
44
+ * The session route's answer. `2xx` is the answer as it is. `401` and `403` are "nobody is signed in" from a
45
+ * route that says so with a status. Anything else is a route that failed, which is not an answer about the
46
+ * person: it rejects, and comments stay as they were.
47
+ */
48
+ async function fetchSession(path: string): Promise<unknown> {
49
+ const response = await fetch(path, {
50
+ headers: { accept: "application/json", "x-comment-service-origin": window.location.origin },
51
+ cache: "no-store",
52
+ });
53
+ const payload = (await response.json().catch(() => null)) as {
54
+ enabled?: unknown;
55
+ error?: unknown;
56
+ } | null;
57
+ const off = response.status === 401 || response.status === 403;
58
+ if (!response.ok && !off) {
59
+ report(path, response.status, payload?.error, true);
60
+ throw new Error(`The session route answered ${response.status}.`);
61
+ }
62
+ if (off || payload?.enabled !== true) {
63
+ report(path, response.status, payload?.error, false);
64
+ forgetSignIns();
65
+ return { enabled: false };
66
+ }
67
+ return payload;
68
+ }
69
+
70
+ /**
71
+ * Reads the session route at most once at a time and reuses the answer until its host token is about to expire.
72
+ * "Comments are off" is kept for the page's lifetime. A failed request is not kept, so the next call retries.
73
+ */
74
+ export function sessionSource(
75
+ path: string,
76
+ load: (path: string) => Promise<unknown> = fetchSession,
77
+ now = Date.now,
78
+ ): GetSession {
79
+ return cachedSession(path, load, now);
80
+ }
81
+
82
+ const sources = new Map<string, GetSession>();
83
+
84
+ /** The page-wide session source for a session route path. */
85
+ export function sessionFor(path: string): GetSession {
86
+ let source = sources.get(path);
87
+ if (!source) {
88
+ source = sessionSource(path);
89
+ sources.set(path, source);
90
+ }
91
+ return source;
92
+ }
93
+
94
+ /** What the host app says now: comments are on (with the session), off, or it could not be asked. */
95
+ export type HostAnswer =
96
+ | { readonly state: "on"; readonly session: Session }
97
+ | { readonly state: "off" }
98
+ | { readonly state: "unknown" };
99
+
100
+ /** Asks the session route again, past any kept answer, or takes the kept one when `fresh` is false. */
101
+ export async function askHost(get: GetSession, fresh = true): Promise<HostAnswer> {
102
+ try {
103
+ const session = await get({ refresh: fresh });
104
+ return session ? { state: "on", session } : { state: "off" };
105
+ } catch (caught) {
106
+ return caught instanceof SessionOff ? { state: "off" } : { state: "unknown" };
107
+ }
108
+ }
@@ -0,0 +1,34 @@
1
+ const TEXT_CONTROL = "input, textarea, select, [contenteditable]";
2
+
3
+ function inToolbar(node: EventTarget | null) {
4
+ return node instanceof Element && Boolean(node.closest("#cs-root"));
5
+ }
6
+
7
+ /**
8
+ * Keeps the page from reacting to the toolbar. Presses on the toolbar never reach the page, so an open menu,
9
+ * popover, or dialog does not treat them as an outside press, and buttons do not pull focus out of it. Focus
10
+ * moving into the toolbar is hidden from document-level focus traps (Radix FocusScope listens for focusin and
11
+ * focusout on document), which would otherwise pull it straight back into an open modal so the composer never
12
+ * takes input. The focus listeners sit on document, so the page's and the toolbar's own React handlers still
13
+ * run; call this before any app dialog opens so stopImmediatePropagation runs ahead of the trap's listeners.
14
+ */
15
+ export function shieldFromPage() {
16
+ const press = (event: Event) => {
17
+ const target = event.target;
18
+ if (!(target instanceof Element) || !inToolbar(target)) return;
19
+ event.stopPropagation();
20
+ if (event.type === "mousedown" && !target.closest(TEXT_CONTROL)) event.preventDefault();
21
+ };
22
+ const focus = (event: Event) => {
23
+ const into = event.type === "focusin" ? event.target : (event as FocusEvent).relatedTarget;
24
+ if (inToolbar(into)) event.stopImmediatePropagation();
25
+ };
26
+ const presses = ["pointerdown", "mousedown", "touchstart"];
27
+ const focuses = ["focusin", "focusout"];
28
+ for (const type of presses) window.addEventListener(type, press, true);
29
+ for (const type of focuses) document.addEventListener(type, focus);
30
+ return () => {
31
+ for (const type of presses) window.removeEventListener(type, press, true);
32
+ for (const type of focuses) document.removeEventListener(type, focus);
33
+ };
34
+ }
@@ -0,0 +1,422 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import {
3
+ ATTACHMENT_MAX_BYTES,
4
+ isReplayFile,
5
+ type Pending,
6
+ type PreviewFile,
7
+ } from "./attachments.js";
8
+ import * as Icon from "./icons.js";
9
+ import { flattenMarkup, loadImage, type MarkupShape } from "./markup.js";
10
+ import { MarkupEditor, type MarkupResult } from "./markup-editor.js";
11
+ import { formatClock } from "./record.js";
12
+ import {
13
+ parseReplay,
14
+ replayLine,
15
+ replayStamp,
16
+ type ReplayEvent,
17
+ type ReplayFile,
18
+ } from "./replay.js";
19
+
20
+ /** The captures a draft holds: pictures (marked up on the stage) and clips. */
21
+ export type ShotCapture = PreviewFile;
22
+
23
+ function ago(at: number | null) {
24
+ if (!at) return "Saving…";
25
+ const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
26
+ if (seconds < 8) return "Saved just now";
27
+ if (seconds < 60) return `Saved ${seconds}s ago`;
28
+ return `Saved ${Math.round(seconds / 60)} min ago`;
29
+ }
30
+
31
+ function sameShapes(a: MarkupShape[], b: MarkupShape[]) {
32
+ return JSON.stringify(a) === JSON.stringify(b);
33
+ }
34
+
35
+ /**
36
+ * The flattened picture `item` needs before it is sent, given the shapes drawn on it: null when the file already
37
+ * shows them, the original when every shape is gone, else a fresh flatten. Rejects when the image can't be read.
38
+ */
39
+ export async function flattenCapture(
40
+ item: Pending,
41
+ shapes: MarkupShape[],
42
+ ): Promise<MarkupResult | null> {
43
+ if (!item.contentType.startsWith("image/")) return null;
44
+ if (sameShapes(shapes, item.markup?.shapes ?? [])) return null;
45
+ const name = item.markup?.name ?? item.name;
46
+ if (!shapes.length) return { blob: null, name, shapes: [] };
47
+ const url = item.markup?.url ?? item.preview;
48
+ if (!url) return null;
49
+ const image = await loadImage(url);
50
+ const flat = await flattenMarkup({
51
+ image,
52
+ width: image.naturalWidth,
53
+ height: image.naturalHeight,
54
+ shapes,
55
+ name,
56
+ maxBytes: ATTACHMENT_MAX_BYTES,
57
+ });
58
+ return { blob: flat.blob, name: flat.name, shapes };
59
+ }
60
+
61
+ /** Where an event sits on a clip's timeline, 0..1, or null when it falls outside the clip. */
62
+ export function timelinePosition(
63
+ event: ReplayEvent,
64
+ clip: { startedAt: number; durationMs: number },
65
+ ) {
66
+ const at = (event.t - clip.startedAt) / clip.durationMs;
67
+ return at >= 0 && at <= 1 ? at : null;
68
+ }
69
+
70
+ function markerKind(event: ReplayEvent) {
71
+ if (event.type === "console" && event.level === "error") return "error";
72
+ if (event.type === "network") return "error";
73
+ if (event.type === "navigate") return "navigate";
74
+ if (event.type === "console") return "warn";
75
+ if (event.type === "say") return "say";
76
+ return "action";
77
+ }
78
+
79
+ /**
80
+ * A clip on the stage with what happened in it underneath: clicks, page changes, errors and failed requests
81
+ * from the replay that was kept beside it, on the clip's clock. A marker seeks the clip; the line under the
82
+ * playhead names the latest event.
83
+ */
84
+ function ClipStage({ clip, replay }: { clip: ShotCapture; replay: ShotCapture | null }) {
85
+ const video = useRef<HTMLVideoElement>(null);
86
+ // Tagged with the replay it came from, so a replay still loading reads as none rather than the last one's.
87
+ const [loaded, setLoaded] = useState<{ id: string; file: ReplayFile | null } | null>(null);
88
+ const [time, setTime] = useState(0);
89
+ useEffect(() => {
90
+ if (!replay) return;
91
+ const id = replay.id;
92
+ let stale = false;
93
+ fetch(replay.url)
94
+ .then((response) => (response.ok ? response.json() : null))
95
+ .then((body: unknown) => {
96
+ if (!stale) setLoaded({ id, file: parseReplay(body) });
97
+ })
98
+ .catch(() => {
99
+ if (!stale) setLoaded({ id, file: null });
100
+ });
101
+ return () => {
102
+ stale = true;
103
+ };
104
+ }, [replay]);
105
+ const file = replay && loaded?.id === replay.id ? loaded.file : null;
106
+ const timeline = file?.clip
107
+ ? { startedAt: file.clip.startedAt, durationMs: file.clip.durationMs }
108
+ : null;
109
+ const events = timeline
110
+ ? file!.events
111
+ .map((event) => ({ event, at: timelinePosition(event, timeline) }))
112
+ .filter((item): item is { event: ReplayEvent; at: number } => item.at !== null)
113
+ : [];
114
+ const seek = (at: number) => {
115
+ const node = video.current;
116
+ if (!node || !timeline) return;
117
+ const seconds = (at * timeline.durationMs) / 1000;
118
+ node.currentTime = Math.max(0, Math.min(node.duration || seconds, seconds));
119
+ setTime(node.currentTime);
120
+ };
121
+ const played = timeline ? (time * 1000) / timeline.durationMs : 0;
122
+ const latest = timeline
123
+ ? [...events].reverse().find((item) => item.at <= played + 0.002)?.event
124
+ : undefined;
125
+ const errors = events.filter((item) => markerKind(item.event) === "error").length;
126
+ return (
127
+ <div className="cs-shot-clip-stage" data-cs-ui="shot-clip">
128
+ <video
129
+ ref={video}
130
+ key={clip.id}
131
+ src={clip.url}
132
+ controls
133
+ playsInline
134
+ preload="metadata"
135
+ onTimeUpdate={(event) => setTime(event.currentTarget.currentTime)}
136
+ />
137
+ {timeline ? (
138
+ <div className="cs-timeline" data-cs-ui="clip-timeline">
139
+ <div className="cs-timeline-track" role="list" aria-label="What happened in this clip">
140
+ <span
141
+ className="cs-timeline-played"
142
+ style={{ width: `${Math.min(100, played * 100)}%` }}
143
+ />
144
+ {events.map(({ event, at }, index) => (
145
+ <button
146
+ key={`${event.t}-${index}`}
147
+ type="button"
148
+ role="listitem"
149
+ className={`cs-timeline-mark is-${markerKind(event)}`}
150
+ style={{ left: `${at * 100}%` }}
151
+ title={`${formatClock(at * timeline.durationMs)} · ${replayLine(event)}`}
152
+ aria-label={`${formatClock(at * timeline.durationMs)}: ${replayLine(event)}`}
153
+ data-cs-ui="clip-mark"
154
+ onClick={() => seek(at)}
155
+ />
156
+ ))}
157
+ </div>
158
+ <div className="cs-timeline-line">
159
+ <span className="cs-timeline-time">{formatClock(time * 1000)}</span>
160
+ <span className="cs-timeline-text" data-cs-ui="clip-latest">
161
+ {latest ? replayLine(latest) : "Play, or pick a marker, to follow what happened."}
162
+ </span>
163
+ <span className="cs-timeline-count">
164
+ {events.length} event{events.length === 1 ? "" : "s"}
165
+ {errors ? ` · ${errors} error${errors === 1 ? "" : "s"}` : ""}
166
+ </span>
167
+ </div>
168
+ </div>
169
+ ) : replay ? (
170
+ <p className="cs-markup-status">Reading what happened in this clip…</p>
171
+ ) : (
172
+ <p className="cs-markup-status">No replay was kept with this clip.</p>
173
+ )}
174
+ </div>
175
+ );
176
+ }
177
+
178
+ /**
179
+ * A draft of captures as a comment: the selected capture fills the surface (a picture with the markup tools live
180
+ * on it, or a clip playing), the others sit in a filmstrip, and the composer (`children`) is a column beside it.
181
+ * Nothing has to be typed, and there is no Done to press: posting flattens whatever was drawn (see the composer's
182
+ * submit in `toolbar.tsx`). "Another page" and "Record" minimise the surface to the dock so the draft can collect
183
+ * more from elsewhere in the app.
184
+ */
185
+ export function ShotSurface({
186
+ captures,
187
+ selectedId,
188
+ shapes,
189
+ savedAt,
190
+ onSelect,
191
+ onRemove,
192
+ onShapes,
193
+ onAnother,
194
+ onRecord,
195
+ onDictate,
196
+ dictating,
197
+ onMinimise,
198
+ onDiscard,
199
+ confirmDiscard,
200
+ onSubmit,
201
+ children,
202
+ }: {
203
+ captures: ShotCapture[];
204
+ selectedId: string | null;
205
+ /** Shapes drawn on each picture so far, by capture id; a picture not listed shows its flattened shapes. */
206
+ shapes: ReadonlyMap<string, MarkupShape[]>;
207
+ savedAt: number | null;
208
+ onSelect: (id: string) => void;
209
+ onRemove: (id: string) => void;
210
+ onShapes: (id: string, shapes: MarkupShape[]) => void;
211
+ onAnother: () => void;
212
+ onRecord: () => void;
213
+ /** Starts (or stops) typing what the person says into the comment; absent when dictation is not available. */
214
+ onDictate?: (() => void) | undefined;
215
+ dictating?: boolean | undefined;
216
+ onMinimise: () => void;
217
+ onDiscard: () => void;
218
+ confirmDiscard: boolean;
219
+ /** Cmd+Enter on the picture: post, as it does in the comment box. */
220
+ onSubmit: () => void;
221
+ children: ReactNode;
222
+ }) {
223
+ // Replays kept beside a clip are not captures of their own; they ride on the clip's slide.
224
+ const companions = captures.filter(isReplayFile);
225
+ const shown = captures.filter((item) => !isReplayFile(item));
226
+ const selected = shown.find((item) => item.id === selectedId) ?? shown[0] ?? null;
227
+ const pictures = shown.filter((item) => item.contentType.startsWith("image/")).length;
228
+ const clips = shown.filter((item) => item.contentType.startsWith("video/")).length;
229
+ const companionOf = (clip: ShotCapture) => {
230
+ const stamp = replayStamp(clip.name);
231
+ return stamp ? (companions.find((item) => replayStamp(item.name) === stamp) ?? null) : null;
232
+ };
233
+ const ribbon = (
234
+ <>
235
+ <div className="cs-ribbon-group">
236
+ <div className="cs-ribbon-row">
237
+ <button
238
+ className="cs-ribbon-big"
239
+ type="button"
240
+ data-cs-ui="shot-another"
241
+ title="Minimises this; go to any page and press S to add it"
242
+ onClick={onAnother}
243
+ >
244
+ <Icon.Camera size={16} />
245
+ <span>Another page</span>
246
+ </button>
247
+ <button
248
+ className="cs-ribbon-big"
249
+ type="button"
250
+ data-cs-ui="shot-record"
251
+ title="Records this tab into the draft, with what happens on the page"
252
+ onClick={onRecord}
253
+ >
254
+ <Icon.Record />
255
+ <span>Record</span>
256
+ </button>
257
+ {onDictate ? (
258
+ <button
259
+ className="cs-ribbon-big"
260
+ type="button"
261
+ data-cs-ui="shot-dictate"
262
+ aria-pressed={Boolean(dictating)}
263
+ title="Type what you say into the comment"
264
+ onClick={onDictate}
265
+ >
266
+ <Icon.Mic />
267
+ <span>{dictating ? "Listening…" : "Dictate"}</span>
268
+ </button>
269
+ ) : null}
270
+ </div>
271
+ <div className="cs-ribbon-name">Add</div>
272
+ </div>
273
+ {selected ? (
274
+ <div className="cs-ribbon-group">
275
+ <div className="cs-ribbon-row cs-ribbon-stack">
276
+ <button
277
+ className="cs-ribbon-small"
278
+ type="button"
279
+ data-cs-ui="shot-remove-selected"
280
+ onClick={() => onRemove(selected.id)}
281
+ >
282
+ <Icon.Trash />
283
+ Remove this capture
284
+ </button>
285
+ <span className="cs-ribbon-text">
286
+ {pictures ? `${pictures} picture${pictures === 1 ? "" : "s"}` : "No pictures"}
287
+ {clips ? ` · ${clips} clip${clips === 1 ? "" : "s"}` : ""}
288
+ {companions.length
289
+ ? ` · ${companions.length} replay${companions.length === 1 ? "" : "s"}`
290
+ : ""}
291
+ </span>
292
+ </div>
293
+ <div className="cs-ribbon-name">This draft</div>
294
+ </div>
295
+ ) : null}
296
+ </>
297
+ );
298
+ return (
299
+ <>
300
+ <div className="cs-shot-scrim cs-hit" data-cs-ui="shot-scrim" onClick={onMinimise} />
301
+ <section
302
+ className="cs-card cs-shot cs-hit"
303
+ data-cs-ui="shot"
304
+ aria-label="Comment on captures of this app"
305
+ >
306
+ <header className="cs-shot-head">
307
+ <span className="cs-shot-title">
308
+ <Icon.Camera size={12} />
309
+ <strong>Draft</strong>
310
+ <span className="cs-shot-count">
311
+ {pictures ? `${pictures} picture${pictures === 1 ? "" : "s"}` : ""}
312
+ {pictures && clips ? " · " : ""}
313
+ {clips ? `${clips} clip${clips === 1 ? "" : "s"}` : ""}
314
+ </span>
315
+ <span className="cs-shot-saved" data-cs-ui="shot-saved">
316
+ <i aria-hidden="true" />
317
+ {ago(savedAt)}
318
+ </span>
319
+ </span>
320
+ <span className="cs-shot-spacer" />
321
+ <button
322
+ className={`cs-button${confirmDiscard ? " is-danger" : ""}`}
323
+ type="button"
324
+ data-cs-ui="shot-discard"
325
+ title="Throw this draft away"
326
+ onClick={onDiscard}
327
+ >
328
+ {confirmDiscard ? "Discard?" : <Icon.Trash />}
329
+ </button>
330
+ <button
331
+ className="cs-icon-button"
332
+ type="button"
333
+ data-cs-ui="shot-minimise"
334
+ aria-label="Minimise. The draft stays, and follows you."
335
+ title="Minimise (the draft stays)"
336
+ onClick={onMinimise}
337
+ >
338
+ <Icon.ChevronDown />
339
+ </button>
340
+ </header>
341
+ <div className="cs-shot-body">
342
+ {/* The film only earns its width with two or more captures; one is the picture on the stage. */}
343
+ {shown.length > 1 ? (
344
+ <aside
345
+ className="cs-shot-film"
346
+ data-cs-ui="shot-film"
347
+ aria-label="Captures in this draft"
348
+ >
349
+ {shown.map((item, index) => {
350
+ const video = item.contentType.startsWith("video/");
351
+ const on = selected?.id === item.id;
352
+ return (
353
+ <div
354
+ key={item.id}
355
+ className={`cs-shot-slide${on ? " is-on" : ""}`}
356
+ data-cs-ui="shot-slide"
357
+ data-kind={video ? "clip" : "picture"}
358
+ >
359
+ <button
360
+ type="button"
361
+ className="cs-shot-thumb"
362
+ aria-label={`Show ${item.name}`}
363
+ aria-pressed={on}
364
+ onClick={() => onSelect(item.id)}
365
+ >
366
+ {video ? (
367
+ <video src={item.url} muted preload="metadata" />
368
+ ) : (
369
+ <img src={item.url} alt="" draggable={false} />
370
+ )}
371
+ {video ? <span className="cs-shot-clip">▶</span> : null}
372
+ </button>
373
+ <span className="cs-shot-cap">
374
+ <b>{index + 1}</b>
375
+ {video ? "Clip" : "Picture"}
376
+ </span>
377
+ <button
378
+ type="button"
379
+ className="cs-shot-remove"
380
+ aria-label={`Remove ${item.name} from the draft`}
381
+ onClick={() => onRemove(item.id)}
382
+ >
383
+ <Icon.Close />
384
+ </button>
385
+ </div>
386
+ );
387
+ })}
388
+ <button type="button" className="cs-shot-add" onClick={onAnother}>
389
+ + Another page
390
+ </button>
391
+ </aside>
392
+ ) : null}
393
+ <div className="cs-shot-stage cs-media">
394
+ {selected?.contentType.startsWith("image/") && selected.markup ? (
395
+ <MarkupEditor
396
+ // Keyed on the original, which outlives every flattened copy, so a save never remounts the tools.
397
+ key={selected.markup.src}
398
+ mode="inline"
399
+ src={selected.markup.src}
400
+ name={selected.markup.name}
401
+ shapes={shapes.get(selected.id) ?? selected.markup.shapes}
402
+ maxBytes={ATTACHMENT_MAX_BYTES}
403
+ ribbon={ribbon}
404
+ onChange={(next) => onShapes(selected.id, next)}
405
+ onCancel={onMinimise}
406
+ onSave={() => {}}
407
+ onSubmit={onSubmit}
408
+ />
409
+ ) : selected?.contentType.startsWith("video/") ? (
410
+ <ClipStage clip={selected} replay={companionOf(selected)} />
411
+ ) : (
412
+ <p className="cs-markup-status">Nothing captured yet. Press S on any page.</p>
413
+ )}
414
+ </div>
415
+ <aside className="cs-shot-side" data-cs-ui="shot-side">
416
+ {children}
417
+ </aside>
418
+ </div>
419
+ </section>
420
+ </>
421
+ );
422
+ }