@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- 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
|
+
}
|