@linktr.ee/messaging-react 3.8.1 → 3.9.0-rc-1783406511

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 (87) hide show
  1. package/dist/Card-BG-Mg_ss.js +221 -0
  2. package/dist/Card-BG-Mg_ss.js.map +1 -0
  3. package/dist/Card-CmRzkuPw.cjs +2 -0
  4. package/dist/Card-CmRzkuPw.cjs.map +1 -0
  5. package/dist/Card-DSG-wR8t.js +96 -0
  6. package/dist/Card-DSG-wR8t.js.map +1 -0
  7. package/dist/Card-DVEuTlf_.cjs +2 -0
  8. package/dist/Card-DVEuTlf_.cjs.map +1 -0
  9. package/dist/Card-K9ky-G-t.js +108 -0
  10. package/dist/Card-K9ky-G-t.js.map +1 -0
  11. package/dist/Card-iKTcn1wg.cjs +2 -0
  12. package/dist/Card-iKTcn1wg.cjs.map +1 -0
  13. package/dist/PurchaseStatusBadge-CJH4S-8Z.js +36 -0
  14. package/dist/PurchaseStatusBadge-CJH4S-8Z.js.map +1 -0
  15. package/dist/PurchaseStatusBadge-UjDzZhLj.cjs +2 -0
  16. package/dist/PurchaseStatusBadge-UjDzZhLj.cjs.map +1 -0
  17. package/dist/TextShell-BiqF2UB0.js +32 -0
  18. package/dist/TextShell-BiqF2UB0.js.map +1 -0
  19. package/dist/TextShell-Bnzh0T75.cjs +2 -0
  20. package/dist/TextShell-Bnzh0T75.cjs.map +1 -0
  21. package/dist/assets/index.css +1 -1
  22. package/dist/{index-BCgRoF_u.js → index-DO_dYsIf.js} +1154 -1113
  23. package/dist/index-DO_dYsIf.js.map +1 -0
  24. package/dist/index-DpzmKL5U.cjs +2 -0
  25. package/dist/index-DpzmKL5U.cjs.map +1 -0
  26. package/dist/index.cjs +1 -1
  27. package/dist/index.d.ts +98 -55
  28. package/dist/index.js +1 -1
  29. package/dist/testing.d.ts +1 -0
  30. package/dist/usePreviewToggle-B5kmPELX.cjs +2 -0
  31. package/dist/usePreviewToggle-B5kmPELX.cjs.map +1 -0
  32. package/dist/usePreviewToggle-D27Xw9Xx.js +323 -0
  33. package/dist/usePreviewToggle-D27Xw9Xx.js.map +1 -0
  34. package/package.json +2 -2
  35. package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -1
  36. package/src/components/CustomMessage/MessageTag.tsx +8 -0
  37. package/src/components/CustomMessage/index.tsx +61 -22
  38. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +410 -333
  39. package/src/components/LockedAttachment/LockedAttachment.test.tsx +104 -0
  40. package/src/components/LockedAttachment/components/Composer/Card.tsx +97 -120
  41. package/src/components/LockedAttachment/components/Media/Card.tsx +237 -0
  42. package/src/components/LockedAttachment/components/Text/Card.tsx +177 -0
  43. package/src/components/LockedAttachment/components/_shared/CardBody.tsx +3 -3
  44. package/src/components/LockedAttachment/components/_shared/CardThumbnail.tsx +92 -0
  45. package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +11 -33
  46. package/src/components/LockedAttachment/components/_shared/LoadingDots.tsx +36 -0
  47. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +21 -11
  48. package/src/components/LockedAttachment/components/_shared/{LockedCardShell.tsx → MediaShell.tsx} +5 -5
  49. package/src/components/LockedAttachment/components/_shared/PreviewToggle.tsx +58 -0
  50. package/src/components/LockedAttachment/components/_shared/PriceLockRow.tsx +21 -0
  51. package/src/components/LockedAttachment/components/_shared/PurchaseStatusBadge.tsx +87 -0
  52. package/src/components/LockedAttachment/components/_shared/{LockedThumbnail.tsx → SingleThumbnail.tsx} +5 -5
  53. package/src/components/LockedAttachment/components/_shared/SkeletonTextLines.tsx +39 -0
  54. package/src/components/LockedAttachment/components/_shared/TextShell.tsx +19 -0
  55. package/src/components/LockedAttachment/components/_shared/usePreviewToggle.test.ts +67 -0
  56. package/src/components/LockedAttachment/components/_shared/usePreviewToggle.ts +89 -0
  57. package/src/components/LockedAttachment/components/_shared/useReceivedSource.ts +83 -0
  58. package/src/components/LockedAttachment/index.tsx +17 -28
  59. package/src/components/LockedAttachment/types.ts +28 -4
  60. package/src/index.ts +2 -5
  61. package/src/stream-custom-data.ts +1 -0
  62. package/src/styles.css +5 -0
  63. package/dist/Card-BRvcFjsq.js +0 -132
  64. package/dist/Card-BRvcFjsq.js.map +0 -1
  65. package/dist/Card-BuXkok0A.cjs +0 -2
  66. package/dist/Card-BuXkok0A.cjs.map +0 -1
  67. package/dist/Card-CvNHv3me.js +0 -107
  68. package/dist/Card-CvNHv3me.js.map +0 -1
  69. package/dist/Card-DOSuQdnk.cjs +0 -2
  70. package/dist/Card-DOSuQdnk.cjs.map +0 -1
  71. package/dist/Card-NnQw99XE.cjs +0 -2
  72. package/dist/Card-NnQw99XE.cjs.map +0 -1
  73. package/dist/Card-jGOcSN1O.js +0 -163
  74. package/dist/Card-jGOcSN1O.js.map +0 -1
  75. package/dist/LockedThumbnail-EsjaAqLR.js +0 -220
  76. package/dist/LockedThumbnail-EsjaAqLR.js.map +0 -1
  77. package/dist/LockedThumbnail-uAauUd5c.cjs +0 -2
  78. package/dist/LockedThumbnail-uAauUd5c.cjs.map +0 -1
  79. package/dist/index-BCgRoF_u.js.map +0 -1
  80. package/dist/index-DRurVQ0-.cjs +0 -2
  81. package/dist/index-DRurVQ0-.cjs.map +0 -1
  82. package/src/components/LockedAttachment/components/Composer/index.ts +0 -2
  83. package/src/components/LockedAttachment/components/Received/Card.tsx +0 -191
  84. package/src/components/LockedAttachment/components/Received/index.ts +0 -2
  85. package/src/components/LockedAttachment/components/Sent/Card.tsx +0 -177
  86. package/src/components/LockedAttachment/components/Sent/index.ts +0 -2
  87. /package/src/components/LockedAttachment/components/{Received → Media}/CardActions.tsx +0 -0
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+
3
+ import { renderWithProviders, screen } from '../../test/utils'
4
+
5
+ import ComposerCard from './components/Composer/Card'
6
+ import MediaCard from './components/Media/Card'
7
+ import TextCard from './components/Text/Card'
8
+
9
+ /**
10
+ * Characterization tests — the safety net for the LockedAttachment
11
+ * separate-concerns refactor. They assert stable, user-visible facts only
12
+ * (lock affordances, CTAs, amount/status text, the preview toggle), never
13
+ * DOM structure or class names — exactly the things the refactor moves
14
+ * around. Storybook remains the pixel-level visual oracle.
15
+ */
16
+ describe('LockedAttachment (characterization)', () => {
17
+ describe('Composer', () => {
18
+ it('media mode: shows the placeholder title when locked with no title', () => {
19
+ renderWithProviders(<ComposerCard />)
20
+ expect(screen.getByText('Attachment title')).toBeInTheDocument()
21
+ })
22
+
23
+ it('media mode: renders a preview toggle when a source fetcher is wired', () => {
24
+ renderWithProviders(
25
+ <ComposerCard onFetchSource={vi.fn().mockResolvedValue(undefined)} />
26
+ )
27
+ expect(
28
+ screen.getByRole('button', { name: 'Toggle preview' })
29
+ ).toBeInTheDocument()
30
+ })
31
+
32
+ it('media mode: renders a dismiss control when onDismiss is provided', () => {
33
+ renderWithProviders(<ComposerCard onDismiss={vi.fn()} />)
34
+ expect(
35
+ screen.getByRole('button', { name: 'Dismiss attachment' })
36
+ ).toBeInTheDocument()
37
+ })
38
+
39
+ it('text mode: shows the draft text and amount', () => {
40
+ renderWithProviders(
41
+ <ComposerCard contentType="text" text="Secret draft" amountText="$5" />
42
+ )
43
+ expect(screen.getByText('Secret draft')).toBeInTheDocument()
44
+ expect(screen.getByText('$5')).toBeInTheDocument()
45
+ })
46
+ })
47
+
48
+ describe('Media (isMine)', () => {
49
+ it('shows the amount in the status badge', () => {
50
+ renderWithProviders(<MediaCard isMine amountText="$5" />)
51
+ expect(screen.getByText('$5')).toBeInTheDocument()
52
+ })
53
+ })
54
+
55
+ describe('Text (isMine)', () => {
56
+ it('pending: shows the Sent status and amount', () => {
57
+ renderWithProviders(
58
+ <TextCard isMine amountText="$5" paymentStatus="pending" />
59
+ )
60
+ expect(screen.getByText('Sent')).toBeInTheDocument()
61
+ expect(screen.getByText('$5')).toBeInTheDocument()
62
+ })
63
+
64
+ it('paid: shows the Sold label', () => {
65
+ renderWithProviders(
66
+ <TextCard isMine amountText="$5" paymentStatus="paid" />
67
+ )
68
+ expect(screen.getByText('Sold')).toBeInTheDocument()
69
+ })
70
+ })
71
+
72
+ describe('Media (!isMine)', () => {
73
+ it('renders the locked-attachment root and an Unlock CTA when locked', () => {
74
+ renderWithProviders(
75
+ <MediaCard isMine={false} amountText="$5" onUnlockClick={vi.fn()} />
76
+ )
77
+ expect(screen.getByTestId('locked-attachment')).toBeInTheDocument()
78
+ expect(
79
+ screen.getByRole('button', { name: 'Unlock' })
80
+ ).toBeInTheDocument()
81
+ })
82
+ })
83
+
84
+ describe('Text (!isMine)', () => {
85
+ it('shows an Unlock control and the amount', () => {
86
+ renderWithProviders(
87
+ <TextCard isMine={false} amountText="$5" onUnlockClick={vi.fn()} />
88
+ )
89
+ expect(
90
+ screen.getByRole('button', { name: 'Unlock' })
91
+ ).toBeInTheDocument()
92
+ expect(screen.getByText('$5')).toBeInTheDocument()
93
+ })
94
+
95
+ it('unlocking: marks the Unlock control busy', () => {
96
+ renderWithProviders(
97
+ <TextCard isMine={false} isUnlocking onUnlockClick={vi.fn()} />
98
+ )
99
+ const button = screen.getByRole('button', { name: 'Unlocking' })
100
+ expect(button).toBeDisabled()
101
+ expect(button).toHaveAttribute('aria-busy', 'true')
102
+ })
103
+ })
104
+ })
@@ -1,23 +1,31 @@
1
- import { ImagesIcon, PencilSimpleIcon, XIcon } from '@phosphor-icons/react'
2
- import React, { useCallback, useRef, useState } from 'react'
1
+ import { PencilSimpleIcon, XIcon } from '@phosphor-icons/react'
2
+ import React from 'react'
3
3
 
