@linktr.ee/messaging-react 1.40.2 → 2.0.1-rc-1778656305

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 (61) hide show
  1. package/dist/Card-CAC3fPjy.js +107 -0
  2. package/dist/Card-CAC3fPjy.js.map +1 -0
  3. package/dist/Card-DLUBUg_w.js +132 -0
  4. package/dist/Card-DLUBUg_w.js.map +1 -0
  5. package/dist/Card-_StSlnYh.js +163 -0
  6. package/dist/Card-_StSlnYh.js.map +1 -0
  7. package/dist/LockedThumbnail-p5RsFOug.js +220 -0
  8. package/dist/LockedThumbnail-p5RsFOug.js.map +1 -0
  9. package/dist/assets/index.css +1 -1
  10. package/dist/index-B1h46F9x.js +3092 -0
  11. package/dist/index-B1h46F9x.js.map +1 -0
  12. package/dist/index.d.ts +109 -30
  13. package/dist/index.js +14 -12
  14. package/package.json +2 -2
  15. package/src/components/ChannelInfoDialog/ChannelInfoDialog.test.tsx +2 -14
  16. package/src/components/ChannelInfoDialog/index.tsx +4 -8
  17. package/src/components/ChannelList/ChannelListContext.tsx +2 -0
  18. package/src/components/ChannelList/CustomChannelPreview.tsx +14 -3
  19. package/src/components/ChannelList/index.tsx +9 -1
  20. package/src/components/ChannelView.test.tsx +11 -0
  21. package/src/components/ChannelView.tsx +44 -33
  22. package/src/components/CustomMessage/index.tsx +24 -7
  23. package/src/components/CustomTypingIndicator/CustomTypingIndicator.stories.tsx +57 -17
  24. package/src/components/CustomTypingIndicator/CustomTypingIndicator.test.tsx +187 -0
  25. package/src/components/CustomTypingIndicator/DmAgentContext.ts +3 -0
  26. package/src/components/CustomTypingIndicator/index.tsx +101 -37
  27. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +230 -89
  28. package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
  29. package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
  30. package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
  31. package/src/components/LockedAttachment/components/Received/CardActions.tsx +91 -0
  32. package/src/components/LockedAttachment/components/Received/index.ts +2 -0
  33. package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
  34. package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
  35. package/src/components/LockedAttachment/components/_shared/CardBody.tsx +94 -0
  36. package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +178 -0
  37. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
  38. package/src/components/LockedAttachment/components/_shared/LockedCardShell.tsx +36 -0
  39. package/src/components/LockedAttachment/components/_shared/LockedThumbnail.tsx +128 -0
  40. package/src/components/LockedAttachment/index.tsx +43 -12
  41. package/src/components/LockedAttachment/types.ts +17 -0
  42. package/src/components/MediaMessage/index.tsx +2 -2
  43. package/src/components/MessagingShell/index.tsx +4 -4
  44. package/src/index.ts +18 -2
  45. package/src/stories/mocks.tsx +2 -9
  46. package/src/styles.css +7 -0
  47. package/src/types.ts +11 -1
  48. package/src/utils/getMessageDisplayText.test.ts +44 -0
  49. package/src/utils/getMessageDisplayText.ts +27 -0
  50. package/dist/Card-A0lkei-S.js +0 -138
  51. package/dist/Card-A0lkei-S.js.map +0 -1
  52. package/dist/Card-DXoAKkv0.js +0 -127
  53. package/dist/Card-DXoAKkv0.js.map +0 -1
  54. package/dist/index-B_PLgcDi.js +0 -2994
  55. package/dist/index-B_PLgcDi.js.map +0 -1
  56. package/src/components/LockedAttachment/components/Creator/Card.tsx +0 -210
  57. package/src/components/LockedAttachment/components/Creator/index.tsx +0 -2
  58. package/src/components/LockedAttachment/components/Visitor/Card.tsx +0 -155
  59. package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +0 -62
  60. package/src/components/LockedAttachment/components/Visitor/LockBadge.tsx +0 -12
  61. package/src/components/LockedAttachment/components/Visitor/index.ts +0 -2
