@linktr.ee/messaging-react 3.10.0 → 3.11.0-rc-1783756377

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 (88) hide show
  1. package/dist/Card-415OfRhw.cjs +2 -0
  2. package/dist/Card-415OfRhw.cjs.map +1 -0
  3. package/dist/Card-BnbNynLQ.js +694 -0
  4. package/dist/Card-BnbNynLQ.js.map +1 -0
  5. package/dist/Card-UnwKmYzt.cjs +2 -0
  6. package/dist/Card-UnwKmYzt.cjs.map +1 -0
  7. package/dist/Card-qS17qnN0.js +103 -0
  8. package/dist/Card-qS17qnN0.js.map +1 -0
  9. package/dist/LoadingDots-Ct3WmVlT.js +59 -0
  10. package/dist/LoadingDots-Ct3WmVlT.js.map +1 -0
  11. package/dist/LoadingDots-D9GGphgM.cjs +2 -0
  12. package/dist/LoadingDots-D9GGphgM.cjs.map +1 -0
  13. package/dist/assets/index.css +1 -1
  14. package/dist/index-CyHyftBi.cjs +2 -0
  15. package/dist/index-CyHyftBi.cjs.map +1 -0
  16. package/dist/{index-BxzzWK4K.js → index-DJXjS2H9.js} +1397 -1358
  17. package/dist/index-DJXjS2H9.js.map +1 -0
  18. package/dist/index.cjs +1 -1
  19. package/dist/index.d.ts +196 -101
  20. package/dist/index.js +1 -1
  21. package/dist/testing.d.ts +1 -0
  22. package/package.json +2 -2
  23. package/src/components/ChannelList/CustomChannelPreview.test.tsx +95 -0
  24. package/src/components/ChannelList/CustomChannelPreview.tsx +21 -9
  25. package/src/components/ChannelList/index.test.tsx +46 -0
  26. package/src/components/ChannelList/index.tsx +12 -0
  27. package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -1
  28. package/src/components/CustomMessage/LockedAttachment/LockedAttachment.stories.tsx +541 -0
  29. package/src/components/CustomMessage/LockedAttachment/LockedAttachment.test.tsx +117 -0
  30. package/src/components/CustomMessage/LockedAttachment/components/Media/Card.tsx +358 -0
  31. package/src/components/{LockedAttachment/components/Received → CustomMessage/LockedAttachment/components/Media}/CardActions.tsx +9 -2
  32. package/src/components/CustomMessage/LockedAttachment/components/Media/types.ts +79 -0
  33. package/src/components/CustomMessage/LockedAttachment/components/Text/Card.tsx +153 -0
  34. package/src/components/CustomMessage/LockedAttachment/components/Text/types.ts +58 -0
  35. package/src/components/{LockedAttachment → CustomMessage/LockedAttachment}/components/_shared/CardBody.tsx +8 -7
  36. package/src/components/CustomMessage/LockedAttachment/components/_shared/CardThumbnail.tsx +92 -0
  37. package/src/components/{LockedAttachment → CustomMessage/LockedAttachment}/components/_shared/GalleryThumbnail.tsx +31 -39
  38. package/src/components/CustomMessage/LockedAttachment/components/_shared/LoadingDots.tsx +36 -0
  39. package/src/components/CustomMessage/LockedAttachment/components/_shared/LockBadge.tsx +63 -0
  40. package/src/components/{LockedAttachment/components/_shared/LockedCardShell.tsx → CustomMessage/LockedAttachment/components/_shared/MediaShell.tsx} +8 -8
  41. package/src/components/CustomMessage/LockedAttachment/components/_shared/PreviewToggle.tsx +58 -0
  42. package/src/components/CustomMessage/LockedAttachment/components/_shared/PriceLockRow.tsx +21 -0
  43. package/src/components/CustomMessage/LockedAttachment/components/_shared/PurchaseStatusBadge.tsx +74 -0
  44. package/src/components/{LockedAttachment/components/_shared/LockedThumbnail.tsx → CustomMessage/LockedAttachment/components/_shared/SingleThumbnail.tsx} +11 -7
  45. package/src/components/CustomMessage/LockedAttachment/components/_shared/SkeletonTextLines.tsx +29 -0
  46. package/src/components/CustomMessage/LockedAttachment/components/_shared/TextShell.tsx +28 -0
  47. package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.test.ts +67 -0
  48. package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.ts +89 -0
  49. package/src/components/CustomMessage/LockedAttachment/components/_shared/useReceivedSource.ts +83 -0
  50. package/src/components/CustomMessage/LockedAttachment/index.tsx +105 -0
  51. package/src/components/CustomMessage/LockedAttachment/types.ts +44 -0
  52. package/src/components/CustomMessage/MessageTag.tsx +8 -0
  53. package/src/components/CustomMessage/context.tsx +1 -1
  54. package/src/components/CustomMessage/index.tsx +76 -42
  55. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +5 -3
  56. package/src/index.ts +3 -8
  57. package/src/stream-custom-data.ts +1 -0
  58. package/src/styles.css +12 -0
  59. package/src/types.ts +35 -2
  60. package/dist/Card-B-L2MST4.js +0 -107
  61. package/dist/Card-B-L2MST4.js.map +0 -1
  62. package/dist/Card-B56QxJW3.cjs +0 -2
  63. package/dist/Card-B56QxJW3.cjs.map +0 -1
  64. package/dist/Card-Bqh-BQaX.js +0 -132
  65. package/dist/Card-Bqh-BQaX.js.map +0 -1
  66. package/dist/Card-ZCE6Lhfc.cjs +0 -2
  67. package/dist/Card-ZCE6Lhfc.cjs.map +0 -1
  68. package/dist/Card-g4JD-2ES.js +0 -163
  69. package/dist/Card-g4JD-2ES.js.map +0 -1
  70. package/dist/Card-wac6AMNc.cjs +0 -2
  71. package/dist/Card-wac6AMNc.cjs.map +0 -1
  72. package/dist/LockedThumbnail-BAQmoIff.js +0 -220
  73. package/dist/LockedThumbnail-BAQmoIff.js.map +0 -1
  74. package/dist/LockedThumbnail-Cr0Gqs3Y.cjs +0 -2
  75. package/dist/LockedThumbnail-Cr0Gqs3Y.cjs.map +0 -1
  76. package/dist/index-BPhZyaxT.cjs +0 -2
  77. package/dist/index-BPhZyaxT.cjs.map +0 -1
  78. package/dist/index-BxzzWK4K.js.map +0 -1
  79. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +0 -450
  80. package/src/components/LockedAttachment/components/Composer/Card.tsx +0 -221
  81. package/src/components/LockedAttachment/components/Composer/index.ts +0 -2
  82. package/src/components/LockedAttachment/components/Received/Card.tsx +0 -191
  83. package/src/components/LockedAttachment/components/Received/index.ts +0 -2
  84. package/src/components/LockedAttachment/components/Sent/Card.tsx +0 -177
  85. package/src/components/LockedAttachment/components/Sent/index.ts +0 -2
  86. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +0 -39
  87. package/src/components/LockedAttachment/index.tsx +0 -63
  88. package/src/components/LockedAttachment/types.ts +0 -51
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+
3
+ import { renderWithProviders, screen } from '../../../test/utils'
4
+
5
+ import MediaCard from './components/Media/Card'
6
+ import TextCard from './components/Text/Card'
7
+
8
+ /**
9
+ * Characterization tests — the safety net for the LockedAttachment
10
+ * separate-concerns refactor. They assert stable, user-visible facts only
11
+ * (lock affordances, CTAs, amount/status text, the preview toggle), never
12
+ * DOM structure or class names — exactly the things the refactor moves
13
+ * around. Storybook remains the pixel-level visual oracle.
14
+ */
15
+ describe('LockedAttachment (characterization)', () => {
16
+ describe('Draft', () => {
17
+ it('media mode: shows the placeholder title when locked with no title', () => {
18
+ renderWithProviders(<MediaCard isDraft />)
19
+ expect(screen.getByText('Attachment title')).toBeInTheDocument()
20
+ })
21
+
22
+ it('media mode: renders a preview toggle when a source fetcher is wired', () => {
23
+ renderWithProviders(
24
+ <MediaCard isDraft onFetchSource={vi.fn().mockResolvedValue(undefined)} />
25
+ )
26
+ expect(
27
+ screen.getByRole('button', { name: 'Toggle preview' })
28
+ ).toBeInTheDocument()
29
+ })
30
+
31
+ it('media mode: renders a dismiss control when onDismiss is provided', () => {
32
+ renderWithProviders(<MediaCard isDraft onDismiss={vi.fn()} />)
33
+ expect(
34
+ screen.getByRole('button', { name: 'Dismiss attachment' })
35
+ ).toBeInTheDocument()
36
+ })
37
+
38
+ it('text mode: shows the draft text and amount', () => {
39
+ renderWithProviders(
40
+ <TextCard isDraft amountText="$5" renderedText="Secret draft" />
41
+ )
42
+ expect(screen.getByText('Secret draft')).toBeInTheDocument()
43
+ expect(screen.getByText('$5')).toBeInTheDocument()
44
+ })
45
+ })
46
+
47
+ describe('Preview', () => {
48
+ it('media mode: shows the placeholder title when locked with no title', () => {
49
+ renderWithProviders(<MediaCard isPreview amountText="$5" />)
50
+ expect(screen.getByText('Attachment title')).toBeInTheDocument()
51
+ expect(screen.getByText('$5')).toBeInTheDocument()
52
+ })
53
+
54
+ it('text mode: never shows real text, only the amount', () => {
55
+ renderWithProviders(<TextCard isPreview amountText="$5" />)
56
+ expect(screen.queryByText('Secret draft')).not.toBeInTheDocument()
57
+ expect(screen.getByText('$5')).toBeInTheDocument()
58
+ })
59
+ })
60
+
61
+ describe('Media (isMine)', () => {
62
+ it('shows the amount in the status badge', () => {
63
+ renderWithProviders(<MediaCard isMine amountText="$5" />)
64
+ expect(screen.getByText('$5')).toBeInTheDocument()
65
+ })
66
+ })
67
+
68
+ describe('Text (isMine)', () => {
69
+ it('pending: shows the amount with no status label', () => {
70
+ renderWithProviders(
71
+ <TextCard isMine amountText="$5" paymentStatus="pending" />
72
+ )
73
+ expect(screen.queryByText('Sent')).not.toBeInTheDocument()
74
+ expect(screen.getByText('$5')).toBeInTheDocument()
75
+ })
76
+
77
+ it('paid: shows the Sold label', () => {
78
+ renderWithProviders(
79
+ <TextCard isMine amountText="$5" paymentStatus="paid" />
80
+ )
81
+ expect(screen.getByText('Sold')).toBeInTheDocument()
82
+ })
83
+ })
84
+
85
+ describe('Media (!isMine)', () => {
86
+ it('renders the locked-attachment root and an Unlock CTA when locked', () => {
87
+ renderWithProviders(
88
+ <MediaCard isMine={false} amountText="$5" onUnlockClick={vi.fn()} />
89
+ )
90
+ expect(screen.getByTestId('locked-attachment')).toBeInTheDocument()
91
+ expect(
92
+ screen.getByRole('button', { name: 'Unlock' })
93
+ ).toBeInTheDocument()
94
+ })
95
+ })
96
+
97
+ describe('Text (!isMine)', () => {
98
+ it('shows an Unlock control and the amount', () => {
99
+ renderWithProviders(
100
+ <TextCard isMine={false} amountText="$5" onUnlockClick={vi.fn()} />
101
+ )
102
+ expect(
103
+ screen.getByRole('button', { name: 'Unlock message' })
104
+ ).toBeInTheDocument()
105
+ expect(screen.getByText('$5')).toBeInTheDocument()
106
+ })
107
+
108
+ it('unlocking: marks the Unlock control busy', () => {
109
+ renderWithProviders(
110
+ <TextCard isMine={false} isUnlocking onUnlockClick={vi.fn()} />
111
+ )
112
+ const button = screen.getByRole('button', { name: 'Unlocking message' })
113
+ expect(button).toBeDisabled()
114
+ expect(button).toHaveAttribute('aria-busy', 'true')
115
+ })
116
+ })
117
+ })
@@ -0,0 +1,358 @@
1
+ import { PencilSimpleIcon, XIcon } from '@phosphor-icons/react'
2
+ import React from 'react'
3
+
4
+ import { getSourceType } from '../../../../AttachmentCard'
5
+ import CardBody from '../_shared/CardBody'
6
+ import CardThumbnail, {
7
+ galleryBodyIcon,
8
+ isGalleryCard,
9
+ } from '../_shared/CardThumbnail'
10
+ import MediaShell from '../_shared/MediaShell'
11
+ import PreviewToggle from '../_shared/PreviewToggle'
12
+ import PurchaseStatusBadge from '../_shared/PurchaseStatusBadge'
13
+ import { usePreviewToggle } from '../_shared/usePreviewToggle'
14
+ import { useReceivedSource } from '../_shared/useReceivedSource'
15
+
16
+ import ReceivedCardActions from './CardActions'
17
+ import type {
18
+ MediaCardDraftProps,
19
+ MediaCardOtherProps,
20
+ MediaCardOwnProps,
21
+ MediaCardPreviewProps,
22
+ MediaCardProps,
23
+ } from './types'
24
+
25
+ export type { MediaCardProps } from './types'
26
+
27
+ /**
28
+ * Media card while still drafting — the locked thumbnail (single or gallery)
29
+ * the composer taps to preview the attachment they're about to send, plus
30
+ * the title/price body with optional dismiss X and edit pencil.
31
+ */
32
+ const DraftMediaCard: React.FC<MediaCardDraftProps> = ({
33
+ title,
34
+ mimeType = 'application/octet-stream',
35
+ thumbnailUrl,
36
+ detail,
37
+ amountText,
38
+ placeholderTitle,
39
+ placeholderAmountText,
40
+ gallery = [],
41
+ onDismiss,
42
+ onPreviewClick,
43
+ onFetchSource,
44
+ onEditClick,
45
+ }) => {
46
+ const isGallery = isGalleryCard(gallery)
47
+
48
+ // Gallery is always previewable in the composer because each item's source
49
+ // is already provided up-front via `gallery[*].sourceUrl`.
50
+ const { source, isPreviewVisible, isLoadingPreview, togglePreview } =
51
+ usePreviewToggle({ isGallery, onFetchSource, onPreviewClick })
52
+
53
+ const showLocked = !isPreviewVisible
54
+ const isBusy = isLoadingPreview
55
+
56
+ const statusBadge = (
57
+ <React.Fragment>
58
+ <span className="text-xs font-medium text-white/55">&bull;</span>
59
+ <span
60
+ className={
61
+ amountText
62
+ ? 'text-xs font-medium text-white/55'
63
+ : 'text-xs font-medium text-white/30'
64
+ }
65
+ >
66
+ {amountText || placeholderAmountText}
67
+ </span>
68
+ </React.Fragment>
69
+ )
70
+
71
+ const dismissButton = onDismiss ? (
72
+ <button
73
+ type="button"
74
+ onClick={(e) => {
75
+ // Stop the click from bubbling up into the outer preview-toggle
76
+ // wrapper when both are wired up at the same time.
77
+ e.stopPropagation()
78
+ onDismiss()
79
+ }}
80
+ className="flex size-6 items-center justify-center rounded-full bg-[#121110] text-white"
81
+ aria-label="Dismiss attachment"
82
+ >
83
+ <XIcon className="size-3" weight="bold" />
84
+ </button>
85
+ ) : undefined
86
+
87
+ const editButton = onEditClick ? (
88
+ <button
89
+ type="button"
90
+ onClick={onEditClick}
91
+ aria-label="Edit attachment"
92
+ className="flex size-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/15"
93
+ >
94
+ <PencilSimpleIcon className="size-5" weight="regular" />
95
+ </button>
96
+ ) : undefined
97
+
98
+ return (
99
+ <MediaShell variant="dark">
100
+ <PreviewToggle
101
+ onToggle={togglePreview}
102
+ isBusy={isBusy}
103
+ isPreviewVisible={!showLocked}
104
+ >
105
+ <CardThumbnail
106
+ variant="dark"
107
+ gallery={gallery}
108
+ title={title}
109
+ showLocked={showLocked}
110
+ mimeType={mimeType}
111
+ thumbnailUrl={thumbnailUrl}
112
+ source={source}
113
+ topRight={dismissButton}
114
+ />
115
+ </PreviewToggle>
116
+
117
+ <CardBody
118
+ variant="dark"
119
+ title={title}
120
+ placeholderTitle={placeholderTitle}
121
+ mimeType={mimeType}
122
+ detail={detail}
123
+ statusBadge={statusBadge}
124
+ icon={isGallery ? galleryBodyIcon('dark') : undefined}
125
+ trailingAction={editButton}
126
+ />
127
+ </MediaShell>
128
+ )
129
+ }
130
+
131
+ /**
132
+ * Static mockup of what the recipient will see before purchase — always
133
+ * locked/blurred (the lock badge renders from `showLocked` alone, no toggle
134
+ * wiring needed), no interactivity, no real content revealed.
135
+ */
136
+ const PreviewMediaCard: React.FC<MediaCardPreviewProps> = ({
137
+ title,
138
+ mimeType = 'application/octet-stream',
139
+ thumbnailUrl,
140
+ detail,
141
+ amountText,
142
+ placeholderTitle,
143
+ placeholderAmountText,
144
+ gallery = [],
145
+ }) => {
146
+ const isGallery = isGalleryCard(gallery)
147
+
148
+ const statusBadge = (
149
+ <React.Fragment>
150
+ <span className="text-xs font-medium text-black/55">&bull;</span>
151
+ <span
152
+ className={
153
+ amountText
154
+ ? 'text-xs font-medium text-black/55'
155
+ : 'text-xs font-medium text-black/30'
156
+ }
157
+ >
158
+ {amountText || placeholderAmountText}
159
+ </span>
160
+ </React.Fragment>
161
+ )
162
+
163
+ return (
164
+ <MediaShell variant="light">
165
+ <CardThumbnail
166
+ variant="light"
167
+ gallery={gallery}
168
+ title={title}
169
+ showLocked
170
+ mimeType={mimeType}
171
+ thumbnailUrl={thumbnailUrl}
172
+ />
173
+
174
+ <CardBody
175
+ variant="light"
176
+ title={title}
177
+ placeholderTitle={placeholderTitle}
178
+ mimeType={mimeType}
179
+ detail={detail}
180
+ statusBadge={statusBadge}
181
+ icon={isGallery ? galleryBodyIcon('light') : undefined}
182
+ />
183
+ </MediaShell>
184
+ )
185
+ }
186
+ /**
187
+ * The sender's ('mine') view of a paid media attachment: the locked
188
+ * thumbnail (single or gallery) they can tap to preview their own
189
+ * attachment, plus the title/purchase-status body.
190
+ */
191
+ const OwnMediaCard: React.FC<MediaCardOwnProps> = ({
192
+ title,
193
+ mimeType = 'application/octet-stream',
194
+ thumbnailUrl,
195
+ detail,
196
+ amountText,
197
+ placeholderTitle,
198
+ paymentStatus = 'pending',
199
+ gallery = [],
200
+ onPreviewClick,
201
+ onFetchSource,
202
+ }) => {
203
+ const isGallery = isGalleryCard(gallery)
204
+
205
+ // Gallery is always previewable for the sender because each item's source
206
+ // is already provided up-front via `gallery[*].sourceUrl`.
207
+ const { source, isPreviewVisible, isLoadingPreview, togglePreview } =
208
+ usePreviewToggle({ isGallery, onFetchSource, onPreviewClick })
209
+
210
+ const showLocked = !isPreviewVisible
211
+ const isBusy = isLoadingPreview
212
+
213
+ return (
214
+ <MediaShell variant="dark">
215
+ <PreviewToggle
216
+ onToggle={togglePreview}
217
+ isBusy={isBusy}
218
+ isPreviewVisible={!showLocked}
219
+ >
220
+ <CardThumbnail
221
+ variant="dark"
222
+ gallery={gallery}
223
+ title={title}
224
+ showLocked={showLocked}
225
+ paymentStatus={paymentStatus}
226
+ mimeType={mimeType}
227
+ thumbnailUrl={thumbnailUrl}
228
+ source={source}
229
+ />
230
+ </PreviewToggle>
231
+
232
+ <CardBody
233
+ variant="dark"
234
+ title={title}
235
+ placeholderTitle={placeholderTitle}
236
+ mimeType={mimeType}
237
+ detail={detail}
238
+ statusBadge={
239
+ <PurchaseStatusBadge
240
+ isMine={true}
241
+ paymentStatus={paymentStatus}
242
+ amountText={amountText}
243
+ />
244
+ }
245
+ icon={isGallery ? galleryBodyIcon('dark') : undefined}
246
+ />
247
+ </MediaShell>
248
+ )
249
+ }
250
+
251
+ /**
252
+ * The recipient's ('other') view of a paid media attachment: the locked
253
+ * thumbnail (single or gallery) with an Unlock/Download CTA. The source is
254
+ * revealed by fetching once — on click when already paid, or automatically
255
+ * the first time a paid card scrolls into view.
256
+ */
257
+ const OtherMediaCard: React.FC<MediaCardOtherProps> = ({
258
+ title,
259
+ amountText,
260
+ thumbnailUrl,
261
+ mimeType = 'application/octet-stream',
262
+ detail,
263
+ gallery = [],
264
+ onUnlockClick,
265
+ onFetchSource,
266
+ onDownloadClick,
267
+ paymentStatus,
268
+ isUnlocking = false,
269
+ }) => {
270
+ const isGallery = isGalleryCard(gallery)
271
+
272
+ const { source, cardRef, handleUnlockClick } = useReceivedSource({
273
+ paymentStatus,
274
+ onFetchSource,
275
+ onUnlockClick,
276
+ })
277
+
278
+ const effectiveSourceUrl = source?.sourceUrl
279
+ const effectiveRedeemUrl = source?.redeemUrl
280
+
281
+ // For gallery, the per-item sources are carried by `gallery` itself, so
282
+ // the lock state is driven by paymentStatus rather than the single
283
+ // `effectiveSourceUrl`. Everything else still respects the source fetch.
284
+ const isLocked = isGallery
285
+ ? paymentStatus !== 'paid'
286
+ : effectiveSourceUrl === undefined
287
+ const sourceType = getSourceType(mimeType)
288
+
289
+ // For gallery, ReceivedCardActions falls back to a plain <button>
290
+ // (no anchor) because the gallery has no single sourceUrl to link to —
291
+ // the per-item sources live on `gallery` itself.
292
+ const actionSourceUrl = isGallery ? undefined : effectiveSourceUrl
293
+
294
+ return (
295
+ <MediaShell
296
+ variant="light"
297
+ rootRef={cardRef}
298
+ data-testid="locked-attachment"
299
+ >
300
+ <CardThumbnail
301
+ variant="light"
302
+ gallery={gallery}
303
+ title={title}
304
+ showLocked={isLocked}
305
+ paymentStatus={paymentStatus}
306
+ mimeType={mimeType}
307
+ thumbnailUrl={thumbnailUrl}
308
+ source={source}
309
+ containedImage={!isLocked && sourceType === 'document'}
310
+ />
311
+
312
+ <CardBody
313
+ variant="light"
314
+ title={title}
315
+ mimeType={mimeType}
316
+ detail={detail}
317
+ statusBadge={
318
+ <PurchaseStatusBadge
319
+ isMine={false}
320
+ paymentStatus={paymentStatus}
321
+ amountText={amountText}
322
+ />
323
+ }
324
+ icon={isGallery ? galleryBodyIcon('light') : undefined}
325
+ action={
326
+ <ReceivedCardActions
327
+ isLocked={isLocked}
328
+ isUnlocking={isUnlocking}
329
+ sourceUrl={actionSourceUrl}
330
+ redeemUrl={effectiveRedeemUrl}
331
+ onUnlockClicked={handleUnlockClick}
332
+ onDownloadClicked={onDownloadClick}
333
+ />
334
+ }
335
+ />
336
+ </MediaShell>
337
+ )
338
+ }
339
+
340
+ /**
341
+ * The card either party sees for a paid media attachment. `isDraft`/`isPreview`
342
+ * select the composer's own drafting view / a static receiver-side mockup;
343
+ * otherwise `isMine` selects the sender's sent view vs. the recipient's
344
+ * unlock/download view.
345
+ */
346
+ const MediaCard: React.FC<MediaCardProps> = (props) => {
347
+ if (props.isDraft) {
348
+ return <DraftMediaCard {...props} />
349
+ } else if (props.isPreview) {
350
+ return <PreviewMediaCard {...props} />
351
+ } else if (props.isMine) {
352
+ return <OwnMediaCard {...props} />
353
+ } else {
354
+ return <OtherMediaCard {...props} />
355
+ }
356
+ }
357
+
358
+ export default MediaCard
@@ -1,6 +1,8 @@
1
1
  import { DownloadSimpleIcon, LockSimpleIcon } from '@phosphor-icons/react'