4
- import type {
5
- LockedAttachmentBaseProps,
6
- LockedAttachmentSource,
7
- } from '../../types'
4
+ import type { FetchSourceHandler, LockedAttachmentBaseProps } from '../../types'
8
5
  import CardBody from '../_shared/CardBody'
9
- import GalleryThumbnail from '../_shared/GalleryThumbnail'
10
- import LockedCardShell from '../_shared/LockedCardShell'
11
- import LockedThumbnail from '../_shared/LockedThumbnail'
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 PriceLockRow from '../_shared/PriceLockRow'
13
+ import TextShell from '../_shared/TextShell'
14
+ import { usePreviewToggle } from '../_shared/usePreviewToggle'
12
15
 
13
16
  export interface ComposerCardProps extends LockedAttachmentBaseProps {
14
- /** Placeholder shown in the title slot before the composer types one. */
15
- placeholderTitle?: string
17
+ /**
18
+ * The message's own text, shown as-is (not redacted yet — the message
19
+ * hasn't been sent). Only used when `contentType` is `"text"`.
20
+ */
21
+ text?: string
16
22
  /** Placeholder shown in the amount slot before one is configured. */
17
23
  placeholderAmountText?: string
18
24
  /**
19
- * When provided, renders a dismiss X in the thumbnail corner. Called when
20
- * the composer clicks it to remove the attachment.
25
+ * Renders a persistent dismiss X on the thumbnail (media) or next to the
26
+ * text (text mode); called to remove the draft attachment. Unrelated to
27
+ * `TextCardProps.onDismiss`, which only appears for `isMine` after a
28
+ * failed payment.
21
29
  */
22
30
  onDismiss?: () => void
23
31
  /** Fired the first time the composer taps the thumbnail to preview. */
@@ -26,8 +34,9 @@ export interface ComposerCardProps extends LockedAttachmentBaseProps {
26
34
  * Lazily loads the underlying source so the composer can preview the
27
35
  * attachment they're about to send. Called the first time the thumbnail is
28
36
  * tapped; the returned source is cached and reused on subsequent toggles.
37
+ * Ignored when `contentType` is `"text"`.
29
38
  */
30
- onFetchSource?: () => Promise<LockedAttachmentSource | void>
39
+ onFetchSource?: FetchSourceHandler
31
40
  /**
32
41
  * When provided, renders a pencil button in the body bottom-right that the
33
42
  * composer can use to edit the attachment metadata (e.g. open the price /
@@ -37,10 +46,42 @@ export interface ComposerCardProps extends LockedAttachmentBaseProps {
37
46
  }
38
47
 
39
48
  /**
40
- * The card the composer sees while drafting a paid attachment.
41
- * Matches the Composer column of the messaging design system in Figma.
49
+ * Composer card in text mode: a normal outgoing chat bubble showing the real
50
+ * draft text (it hasn't been sent/redacted yet) plus a dismiss X and a price
51
+ * row. Media-only props are ignored here.
52
+ */
53
+ const ComposerTextCard: React.FC<ComposerCardProps> = ({
54
+ text,
55
+ amountText,
56
+ placeholderAmountText,
57
+ onDismiss,
58
+ }) => (
59
+ <TextShell>
60
+ <div className="pr-8">{text}</div>
61
+ <PriceLockRow>
62
+ <span className={amountText ? 'text-white/55' : 'text-white/30'}>
63
+ {amountText || placeholderAmountText}
64
+ </span>
65
+ </PriceLockRow>
66
+ {onDismiss && (
67
+ <button
68
+ type="button"
69
+ onClick={onDismiss}
70
+ className="absolute right-3 top-3 flex size-6 shrink-0 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/15"
71
+ aria-label="Dismiss attachment"
72
+ >
73
+ <XIcon className="size-3" weight="bold" />
74
+ </button>
75
+ )}
76
+ </TextShell>
77
+ )
78
+
79
+ /**
80
+ * Composer card in media mode: the locked thumbnail (single or gallery) the
81
+ * composer taps to preview the attachment they're about to send, plus the
82
+ * title/price body with optional dismiss X and edit pencil.
42
83
  */
43
- const ComposerCard: React.FC<ComposerCardProps> = ({
84
+ const ComposerMediaCard: React.FC<ComposerCardProps> = ({
44
85
  title,
45
86
  mimeType = 'application/octet-stream',
46
87
  thumbnailUrl,
@@ -48,55 +89,19 @@ const ComposerCard: React.FC<ComposerCardProps> = ({
48
89
  amountText,
49
90
  placeholderTitle = 'Attachment title',
50
91
  placeholderAmountText,
51
- gallery,
92
+ gallery = [],
52
93
  onDismiss,
53
94
  onPreviewClick,
54
95
  onFetchSource,
55
96
  onEditClick,
56
97
  }) => {
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])
98
+ const isGallery = isGalleryCard(gallery)
95
99
 
96
100
  // Gallery is always previewable in the composer because each item's source
97
101
  // is already provided up-front via `gallery[*].sourceUrl`.
98
- const togglePreview =
99
- isGallery || onFetchSource || onPreviewClick ? handleToggle : undefined
102
+ const { source, isPreviewVisible, isLoadingPreview, togglePreview } =
103
+ usePreviewToggle({ isGallery, onFetchSource, onPreviewClick })
104
+
100
105
  const showLocked = !isPreviewVisible
101
106
  const isBusy = isLoadingPreview
102
107
 
@@ -142,63 +147,24 @@ const ComposerCard: React.FC<ComposerCardProps> = ({
142
147
  </button>
143
148
  ) : undefined
144
149
 
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
150
  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
- )}
151
+ <MediaShell variant="dark">
152
+ <PreviewToggle
153
+ onToggle={togglePreview}
154
+ isBusy={isBusy}
155
+ isPreviewVisible={!showLocked}
156
+ >
157
+ <CardThumbnail
158
+ variant="dark"
159
+ gallery={gallery}
160
+ title={title}
161
+ showLocked={showLocked}
162
+ mimeType={mimeType}
163
+ thumbnailUrl={thumbnailUrl}
164
+ source={source}
165
+ topRight={dismissButton}
166
+ />
167
+ </PreviewToggle>
202
168
 
203
169
  <CardBody
204
170
  variant="dark"
@@ -207,15 +173,26 @@ const ComposerCard: React.FC<ComposerCardProps> = ({
207
173
  mimeType={mimeType}
208
174
  detail={detail}
209
175
  statusBadge={statusBadge}
210
- icon={
211
- isGallery ? (
212
- <ImagesIcon className="size-5 shrink-0 text-white/55" />
213
- ) : undefined
214
- }
176
+ icon={isGallery ? galleryBodyIcon('dark') : undefined}
215
177
  trailingAction={editButton}
216
178
  />
217
- </LockedCardShell>
179
+ </MediaShell>
218
180
  )
219
181
  }
220
182
 
183
+ /**
184
+ * The card the composer sees while drafting a paid attachment.
185
+ * Matches the Composer column of the messaging design system in Figma.
186
+ *
187
+ * When `contentType === "text"`, renders as a normal outgoing chat bubble
188
+ * showing the real draft text (it hasn't been sent/redacted yet) plus a
189
+ * dismiss X and a price row, instead of a media thumbnail card.
190
+ */
191
+ const ComposerCard: React.FC<ComposerCardProps> = (props) =>
192
+ props.contentType === 'text' ? (
193
+ <ComposerTextCard {...props} />
194
+ ) : (
195
+ <ComposerMediaCard {...props} />
196
+ )
197
+
221
198
  export default ComposerCard
@@ -0,0 +1,237 @@
1
+ import type React from 'react'
2
+
3
+ import { getSourceType } from '../../../AttachmentCard'
4
+ import type {
5
+ FetchSourceHandler,
6
+ LockedAttachmentGalleryItem,
7
+ PaymentStatus,
8
+ } from '../../types'
9
+ import CardBody from '../_shared/CardBody'
10
+ import CardThumbnail, {
11
+ galleryBodyIcon,
12
+ isGalleryCard,
13
+ } from '../_shared/CardThumbnail'
14
+ import MediaShell from '../_shared/MediaShell'
15
+ import PreviewToggle from '../_shared/PreviewToggle'
16
+ import PurchaseStatusBadge from '../_shared/PurchaseStatusBadge'
17
+ import { usePreviewToggle } from '../_shared/usePreviewToggle'
18
+ import { useReceivedSource } from '../_shared/useReceivedSource'
19
+
20
+ import ReceivedCardActions from './CardActions'
21
+
22
+ interface MediaCardSharedProps {
23
+ title?: string
24
+ mimeType?: string
25
+ thumbnailUrl?: string
26
+ detail?: string
27
+ amountText?: string
28
+ paymentStatus?: PaymentStatus
29
+ gallery?: LockedAttachmentGalleryItem[]
30
+ onFetchSource?: FetchSourceHandler
31
+ }
32
+
33
+ export type MediaCardProps =
34
+ | (MediaCardSharedProps & {
35
+ isMine: true
36
+ /** Dimmed fallback shown in the title slot when `title` is unset. */
37
+ placeholderTitle?: string
38
+ /** Fired the first time the sender taps the thumbnail to preview their own attachment. */
39
+ onPreviewClick?: () => void
40
+ })
41
+ | (MediaCardSharedProps & {
42
+ isMine: false
43
+ /**
44
+ * When true, shows a loading spinner on the Unlock button. Driven by
45
+ * the LockedAttachmentContext (e.g. checkout in progress).
46
+ */
47
+ isUnlocking?: boolean
48
+ /**
49
+ * Called when the recipient clicks Unlock on an unpaid attachment.
50
+ * Use this to open a checkout flow. Omit to hide the Unlock button.
51
+ */
52
+ onUnlockClick?: () => void
53
+ /**
54
+ * Called when the recipient clicks Download on an unlocked card.
55
+ * Omit to hide the Download button.
56
+ */
57
+ onDownloadClick?: () => void
58
+ })
59
+
60
+ /**
61
+ * The sender's ('mine') view of a paid media attachment: the locked
62
+ * thumbnail (single or gallery) they can tap to preview their own
63
+ * attachment, plus the title/purchase-status body.
64
+ */
65
+ const OwnMediaCard: React.FC<
66
+ MediaCardSharedProps & {
67
+ isMine: true
68
+ placeholderTitle?: string
69
+ onPreviewClick?: () => void
70
+ }
71
+ > = ({
72
+ title,
73
+ mimeType = 'application/octet-stream',
74
+ thumbnailUrl,
75
+ detail,
76
+ amountText,
77
+ placeholderTitle = 'Attachment title',
78
+ paymentStatus = 'pending',
79
+ gallery = [],
80
+ onPreviewClick,
81
+ onFetchSource,
82
+ }) => {
83
+ const isGallery = isGalleryCard(gallery)
84
+
85
+ // Gallery is always previewable for the sender because each item's source
86
+ // is already provided up-front via `gallery[*].sourceUrl`.
87
+ const { source, isPreviewVisible, isLoadingPreview, togglePreview } =
88
+ usePreviewToggle({ isGallery, onFetchSource, onPreviewClick })
89
+
90
+ const showLocked = !isPreviewVisible
91
+ const isBusy = isLoadingPreview
92
+
93
+ return (
94
+ <MediaShell variant="dark">
95
+ <PreviewToggle
96
+ onToggle={togglePreview}
97
+ isBusy={isBusy}
98
+ isPreviewVisible={!showLocked}
99
+ >
100
+ <CardThumbnail
101
+ variant="dark"
102
+ gallery={gallery}
103
+ title={title}
104
+ showLocked={showLocked}
105
+ paymentStatus={paymentStatus}
106
+ mimeType={mimeType}
107
+ thumbnailUrl={thumbnailUrl}
108
+ source={source}
109
+ />
110
+ </PreviewToggle>
111
+
112
+ <CardBody
113
+ variant="dark"
114
+ title={title}
115
+ placeholderTitle={placeholderTitle}
116
+ mimeType={mimeType}
117
+ detail={detail}
118
+ statusBadge={
119
+ <PurchaseStatusBadge
120
+ isMine={true}
121
+ paymentStatus={paymentStatus}
122
+ amountText={amountText}
123
+ />
124
+ }
125
+ icon={isGallery ? galleryBodyIcon('dark') : undefined}
126
+ />
127
+ </MediaShell>
128
+ )
129
+ }
130
+
131
+ /**
132
+ * The recipient's ('other') view of a paid media attachment: the locked
133
+ * thumbnail (single or gallery) with an Unlock/Download CTA. The source is
134
+ * revealed by fetching once — on click when already paid, or automatically
135
+ * the first time a paid card scrolls into view.
136
+ */
137
+ const OtherMediaCard: React.FC<
138
+ MediaCardSharedProps & {
139
+ isMine: false
140
+ onUnlockClick?: () => void
141
+ onDownloadClick?: () => void
142
+ isUnlocking?: boolean
143
+ }
144
+ > = ({
145
+ title,
146
+ amountText,
147
+ thumbnailUrl,
148
+ mimeType = 'application/octet-stream',
149
+ detail,
150
+ gallery = [],
151
+ onUnlockClick,
152
+ onFetchSource,
153
+ onDownloadClick,
154
+ paymentStatus,
155
+ isUnlocking = false,
156
+ }) => {
157
+ const isGallery = isGalleryCard(gallery)
158
+
159
+ const { source, cardRef, handleUnlockClick } = useReceivedSource({
160
+ paymentStatus,
161
+ onFetchSource,
162
+ onUnlockClick,
163
+ })
164
+
165
+ const effectiveSourceUrl = source?.sourceUrl
166
+ const effectiveRedeemUrl = source?.redeemUrl
167
+
168
+ // For gallery, the per-item sources are carried by `gallery` itself, so
169
+ // the lock state is driven by paymentStatus rather than the single
170
+ // `effectiveSourceUrl`. Everything else still respects the source fetch.
171
+ const isLocked = isGallery
172
+ ? paymentStatus !== 'paid'
173
+ : effectiveSourceUrl === undefined
174
+ const sourceType = getSourceType(mimeType)
175
+
176
+ // For gallery, ReceivedCardActions falls back to a plain <button>
177
+ // (no anchor) because the gallery has no single sourceUrl to link to —
178
+ // the per-item sources live on `gallery` itself.
179
+ const actionSourceUrl = isGallery ? undefined : effectiveSourceUrl
180
+
181
+ return (
182
+ <MediaShell
183
+ variant="light"
184
+ rootRef={cardRef}
185
+ data-testid="locked-attachment"
186
+ >
187
+ <CardThumbnail
188
+ variant="light"
189
+ gallery={gallery}
190
+ title={title}
191
+ showLocked={isLocked}
192
+ paymentStatus={paymentStatus}
193
+ mimeType={mimeType}
194
+ thumbnailUrl={thumbnailUrl}
195
+ source={source}
196
+ containedImage={
197
+ !isLocked && (sourceType === 'image' || sourceType === 'document')
198
+ }
199
+ />
200
+
201
+ <CardBody
202
+ variant="light"
203
+ title={title}
204
+ mimeType={mimeType}
205
+ detail={detail}
206
+ statusBadge={
207
+ <PurchaseStatusBadge
208
+ isMine={false}
209
+ paymentStatus={paymentStatus}
210
+ amountText={amountText}
211
+ />
212
+ }
213
+ icon={isGallery ? galleryBodyIcon('light') : undefined}
214
+ action={
215
+ <ReceivedCardActions
216
+ isLocked={isLocked}
217
+ isUnlocking={isUnlocking}
218
+ sourceUrl={actionSourceUrl}
219
+ redeemUrl={effectiveRedeemUrl}
220
+ onUnlockClicked={handleUnlockClick}
221
+ onDownloadClicked={onDownloadClick}
222
+ />
223
+ }
224
+ />
225
+ </MediaShell>
226
+ )
227
+ }
228
+
229
+ /**
230
+ * The card either party sees for a paid media attachment. `isMine` selects
231
+ * the sender's preview view (tap-to-preview own draft) vs. the recipient's
232
+ * unlock/download view.
233
+ */
234
+ const MediaCard: React.FC<MediaCardProps> = (props) =>
235
+ props.isMine ? <OwnMediaCard {...props} /> : <OtherMediaCard {...props} />
236
+
237
+ export default MediaCard