@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,1071 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer attachments — the rendered half (ADR-151 D1, D4, D5, D6).
|
|
3
|
+
*
|
|
4
|
+
* Four things live here, and they are together because they are one feature
|
|
5
|
+
* seen from four places: the tray of chips under the composer, the button and
|
|
6
|
+
* drop target that fill it, the GIF picker that fills it a different way, and
|
|
7
|
+
* the renderers a posted comment uses.
|
|
8
|
+
*
|
|
9
|
+
* ## Reserve the slot, always
|
|
10
|
+
*
|
|
11
|
+
* Every surface below declares its box before it has anything to put in it —
|
|
12
|
+
* the chip has a fixed height, the progress bar occupies its row whether or not
|
|
13
|
+
* it is moving, the thumbnail uses the declared `aspectRatio`, and a broken
|
|
14
|
+
* image becomes a placeholder inside the same box rather than a collapse. The
|
|
15
|
+
* layout is not allowed to depend on whether bytes arrived.
|
|
16
|
+
*
|
|
17
|
+
* ## Motion
|
|
18
|
+
*
|
|
19
|
+
* `prefers-reduced-motion` is not decoration here. An autoplaying wall of GIFs
|
|
20
|
+
* is the single most reduced-motion-hostile thing this product could ship
|
|
21
|
+
* (ADR-151 D4), so under that preference a GIF renders as a still frame with an
|
|
22
|
+
* explicit play control, and pressing play is a choice the person made. A
|
|
23
|
+
* Giphy pick stores an **mp4**, so its `<video>` can genuinely hold the first
|
|
24
|
+
* frame. An uploaded GIF stays `kind: 'image'`; its renderer snapshots the
|
|
25
|
+
* loaded `<img>` to a canvas once and shows that still until the person elects
|
|
26
|
+
* to swap the live image back in.
|
|
27
|
+
*
|
|
28
|
+
* ## "Powered by GIPHY"
|
|
29
|
+
*
|
|
30
|
+
* Rendered in the picker. `TODO(legal):` required by Giphy's terms; noted once
|
|
31
|
+
* here and in ADR-151 D4, not litigated.
|
|
32
|
+
*
|
|
33
|
+
* ## Why this is not `packages/ui/src/file-upload/*`
|
|
34
|
+
*
|
|
35
|
+
* That family already ships — `FileUploadButton`, `FileDropZone`,
|
|
36
|
+
* `FilePreviewGrid`, `useFileUpload` — and CLAUDE.md §2 is explicit that
|
|
37
|
+
* forking a primitive is a defect. This is a deliberate exception, and the
|
|
38
|
+
* reasons are specific rather than aesthetic:
|
|
39
|
+
*
|
|
40
|
+
* - **`useFileUpload` allows what ADR-151 D3 forbids.** Its accept list
|
|
41
|
+
* includes `text/html`, `application/javascript` and Office documents; D3
|
|
42
|
+
* enumerates nine formats and refuses everything else by name.
|
|
43
|
+
* - **It uploads directly.** It drives its own XHR to a destination; D1
|
|
44
|
+
* requires the two-step dance — bytes to the engine, then a server-verified
|
|
45
|
+
* ticket — and the tray's states are `uploading | ready | failed` against
|
|
46
|
+
* that ticket, not against a transfer.
|
|
47
|
+
* - **Its 20 MB cap is hardcoded** and disagrees with D3's 25 MB, and it has
|
|
48
|
+
* no per-comment total at all.
|
|
49
|
+
* - **It calls `toast` internally**, so a refusal is announced by the
|
|
50
|
+
* primitive rather than reported to the surface, and this composer needs
|
|
51
|
+
* the message inline on the chip.
|
|
52
|
+
* - Nothing in it has a concept of a pasted-text chip, an "Insert as text
|
|
53
|
+
* instead" undo, or a GIF.
|
|
54
|
+
*
|
|
55
|
+
* **What that fork costs, stated rather than discovered later:** `FileDropZone`
|
|
56
|
+
* recurses into dropped FOLDERS via `FileSystemEntry`; `AttachmentDropZone`
|
|
57
|
+
* takes `dataTransfer.files` only, so dropping a folder attaches nothing. If
|
|
58
|
+
* folder drop is wanted here, lift that traversal out of `file-upload/` into a
|
|
59
|
+
* shared helper rather than copying it a second time.
|
|
60
|
+
*
|
|
61
|
+
* Recorded in `docs/follow-ups/_pending/adr-151-attachments-open-edges.md` §8
|
|
62
|
+
* as well, because a fork that is only justified in a file header is a fork
|
|
63
|
+
* nobody finds.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import {
|
|
67
|
+
useCallback,
|
|
68
|
+
useEffect,
|
|
69
|
+
useId,
|
|
70
|
+
useRef,
|
|
71
|
+
useState,
|
|
72
|
+
type DragEvent,
|
|
73
|
+
type ReactNode,
|
|
74
|
+
} from 'react'
|
|
75
|
+
import { FileText, Image as ImageIcon, ImageOff, Paperclip, Play, Search, X } from 'lucide-react'
|
|
76
|
+
import { Button } from '../button'
|
|
77
|
+
import { Input } from '../input'
|
|
78
|
+
import { cn } from '../lib/utils'
|
|
79
|
+
import {
|
|
80
|
+
GIF_SEARCH_DEBOUNCE_MS,
|
|
81
|
+
useAttachmentUrl,
|
|
82
|
+
type ComposerAttachment,
|
|
83
|
+
} from './use-attachments'
|
|
84
|
+
import type { ThreadAttachment } from './types'
|
|
85
|
+
|
|
86
|
+
/* ── shared bits ────────────────────────────────────────────────────────── */
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The same quiet icon button `actions.tsx` uses, for the same reason.
|
|
90
|
+
*
|
|
91
|
+
* Exported (module-level, NOT from the package barrel) because `link-preview.tsx`
|
|
92
|
+
* needs the identical treatment for the identical reason: a 12px dismiss cross
|
|
93
|
+
* sitting inside an already-bordered card. Sharing the constant is what keeps
|
|
94
|
+
* "pick one hover treatment and use it for every interactive surface" true;
|
|
95
|
+
* `actions.tsx` holds a second, older copy of this pair, and a FOURTH consumer
|
|
96
|
+
* should promote the three of them into one module rather than copy it again.
|
|
97
|
+
*
|
|
98
|
+
* CLAUDE.md §2 bans `ghost` as the DEFAULT lesser button — "buttons must read
|
|
99
|
+
* as buttons" — with the exception it names being "genuinely chrome-dense
|
|
100
|
+
* contexts … inline icon toolbars". A 12px remove-cross inside a chip is that
|
|
101
|
+
* exception: a bordered button inside a bordered chip is two borders 4px apart,
|
|
102
|
+
* and the chip already reads as a button-shaped thing. The primary action of
|
|
103
|
+
* this whole surface is the composer's send, and it stays the only filled one.
|
|
104
|
+
*/
|
|
105
|
+
export const QUIET_ICON = cn(
|
|
106
|
+
'inline-flex shrink-0 items-center justify-center border-0 bg-transparent',
|
|
107
|
+
'text-[rgb(var(--text-tertiary))]',
|
|
108
|
+
'transition-[background-color,color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
109
|
+
'motion-reduce:transition-none',
|
|
110
|
+
'hover:bg-[rgb(var(--surface-overlay))] hover:text-[rgb(var(--foreground))]',
|
|
111
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
112
|
+
'disabled:cursor-default disabled:opacity-60',
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
/** The 24px hit-area floor from the interface standards §6, met by padding. */
|
|
116
|
+
export const QUIET_ICON_BOX = {
|
|
117
|
+
minBlockSize: 'var(--space-6)',
|
|
118
|
+
minInlineSize: 'var(--space-6)',
|
|
119
|
+
borderRadius: 'var(--radius-xs)',
|
|
120
|
+
} as const
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* "2,500 characters" — grouped, because the whole reason this chip exists is
|
|
124
|
+
* that the number is large, and an ungrouped five-digit run is the one shape a
|
|
125
|
+
* reader cannot size at a glance.
|
|
126
|
+
*/
|
|
127
|
+
export function formatCharacterCount(count: number): string {
|
|
128
|
+
const n = Math.max(0, Math.round(count))
|
|
129
|
+
return `${n.toLocaleString('en-US')} ${n === 1 ? 'character' : 'characters'}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function formatAttachmentSize(bytes: number): string {
|
|
133
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return ''
|
|
134
|
+
if (bytes < 1024) return `${String(bytes)} B`
|
|
135
|
+
if (bytes < 1024 * 1024) return `${String(Math.round(bytes / 1024))} KB`
|
|
136
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Does this viewer want less motion?
|
|
141
|
+
*
|
|
142
|
+
* Read as state and subscribed to, not read once: the preference can change
|
|
143
|
+
* while the page is open, and a GIF that keeps playing after someone turns
|
|
144
|
+
* reduced motion on has ignored them at the moment they asked.
|
|
145
|
+
*/
|
|
146
|
+
export function usePrefersReducedMotion(): boolean {
|
|
147
|
+
const [reduced, setReduced] = useState(false)
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
|
150
|
+
const query = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
151
|
+
setReduced(query.matches)
|
|
152
|
+
const onChange = (event: MediaQueryListEvent) => {
|
|
153
|
+
setReduced(event.matches)
|
|
154
|
+
}
|
|
155
|
+
query.addEventListener('change', onChange)
|
|
156
|
+
return () => {
|
|
157
|
+
query.removeEventListener('change', onChange)
|
|
158
|
+
}
|
|
159
|
+
}, [])
|
|
160
|
+
return reduced
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ── the tray (D1, D5) ──────────────────────────────────────────────────── */
|
|
164
|
+
|
|
165
|
+
export interface AttachmentTrayProps {
|
|
166
|
+
readonly attachments: readonly ComposerAttachment[]
|
|
167
|
+
readonly onRemove: (localId: string) => void
|
|
168
|
+
/**
|
|
169
|
+
* D5's undo. Given only for pasted-text chips; the action does not render on
|
|
170
|
+
* anything else, because there is nothing to restore.
|
|
171
|
+
*/
|
|
172
|
+
readonly onInsertAsText?: ((localId: string) => void) | undefined
|
|
173
|
+
readonly className?: string | undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The chips under the composer.
|
|
178
|
+
*
|
|
179
|
+
* Nothing here is a pill: chips are lightly-rounded bars, per the data-display
|
|
180
|
+
* recipe. The row reserves its own height so adding the first attachment does
|
|
181
|
+
* not shove the editor upward, and the progress bar keeps its track whether or
|
|
182
|
+
* not it is moving.
|
|
183
|
+
*/
|
|
184
|
+
export function AttachmentTray({
|
|
185
|
+
attachments,
|
|
186
|
+
onRemove,
|
|
187
|
+
onInsertAsText,
|
|
188
|
+
className,
|
|
189
|
+
}: AttachmentTrayProps) {
|
|
190
|
+
if (attachments.length === 0) return null
|
|
191
|
+
return (
|
|
192
|
+
<ul
|
|
193
|
+
className={cn('flex list-none flex-wrap gap-[var(--space-2)] p-0', className)}
|
|
194
|
+
style={{ marginBlockStart: 'var(--space-2)' }}
|
|
195
|
+
>
|
|
196
|
+
{attachments.map((attachment) => (
|
|
197
|
+
<AttachmentChip
|
|
198
|
+
key={attachment.localId}
|
|
199
|
+
attachment={attachment}
|
|
200
|
+
onRemove={onRemove}
|
|
201
|
+
{...(onInsertAsText === undefined ? {} : { onInsertAsText })}
|
|
202
|
+
/>
|
|
203
|
+
))}
|
|
204
|
+
</ul>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function AttachmentChip({
|
|
209
|
+
attachment,
|
|
210
|
+
onRemove,
|
|
211
|
+
onInsertAsText,
|
|
212
|
+
}: {
|
|
213
|
+
attachment: ComposerAttachment
|
|
214
|
+
onRemove: (localId: string) => void
|
|
215
|
+
onInsertAsText?: ((localId: string) => void) | undefined
|
|
216
|
+
}) {
|
|
217
|
+
const uploading = attachment.state === 'uploading'
|
|
218
|
+
const failed = attachment.state === 'failed'
|
|
219
|
+
const restorable = onInsertAsText !== undefined && attachment.pastedText !== undefined
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<li
|
|
223
|
+
className={cn(
|
|
224
|
+
'flex min-w-0 items-center gap-[var(--space-2)] border px-[var(--space-2)] py-[var(--space-1)]',
|
|
225
|
+
'text-[12px]',
|
|
226
|
+
)}
|
|
227
|
+
style={{
|
|
228
|
+
borderRadius: 'var(--radius-sm)',
|
|
229
|
+
borderColor: failed ? 'rgb(var(--destructive))' : 'rgb(var(--border))',
|
|
230
|
+
// `--muted`, not `--surface-overlay*`. CLAUDE.md §2 names chips as
|
|
231
|
+
// exactly what the flat opaque grey is for; the translucent overlay
|
|
232
|
+
// tints wash out when they stack on another layer, and a chip inside a
|
|
233
|
+
// composer inside a card is two layers deep already.
|
|
234
|
+
background: 'rgb(var(--muted))',
|
|
235
|
+
// The chip's height cannot depend on whether it holds a thumbnail, a
|
|
236
|
+
// progress bar or an error, or the composer would jump on every state
|
|
237
|
+
// change. One declared minimum, three contents.
|
|
238
|
+
minBlockSize: 'var(--ctrl-md)',
|
|
239
|
+
maxInlineSize: 'calc(var(--space-12) * 5)',
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
<AttachmentChipThumb attachment={attachment} />
|
|
243
|
+
|
|
244
|
+
<span className="flex min-w-0 flex-col">
|
|
245
|
+
<span className="truncate" style={{ color: 'rgb(var(--foreground))' }}>
|
|
246
|
+
{attachment.name}
|
|
247
|
+
</span>
|
|
248
|
+
<span
|
|
249
|
+
className="flex items-center gap-[var(--space-2)] tabular-nums"
|
|
250
|
+
style={{
|
|
251
|
+
color: failed ? 'rgb(var(--destructive))' : 'rgb(var(--text-tertiary))',
|
|
252
|
+
// Reserved so the second line exists before there is anything on it.
|
|
253
|
+
minBlockSize: 'var(--space-4)',
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
256
|
+
{failed ? (
|
|
257
|
+
<span className="truncate">{attachment.error ?? 'Upload failed'}</span>
|
|
258
|
+
) : uploading ? (
|
|
259
|
+
<AttachmentProgress value={attachment.progress} label={attachment.name} />
|
|
260
|
+
) : attachment.charCount === undefined ? (
|
|
261
|
+
<span>{formatAttachmentSize(attachment.sizeBytes)}</span>
|
|
262
|
+
) : (
|
|
263
|
+
// D5, verbatim: "the character count shown on the chip". A byte
|
|
264
|
+
// size would be the wrong unit here — the person pasted characters,
|
|
265
|
+
// and for anything non-ASCII the two numbers differ.
|
|
266
|
+
<span>{formatCharacterCount(attachment.charCount)}</span>
|
|
267
|
+
)}
|
|
268
|
+
</span>
|
|
269
|
+
</span>
|
|
270
|
+
|
|
271
|
+
{restorable ? (
|
|
272
|
+
<Button
|
|
273
|
+
type="button"
|
|
274
|
+
variant="secondary"
|
|
275
|
+
size="sm"
|
|
276
|
+
onClick={() => {
|
|
277
|
+
onInsertAsText?.(attachment.localId)
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
Insert as text instead
|
|
281
|
+
</Button>
|
|
282
|
+
) : null}
|
|
283
|
+
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
aria-label={uploading ? `Cancel ${attachment.name}` : `Remove ${attachment.name}`}
|
|
287
|
+
className={QUIET_ICON}
|
|
288
|
+
style={QUIET_ICON_BOX}
|
|
289
|
+
onClick={() => {
|
|
290
|
+
onRemove(attachment.localId)
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
<X size={12} strokeWidth={1.75} aria-hidden="true" />
|
|
294
|
+
</button>
|
|
295
|
+
</li>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A square thumbnail slot that exists whether or not there is an image in it. */
|
|
300
|
+
function AttachmentChipThumb({ attachment }: { attachment: ComposerAttachment }) {
|
|
301
|
+
const visual = attachment.kind === 'image' || attachment.kind === 'gif'
|
|
302
|
+
return (
|
|
303
|
+
<span
|
|
304
|
+
className="flex shrink-0 items-center justify-center overflow-hidden border"
|
|
305
|
+
style={{
|
|
306
|
+
inlineSize: 'var(--space-6)',
|
|
307
|
+
blockSize: 'var(--space-6)',
|
|
308
|
+
borderRadius: 'var(--radius-xs)',
|
|
309
|
+
borderColor: 'rgb(var(--border))',
|
|
310
|
+
background: 'rgb(var(--muted))',
|
|
311
|
+
color: 'rgb(var(--text-tertiary))',
|
|
312
|
+
}}
|
|
313
|
+
>
|
|
314
|
+
{visual && attachment.previewUrl !== undefined ? (
|
|
315
|
+
<img src={attachment.previewUrl} alt="" className="h-full w-full object-cover" />
|
|
316
|
+
) : visual ? (
|
|
317
|
+
<ImageIcon size={12} strokeWidth={1.5} aria-hidden="true" />
|
|
318
|
+
) : (
|
|
319
|
+
<FileText size={12} strokeWidth={1.5} aria-hidden="true" />
|
|
320
|
+
)}
|
|
321
|
+
</span>
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A determinate bar in a track that is always there.
|
|
327
|
+
*
|
|
328
|
+
* `role="progressbar"` with the real values, so a screen reader gets the number
|
|
329
|
+
* rather than a decorative div. The transition is on `inline-size` alone and
|
|
330
|
+
* drops out under reduced motion — this is a data change, and D-principles say
|
|
331
|
+
* data settles with ease-out and never springs.
|
|
332
|
+
*/
|
|
333
|
+
function AttachmentProgress({ value, label }: { value: number; label: string }) {
|
|
334
|
+
const percent = Math.round(Math.max(0, Math.min(1, value)) * 100)
|
|
335
|
+
return (
|
|
336
|
+
<span
|
|
337
|
+
role="progressbar"
|
|
338
|
+
aria-label={`Uploading ${label}`}
|
|
339
|
+
aria-valuemin={0}
|
|
340
|
+
aria-valuemax={100}
|
|
341
|
+
aria-valuenow={percent}
|
|
342
|
+
className="block overflow-hidden"
|
|
343
|
+
style={{
|
|
344
|
+
inlineSize: 'calc(var(--space-12) * 2)',
|
|
345
|
+
blockSize: 'var(--space-1)',
|
|
346
|
+
borderRadius: 'var(--radius-full)',
|
|
347
|
+
background: 'rgb(var(--muted))',
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
350
|
+
<span
|
|
351
|
+
className="block transition-[inline-size] duration-[var(--dur-base)] ease-[var(--ease-out)] motion-reduce:transition-none"
|
|
352
|
+
style={{
|
|
353
|
+
inlineSize: `${String(percent)}%`,
|
|
354
|
+
blockSize: '100%',
|
|
355
|
+
background: 'rgb(var(--accent))',
|
|
356
|
+
}}
|
|
357
|
+
/>
|
|
358
|
+
</span>
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* ── the button + the drop target (D1) ──────────────────────────────────── */
|
|
363
|
+
|
|
364
|
+
export interface AttachmentButtonProps {
|
|
365
|
+
readonly onFiles: (files: FileList) => void
|
|
366
|
+
/** `accept` for the file input. Mirrors the server's extension allowlist. */
|
|
367
|
+
readonly accept?: string | undefined
|
|
368
|
+
readonly disabled?: boolean | undefined
|
|
369
|
+
readonly label?: string | undefined
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The paperclip. A real `<input type="file">` behind a labelled button. */
|
|
373
|
+
export function AttachmentButton({
|
|
374
|
+
onFiles,
|
|
375
|
+
accept,
|
|
376
|
+
disabled = false,
|
|
377
|
+
label = 'Attach a file',
|
|
378
|
+
}: AttachmentButtonProps) {
|
|
379
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
380
|
+
const inputId = useId()
|
|
381
|
+
return (
|
|
382
|
+
<>
|
|
383
|
+
<input
|
|
384
|
+
ref={inputRef}
|
|
385
|
+
id={inputId}
|
|
386
|
+
type="file"
|
|
387
|
+
multiple
|
|
388
|
+
hidden
|
|
389
|
+
{...(accept === undefined ? {} : { accept })}
|
|
390
|
+
onChange={(event) => {
|
|
391
|
+
const { files } = event.currentTarget
|
|
392
|
+
if (files !== null && files.length > 0) onFiles(files)
|
|
393
|
+
// Reset so re-choosing the SAME file fires `change` again.
|
|
394
|
+
event.currentTarget.value = ''
|
|
395
|
+
}}
|
|
396
|
+
/>
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
aria-label={label}
|
|
400
|
+
className={QUIET_ICON}
|
|
401
|
+
style={QUIET_ICON_BOX}
|
|
402
|
+
disabled={disabled}
|
|
403
|
+
onClick={() => {
|
|
404
|
+
inputRef.current?.click()
|
|
405
|
+
}}
|
|
406
|
+
>
|
|
407
|
+
<Paperclip size={14} strokeWidth={1.75} aria-hidden="true" />
|
|
408
|
+
</button>
|
|
409
|
+
</>
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export interface AttachmentDropZoneProps {
|
|
414
|
+
readonly onFiles: (files: FileList) => void
|
|
415
|
+
readonly disabled?: boolean | undefined
|
|
416
|
+
readonly children: ReactNode
|
|
417
|
+
readonly className?: string | undefined
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Wrap the composer to accept a drop.
|
|
422
|
+
*
|
|
423
|
+
* The overlay is `visibility`-toggled inside a slot that is always present, so
|
|
424
|
+
* a drag does not resize the composer — same reserve-the-slot rule as
|
|
425
|
+
* everything else here. The drag counter exists because `dragleave` fires when
|
|
426
|
+
* the pointer crosses a CHILD boundary, and a naive boolean flickers the
|
|
427
|
+
* overlay every time the cursor passes over the toolbar.
|
|
428
|
+
*/
|
|
429
|
+
export function AttachmentDropZone({
|
|
430
|
+
onFiles,
|
|
431
|
+
disabled = false,
|
|
432
|
+
children,
|
|
433
|
+
className,
|
|
434
|
+
}: AttachmentDropZoneProps) {
|
|
435
|
+
const depth = useRef(0)
|
|
436
|
+
const [over, setOver] = useState(false)
|
|
437
|
+
|
|
438
|
+
const onDragEnter = useCallback(
|
|
439
|
+
(event: DragEvent<HTMLDivElement>) => {
|
|
440
|
+
if (disabled || !Array.from(event.dataTransfer.types).includes('Files')) return
|
|
441
|
+
depth.current += 1
|
|
442
|
+
setOver(true)
|
|
443
|
+
},
|
|
444
|
+
[disabled],
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
const onDragLeave = useCallback(() => {
|
|
448
|
+
depth.current = Math.max(0, depth.current - 1)
|
|
449
|
+
if (depth.current === 0) setOver(false)
|
|
450
|
+
}, [])
|
|
451
|
+
|
|
452
|
+
return (
|
|
453
|
+
<div
|
|
454
|
+
className={cn('relative', className)}
|
|
455
|
+
onDragEnter={onDragEnter}
|
|
456
|
+
onDragOver={(event) => {
|
|
457
|
+
if (!disabled && Array.from(event.dataTransfer.types).includes('Files')) {
|
|
458
|
+
event.preventDefault()
|
|
459
|
+
}
|
|
460
|
+
}}
|
|
461
|
+
onDragLeave={onDragLeave}
|
|
462
|
+
onDrop={(event) => {
|
|
463
|
+
depth.current = 0
|
|
464
|
+
setOver(false)
|
|
465
|
+
if (disabled) return
|
|
466
|
+
const { files } = event.dataTransfer
|
|
467
|
+
if (files.length === 0) return
|
|
468
|
+
event.preventDefault()
|
|
469
|
+
onFiles(files)
|
|
470
|
+
}}
|
|
471
|
+
>
|
|
472
|
+
{children}
|
|
473
|
+
<div
|
|
474
|
+
aria-hidden="true"
|
|
475
|
+
className="pointer-events-none absolute inset-0 flex items-center justify-center border border-dashed transition-opacity duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none"
|
|
476
|
+
style={{
|
|
477
|
+
visibility: over ? 'visible' : 'hidden',
|
|
478
|
+
opacity: over ? 1 : 0,
|
|
479
|
+
borderRadius: 'var(--radius-md)',
|
|
480
|
+
borderColor: 'rgb(var(--accent))',
|
|
481
|
+
background: 'rgb(var(--accent-subtle))',
|
|
482
|
+
color: 'rgb(var(--accent-ink))',
|
|
483
|
+
}}
|
|
484
|
+
>
|
|
485
|
+
Drop to attach
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/* ── the GIF picker (D4) ────────────────────────────────────────────────── */
|
|
492
|
+
|
|
493
|
+
export interface GifPickerResult {
|
|
494
|
+
readonly id: string
|
|
495
|
+
readonly title: string
|
|
496
|
+
readonly previewUrl: string
|
|
497
|
+
readonly stillUrl: string
|
|
498
|
+
readonly width: number
|
|
499
|
+
readonly height: number
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export interface GifPickerProps {
|
|
503
|
+
/** Injected by the host — the primitive owns no fetch client (ADR-147 D5). */
|
|
504
|
+
readonly search: (query: string) => Promise<readonly GifPickerResult[]>
|
|
505
|
+
readonly onPick: (gif: GifPickerResult) => void
|
|
506
|
+
readonly className?: string | undefined
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Type-to-search, debounced, with the still frame under reduced motion.
|
|
511
|
+
*
|
|
512
|
+
* The debounce is `GIF_SEARCH_DEBOUNCE_MS` and it is load-bearing rather than
|
|
513
|
+
* polish: Giphy's free tier is 100 requests/hour and an undebounced picker
|
|
514
|
+
* spends it in a minute.
|
|
515
|
+
*
|
|
516
|
+
* The grid reserves its own height so results appearing does not shove the
|
|
517
|
+
* composer, and every tile declares its aspect ratio so the grid does not
|
|
518
|
+
* reflow as renditions load at different sizes.
|
|
519
|
+
*/
|
|
520
|
+
export function GifPicker({ search, onPick, className }: GifPickerProps) {
|
|
521
|
+
const [query, setQuery] = useState('')
|
|
522
|
+
const [results, setResults] = useState<readonly GifPickerResult[]>([])
|
|
523
|
+
const [state, setState] = useState<'idle' | 'searching' | 'error'>('idle')
|
|
524
|
+
const reduced = usePrefersReducedMotion()
|
|
525
|
+
const listId = useId()
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* `search` is read through a ref and is NOT a dependency — the same hazard
|
|
529
|
+
* `useAttachmentUrl` documents. A host writing
|
|
530
|
+
* `search={(q) => api.searchGifs(brandId, q)}` hands over a new identity every
|
|
531
|
+
* render; with that in the dependency array this effect would re-run, call
|
|
532
|
+
* `setState('searching')`, re-render, and re-run forever. The query is what
|
|
533
|
+
* identifies a search, and it is the only thing that should restart one.
|
|
534
|
+
*/
|
|
535
|
+
const searchRef = useRef(search)
|
|
536
|
+
searchRef.current = search
|
|
537
|
+
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
const trimmed = query.trim()
|
|
540
|
+
if (trimmed.length === 0) {
|
|
541
|
+
setResults([])
|
|
542
|
+
setState('idle')
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
let live = true
|
|
546
|
+
setState('searching')
|
|
547
|
+
const timer = setTimeout(() => {
|
|
548
|
+
void searchRef
|
|
549
|
+
.current(trimmed)
|
|
550
|
+
.then((next) => {
|
|
551
|
+
if (!live) return
|
|
552
|
+
setResults(next)
|
|
553
|
+
setState('idle')
|
|
554
|
+
})
|
|
555
|
+
.catch(() => {
|
|
556
|
+
if (!live) return
|
|
557
|
+
setResults([])
|
|
558
|
+
setState('error')
|
|
559
|
+
})
|
|
560
|
+
}, GIF_SEARCH_DEBOUNCE_MS)
|
|
561
|
+
return () => {
|
|
562
|
+
live = false
|
|
563
|
+
clearTimeout(timer)
|
|
564
|
+
}
|
|
565
|
+
}, [query])
|
|
566
|
+
|
|
567
|
+
return (
|
|
568
|
+
<div className={cn('flex flex-col gap-[var(--space-3)]', className)}>
|
|
569
|
+
<Input
|
|
570
|
+
value={query}
|
|
571
|
+
onChange={(event) => {
|
|
572
|
+
setQuery(event.currentTarget.value)
|
|
573
|
+
}}
|
|
574
|
+
placeholder="Search GIFs"
|
|
575
|
+
aria-label="Search GIFs"
|
|
576
|
+
aria-controls={listId}
|
|
577
|
+
leadingAffix={<Search size={14} strokeWidth={1.75} aria-hidden="true" />}
|
|
578
|
+
/>
|
|
579
|
+
|
|
580
|
+
<div
|
|
581
|
+
id={listId}
|
|
582
|
+
className="grid grid-cols-3 gap-[var(--space-2)] overflow-y-auto"
|
|
583
|
+
style={{
|
|
584
|
+
// Declared, so the panel is the same size empty, searching and full.
|
|
585
|
+
blockSize: 'calc(var(--space-12) * 6)',
|
|
586
|
+
}}
|
|
587
|
+
>
|
|
588
|
+
{results.map((gif) => (
|
|
589
|
+
<button
|
|
590
|
+
key={gif.id}
|
|
591
|
+
type="button"
|
|
592
|
+
onClick={() => {
|
|
593
|
+
onPick(gif)
|
|
594
|
+
}}
|
|
595
|
+
className="block overflow-hidden border transition-[border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:border-[rgb(var(--border-strong))] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)] motion-reduce:transition-none"
|
|
596
|
+
style={{
|
|
597
|
+
borderRadius: 'var(--radius-sm)',
|
|
598
|
+
borderColor: 'rgb(var(--border))',
|
|
599
|
+
background: 'rgb(var(--muted))',
|
|
600
|
+
aspectRatio: gif.height > 0 ? String(gif.width / gif.height) : '1',
|
|
601
|
+
}}
|
|
602
|
+
>
|
|
603
|
+
{reduced ? (
|
|
604
|
+
<img src={gif.stillUrl} alt={gif.title} className="h-full w-full object-cover" />
|
|
605
|
+
) : (
|
|
606
|
+
<video
|
|
607
|
+
src={gif.previewUrl}
|
|
608
|
+
poster={gif.stillUrl}
|
|
609
|
+
autoPlay
|
|
610
|
+
loop
|
|
611
|
+
muted
|
|
612
|
+
playsInline
|
|
613
|
+
aria-label={gif.title}
|
|
614
|
+
className="h-full w-full object-cover"
|
|
615
|
+
/>
|
|
616
|
+
)}
|
|
617
|
+
</button>
|
|
618
|
+
))}
|
|
619
|
+
{results.length === 0 ? (
|
|
620
|
+
<p
|
|
621
|
+
className="col-span-3 self-center text-center text-[12px]"
|
|
622
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
623
|
+
>
|
|
624
|
+
{state === 'error'
|
|
625
|
+
? "GIF search isn't available right now."
|
|
626
|
+
: state === 'searching'
|
|
627
|
+
? 'Searching…'
|
|
628
|
+
: query.trim().length === 0
|
|
629
|
+
? 'Type to search.'
|
|
630
|
+
: 'No GIFs matched.'}
|
|
631
|
+
</p>
|
|
632
|
+
) : null}
|
|
633
|
+
</div>
|
|
634
|
+
|
|
635
|
+
{/* TODO(legal): "Powered by GIPHY" attribution is required by Giphy's
|
|
636
|
+
terms of service. Noted here and in ADR-151 D4. */}
|
|
637
|
+
<p
|
|
638
|
+
className="text-[11px] uppercase"
|
|
639
|
+
style={{ color: 'rgb(var(--text-tertiary))', letterSpacing: '0.06em' }}
|
|
640
|
+
>
|
|
641
|
+
Powered by GIPHY
|
|
642
|
+
</p>
|
|
643
|
+
</div>
|
|
644
|
+
)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/* ── posted-comment rendering (D6) ──────────────────────────────────────── */
|
|
648
|
+
|
|
649
|
+
export interface AttachmentGifProps {
|
|
650
|
+
readonly attachment: ThreadAttachment
|
|
651
|
+
/** Resolves the short-lived URL. Omit and the placeholder renders instead. */
|
|
652
|
+
readonly resolveUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
|
|
653
|
+
readonly className?: string | undefined
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* A GIF in a posted comment, under `prefers-reduced-motion`.
|
|
658
|
+
*
|
|
659
|
+
* The stored rendition is an mp4, so "still frame with a play control" is a
|
|
660
|
+
* `<video>` that has not been told to play: it paints its poster frame and sits
|
|
661
|
+
* there. Pressing play is an explicit choice, and the control disappears once
|
|
662
|
+
* it is playing because at that point the video's own affordances take over.
|
|
663
|
+
*
|
|
664
|
+
* Without the preference it autoplays muted and looping, which is what a GIF
|
|
665
|
+
* is. With it, nothing moves until asked.
|
|
666
|
+
*/
|
|
667
|
+
export function AttachmentGif({ attachment, resolveUrl, className }: AttachmentGifProps) {
|
|
668
|
+
const reduced = usePrefersReducedMotion()
|
|
669
|
+
const { url, failed, loading } = useAttachmentUrl(attachment.id, resolveUrl)
|
|
670
|
+
const [playing, setPlaying] = useState(false)
|
|
671
|
+
const videoRef = useRef<HTMLVideoElement>(null)
|
|
672
|
+
|
|
673
|
+
// The preference can flip while the comment is on screen. Honouring it only
|
|
674
|
+
// at mount would leave a GIF looping at exactly the moment someone asked for
|
|
675
|
+
// less motion.
|
|
676
|
+
useEffect(() => {
|
|
677
|
+
if (reduced) setPlaying(false)
|
|
678
|
+
}, [reduced])
|
|
679
|
+
|
|
680
|
+
const shouldPlay = !reduced || playing
|
|
681
|
+
|
|
682
|
+
if (url === null || failed) {
|
|
683
|
+
return (
|
|
684
|
+
<AttachmentPlaceholder
|
|
685
|
+
attachment={attachment}
|
|
686
|
+
state={loading && !failed ? 'loading' : 'broken'}
|
|
687
|
+
className={className}
|
|
688
|
+
/>
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return (
|
|
693
|
+
<span
|
|
694
|
+
className={cn('relative block overflow-hidden border', className)}
|
|
695
|
+
style={{
|
|
696
|
+
aspectRatio: String(attachment.aspectRatio ?? 16 / 10),
|
|
697
|
+
borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
|
|
698
|
+
borderColor: 'rgb(var(--border))',
|
|
699
|
+
background: 'rgb(var(--surface-inset))',
|
|
700
|
+
inlineSize: 'calc(var(--space-12) * 3)',
|
|
701
|
+
minInlineSize: 'calc(var(--space-12) * 3)',
|
|
702
|
+
}}
|
|
703
|
+
>
|
|
704
|
+
<video
|
|
705
|
+
ref={videoRef}
|
|
706
|
+
src={url}
|
|
707
|
+
loop
|
|
708
|
+
muted
|
|
709
|
+
playsInline
|
|
710
|
+
autoPlay={shouldPlay}
|
|
711
|
+
aria-label={attachment.name}
|
|
712
|
+
className="h-full w-full object-cover"
|
|
713
|
+
/>
|
|
714
|
+
{shouldPlay ? null : (
|
|
715
|
+
<button
|
|
716
|
+
type="button"
|
|
717
|
+
aria-label={`Play ${attachment.name}`}
|
|
718
|
+
onClick={() => {
|
|
719
|
+
setPlaying(true)
|
|
720
|
+
void videoRef.current?.play()
|
|
721
|
+
}}
|
|
722
|
+
className="absolute inset-0 flex items-center justify-center focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]"
|
|
723
|
+
style={{ background: 'rgb(var(--media-scrim-mid))', color: 'rgb(var(--media-on-scrim))' }}
|
|
724
|
+
>
|
|
725
|
+
<Play size={20} strokeWidth={1.75} aria-hidden="true" />
|
|
726
|
+
</button>
|
|
727
|
+
)}
|
|
728
|
+
</span>
|
|
729
|
+
)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* The box an image keeps when it has nothing to show.
|
|
734
|
+
*
|
|
735
|
+
* D6: "a broken image is a placeholder with a small icon, never a collapsed
|
|
736
|
+
* box". The dimensions come from the DECLARED aspect ratio, which is why this
|
|
737
|
+
* is the same size as the image would have been.
|
|
738
|
+
*/
|
|
739
|
+
export function AttachmentPlaceholder({
|
|
740
|
+
attachment,
|
|
741
|
+
state = 'broken',
|
|
742
|
+
className,
|
|
743
|
+
}: {
|
|
744
|
+
attachment: ThreadAttachment
|
|
745
|
+
/**
|
|
746
|
+
* `loading` while the URL is still being resolved, `broken` once it is known
|
|
747
|
+
* not to be coming. Same BOX either way — the layout must not depend on which
|
|
748
|
+
* — but not the same picture: the broken icon is a statement about the file,
|
|
749
|
+
* and saying it while the answer is still in flight is saying something
|
|
750
|
+
* untrue about someone's own comment.
|
|
751
|
+
*/
|
|
752
|
+
state?: 'loading' | 'broken'
|
|
753
|
+
className?: string | undefined
|
|
754
|
+
}) {
|
|
755
|
+
return (
|
|
756
|
+
<span
|
|
757
|
+
className={cn('flex items-center justify-center overflow-hidden border', className)}
|
|
758
|
+
style={{
|
|
759
|
+
aspectRatio: String(attachment.aspectRatio ?? 16 / 10),
|
|
760
|
+
borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
|
|
761
|
+
borderColor: 'rgb(var(--border))',
|
|
762
|
+
background: 'rgb(var(--surface-inset))',
|
|
763
|
+
color: 'rgb(var(--text-tertiary))',
|
|
764
|
+
inlineSize: 'calc(var(--space-12) * 3)',
|
|
765
|
+
minInlineSize: 'calc(var(--space-12) * 3)',
|
|
766
|
+
}}
|
|
767
|
+
aria-label={state === 'loading' ? `Loading ${attachment.name}` : undefined}
|
|
768
|
+
>
|
|
769
|
+
{state === 'broken' ? <ImageOff size={14} strokeWidth={1.5} aria-hidden="true" /> : null}
|
|
770
|
+
</span>
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export interface AttachmentImageProps {
|
|
775
|
+
readonly attachment: ThreadAttachment
|
|
776
|
+
readonly onOpen?: ((attachment: ThreadAttachment) => void) | undefined
|
|
777
|
+
/** Resolves the short-lived URL. Omit and the placeholder renders instead. */
|
|
778
|
+
readonly resolveUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
|
|
779
|
+
readonly className?: string | undefined
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* A posted image with a stable reserved box and a real reduced-motion path for
|
|
784
|
+
* uploaded GIFs.
|
|
785
|
+
*
|
|
786
|
+
* Uploaded GIFs intentionally remain `kind: 'image'`: unlike a Giphy mp4, the
|
|
787
|
+
* stored file cannot be paused. Its MIME type lets this renderer capture the
|
|
788
|
+
* loaded image into a canvas once. Under reduced motion the live image is
|
|
789
|
+
* hidden behind that still; the labelled play button swaps it back only after
|
|
790
|
+
* an explicit choice.
|
|
791
|
+
*/
|
|
792
|
+
export function AttachmentImage({
|
|
793
|
+
attachment,
|
|
794
|
+
onOpen,
|
|
795
|
+
resolveUrl,
|
|
796
|
+
className,
|
|
797
|
+
}: AttachmentImageProps) {
|
|
798
|
+
const reduced = usePrefersReducedMotion()
|
|
799
|
+
const [broken, setBroken] = useState(false)
|
|
800
|
+
const [playing, setPlaying] = useState(false)
|
|
801
|
+
const [capturedSrc, setCapturedSrc] = useState<string | null>(null)
|
|
802
|
+
const imageRef = useRef<HTMLImageElement>(null)
|
|
803
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
804
|
+
const capturedSrcRef = useRef<string | null>(null)
|
|
805
|
+
|
|
806
|
+
// Two ways a picture can be absent, and they are NOT the same: `url` on the
|
|
807
|
+
// attachment (a host that already has one) versus a lazy resolve. Both end at
|
|
808
|
+
// the same placeholder, which is why the box is declared above either.
|
|
809
|
+
const resolved = useAttachmentUrl(attachment.id, attachment.url === undefined ? resolveUrl : undefined)
|
|
810
|
+
const src = attachment.url ?? resolved.url ?? undefined
|
|
811
|
+
const uploadedGif = attachment.mime?.toLowerCase() === 'image/gif'
|
|
812
|
+
|
|
813
|
+
const captureStill = useCallback(
|
|
814
|
+
(image: HTMLImageElement) => {
|
|
815
|
+
if (!uploadedGif || src === undefined || capturedSrcRef.current === src) return
|
|
816
|
+
const canvas = canvasRef.current
|
|
817
|
+
const width = image.naturalWidth
|
|
818
|
+
const height = image.naturalHeight
|
|
819
|
+
if (canvas === null || width <= 0 || height <= 0) return
|
|
820
|
+
canvas.width = width
|
|
821
|
+
canvas.height = height
|
|
822
|
+
const context = canvas.getContext('2d')
|
|
823
|
+
if (context === null) return
|
|
824
|
+
context.drawImage(image, 0, 0, width, height)
|
|
825
|
+
capturedSrcRef.current = src
|
|
826
|
+
setCapturedSrc(src)
|
|
827
|
+
},
|
|
828
|
+
[src, uploadedGif],
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
// The preference can flip while the comment is mounted. Capture normally
|
|
832
|
+
// happens on load; the complete check also covers a cached image whose load
|
|
833
|
+
// event preceded the preference change.
|
|
834
|
+
useEffect(() => {
|
|
835
|
+
if (!reduced) return
|
|
836
|
+
setPlaying(false)
|
|
837
|
+
const image = imageRef.current
|
|
838
|
+
if (image?.complete === true) captureStill(image)
|
|
839
|
+
}, [captureStill, reduced])
|
|
840
|
+
|
|
841
|
+
const missing = broken || resolved.failed || src === undefined
|
|
842
|
+
// Still resolving is NOT the same as broken. Both keep the reserved box; only
|
|
843
|
+
// one of them says the file is gone.
|
|
844
|
+
const pending = resolved.loading && !broken && !resolved.failed
|
|
845
|
+
const interactive = onOpen !== undefined && !missing
|
|
846
|
+
const showStill = uploadedGif && reduced && !playing && !missing
|
|
847
|
+
|
|
848
|
+
const body = pending ? null : missing ? (
|
|
849
|
+
<span
|
|
850
|
+
className="flex h-full w-full items-center justify-center"
|
|
851
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
852
|
+
>
|
|
853
|
+
<ImageOff size={14} strokeWidth={1.5} aria-hidden="true" />
|
|
854
|
+
</span>
|
|
855
|
+
) : (
|
|
856
|
+
<span className="relative block h-full w-full">
|
|
857
|
+
<img
|
|
858
|
+
ref={imageRef}
|
|
859
|
+
src={src}
|
|
860
|
+
alt={attachment.name}
|
|
861
|
+
loading="lazy"
|
|
862
|
+
className="h-full w-full object-cover"
|
|
863
|
+
style={{ visibility: showStill ? 'hidden' : 'visible' }}
|
|
864
|
+
onLoad={(event) => {
|
|
865
|
+
captureStill(event.currentTarget)
|
|
866
|
+
}}
|
|
867
|
+
onError={() => {
|
|
868
|
+
setBroken(true)
|
|
869
|
+
}}
|
|
870
|
+
/>
|
|
871
|
+
{uploadedGif ? (
|
|
872
|
+
<canvas
|
|
873
|
+
ref={canvasRef}
|
|
874
|
+
aria-hidden="true"
|
|
875
|
+
hidden={!showStill || capturedSrc !== src}
|
|
876
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
877
|
+
/>
|
|
878
|
+
) : null}
|
|
879
|
+
</span>
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
return (
|
|
883
|
+
<span
|
|
884
|
+
className={cn(
|
|
885
|
+
'relative block overflow-hidden border',
|
|
886
|
+
'transition-[border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none',
|
|
887
|
+
interactive && !showStill && 'hover:border-[rgb(var(--border-strong))]',
|
|
888
|
+
(interactive || showStill) &&
|
|
889
|
+
'focus-within:outline-none focus-within:[box-shadow:var(--ring-focus)]',
|
|
890
|
+
className,
|
|
891
|
+
)}
|
|
892
|
+
style={{
|
|
893
|
+
// Declared up front so the row cannot reflow when the bytes land.
|
|
894
|
+
aspectRatio: String(attachment.aspectRatio ?? 16 / 10),
|
|
895
|
+
borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
|
|
896
|
+
borderColor: 'rgb(var(--border))',
|
|
897
|
+
background: 'rgb(var(--surface-inset))',
|
|
898
|
+
inlineSize: 'calc(var(--space-12) * 3)',
|
|
899
|
+
minInlineSize: 'calc(var(--space-12) * 3)',
|
|
900
|
+
}}
|
|
901
|
+
>
|
|
902
|
+
{interactive ? (
|
|
903
|
+
<button
|
|
904
|
+
type="button"
|
|
905
|
+
aria-label={`Open ${attachment.name}`}
|
|
906
|
+
aria-hidden={showStill ? true : undefined}
|
|
907
|
+
disabled={showStill}
|
|
908
|
+
onClick={() => {
|
|
909
|
+
onOpen?.(attachment)
|
|
910
|
+
}}
|
|
911
|
+
className="block h-full w-full focus-visible:outline-none"
|
|
912
|
+
>
|
|
913
|
+
{body}
|
|
914
|
+
</button>
|
|
915
|
+
) : (
|
|
916
|
+
body
|
|
917
|
+
)}
|
|
918
|
+
{showStill ? (
|
|
919
|
+
<button
|
|
920
|
+
type="button"
|
|
921
|
+
aria-label={`Play ${attachment.name}`}
|
|
922
|
+
onClick={() => {
|
|
923
|
+
setPlaying(true)
|
|
924
|
+
}}
|
|
925
|
+
className="absolute inset-0 flex items-center justify-center focus-visible:outline-none"
|
|
926
|
+
style={{ background: 'rgb(var(--media-scrim-mid))', color: 'rgb(var(--media-on-scrim))' }}
|
|
927
|
+
>
|
|
928
|
+
<Play size={20} strokeWidth={1.75} aria-hidden="true" />
|
|
929
|
+
</button>
|
|
930
|
+
) : null}
|
|
931
|
+
</span>
|
|
932
|
+
)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/* ── the lightbox (D6) ──────────────────────────────────────────────────── */
|
|
936
|
+
|
|
937
|
+
export interface AttachmentLightboxProps {
|
|
938
|
+
readonly attachment: ThreadAttachment | null
|
|
939
|
+
readonly onClose: () => void
|
|
940
|
+
readonly resolveUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Full-size view of one attachment.
|
|
945
|
+
*
|
|
946
|
+
* Deliberately small: a scrim, the image, a close button, and `Escape`. It is
|
|
947
|
+
* not a gallery — ADR-151 says "click opens a lightbox", and a carousel with
|
|
948
|
+
* keyboard paging is a different feature with a different accessibility
|
|
949
|
+
* surface.
|
|
950
|
+
*
|
|
951
|
+
* Focus moves to the close button on open, because otherwise `Escape` is the
|
|
952
|
+
* only way out for a keyboard and the focus ring is somewhere behind the scrim.
|
|
953
|
+
*/
|
|
954
|
+
export function AttachmentLightbox({ attachment, onClose, resolveUrl }: AttachmentLightboxProps) {
|
|
955
|
+
const closeRef = useRef<HTMLButtonElement>(null)
|
|
956
|
+
const id = attachment?.id ?? ''
|
|
957
|
+
const { url, failed } = useAttachmentUrl(id, attachment === null ? undefined : resolveUrl)
|
|
958
|
+
|
|
959
|
+
const surfaceRef = useRef<HTMLDivElement>(null)
|
|
960
|
+
|
|
961
|
+
useEffect(() => {
|
|
962
|
+
if (attachment === null) return
|
|
963
|
+
closeRef.current?.focus()
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* `aria-modal="true"` asserts that nothing behind the scrim is reachable.
|
|
967
|
+
* Without a trap that assertion is false — Tab walks straight out into a
|
|
968
|
+
* page a sighted user can see is covered and a screen-reader user has been
|
|
969
|
+
* told is not there. Focus is therefore wrapped rather than merely placed.
|
|
970
|
+
*
|
|
971
|
+
* The dialog holds two focusables at most (the close button, and a video's
|
|
972
|
+
* controls), so a full focus-trap library would be more machinery than the
|
|
973
|
+
* problem: collect what is focusable at the moment Tab is pressed, and
|
|
974
|
+
* wrap at the ends.
|
|
975
|
+
*/
|
|
976
|
+
const onKey = (event: KeyboardEvent) => {
|
|
977
|
+
if (event.key === 'Escape') {
|
|
978
|
+
onClose()
|
|
979
|
+
return
|
|
980
|
+
}
|
|
981
|
+
if (event.key !== 'Tab') return
|
|
982
|
+
const root = surfaceRef.current
|
|
983
|
+
if (root === null) return
|
|
984
|
+
const focusable = Array.from(
|
|
985
|
+
root.querySelectorAll<HTMLElement>(
|
|
986
|
+
'a[href], button:not([disabled]), video[controls], [tabindex]:not([tabindex="-1"])',
|
|
987
|
+
),
|
|
988
|
+
)
|
|
989
|
+
if (focusable.length === 0) {
|
|
990
|
+
event.preventDefault()
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
const first = focusable[0]
|
|
994
|
+
const last = focusable[focusable.length - 1]
|
|
995
|
+
const active = document.activeElement
|
|
996
|
+
if (event.shiftKey && (active === first || !root.contains(active))) {
|
|
997
|
+
event.preventDefault()
|
|
998
|
+
last?.focus()
|
|
999
|
+
} else if (!event.shiftKey && (active === last || !root.contains(active))) {
|
|
1000
|
+
event.preventDefault()
|
|
1001
|
+
first?.focus()
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
document.addEventListener('keydown', onKey)
|
|
1005
|
+
return () => {
|
|
1006
|
+
document.removeEventListener('keydown', onKey)
|
|
1007
|
+
}
|
|
1008
|
+
}, [attachment, onClose])
|
|
1009
|
+
|
|
1010
|
+
if (attachment === null) return null
|
|
1011
|
+
|
|
1012
|
+
return (
|
|
1013
|
+
<div
|
|
1014
|
+
role="dialog"
|
|
1015
|
+
aria-modal="true"
|
|
1016
|
+
aria-label={attachment.name}
|
|
1017
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-[var(--space-6)]"
|
|
1018
|
+
style={{ background: 'rgb(var(--media-scrim-strong))' }}
|
|
1019
|
+
onClick={onClose}
|
|
1020
|
+
>
|
|
1021
|
+
<div
|
|
1022
|
+
ref={surfaceRef}
|
|
1023
|
+
className="relative flex max-h-full max-w-full items-center justify-center"
|
|
1024
|
+
onClick={(event) => {
|
|
1025
|
+
event.stopPropagation()
|
|
1026
|
+
}}
|
|
1027
|
+
>
|
|
1028
|
+
{url === null || failed ? (
|
|
1029
|
+
<p style={{ color: 'rgb(var(--media-on-scrim))' }}>
|
|
1030
|
+
{failed ? "This file isn't available." : 'Loading…'}
|
|
1031
|
+
</p>
|
|
1032
|
+
) : attachment.kind === 'gif' ? (
|
|
1033
|
+
<video
|
|
1034
|
+
src={url}
|
|
1035
|
+
controls
|
|
1036
|
+
loop
|
|
1037
|
+
muted
|
|
1038
|
+
playsInline
|
|
1039
|
+
aria-label={attachment.name}
|
|
1040
|
+
className="max-h-full max-w-full"
|
|
1041
|
+
/>
|
|
1042
|
+
) : (
|
|
1043
|
+
<img src={url} alt={attachment.name} className="max-h-full max-w-full object-contain" />
|
|
1044
|
+
)}
|
|
1045
|
+
<span className="absolute" style={{ insetBlockStart: 0, insetInlineEnd: 0 }}>
|
|
1046
|
+
<button
|
|
1047
|
+
ref={closeRef}
|
|
1048
|
+
type="button"
|
|
1049
|
+
aria-label="Close"
|
|
1050
|
+
className={QUIET_ICON}
|
|
1051
|
+
style={{ ...QUIET_ICON_BOX, color: 'rgb(var(--media-on-scrim))' }}
|
|
1052
|
+
onClick={onClose}
|
|
1053
|
+
>
|
|
1054
|
+
<X size={14} strokeWidth={1.75} aria-hidden="true" />
|
|
1055
|
+
</button>
|
|
1056
|
+
</span>
|
|
1057
|
+
</div>
|
|
1058
|
+
</div>
|
|
1059
|
+
)
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* The `accept` string for the file input, mirroring the extension allowlist the
|
|
1064
|
+
* server enforces (ADR-151 D3). Exported so a host does not re-type the list
|
|
1065
|
+
* and drift from it.
|
|
1066
|
+
*
|
|
1067
|
+
* It is a filter, never a check: the worker reads the MAGIC NUMBER, so a
|
|
1068
|
+
* renamed file sails past `accept` and is refused there. What this buys is that
|
|
1069
|
+
* the file chooser shows the right files.
|
|
1070
|
+
*/
|
|
1071
|
+
export const ATTACHMENT_ACCEPT = '.png,.jpg,.jpeg,.webp,.gif,.pdf,.txt,.md,.csv,.json'
|