2
2
  import React from 'react'
3
3
 
4
+ import LoadingDots from '../_shared/LoadingDots'
5
+
4
6
  export interface ReceivedCardActionsProps {
5
7
  /** Whether the card is currently locked. Drives which CTA we render. */
6
8
  isLocked: boolean
@@ -19,7 +21,7 @@ export interface ReceivedCardActionsProps {
19
21
  }
20
22
 
21
23
  const buttonClass =
22
- 'mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full bg-[#121110] px-4 text-sm font-medium leading-none text-white hover:bg-[#2a2928] disabled:opacity-70'
24
+ 'mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full bg-[#121110] px-4 text-sm font-medium leading-none text-white hover:bg-[#2a2928]'
23
25
 
24
26
  /**
25
27
  * Renders the primary CTA below the body of a Received card.
@@ -42,10 +44,13 @@ const ReceivedCardActions: React.FC<ReceivedCardActionsProps> = ({
42
44
  type="button"
43
45
  onClick={onUnlockClicked}
44
46
  disabled={isUnlocking}
47
+ aria-label={isUnlocking ? 'Unlocking' : 'Unlock'}
48
+ aria-busy={isUnlocking}
49
+ title={isUnlocking ? 'Unlocking' : 'Unlock'}
45
50
  className={buttonClass}
46
51
  >
47
52
  {isUnlocking ? (
48
- <span className="size-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
53
+ <LoadingDots />
49
54
  ) : (
50
55
  <React.Fragment>
51
56
  <LockSimpleIcon className="size-4" weight="fill" />
@@ -62,6 +67,7 @@ const ReceivedCardActions: React.FC<ReceivedCardActionsProps> = ({
62
67
  return (
63
68
  <a
64
69
  href={href}
70
+ aria-label="Download"
65
71
  target="_blank"
66
72
  rel="noopener noreferrer"
67
73
  onClick={onDownloadClicked}
@@ -77,6 +83,7 @@ const ReceivedCardActions: React.FC<ReceivedCardActionsProps> = ({
77
83
  <button
78
84
  type="button"
79
85
  onClick={onDownloadClicked}
86
+ aria-label="Download"
80
87
  className={buttonClass}
81
88
  >
82
89
  <DownloadSimpleIcon className="size-4" weight="bold" />
@@ -0,0 +1,79 @@
1
+ import type {
2
+ FetchSourceHandler,
3
+ LockedAttachmentGalleryItem,
4
+ PaymentStatus,
5
+ } from '../../types'
6
+
7
+ interface MediaCardBaseProps {
8
+ /** Attachment title. */
9
+ title?: string
10
+ /** Fallback shown when `title` is unset. */
11
+ placeholderTitle?: string
12
+ /** Drives icon/thumbnail rendering. */
13
+ mimeType?: string
14
+ /** Poster image, locked or unlocked. */
15
+ thumbnailUrl?: string
16
+ /** Secondary description text. */
17
+ detail?: string
18
+ /** Amount label. */
19
+ amountText?: string
20
+ /** 2+ items renders as a carousel instead of a single thumbnail. */
21
+ gallery?: LockedAttachmentGalleryItem[]
22
+ }
23
+
24
+ export interface MediaCardDraftProps extends MediaCardBaseProps {
25
+ /** True while composing — shows the real (unredacted) thumbnail. */
26
+ isDraft: true
27
+ isPreview?: false
28
+ /** Fallback amount before a price is set. */
29
+ placeholderAmountText?: string
30
+ /** Lazily fetches the source on first tap; cached and reused after. */
31
+ onFetchSource?: FetchSourceHandler
32
+ /** Fired on first tap to preview. */
33
+ onPreviewClick?: () => void
34
+ /** Removes the draft attachment. */
35
+ onDismiss?: () => void
36
+ /** Renders a pencil button to edit the attachment metadata. */
37
+ onEditClick?: () => void
38
+ }
39
+
40
+ export interface MediaCardPreviewProps extends MediaCardBaseProps {
41
+ /** True to render exactly as the recipient will see it before purchase — always locked/blurred. */
42
+ isDraft?: false
43
+ isPreview: true
44
+ /** Fallback amount before a price is set. */
45
+ placeholderAmountText?: string
46
+ }
47
+
48
+ interface MediaCardSentBaseProps extends MediaCardBaseProps {
49
+ /** False once sent. */
50
+ isDraft?: false
51
+ isPreview?: false
52
+ /** Drives the lock/skeleton/badge state. */
53
+ paymentStatus?: PaymentStatus
54
+ onFetchSource?: FetchSourceHandler
55
+ }
56
+
57
+ export type MediaCardOwnProps = MediaCardSentBaseProps & {
58
+ /** Sender's own view. */
59
+ isMine: true
60
+ /** Fired on first tap to preview own attachment. */
61
+ onPreviewClick?: () => void
62
+ }
63
+
64
+ export type MediaCardOtherProps = MediaCardSentBaseProps & {
65
+ /** Recipient's view. */
66
+ isMine: false
67
+ /** Opens checkout on click. Omit to hide the button. */
68
+ onUnlockClick?: () => void
69
+ /** Omit to hide the Download button. */
70
+ onDownloadClick?: () => void
71
+ /** Spinner on Unlock while checkout runs. */
72
+ isUnlocking?: boolean
73
+ }
74
+
75
+ export type MediaCardProps =
76
+ | MediaCardDraftProps
77
+ | MediaCardPreviewProps
78
+ | MediaCardOwnProps
79
+ | MediaCardOtherProps