@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,300 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { ApiError, listThreads, openLive } from "./client.js";
3
+ import { nextPoll } from "./freshness.js";
4
+ import {
5
+ KEEPALIVE_MS,
6
+ MAX_QUICK_RETRIES,
7
+ OPEN_WAIT_MS,
8
+ parseLiveMessage,
9
+ PUSH_BATCH_MS,
10
+ RETRY_LATER_MS,
11
+ } from "./live-model.js";
12
+ import type { Session } from "./session.js";
13
+ import { announceBuild } from "./update.js";
14
+
15
+ export type ThreadsPayload = Awaited<ReturnType<typeof listThreads>>;
16
+
17
+ export { parseLiveMessage, type LiveMessage } from "./live-model.js";
18
+
19
+ /** A tab hidden this long closes its live socket. Showing it again reconnects, and the reconnect catches up. */
20
+ const HIDDEN_CLOSE_MS = 60_000;
21
+ /** After navigating, a refetch waits until the address has settled. */
22
+ const NAVIGATION_SETTLE_MS = 1_000;
23
+
24
+ export function usePageVisible() {
25
+ const [visible, setVisible] = useState(() => document.visibilityState !== "hidden");
26
+ useEffect(() => {
27
+ const sync = () => setVisible(document.visibilityState !== "hidden");
28
+ document.addEventListener("visibilitychange", sync);
29
+ return () => document.removeEventListener("visibilitychange", sync);
30
+ }, []);
31
+ return visible;
32
+ }
33
+
34
+ /**
35
+ * Keeps threads current with as few requests as possible. The live socket pushes "changed" and each push batch
36
+ * refetches once; nothing is fetched while the tab is hidden. Without a socket, threads are polled only while the
37
+ * tab is visible and something on screen shows them (`wanted`), backing off while nothing changes. A refetch that
38
+ * returns what is already shown doesn't touch state.
39
+ */
40
+ export function useThreadsSync({
41
+ session,
42
+ member,
43
+ pollMs,
44
+ wanted,
45
+ href,
46
+ onThreads,
47
+ onUnauthorized,
48
+ onDisabled,
49
+ }: {
50
+ session: Session | null;
51
+ member: string | null;
52
+ pollMs: number;
53
+ wanted: boolean;
54
+ href: string;
55
+ onThreads: (next: ThreadsPayload) => void;
56
+ onUnauthorized: () => void;
57
+ /** The project turned comments off. The sign-in is kept, so turning them back on needs nothing from anyone. */
58
+ onDisabled?: () => void;
59
+ }) {
60
+ const [live, setLive] = useState(false);
61
+ const visible = usePageVisible();
62
+ const current = useRef({ session, member, onThreads, onUnauthorized, onDisabled });
63
+ useEffect(() => {
64
+ current.current = { session, member, onThreads, onUnauthorized, onDisabled };
65
+ }, [member, onDisabled, onThreads, onUnauthorized, session]);
66
+ const shown = useRef("");
67
+ const lastFetch = useRef(0);
68
+ const quiet = useRef(0);
69
+ const inflight = useRef<Promise<boolean> | null>(null);
70
+ const again = useRef(false);
71
+ /** A live socket is open or on its way (connecting, or reconnecting after a drop); its opening catches up. */
72
+ const socketUp = useRef(false);
73
+
74
+ useEffect(() => {
75
+ const signedOut = session === null || member === null;
76
+ shown.current = signedOut ? "" : "";
77
+ lastFetch.current = 0;
78
+ quiet.current = 0;
79
+ }, [member, session]);
80
+
81
+ /** Fetches threads; resolves true when what's shown changed. Calls during a fetch queue exactly one more. */
82
+ const refresh = useCallback(function refresh(): Promise<boolean> {
83
+ const { session: activeSession, member: token } = current.current;
84
+ if (!activeSession || !token) return Promise.resolve(false);
85
+ if (inflight.current) {
86
+ again.current = true;
87
+ return inflight.current;
88
+ }
89
+ lastFetch.current = Date.now();
90
+ const run = listThreads(activeSession, token)
91
+ .then((next) => {
92
+ if (current.current.member !== token) return false;
93
+ const text = JSON.stringify(next);
94
+ if (text === shown.current) return false;
95
+ shown.current = text;
96
+ current.current.onThreads(next);
97
+ return true;
98
+ })
99
+ .catch((caught) => {
100
+ if (caught instanceof ApiError && caught.status === 401 && current.current.member === token)
101
+ current.current.onUnauthorized();
102
+ if (
103
+ caught instanceof ApiError &&
104
+ caught.status === 403 &&
105
+ caught.code === "module_disabled" &&
106
+ current.current.member === token
107
+ ) {
108
+ shown.current = "";
109
+ current.current.onDisabled?.();
110
+ }
111
+ return false;
112
+ })
113
+ .finally(() => {
114
+ inflight.current = null;
115
+ })
116
+ .then((changed) => {
117
+ if (!again.current) return changed;
118
+ again.current = false;
119
+ return refresh();
120
+ });
121
+ inflight.current = run;
122
+ return run;
123
+ }, []);
124
+
125
+ useEffect(() => {
126
+ if (!session || !member) return;
127
+ if (typeof WebSocket === "undefined") {
128
+ void refresh();
129
+ return;
130
+ }
131
+ let socket: WebSocket | null = null;
132
+ let stopped = false;
133
+ let failures = 0;
134
+ let stale = false;
135
+ let opened = false;
136
+ let retry = 0;
137
+ let batch = 0;
138
+ let openWait = 0;
139
+ let closeHidden = 0;
140
+ const hidden = () => document.visibilityState === "hidden";
141
+ const fireBatch = () => {
142
+ void refresh();
143
+ };
144
+ const changed = () => {
145
+ if (hidden()) {
146
+ stale = true;
147
+ return;
148
+ }
149
+ window.clearTimeout(batch);
150
+ batch = window.setTimeout(fireBatch, PUSH_BATCH_MS);
151
+ };
152
+ const fireOpenWait = () => {
153
+ if (!opened) changed();
154
+ };
155
+ const armOpenWait = () => {
156
+ window.clearTimeout(openWait);
157
+ openWait = window.setTimeout(fireOpenWait, OPEN_WAIT_MS);
158
+ };
159
+ const fireCloseHidden = () => {
160
+ socket?.close();
161
+ };
162
+ let connect: () => void = () => {};
163
+ const armRetry = (delay: number) => {
164
+ window.clearTimeout(retry);
165
+ retry = window.setTimeout(connect, delay);
166
+ };
167
+ connect = () => {
168
+ window.clearTimeout(retry);
169
+ let opening: WebSocket;
170
+ try {
171
+ opening = openLive(session, member);
172
+ } catch {
173
+ changed();
174
+ return;
175
+ }
176
+ socket = opening;
177
+ socketUp.current = true;
178
+ opened = false;
179
+ armOpenWait();
180
+ opening.onopen = () => {
181
+ opened = true;
182
+ failures = 0;
183
+ stale = false;
184
+ window.clearTimeout(openWait);
185
+ setLive(true);
186
+ changed();
187
+ };
188
+ opening.onmessage = (event) => {
189
+ const message = parseLiveMessage(event.data);
190
+ if (message.type === "build") announceBuild(message.build);
191
+ else if (message.type === "changed") changed();
192
+ };
193
+ opening.onclose = () => {
194
+ window.clearTimeout(openWait);
195
+ if (socket === opening) socket = null;
196
+ setLive(false);
197
+ if (stopped || hidden()) {
198
+ socketUp.current = false;
199
+ return;
200
+ }
201
+ if (!opened) {
202
+ socketUp.current = false;
203
+ failures += 1;
204
+ if (failures === 1) changed();
205
+ }
206
+ armRetry(failures > MAX_QUICK_RETRIES ? RETRY_LATER_MS : 1_000 * 2 ** failures);
207
+ };
208
+ };
209
+ const onVisibility = () => {
210
+ if (hidden()) {
211
+ window.clearTimeout(closeHidden);
212
+ closeHidden = window.setTimeout(fireCloseHidden, HIDDEN_CLOSE_MS);
213
+ return;
214
+ }
215
+ window.clearTimeout(closeHidden);
216
+ if (!socket) {
217
+ failures = 0;
218
+ connect();
219
+ } else if (stale) {
220
+ stale = false;
221
+ changed();
222
+ }
223
+ };
224
+ const keepAlive = window.setInterval(() => {
225
+ if (socket?.readyState === WebSocket.OPEN) socket.send("ping");
226
+ }, KEEPALIVE_MS);
227
+ if (!hidden()) connect();
228
+ document.addEventListener("visibilitychange", onVisibility);
229
+ return () => {
230
+ stopped = true;
231
+ socketUp.current = false;
232
+ window.clearTimeout(retry);
233
+ window.clearTimeout(batch);
234
+ window.clearTimeout(openWait);
235
+ window.clearTimeout(closeHidden);
236
+ window.clearInterval(keepAlive);
237
+ document.removeEventListener("visibilitychange", onVisibility);
238
+ socket?.close();
239
+ setLive(false);
240
+ };
241
+ }, [member, refresh, session]);
242
+
243
+ useEffect(() => {
244
+ if (!session || !member) return;
245
+ const delay = nextPoll({ live, visible, wanted, quiet: quiet.current, pollMs });
246
+ if (delay === null) return;
247
+ let stopped = false;
248
+ let timer = 0;
249
+ let wait = delay;
250
+ function afterWait() {
251
+ void refresh().then((changed) => {
252
+ quiet.current = changed ? 0 : quiet.current + 1;
253
+ if (stopped) return;
254
+ wait = nextPoll({ live, visible, wanted, quiet: quiet.current, pollMs }) ?? pollMs;
255
+ tick();
256
+ });
257
+ }
258
+ function tick() {
259
+ window.clearTimeout(timer);
260
+ timer = window.setTimeout(afterWait, wait);
261
+ }
262
+ timer = window.setTimeout(afterWait, delay);
263
+ return () => {
264
+ stopped = true;
265
+ window.clearTimeout(timer);
266
+ };
267
+ }, [live, member, pollMs, refresh, session, visible, wanted]);
268
+
269
+ // Opening the panel or coming back to the tab catches up at once when no socket will.
270
+ useEffect(() => {
271
+ if (
272
+ !session ||
273
+ !member ||
274
+ live ||
275
+ socketUp.current ||
276
+ !visible ||
277
+ !wanted ||
278
+ !lastFetch.current
279
+ )
280
+ return;
281
+ if (Date.now() - lastFetch.current < pollMs) return;
282
+ quiet.current = 0;
283
+ void refresh();
284
+ }, [live, member, pollMs, refresh, session, visible, wanted]);
285
+
286
+ const lastHref = useRef(href);
287
+ useEffect(() => {
288
+ if (href === lastHref.current) return;
289
+ lastHref.current = href;
290
+ if (!session || !member || live || socketUp.current || !lastFetch.current) return;
291
+ const timer = window.setTimeout(() => {
292
+ if (Date.now() - lastFetch.current < pollMs) return;
293
+ quiet.current = 0;
294
+ void refresh();
295
+ }, NAVIGATION_SETTLE_MS);
296
+ return () => window.clearTimeout(timer);
297
+ }, [href, live, member, pollMs, refresh, session]);
298
+
299
+ return { live, refresh, visible };
300
+ }
@@ -0,0 +1,71 @@
1
+ /** Who wrote, who is named, and how a thread reads in lists. DOM-free, so native clients share it. */
2
+ import type { CommentMessage, Thread } from "./api.js";
3
+ import { ago } from "./format.js";
4
+
5
+ /** Whether the signed-in member wrote `message` (author id when stored, else the name). */
6
+ export function isMine(message: CommentMessage, memberId: string | undefined, memberName: string) {
7
+ return message.authorId ? message.authorId === memberId : message.author === memberName;
8
+ }
9
+
10
+ /** Every version of a message, oldest first; the last is the current body. */
11
+ export function versionsOf(message: CommentMessage) {
12
+ return [
13
+ ...(message.revisions ?? []),
14
+ {
15
+ body: message.body,
16
+ mentions: message.mentions,
17
+ createdAt: message.editedAt ?? message.createdAt,
18
+ },
19
+ ];
20
+ }
21
+
22
+ /** `ago` as a phrase: "just now", "5 min ago". */
23
+ export function agoPhrase(iso: string) {
24
+ const text = ago(iso);
25
+ return text === "now" ? "just now" : `${text} ago`;
26
+ }
27
+
28
+ /** A message's first sentence when it is a readable length, else the whole trimmed text. */
29
+ export function leadSentence(body: string) {
30
+ const text = body.trim();
31
+ const split = text.search(/[.!?]\s/);
32
+ if (split > 24 && split < 96) return text.slice(0, split + 1);
33
+ return text;
34
+ }
35
+
36
+ /** Whether `name` started the thread or wrote in it. */
37
+ export function involved(thread: Thread, name: string) {
38
+ return thread.createdBy === name || thread.messages.some((message) => message.author === name);
39
+ }
40
+
41
+ /** Whether a message @mentions the signed-in member (by id, else by name). */
42
+ export function namesYou(
43
+ message: CommentMessage,
44
+ memberId: string | undefined,
45
+ memberName: string,
46
+ ) {
47
+ return (message.mentions ?? []).some(
48
+ (mention) =>
49
+ (memberId !== undefined && memberId !== null && mention.id === memberId) ||
50
+ (memberName !== "" && mention.name === memberName),
51
+ );
52
+ }
53
+
54
+ /** Whether the signed-in member wrote a message (by id, else by name). */
55
+ export function fromYou(message: CommentMessage, memberId: string | undefined, memberName: string) {
56
+ return (
57
+ (memberId !== undefined && memberId !== null && message.authorId === memberId) ||
58
+ (memberName !== "" && message.author === memberName)
59
+ );
60
+ }
61
+
62
+ /** Latest message from someone else that @mentions the signed-in member. */
63
+ export function mentionOfYou(thread: Thread, memberId: string | undefined, memberName: string) {
64
+ let hit: CommentMessage | null = null;
65
+ for (const message of thread.messages) {
66
+ if (fromYou(message, memberId, memberName) || !namesYou(message, memberId, memberName))
67
+ continue;
68
+ hit = message;
69
+ }
70
+ return hit;
71
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The tracker's ticket form a tester fills while commenting (`GET /v1/ticket-form`). The comment
3
+ * supplies the subject, description and files; the tester picks the Type (defect, enhancement…), the
4
+ * Priority and the project's other fields. The answers are saved with the comment, and "Create ticket"
5
+ * in a connection's inbox starts from them.
6
+ *
7
+ * Mirrors the connection's own ticket form type; the toolbar bundle doesn't import it.
8
+ * A field is visible when it has no condition, or its controller (always earlier in the form) is visible
9
+ * and holds one of the condition's values. That is one forward pass in form order.
10
+ */
11
+ export interface TicketFormCondition {
12
+ readonly fieldKey: string;
13
+ readonly equalsAnyOf: readonly string[];
14
+ }
15
+
16
+ export interface TicketFormField {
17
+ readonly key: string;
18
+ readonly kind:
19
+ | "short_text"
20
+ | "long_text"
21
+ | "email"
22
+ | "phone"
23
+ | "url"
24
+ | "number"
25
+ | "select_single"
26
+ | "select_multi"
27
+ | "checkbox"
28
+ | "date"
29
+ | "datetime"
30
+ | "rating";
31
+ readonly label: string;
32
+ readonly required: boolean;
33
+ readonly helpText?: string;
34
+ readonly options?: readonly {
35
+ readonly value: string;
36
+ readonly label: string;
37
+ readonly when?: TicketFormCondition;
38
+ }[];
39
+ readonly min?: number;
40
+ readonly max?: number;
41
+ readonly scale?: 5 | 10;
42
+ readonly visibleWhen?: TicketFormCondition;
43
+ }
44
+
45
+ export interface TicketForm {
46
+ readonly formVersionId: string;
47
+ readonly fields: readonly TicketFormField[];
48
+ }
49
+
50
+ export type TicketValue = string | number | boolean | readonly string[];
51
+ export type TicketValues = Readonly<Record<string, TicketValue>>;
52
+
53
+ /** What `POST /v1/threads` takes as `ticket`. */
54
+ export interface TicketDraft {
55
+ readonly formVersionId: string;
56
+ readonly values: TicketValues;
57
+ }
58
+
59
+ /** Up to this many options show as chips; more become a dropdown. */
60
+ export const CHIP_OPTIONS_MAX = 6;
61
+
62
+ function matchesCondition(condition: TicketFormCondition, value: TicketValue | undefined) {
63
+ if (value === undefined) return false;
64
+ if (typeof value === "boolean") return condition.equalsAnyOf.includes(value ? "true" : "false");
65
+ if (Array.isArray(value)) return value.some((entry) => condition.equalsAnyOf.includes(entry));
66
+ return condition.equalsAnyOf.includes(String(value));
67
+ }
68
+
69
+ /** Field key → visible, for the values so far. */
70
+ export function ticketFieldVisibility(
71
+ form: TicketForm,
72
+ values: TicketValues,
73
+ ): ReadonlyMap<string, boolean> {
74
+ const visible = new Map<string, boolean>();
75
+ for (const field of form.fields) {
76
+ const condition = field.visibleWhen;
77
+ visible.set(
78
+ field.key,
79
+ condition === undefined ||
80
+ (visible.get(condition.fieldKey) !== false &&
81
+ matchesCondition(condition, values[condition.fieldKey])),
82
+ );
83
+ }
84
+ return visible;
85
+ }
86
+
87
+ /** Whether a ticket answer holds something (a checked box, a non-blank text, a picked option). */
88
+ export function ticketValueFilled(value: TicketValue | undefined) {
89
+ if (value === undefined) return false;
90
+ if (typeof value === "string") return value.trim().length > 0;
91
+ if (Array.isArray(value)) return value.length > 0;
92
+ return true;
93
+ }
94
+
95
+ /** Labels of required fields that are visible and still empty. */
96
+ export function missingTicketFields(form: TicketForm, values: TicketValues): readonly string[] {
97
+ const visible = ticketFieldVisibility(form, values);
98
+ return form.fields
99
+ .filter(
100
+ (field) =>
101
+ field.required &&
102
+ field.kind !== "checkbox" &&
103
+ visible.get(field.key) !== false &&
104
+ !ticketValueFilled(values[field.key]),
105
+ )
106
+ .map((field) => field.label);
107
+ }
108
+
109
+ /** The answers to send: visible, filled fields only. */
110
+ export function ticketDraftPayload(form: TicketForm, values: TicketValues): TicketDraft | null {
111
+ const visible = ticketFieldVisibility(form, values);
112
+ const kept: Record<string, TicketValue> = {};
113
+ for (const field of form.fields) {
114
+ const value = values[field.key];
115
+ if (visible.get(field.key) !== false && ticketValueFilled(value)) kept[field.key] = value!;
116
+ }
117
+ return Object.keys(kept).length === 0
118
+ ? null
119
+ : { formVersionId: form.formVersionId, values: kept };
120
+ }
121
+
122
+ /** The field's options that apply to the values so far. */
123
+ export function ticketFieldOptions(field: TicketFormField, values: TicketValues) {
124
+ return (field.options ?? []).filter(
125
+ (option) =>
126
+ option.when === undefined || matchesCondition(option.when, values[option.when.fieldKey]),
127
+ );
128
+ }