@lovett/ui 0.0.11 → 0.2.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 (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,598 @@
1
+ /**
2
+ * Composer attachments — the headless half (ADR-151 D1 and D5).
3
+ *
4
+ * The primitive owns no fetch client and no base URL (ADR-147 D5's rule for
5
+ * this whole package), so the host injects two functions: one that puts a
6
+ * `Blob` somewhere and comes back with a verified ticket, and one that turns a
7
+ * Giphy id into the same thing. Everything else — per-file progress, cancel,
8
+ * the count and byte ceilings, the paste threshold, the undo — is here.
9
+ *
10
+ * ## D1: each file is its own unit
11
+ *
12
+ * An upload starts the moment a file lands and carries its own progress and its
13
+ * own cancel. Send stays enabled and waits for whatever is still in flight,
14
+ * rather than being greyed out while a 20 MB file finishes — the person is
15
+ * usually still typing, and disabling send is how a composer starts feeling
16
+ * like it is having a conversation with itself.
17
+ *
18
+ * ## D5: a large paste is an OFFER, not a seizure
19
+ *
20
+ * A paste of 2,500 characters or more becomes a `.txt` attachment through the
21
+ * exact same path a dropped file takes — `new Blob([text], …)` is the whole
22
+ * mechanism, which is why D5 is cheap once D1 exists.
23
+ *
24
+ * **The original string is kept verbatim on the chip**, so "Insert as text
25
+ * instead" restores it byte for byte. That is not a nicety: a paste silently
26
+ * becoming a file the person did not want is worse than a long comment, and it
27
+ * is the first thing to get wrong. The undo lives as long as the composer does.
28
+ *
29
+ * The threshold is checked on the RAW pasted string, before any markdown
30
+ * parsing — parsing 200 KB only to throw the AST away is work nobody asked for.
31
+ */
32
+
33
+ import { useCallback, useEffect, useRef, useState } from 'react'
34
+
35
+ /**
36
+ * ADR-151 D5. Long enough that a normal paragraph, a stack trace or a pasted
37
+ * list is never intercepted; short enough that the thing which would have
38
+ * drowned the comment does not. Recorded here with the reasoning rather than
39
+ * scattered as a magic number.
40
+ */
41
+ export const PASTE_ATTACHMENT_THRESHOLD = 2_500
42
+
43
+ /**
44
+ * ADR-151 D4. Giphy's free tier is 100 requests/hour, and a type-as-you-search
45
+ * picker burns that in about a minute. This debounce and the worker's
46
+ * five-minute cache are the two halves of keeping the picker affordable;
47
+ * neither is optional.
48
+ */
49
+ export const GIF_SEARCH_DEBOUNCE_MS = 300
50
+
51
+ /** Mirrors the worker's D3 caps so the composer can refuse before uploading. */
52
+ export const ATTACHMENT_LIMITS = {
53
+ fileMaxBytes: 25 * 1024 * 1024,
54
+ commentMaxBytes: 50 * 1024 * 1024,
55
+ maxPerComment: 10,
56
+ } as const
57
+
58
+ export type ComposerAttachmentKind = 'image' | 'gif' | 'file'
59
+
60
+ export type ComposerAttachmentState = 'uploading' | 'ready' | 'failed'
61
+
62
+ /** One chip in the tray. */
63
+ export interface ComposerAttachment {
64
+ /** Stable for the life of the chip, including before a ticket exists. */
65
+ readonly localId: string
66
+ readonly name: string
67
+ readonly sizeBytes: number
68
+ readonly kind: ComposerAttachmentKind
69
+ readonly state: ComposerAttachmentState
70
+ /** 0..1. Meaningful only while `state === 'uploading'`. */
71
+ readonly progress: number
72
+ /** The server's ticket. Present once `state === 'ready'`. */
73
+ readonly ticketId?: string | undefined
74
+ /** A message already in the product's voice, straight from the server. */
75
+ readonly error?: string | undefined
76
+ /** A local object URL (or a GIF still). Never a stored URL. */
77
+ readonly previewUrl?: string | undefined
78
+ readonly aspectRatio?: number | undefined
79
+ /**
80
+ * D5 — the exact text this chip was made from, kept so "Insert as text
81
+ * instead" restores it byte for byte. Present only for a pasted-text chip.
82
+ */
83
+ readonly pastedText?: string | undefined
84
+ /**
85
+ * D5 — "the character count shown on the chip". Present only for a paste,
86
+ * and it is the count of CHARACTERS, not of bytes: the person pasted
87
+ * characters, and for anything non-ASCII the two differ enough that showing
88
+ * bytes under a character label would be a small lie.
89
+ */
90
+ readonly charCount?: number | undefined
91
+ }
92
+
93
+ /** What the host's uploader hands back. Mirrors the worker's ticket shape. */
94
+ export interface AttachmentTicketLike {
95
+ id: string
96
+ kind: string
97
+ name: string
98
+ sizeBytes: number
99
+ mime: string
100
+ aspectRatio: number | null
101
+ }
102
+
103
+ export interface UseAttachmentsOptions {
104
+ /**
105
+ * Put the bytes somewhere and come back with a verified ticket.
106
+ *
107
+ * The host is expected to run the upload AND the server-side verification;
108
+ * this hook only tracks the result. `signal` must genuinely abort the
109
+ * transfer — a cancel that only hides the chip leaves the bytes uploading.
110
+ */
111
+ readonly upload?: (
112
+ file: Blob,
113
+ opts: {
114
+ filename: string
115
+ signal: AbortSignal
116
+ onProgress: (fraction: number) => void
117
+ },
118
+ ) => Promise<AttachmentTicketLike>
119
+ /** Turn a Giphy id into a ticket. Omit to disable the GIF path entirely. */
120
+ readonly attachGif?: ((giphyId: string) => Promise<AttachmentTicketLike>) | undefined
121
+ /** Told about a refusal so the surface can say it once, in its own voice. */
122
+ readonly onError?: ((message: string) => void) | undefined
123
+ readonly maxPerComment?: number | undefined
124
+ readonly maxFileBytes?: number | undefined
125
+ readonly maxTotalBytes?: number | undefined
126
+ }
127
+
128
+ export interface UseAttachmentsResult {
129
+ readonly attachments: readonly ComposerAttachment[]
130
+ /** Ready tickets in render order — what the comment POST carries. */
131
+ readonly ticketIds: readonly string[]
132
+ /** True while anything is still in flight. Send WAITS on this; it is not disabled by it. */
133
+ readonly isUploading: boolean
134
+ readonly addFiles: (files: Iterable<File>) => void
135
+ /**
136
+ * D5. Returns `true` when the paste was intercepted, so the caller can
137
+ * `preventDefault()` — and `false` when it was not, so a short paste keeps
138
+ * ADR-148 D8's existing behaviour untouched.
139
+ */
140
+ readonly addPastedText: (text: string) => boolean
141
+ readonly addGif: (gif: { id: string; title: string; stillUrl: string; width: number; height: number }) => void
142
+ /** Cancels an in-flight upload as well as dropping the chip. */
143
+ readonly remove: (localId: string) => void
144
+ /** D5's undo. Returns the original text and drops the chip; `null` if not a paste. */
145
+ readonly insertAsText: (localId: string) => string | null
146
+ readonly clear: () => void
147
+ }
148
+
149
+ let localIdCounter = 0
150
+ function nextLocalId(): string {
151
+ localIdCounter += 1
152
+ return `att-local-${String(localIdCounter)}`
153
+ }
154
+
155
+ /** D5: derived and honest. Never a fabricated filename. */
156
+ export function pastedTextFilename(index: number): string {
157
+ return `pasted-text-${String(index)}.txt`
158
+ }
159
+
160
+ /** The threshold check, on the RAW string, before any parse. */
161
+ export function shouldAttachPaste(text: string): boolean {
162
+ return text.length >= PASTE_ATTACHMENT_THRESHOLD
163
+ }
164
+
165
+ function kindOf(ticketKind: string): ComposerAttachmentKind {
166
+ if (ticketKind === 'image' || ticketKind === 'gif') return ticketKind
167
+ return 'file'
168
+ }
169
+
170
+ /** An image preview costs one object URL and no round trip. */
171
+ function previewFor(file: File): string | undefined {
172
+ if (!file.type.startsWith('image/')) return undefined
173
+ try {
174
+ return URL.createObjectURL(file)
175
+ } catch {
176
+ return undefined
177
+ }
178
+ }
179
+
180
+ export function useAttachments(options: UseAttachmentsOptions): UseAttachmentsResult {
181
+ const {
182
+ upload,
183
+ attachGif,
184
+ onError,
185
+ maxPerComment = ATTACHMENT_LIMITS.maxPerComment,
186
+ maxFileBytes = ATTACHMENT_LIMITS.fileMaxBytes,
187
+ maxTotalBytes = ATTACHMENT_LIMITS.commentMaxBytes,
188
+ } = options
189
+
190
+ const [attachments, setAttachments] = useState<readonly ComposerAttachment[]>([])
191
+
192
+ /**
193
+ * A synchronous mirror of the list.
194
+ *
195
+ * The ceiling checks have to run BEFORE an upload starts, and they have to
196
+ * see files added earlier in the same batch — drop eleven at once and the
197
+ * eleventh must be refused, not started against a list that has not
198
+ * re-rendered. Reading that from a `setState` updater would mean doing the
199
+ * work inside it, which React is explicitly allowed to run twice. So the
200
+ * mirror is the read path and `setAttachments` stays a pure write.
201
+ */
202
+ const listRef = useRef<readonly ComposerAttachment[]>([])
203
+
204
+ const commit = useCallback(
205
+ (next: (current: readonly ComposerAttachment[]) => readonly ComposerAttachment[]) => {
206
+ const value = next(listRef.current)
207
+ listRef.current = value
208
+ setAttachments(value)
209
+ },
210
+ [],
211
+ )
212
+
213
+ // Live handles the render never reads. Keeping them in refs is what lets a
214
+ // cancel reach an upload that started three renders ago.
215
+ const controllers = useRef(new Map<string, AbortController>())
216
+ const objectUrls = useRef(new Set<string>())
217
+ const pasteCount = useRef(0)
218
+ // The uploader and the error sink change identity on most renders in a
219
+ // typical host; reading them through a ref keeps `addFiles` stable so the
220
+ // composer's own memoized handlers do not churn.
221
+ const uploadRef = useRef(upload)
222
+ const errorRef = useRef(onError)
223
+ uploadRef.current = upload
224
+ errorRef.current = onError
225
+ const gifRef = useRef(attachGif)
226
+ gifRef.current = attachGif
227
+
228
+ // Revoke every object URL that is still outstanding, on unmount. Individual
229
+ // URLs are released by `releasePreview` as their chips go — see there for why
230
+ // that release is deferred rather than immediate.
231
+ useEffect(() => {
232
+ const urls = objectUrls.current
233
+ const inFlight = controllers.current
234
+ return () => {
235
+ for (const url of urls) URL.revokeObjectURL(url)
236
+ urls.clear()
237
+ for (const controller of inFlight.values()) controller.abort()
238
+ inFlight.clear()
239
+ }
240
+ }, [])
241
+
242
+ /**
243
+ * Release one preview's object URL, a beat after its chip is gone.
244
+ *
245
+ * Revoking immediately is wrong: a chip can be removed while its `<img>` is
246
+ * still painting the very frame being torn down, and pulling the URL out from
247
+ * under it turns the preview into a broken icon on the way out. Never
248
+ * revoking is also wrong, and was the previous behaviour — `clear()` runs
249
+ * after every successful send, so a long-lived composer accumulated the File
250
+ * blob of every image ever attached until it unmounted.
251
+ *
252
+ * A macrotask is enough: React has committed the removal by then, so nothing
253
+ * is left holding the URL.
254
+ */
255
+ const releasePreview = useCallback((url: string | undefined) => {
256
+ if (url === undefined || !objectUrls.current.has(url)) return
257
+ objectUrls.current.delete(url)
258
+ setTimeout(() => {
259
+ URL.revokeObjectURL(url)
260
+ }, 0)
261
+ }, [])
262
+
263
+ const patch = useCallback(
264
+ (localId: string, next: Partial<ComposerAttachment>) => {
265
+ commit((current) => current.map((a) => (a.localId === localId ? { ...a, ...next } : a)))
266
+ },
267
+ [commit],
268
+ )
269
+
270
+ const fail = useCallback((message: string) => {
271
+ errorRef.current?.(message)
272
+ }, [])
273
+
274
+ /**
275
+ * The one path every attachment takes — a dropped file, a pasted string, a
276
+ * GIF. D5's "there is no second upload mechanism" is this function having
277
+ * exactly one caller shape.
278
+ */
279
+ const start = useCallback(
280
+ (
281
+ blob: Blob,
282
+ meta: {
283
+ name: string
284
+ kind: ComposerAttachmentKind
285
+ previewUrl?: string | undefined
286
+ pastedText?: string | undefined
287
+ charCount?: number | undefined
288
+ },
289
+ ) => {
290
+ const uploader = uploadRef.current
291
+ // A rich composer can exist without a host upload seam. In that case
292
+ // files and long text stay in their native paths rather than becoming
293
+ // chips that can never finish.
294
+ if (uploader === undefined) return
295
+ const localId = nextLocalId()
296
+ const controller = new AbortController()
297
+ controllers.current.set(localId, controller)
298
+ if (meta.previewUrl !== undefined) objectUrls.current.add(meta.previewUrl)
299
+
300
+ commit((current) => [
301
+ ...current,
302
+ {
303
+ localId,
304
+ name: meta.name,
305
+ sizeBytes: blob.size,
306
+ kind: meta.kind,
307
+ state: 'uploading',
308
+ progress: 0,
309
+ previewUrl: meta.previewUrl,
310
+ pastedText: meta.pastedText,
311
+ charCount: meta.charCount,
312
+ },
313
+ ])
314
+
315
+ void uploader(blob, {
316
+ filename: meta.name,
317
+ signal: controller.signal,
318
+ onProgress: (fraction) => {
319
+ patch(localId, { progress: Math.max(0, Math.min(1, fraction)) })
320
+ },
321
+ })
322
+ .then((ticket) => {
323
+ controllers.current.delete(localId)
324
+ patch(localId, {
325
+ state: 'ready',
326
+ progress: 1,
327
+ ticketId: ticket.id,
328
+ // The server's own account wins over the local guess: it read the
329
+ // magic number and we read a filename.
330
+ name: ticket.name,
331
+ sizeBytes: ticket.sizeBytes,
332
+ kind: kindOf(ticket.kind),
333
+ ...(ticket.aspectRatio === null ? {} : { aspectRatio: ticket.aspectRatio }),
334
+ })
335
+ })
336
+ .catch((err: unknown) => {
337
+ controllers.current.delete(localId)
338
+ // A cancel is not a failure and must not leave a red chip behind —
339
+ // `remove` has already dropped it.
340
+ if (err instanceof DOMException && err.name === 'AbortError') return
341
+ const message =
342
+ err instanceof Error && err.message.length > 0
343
+ ? err.message
344
+ : "That file couldn't be attached."
345
+ patch(localId, { state: 'failed', error: message })
346
+ fail(message)
347
+ })
348
+
349
+ return localId
350
+ },
351
+ [commit, fail, patch],
352
+ )
353
+
354
+ /**
355
+ * The two ceilings, checked before an upload starts.
356
+ *
357
+ * A courtesy, never the check: the worker re-measures both against the bytes
358
+ * R2 actually holds, so a client that skipped this entirely is refused all
359
+ * the same. What it buys is that a 30 MB file is refused in front of the
360
+ * person instead of two minutes later.
361
+ */
362
+ const admits = useCallback(
363
+ (size: number, label: string): boolean => {
364
+ const current = listRef.current
365
+ if (current.length >= maxPerComment) {
366
+ fail(`You can attach up to ${String(maxPerComment)} files to a comment.`)
367
+ return false
368
+ }
369
+ if (size > maxFileBytes) {
370
+ fail(`${label} is too large. Files can be up to 25 MB.`)
371
+ return false
372
+ }
373
+ const total = current.reduce((sum, a) => sum + a.sizeBytes, 0)
374
+ if (total + size > maxTotalBytes) {
375
+ fail('That would put this comment over 50 MB of attachments.')
376
+ return false
377
+ }
378
+ return true
379
+ },
380
+ [fail, maxFileBytes, maxPerComment, maxTotalBytes],
381
+ )
382
+
383
+ const addFiles = useCallback(
384
+ (files: Iterable<File>) => {
385
+ if (uploadRef.current === undefined) return
386
+ // `start` writes through `commit`, which updates the mirror synchronously
387
+ // — so the ceiling check on the second file already counts the first.
388
+ //
389
+ // `continue`, NOT `break`. An earlier version stopped at the first
390
+ // refusal, so dropping five files where the second was 30 MB uploaded
391
+ // the first, reported the second, and made the last three vanish with no
392
+ // message at all. D3 requires every refusal be named and shown; three
393
+ // files disappearing silently is the exact failure that rule exists to
394
+ // prevent. Skipping the oversized one and carrying on is also what a
395
+ // person means by dropping a folder.
396
+ for (const file of files) {
397
+ if (!admits(file.size, file.name)) continue
398
+ const preview = previewFor(file)
399
+ start(file, {
400
+ name: file.name,
401
+ kind: file.type.startsWith('image/') ? 'image' : 'file',
402
+ previewUrl: preview,
403
+ })
404
+ }
405
+ },
406
+ [admits, start],
407
+ )
408
+
409
+ const addPastedText = useCallback(
410
+ (text: string): boolean => {
411
+ if (uploadRef.current === undefined) return false
412
+ if (!shouldAttachPaste(text)) return false
413
+ pasteCount.current += 1
414
+ const name = pastedTextFilename(pasteCount.current)
415
+ const blob = new Blob([text], { type: 'text/plain' })
416
+ if (!admits(blob.size, 'That paste')) {
417
+ // Refused for size or count: it stays PROSE rather than vanishing. The
418
+ // caller does not preventDefault, so the paste lands in the body.
419
+ pasteCount.current -= 1
420
+ return false
421
+ }
422
+ start(blob, { name, kind: 'file', pastedText: text, charCount: text.length })
423
+ return true
424
+ },
425
+ [admits, start],
426
+ )
427
+
428
+ const addGif = useCallback(
429
+ (gif: { id: string; title: string; stillUrl: string; width: number; height: number }) => {
430
+ const attach = gifRef.current
431
+ if (attach === undefined) return
432
+ if (listRef.current.length >= maxPerComment) {
433
+ fail(`You can attach up to ${String(maxPerComment)} files to a comment.`)
434
+ return
435
+ }
436
+ const localId = nextLocalId()
437
+ commit((current) => [
438
+ ...current,
439
+ {
440
+ localId,
441
+ name: gif.title.length > 0 ? gif.title : 'GIF',
442
+ sizeBytes: 0,
443
+ kind: 'gif',
444
+ state: 'uploading',
445
+ progress: 0,
446
+ previewUrl: gif.stillUrl,
447
+ ...(gif.height > 0 ? { aspectRatio: gif.width / gif.height } : {}),
448
+ },
449
+ ])
450
+
451
+ void attach(gif.id)
452
+ .then((ticket) => {
453
+ patch(localId, {
454
+ state: 'ready',
455
+ progress: 1,
456
+ ticketId: ticket.id,
457
+ name: ticket.name,
458
+ sizeBytes: ticket.sizeBytes,
459
+ kind: 'gif',
460
+ ...(ticket.aspectRatio === null ? {} : { aspectRatio: ticket.aspectRatio }),
461
+ })
462
+ })
463
+ .catch((err: unknown) => {
464
+ const message =
465
+ err instanceof Error && err.message.length > 0
466
+ ? err.message
467
+ : "That GIF couldn't be attached."
468
+ patch(localId, { state: 'failed', error: message })
469
+ fail(message)
470
+ })
471
+ },
472
+ [commit, fail, maxPerComment, patch],
473
+ )
474
+
475
+ const remove = useCallback(
476
+ (localId: string) => {
477
+ // Abort FIRST. A chip that disappears while its bytes keep uploading is a
478
+ // cancel that cancelled nothing.
479
+ controllers.current.get(localId)?.abort()
480
+ controllers.current.delete(localId)
481
+ releasePreview(listRef.current.find((a) => a.localId === localId)?.previewUrl)
482
+ commit((current) => current.filter((a) => a.localId !== localId))
483
+ },
484
+ [commit, releasePreview],
485
+ )
486
+
487
+ const insertAsText = useCallback(
488
+ (localId: string): string | null => {
489
+ const found = listRef.current.find((a) => a.localId === localId)
490
+ const text = found?.pastedText ?? null
491
+ if (text === null) return null
492
+ controllers.current.get(localId)?.abort()
493
+ controllers.current.delete(localId)
494
+ releasePreview(found?.previewUrl)
495
+ commit((current) => current.filter((a) => a.localId !== localId))
496
+ return text
497
+ },
498
+ [commit, releasePreview],
499
+ )
500
+
501
+ const clear = useCallback(() => {
502
+ for (const controller of controllers.current.values()) controller.abort()
503
+ controllers.current.clear()
504
+ for (const attachment of listRef.current) releasePreview(attachment.previewUrl)
505
+ commit(() => [])
506
+ }, [commit, releasePreview])
507
+
508
+ return {
509
+ attachments,
510
+ ticketIds: attachments
511
+ .filter((a) => a.state === 'ready' && a.ticketId !== undefined)
512
+ .map((a) => a.ticketId ?? ''),
513
+ isUploading: attachments.some((a) => a.state === 'uploading'),
514
+ addFiles,
515
+ addPastedText,
516
+ addGif,
517
+ remove,
518
+ insertAsText,
519
+ clear,
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Resolve a posted attachment's short-lived URL, once, lazily (ADR-151 D6).
525
+ *
526
+ * The wire carries no URL — the storage key never leaves the worker — so a
527
+ * renderer is handed the SHAPE of the slot and asks for the bytes separately.
528
+ * The box is already reserved from the declared aspect ratio by the time this
529
+ * runs, which is the whole point of the ordering: nothing reflows when the
530
+ * picture lands, or fails to.
531
+ *
532
+ * `null` means "asked and could not have it", and the caller renders the
533
+ * placeholder — never a collapsed box.
534
+ */
535
+ export function useAttachmentUrl(
536
+ attachmentId: string,
537
+ resolve: ((attachmentId: string) => Promise<string | null>) | undefined,
538
+ ): { url: string | null; failed: boolean; loading: boolean } {
539
+ const [url, setUrl] = useState<string | null>(null)
540
+ const [failed, setFailed] = useState(false)
541
+ /**
542
+ * Distinct from `failed`, and the distinction is the whole point.
543
+ *
544
+ * An earlier version rendered the broken-image placeholder whenever there was
545
+ * no URL — which meant every attachment showed the failure treatment for the
546
+ * whole time it was resolving. D6 asks for a placeholder when an image is
547
+ * BROKEN; showing it while the answer is still on its way tells the reader
548
+ * something untrue about their own comment.
549
+ */
550
+ const [loading, setLoading] = useState(resolve !== undefined)
551
+
552
+ /**
553
+ * The resolver is read through a ref and is NOT a dependency.
554
+ *
555
+ * This is deliberate and it is load-bearing. The resolver arrives from the
556
+ * host, through `ThreadProps` and the render context, and a host that writes
557
+ * `resolveAttachmentUrl={(id) => api.attachmentUrl(brandId, …, id)}` — which
558
+ * is the obvious and correct-looking way to write it — hands over a NEW
559
+ * function identity on every render. With that in the dependency array the
560
+ * effect re-runs, sets state, re-renders, and re-runs: an infinite fetch loop
561
+ * that presents as a hung tab or an out-of-memory test, not as an error.
562
+ *
563
+ * So the effect keys on the attachment id, which is the thing that actually
564
+ * identifies what to fetch, plus whether a resolver exists at all (so a host
565
+ * that supplies one late still resolves). A resolver whose BEHAVIOUR changes
566
+ * without the id changing is not a case that occurs: a different brand or a
567
+ * different thread brings different attachment ids with it.
568
+ */
569
+ const resolveRef = useRef(resolve)
570
+ resolveRef.current = resolve
571
+ const enabled = resolve !== undefined
572
+
573
+ useEffect(() => {
574
+ const current = resolveRef.current
575
+ if (current === undefined) return
576
+ let live = true
577
+ setUrl(null)
578
+ setFailed(false)
579
+ setLoading(true)
580
+ void current(attachmentId)
581
+ .then((next) => {
582
+ if (!live) return
583
+ if (next === null || next.length === 0) setFailed(true)
584
+ else setUrl(next)
585
+ setLoading(false)
586
+ })
587
+ .catch(() => {
588
+ if (!live) return
589
+ setFailed(true)
590
+ setLoading(false)
591
+ })
592
+ return () => {
593
+ live = false
594
+ }
595
+ }, [attachmentId, enabled])
596
+
597
+ return { url, failed, loading }
598
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * useNowTick — ONE 60-second clock, shared by every thread on the page.
3
+ *
4
+ * ADR-147 D16 and acceptance criterion 9: `Date.now()` is never called inside
5
+ * a render path in the thread primitive.
6
+ *
7
+ * Two things are wrong with the obvious `useState(() => Date.now())`:
8
+ *
9
+ * 1. A lazy state initialiser still executes during the component's first
10
+ * render, so the component is non-deterministic — CLAUDE.md §7 says tests
11
+ * must not be time-dependent without a frozen clock, and a component that
12
+ * reads the wall clock while rendering cannot be frozen from outside.
13
+ * 2. One interval per thread means N timers drifting against each other, so
14
+ * two timestamps that should both flip from "now" to "1m" do it on
15
+ * different frames.
16
+ *
17
+ * So the clock is a module-scope external store read through
18
+ * `useSyncExternalStore`. `Date.now()` appears exactly twice, in `tick()` and
19
+ * at module load — never in a component body. Every subscriber re-renders from
20
+ * the same value on the same frame, and a test freezes time by passing an
21
+ * explicit `now` (which disables the subscription entirely, so no timer runs
22
+ * and no fake-timer plumbing is needed).
23
+ */
24
+
25
+ import { useSyncExternalStore } from 'react'
26
+
27
+ const TICK_MS = 60_000
28
+
29
+ let current = Date.now()
30
+ let timer: ReturnType<typeof setInterval> | null = null
31
+ const listeners = new Set<() => void>()
32
+
33
+ function tick(): void {
34
+ current = Date.now()
35
+ for (const listener of listeners) listener()
36
+ }
37
+
38
+ function subscribe(listener: () => void): () => void {
39
+ listeners.add(listener)
40
+ if (timer === null) {
41
+ // The module may have been imported long before the first thread mounted,
42
+ // so refresh on the first subscriber rather than trusting load time.
43
+ // React re-reads the snapshot immediately after subscribing, so this is
44
+ // picked up without notifying mid-subscribe.
45
+ current = Date.now()
46
+ timer = setInterval(tick, TICK_MS)
47
+ }
48
+ return () => {
49
+ listeners.delete(listener)
50
+ if (listeners.size === 0 && timer !== null) {
51
+ clearInterval(timer)
52
+ timer = null
53
+ }
54
+ }
55
+ }
56
+
57
+ const noopSubscribe = (): (() => void) => () => {}
58
+
59
+ function getSnapshot(): number {
60
+ return current
61
+ }
62
+
63
+ /**
64
+ * @param enabled false when the caller injected its own `now` — no timer runs
65
+ * and the returned value is ignored. Frozen clock, zero side effects.
66
+ */
67
+ export function useNowTick(enabled: boolean): number {
68
+ return useSyncExternalStore(
69
+ enabled ? subscribe : noopSubscribe,
70
+ getSnapshot,
71
+ getSnapshot,
72
+ )
73
+ }