@@ -1,6 +1,8 @@
1
1
  import type { Meta, StoryFn } from '@storybook/react'
2
2
  import React, { useState } from 'react'
3
3
 
4
+ import type { LockedAttachmentGalleryItem } from './types'
5
+
4
6
  import LockedAttachment from '.'
5
7
 
6
8
  const VIDEO_THUMBNAIL_BLURRED = '/video-thumbnail-blurred.jpg'
@@ -73,6 +75,37 @@ const VARIANTS = [
73
75
  },
74
76
  ]
75
77
 
78
+ /**
79
+ * A mixed-media collection (a video + 3 photos) used by the Gallery stories
80
+ * below. Each `LockedAttachment.*` card accepts a `gallery` prop and renders
81
+ * a carousel of these items when 2+ items are provided.
82
+ */
83
+ const GALLERY_ITEMS: LockedAttachmentGalleryItem[] = [
84
+ {
85
+ mimeType: 'video/mp4',
86
+ thumbnailUrl: VIDEO_THUMBNAIL_BLURRED,
87
+ sourceUrl: VIDEO_SOURCE,
88
+ },
89
+ {
90
+ mimeType: 'image/jpeg',
91
+ thumbnailUrl: IMAGE_THUMBNAIL_BLURRED,
92
+ sourceUrl: IMAGE_SOURCE,
93
+ },
94
+ {
95
+ mimeType: 'image/jpeg',
96
+ thumbnailUrl: DOCUMENT_THUMBNAIL_BLURRED,
97
+ sourceUrl: DOCUMENT_SOURCE,
98
+ },
99
+ {
100
+ mimeType: 'image/jpeg',
101
+ thumbnailUrl: AUDIO_THUMBNAIL_BLURRED,
102
+ sourceUrl: AUDIO_SOURCE,
103
+ },
104
+ ]
105
+
106
+ const GALLERY_DETAIL = '1 video, 3 photos'
107
+ const GALLERY_TITLE = 'Workout Guide'
108
+
76
109
  const Table = ({ children }: { children: React.ReactNode }) => (
77
110
  <div className="min-h-screen w-full p-12 bg-[#F9F7F4]">
78
111
  <table className="border-separate border-spacing-4">{children}</table>
@@ -99,91 +132,7 @@ const TableHead = ({
99
132
  </thead>
100
133
  )
101
134
 
102
- export const Received: StoryFn = () => {
103
- const [isPaid, setPaid] = useState<string | undefined>()
104
- const [isUnlocking, setUnlocking] = useState<string | undefined>()
105
-
106
- return (
107
- <Table>
108
- <TableHead variants={VARIANTS} />
109
- <tbody>
110
- <tr>
111
- <td className="text-xs text-right font-medium text-black/40 pr-4 align-top pt-2">
112
- Locked
113
- </td>
114
- {VARIANTS.map(
115
- ({
116
- title,
117
- mimeType,
118
- detail,
119
- thumbnailUrl,
120
- thumbnailUnlockedUrl,
121
- sourceUrl,
122
- }) => (
123
- <td key={mimeType} className="align-top">
124
- <LockedAttachment.Visitor
125
- title={title}
126
- thumbnailUrl={thumbnailUrl}
127
- mimeType={mimeType}
128
- detail={detail}
129
- paymentStatus={isPaid === mimeType ? 'paid' : undefined}
130
- amountText="AU$9.99"
131
- isUnlocking={isUnlocking === mimeType}
132
- onUnlockClick={() => {
133
- setUnlocking(mimeType)
134
- setTimeout(() => {
135
- setUnlocking(undefined)
136
- setPaid(mimeType)
137
- }, 1500)
138
- }}
139
- onDownloadClick={() => alert('Download clicked')}
140
- onFetchSource={async () => ({
141
- sourceUrl: sourceUrl,
142
- thumbnailUrl: thumbnailUnlockedUrl,
143
- })}
144
- />
145
- </td>
146
- )
147
- )}
148
- </tr>
149
- <tr>
150
- <td className="text-xs text-right font-medium text-black/40 pr-4 align-top pt-2">
151
- Unlocked
152
- </td>
153
- {VARIANTS.map(
154
- ({
155
- title,
156
- mimeType,
157
- detail,
158
- thumbnailUrl,
159
- thumbnailUnlockedUrl,
160
- sourceUrl,
161
- }) => (
162
- <td key={mimeType} className="align-top">
163
- <LockedAttachment.Visitor
164
- title={title}
165
- thumbnailUrl={thumbnailUrl}
166
- mimeType={mimeType}
167
- detail={detail}
168
- amountText="AU$9.99"
169
- paymentStatus="paid"
170
- onUnlockClick={() => alert('Unlock clicked')}
171
- onDownloadClick={() => alert('Download clicked')}
172
- onFetchSource={async () => ({
173
- sourceUrl: sourceUrl,
174
- thumbnailUrl: thumbnailUnlockedUrl,
175
- })}
176
- />
177
- </td>
178
- )
179
- )}
180
- </tr>
181
- </tbody>
182
- </Table>
183
- )
184
- }
185
-
186
- export const Sent: StoryFn = () => (
135
+ export const Composer: StoryFn = () => (
187
136
  <Table>
188
137
  <TableHead variants={VARIANTS} />
189
138
  <tbody>
@@ -200,7 +149,7 @@ export const Sent: StoryFn = () => (
200
149
  sourceUrl,
201
150
  }) => (
202
151
  <td key={mimeType} className="align-top">
203
- <LockedAttachment.Creator
152
+ <LockedAttachment.Composer
204
153
  placeholderTitle="Attachment title"
205
154
  placeholderAmountText="AU$0.00"
206
155
  thumbnailUrl={thumbnailUrl}
@@ -224,7 +173,7 @@ export const Sent: StoryFn = () => (
224
173
  </td>
225
174
  {VARIANTS.map(({ title, mimeType, detail, thumbnailUrl }) => (
226
175
  <td key={mimeType} className="align-top">
227
- <LockedAttachment.Creator
176
+ <LockedAttachment.Composer
228
177
  title={title}
229
178
  thumbnailUrl={thumbnailUrl}
230
179
  mimeType={mimeType}
@@ -235,6 +184,14 @@ export const Sent: StoryFn = () => (
235
184
  </td>
236
185
  ))}
237
186
  </tr>
187
+ </tbody>
188
+ </Table>
189
+ )
190
+
191
+ export const Sent: StoryFn = () => (
192
+ <Table>
193
+ <TableHead variants={VARIANTS} />
194
+ <tbody>
238
195
  <tr>
239
196
  <td className="text-xs text-right font-medium text-black/40 pr-4 align-top pt-2">
240
197
  Sent
@@ -249,7 +206,7 @@ export const Sent: StoryFn = () => (
249
206
  sourceUrl,
250
207
  }) => (
251
208
  <td key={mimeType} className="align-top">
252
- <LockedAttachment.Creator
209
+ <LockedAttachment.Sent
253
210
  title={title}
254
211
  thumbnailUrl={thumbnailUrl}
255
212
  mimeType={mimeType}
@@ -281,7 +238,7 @@ export const Sent: StoryFn = () => (
281
238
  sourceUrl,
282
239
  }) => (
283
240
  <td key={mimeType} className="align-top">
284
- <LockedAttachment.Creator
241
+ <LockedAttachment.Sent
285
242
  title={title}
286
243
  thumbnailUrl={thumbnailUrl}
287
244
  mimeType={mimeType}
@@ -303,3 +260,187 @@ export const Sent: StoryFn = () => (
303
260
  </tbody>
304
261
  </Table>
305
262
  )
263
+
264
+ export const Gallery: StoryFn = () => {
265
+ const [isPaid, setPaid] = useState(false)
266
+ const [isUnlocking, setUnlocking] = useState(false)
267
+
268
+ return (
269
+ <Table>
270
+ <thead>
271
+ <tr>
272
+ <th className="text-left text-xs font-medium text-black/40 pb-2" />
273
+ <th className="text-left text-xs font-medium text-black/40 pb-2">
274
+ Composer
275
+ </th>
276
+ <th className="text-left text-xs font-medium text-black/40 pb-2">
277
+ Sent
278
+ </th>
279
+ <th className="text-left text-xs font-medium text-black/40 pb-2">
280
+ Received
281
+ </th>
282
+ </tr>
283
+ </thead>
284
+ <tbody>
285
+ <tr>
286
+ <td className="text-xs text-right font-medium text-black/40 pr-4 align-top pt-2">
287
+ Locked
288
+ </td>
289
+ <td className="align-top">
290
+ <LockedAttachment.Composer
291
+ title={GALLERY_TITLE}
292
+ detail={GALLERY_DETAIL}
293
+ amountText="AU$50.00"
294
+ gallery={GALLERY_ITEMS}
295
+ onDismiss={() => alert('Dismissed gallery')}
296
+ onEditClick={() => alert('Edit gallery')}
297
+ />
298
+ </td>
299
+ <td className="align-top">
300
+ <LockedAttachment.Sent
301
+ title={GALLERY_TITLE}
302
+ detail={GALLERY_DETAIL}
303
+ amountText="AU$50.00"
304
+ gallery={GALLERY_ITEMS}
305
+ />
306
+ </td>
307
+ <td className="align-top">
308
+ <LockedAttachment.Received
309
+ title={GALLERY_TITLE}
310
+ detail={GALLERY_DETAIL}
311
+ amountText="AU$50.00"
312
+ gallery={GALLERY_ITEMS}
313
+ paymentStatus={isPaid ? 'paid' : undefined}
314
+ isUnlocking={isUnlocking}
315
+ onUnlockClick={() => {
316
+ setUnlocking(true)
317
+ setTimeout(() => {
318
+ setUnlocking(false)
319
+ setPaid(true)
320
+ }, 1500)
321
+ }}
322
+ onDownloadClick={() => alert('Download gallery')}
323
+ />
324
+ </td>
325
+ </tr>
326
+ <tr>
327
+ <td className="text-xs text-right font-medium text-black/40 pr-4 align-top pt-2">
328
+ Sold / Purchased
329
+ </td>
330
+ <td className="align-top">
331
+ <LockedAttachment.Composer
332
+ title={GALLERY_TITLE}
333
+ detail={GALLERY_DETAIL}
334
+ amountText="AU$50.00"
335
+ gallery={GALLERY_ITEMS}
336
+ onEditClick={() => alert('Edit gallery')}
337
+ />
338
+ </td>
339
+ <td className="align-top">
340
+ <LockedAttachment.Sent
341
+ title={GALLERY_TITLE}
342
+ detail={GALLERY_DETAIL}
343
+ amountText="AU$50.00"
344
+ paymentStatus="paid"
345
+ gallery={GALLERY_ITEMS}
346
+ />
347
+ </td>
348
+ <td className="align-top">
349
+ <LockedAttachment.Received
350
+ title={GALLERY_TITLE}
351
+ detail={GALLERY_DETAIL}
352
+ amountText="AU$50.00"
353
+ paymentStatus="paid"
354
+ gallery={GALLERY_ITEMS}
355
+ onDownloadClick={() => alert('Download gallery')}
356
+ />
357
+ </td>
358
+ </tr>
359
+ </tbody>
360
+ </Table>
361
+ )
362
+ }
363
+
364
+ export const Received: StoryFn = () => {
365
+ const [isPaid, setPaid] = useState<string | undefined>()
366
+ const [isUnlocking, setUnlocking] = useState<string | undefined>()
367
+
368
+ return (
369
+ <Table>
370
+ <TableHead variants={VARIANTS} />
371
+ <tbody>
372
+ <tr>
373
+ <td className="text-xs text-right font-medium text-black/40 pr-4 align-top pt-2">
374
+ Locked
375
+ </td>
376
+ {VARIANTS.map(
377
+ ({
378
+ title,
379
+ mimeType,
380
+ detail,
381
+ thumbnailUrl,
382
+ thumbnailUnlockedUrl,
383
+ sourceUrl,
384
+ }) => (
385
+ <td key={mimeType} className="align-top">
386
+ <LockedAttachment.Received
387
+ title={title}
388
+ thumbnailUrl={thumbnailUrl}
389
+ mimeType={mimeType}
390
+ detail={detail}
391
+ paymentStatus={isPaid === mimeType ? 'paid' : undefined}
392
+ amountText="AU$9.99"
393
+ isUnlocking={isUnlocking === mimeType}
394
+ onUnlockClick={() => {
395
+ setUnlocking(mimeType)
396
+ setTimeout(() => {
397
+ setUnlocking(undefined)
398
+ setPaid(mimeType)
399
+ }, 1500)
400
+ }}
401
+ onDownloadClick={() => alert('Download clicked')}
402
+ onFetchSource={async () => ({
403
+ sourceUrl: sourceUrl,
404
+ thumbnailUrl: thumbnailUnlockedUrl,
405
+ })}
406
+ />
407
+ </td>
408
+ )
409
+ )}
410
+ </tr>
411
+ <tr>
412
+ <td className="text-xs text-right font-medium text-black/40 pr-4 align-top pt-2">
413
+ Unlocked
414
+ </td>
415
+ {VARIANTS.map(
416
+ ({
417
+ title,
418
+ mimeType,
419
+ detail,
420
+ thumbnailUrl,
421
+ thumbnailUnlockedUrl,
422
+ sourceUrl,
423
+ }) => (
424
+ <td key={mimeType} className="align-top">
425
+ <LockedAttachment.Received
426
+ title={title}
427
+ thumbnailUrl={thumbnailUrl}
428
+ mimeType={mimeType}
429
+ detail={detail}
430
+ amountText="AU$9.99"
431
+ paymentStatus="paid"
432
+ onUnlockClick={() => alert('Unlock clicked')}
433
+ onDownloadClick={() => alert('Download clicked')}
434
+ onFetchSource={async () => ({
435
+ sourceUrl: sourceUrl,
436
+ thumbnailUrl: thumbnailUnlockedUrl,
437
+ })}
438
+ />
439
+ </td>
440
+ )
441
+ )}
442
+ </tr>
443
+ </tbody>
444
+ </Table>
445
+ )
446
+ }
@@ -0,0 +1,221 @@
1
+ import { ImagesIcon, PencilSimpleIcon, XIcon } from '@phosphor-icons/react'
2
+ import React, { useCallback, useRef, useState } from 'react'
3
+
4
+ import type {
5
+ LockedAttachmentBaseProps,
6
+ LockedAttachmentSource,
7
+ } from '../../types'
8
+ import CardBody from '../_shared/CardBody'
9
+ import GalleryThumbnail from '../_shared/GalleryThumbnail'
10
+ import LockedCardShell from '../_shared/LockedCardShell'
11
+ import LockedThumbnail from '../_shared/LockedThumbnail'
12
+
13
+ export interface ComposerCardProps extends LockedAttachmentBaseProps {
14
+ /** Placeholder shown in the title slot before the composer types one. */
15
+ placeholderTitle?: string
16
+ /** Placeholder shown in the amount slot before one is configured. */
17
+ placeholderAmountText?: string
18
+ /**
19
+ * When provided, renders a dismiss X in the thumbnail corner. Called when
20
+ * the composer clicks it to remove the attachment.
21
+ */
22
+ onDismiss?: () => void
23
+ /** Fired the first time the composer taps the thumbnail to preview. */
24
+ onPreviewClick?: () => void
25
+ /**
26
+ * Lazily loads the underlying source so the composer can preview the
27
+ * attachment they're about to send. Called the first time the thumbnail is
28
+ * tapped; the returned source is cached and reused on subsequent toggles.
29
+ */
30
+ onFetchSource?: () => Promise<LockedAttachmentSource | void>
31
+ /**
32
+ * When provided, renders a pencil button in the body bottom-right that the
33
+ * composer can use to edit the attachment metadata (e.g. open the price /
34
+ * gallery editor). Matches the Composer "Button" instance in Figma.
35
+ */
36
+ onEditClick?: () => void
37
+ }
38
+
39
+ /**
40
+ * The card the composer sees while drafting a paid attachment.
41
+ * Matches the Composer column of the messaging design system in Figma.
42
+ */
43
+ const ComposerCard: React.FC<ComposerCardProps> = ({
44
+ title,
45
+ mimeType = 'application/octet-stream',
46
+ thumbnailUrl,
47
+ detail,
48
+ amountText,
49
+ placeholderTitle = 'Attachment title',
50
+ placeholderAmountText,
51
+ gallery,
52
+ onDismiss,
53
+ onPreviewClick,
54
+ onFetchSource,
55
+ onEditClick,
56
+ }) => {
57
+ const [source, setSource] = useState<LockedAttachmentSource | undefined>()
58
+ const [isPreviewVisible, setIsPreviewVisible] = useState(false)
59
+ const [isLoadingPreview, setIsLoadingPreview] = useState(false)
60
+ const fetchingRef = useRef(false)
61
+
62
+ const isGallery = (gallery?.length ?? 0) >= 2
63
+
64
+ const handleToggle = useCallback(async () => {
65
+ onPreviewClick?.()
66
+ if (isPreviewVisible) {
67
+ setIsPreviewVisible(false)
68
+ return
69
+ }
70
+ // Gallery items carry their own per-item sources on the `gallery` prop, so
71
+ // we just flip visibility — no async source fetch is needed.
72
+ if (isGallery) {
73
+ setIsPreviewVisible(true)
74
+ return
75
+ }
76
+ if (source) {
77
+ setIsPreviewVisible(true)
78
+ return
79
+ }
80
+ if (!onFetchSource) return
81
+ if (fetchingRef.current) return
82
+ fetchingRef.current = true
83
+ setIsLoadingPreview(true)
84
+ try {
85
+ const result = await onFetchSource()
86
+ if (result) {
87
+ setSource(result)
88
+ setIsPreviewVisible(true)
89
+ }
90
+ } finally {
91
+ fetchingRef.current = false
92
+ setIsLoadingPreview(false)
93
+ }
94
+ }, [isPreviewVisible, isGallery, source, onPreviewClick, onFetchSource])
95
+
96
+ // Gallery is always previewable in the composer because each item's source
97
+ // is already provided up-front via `gallery[*].sourceUrl`.
98
+ const togglePreview =
99
+ isGallery || onFetchSource || onPreviewClick ? handleToggle : undefined
100
+ const showLocked = !isPreviewVisible
101
+ const isBusy = isLoadingPreview
102
+
103
+ const statusBadge = (
104
+ <React.Fragment>
105
+ <span className="text-xs font-medium text-white/55">&bull;</span>
106
+ <span
107
+ className={
108
+ amountText
109
+ ? 'text-xs font-medium text-white/55'
110
+ : 'text-xs font-medium text-white/30'
111
+ }
112
+ >
113
+ {amountText || placeholderAmountText}
114
+ </span>
115
+ </React.Fragment>
116
+ )
117
+
118
+ const dismissButton = onDismiss ? (
119
+ <button
120
+ type="button"
121
+ onClick={(e) => {
122
+ // Stop the click from bubbling up into the outer preview-toggle
123
+ // wrapper when both are wired up at the same time.
124
+ e.stopPropagation()
125
+ onDismiss()
126
+ }}
127
+ className="flex size-6 items-center justify-center rounded-full bg-[#121110] text-white"
128
+ aria-label="Dismiss attachment"
129
+ >
130
+ <XIcon className="size-3" weight="bold" />
131
+ </button>
132
+ ) : undefined
133
+
134
+ const editButton = onEditClick ? (
135
+ <button
136
+ type="button"
137
+ onClick={onEditClick}
138
+ aria-label="Edit attachment"
139
+ className="flex size-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/15"
140
+ >
141
+ <PencilSimpleIcon className="size-5" weight="regular" />
142
+ </button>
143
+ ) : undefined
144
+
145
+ const thumbnail = isGallery ? (
146
+ <GalleryThumbnail
147
+ variant="dark"
148
+ gallery={gallery!}
149
+ title={title}
150
+ showLocked={showLocked}
151
+ topRight={dismissButton}
152
+ />
153
+ ) : (
154
+ <LockedThumbnail
155
+ variant="dark"
156
+ mimeType={mimeType}
157
+ thumbnailUrl={thumbnailUrl}
158
+ title={title}
159
+ source={source}
160
+ showLocked={showLocked}
161
+ topRight={dismissButton}
162
+ />
163
+ )
164
+
165
+ return (
166
+ <LockedCardShell variant="dark">
167
+ {togglePreview ? (
168
+ // Uses a `<div role="button">` rather than a native `<button>` so the
169
+ // dismiss control rendered inside `thumbnail` (also a `<button>`)
170
+ // doesn't produce invalid nested interactive elements.
171
+ <div
172
+ role="button"
173
+ tabIndex={isBusy ? -1 : 0}
174
+ aria-label="Toggle preview"
175
+ aria-busy={isBusy}
176
+ aria-pressed={!showLocked}
177
+ aria-disabled={isBusy || undefined}
178
+ onClick={isBusy ? undefined : togglePreview}
179
+ onKeyDown={(e) => {
180
+ if (isBusy) return
181
+ // Only handle keys that originate on the wrapper itself, not on
182
+ // inner interactive elements (dismiss X, carousel arrows). Without
183
+ // this guard, pressing Enter/Space on a focused child would bubble
184
+ // up and toggle the preview in addition to activating the child.
185
+ if (e.target !== e.currentTarget) return
186
+ if (e.key === 'Enter' || e.key === ' ') {
187
+ e.preventDefault()
188
+ void togglePreview()
189
+ }
190
+ }}
191
+ className={
192
+ !isBusy
193
+ ? 'block w-full cursor-pointer text-left'
194
+ : 'block w-full text-left'
195
+ }
196
+ >
197
+ {thumbnail}
198
+ </div>
199
+ ) : (
200
+ thumbnail
201
+ )}
202
+
203
+ <CardBody
204
+ variant="dark"
205
+ title={title}
206
+ placeholderTitle={placeholderTitle}
207
+ mimeType={mimeType}
208
+ detail={detail}
209
+ statusBadge={statusBadge}
210
+ icon={
211
+ isGallery ? (
212
+ <ImagesIcon className="size-5 shrink-0 text-white/55" />
213
+ ) : undefined
214
+ }
215
+ trailingAction={editButton}
216
+ />
217
+ </LockedCardShell>
218
+ )
219
+ }
220
+
221
+ export default ComposerCard
@@ -0,0 +1,2 @@
1
+ export { default as ComposerCard } from './Card'
2
+ export type { ComposerCardProps } from './Card'