@linktr.ee/messaging-react 2.0.0 → 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 (50) 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-Brz9orsI.js → index-B1h46F9x.js} +811 -772
  11. package/dist/index-B1h46F9x.js.map +1 -0
  12. package/dist/index.d.ts +87 -28
  13. package/dist/index.js +3 -3
  14. package/package.json +1 -1
  15. package/src/components/ChannelView.test.tsx +11 -0
  16. package/src/components/ChannelView.tsx +35 -32
  17. package/src/components/CustomMessage/index.tsx +2 -3
  18. package/src/components/CustomTypingIndicator/CustomTypingIndicator.stories.tsx +57 -17
  19. package/src/components/CustomTypingIndicator/CustomTypingIndicator.test.tsx +187 -0
  20. package/src/components/CustomTypingIndicator/DmAgentContext.ts +3 -0
  21. package/src/components/CustomTypingIndicator/index.tsx +101 -37
  22. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +230 -89
  23. package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
  24. package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
  25. package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
  26. package/src/components/LockedAttachment/components/Received/CardActions.tsx +91 -0
  27. package/src/components/LockedAttachment/components/Received/index.ts +2 -0
  28. package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
  29. package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
  30. package/src/components/LockedAttachment/components/_shared/CardBody.tsx +94 -0
  31. package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +178 -0
  32. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
  33. package/src/components/LockedAttachment/components/_shared/LockedCardShell.tsx +36 -0
  34. package/src/components/LockedAttachment/components/_shared/LockedThumbnail.tsx +128 -0
  35. package/src/components/LockedAttachment/index.tsx +43 -12
  36. package/src/components/LockedAttachment/types.ts +17 -0
  37. package/src/components/MediaMessage/index.tsx +2 -2
  38. package/src/index.ts +6 -1
  39. package/src/styles.css +7 -0
  40. package/dist/Card-BHknCeHw.js +0 -138
  41. package/dist/Card-BHknCeHw.js.map +0 -1
  42. package/dist/Card-DT7_ms2p.js +0 -127
  43. package/dist/Card-DT7_ms2p.js.map +0 -1
  44. package/dist/index-Brz9orsI.js.map +0 -1
  45. package/src/components/LockedAttachment/components/Creator/Card.tsx +0 -210
  46. package/src/components/LockedAttachment/components/Creator/index.tsx +0 -2
  47. package/src/components/LockedAttachment/components/Visitor/Card.tsx +0 -155
  48. package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +0 -62
  49. package/src/components/LockedAttachment/components/Visitor/LockBadge.tsx +0 -12
  50. package/src/components/LockedAttachment/components/Visitor/index.ts +0 -2
@@ -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'
@@ -0,0 +1,191 @@
1
+ import { CheckCircleIcon, ImagesIcon } from '@phosphor-icons/react'
2
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
3
+
4
+ import { getSourceType } from '../../../AttachmentCard'
5
+ import type {
6
+ LockedAttachmentBaseProps,
7
+ LockedAttachmentSource,
8
+ } from '../../types'
9
+ import CardBody from '../_shared/CardBody'
10
+ import GalleryThumbnail from '../_shared/GalleryThumbnail'
11
+ import LockedCardShell from '../_shared/LockedCardShell'
12
+ import LockedThumbnail from '../_shared/LockedThumbnail'
13
+
14
+ import ReceivedCardActions from './CardActions'
15
+
16
+ export interface ReceivedCardProps extends LockedAttachmentBaseProps {
17
+ /**
18
+ * Called when the recipient clicks Unlock on an unpaid attachment.
19
+ * Use this to open a checkout flow. Omit to hide the Unlock button.
20
+ */
21
+ onUnlockClick?: () => void
22
+ /**
23
+ * Called to fetch the attachment source — fired automatically when
24
+ * `paymentStatus` transitions to `'paid'`, or immediately on click when
25
+ * `paymentStatus` is already `'paid'`. Return a `LockedAttachmentSource`
26
+ * to unlock the card.
27
+ */
28
+ onFetchSource?: () => Promise<LockedAttachmentSource | void>
29
+ /**
30
+ * Called when the recipient clicks Download on an unlocked card.
31
+ * Omit to hide the Download button.
32
+ */
33
+ onDownloadClick?: () => void
34
+ /**
35
+ * When true, shows a loading spinner on the Unlock button.
36
+ * Driven by the LockedAttachmentContext (e.g. checkout in progress).
37
+ */
38
+ isUnlocking?: boolean
39
+ }
40
+
41
+ /**
42
+ * The card the recipient sees in chat for a paid attachment.
43
+ * Matches the Received column of the messaging design system in Figma:
44
+ * locked → blurred thumbnail + Unlock CTA, unlocked → clear image + Download CTA.
45
+ */
46
+ const ReceivedCard: React.FC<ReceivedCardProps> = ({
47
+ title,
48
+ amountText,
49
+ thumbnailUrl,
50
+ mimeType = 'application/octet-stream',
51
+ detail,
52
+ gallery,
53
+ onUnlockClick,
54
+ onFetchSource,
55
+ onDownloadClick,
56
+ paymentStatus,
57
+ isUnlocking = false,
58
+ }) => {
59
+ const isGallery = (gallery?.length ?? 0) >= 2
60
+ const [source, setSource] = useState<LockedAttachmentSource | undefined>()
61
+
62
+ const cardRef = useRef<HTMLDivElement>(null)
63
+ const fetchingRef = useRef(false)
64
+
65
+ const onFetchSourceRef = useRef(onFetchSource)
66
+ onFetchSourceRef.current = onFetchSource
67
+
68
+ const effectiveSourceUrl = source?.sourceUrl
69
+ const effectiveRedeemUrl = source?.redeemUrl
70
+
71
+ const fetchSource = useCallback(async (): Promise<void> => {
72
+ if (fetchingRef.current) return
73
+ fetchingRef.current = true
74
+ try {
75
+ const result = await onFetchSourceRef.current?.()
76
+ if (result) setSource(result)
77
+ } finally {
78
+ fetchingRef.current = false
79
+ }
80
+ }, [])
81
+
82
+ const handleUnlockClick = useCallback(() => {
83
+ if (paymentStatus === 'paid') {
84
+ void fetchSource()
85
+ } else {
86
+ onUnlockClick?.()
87
+ }
88
+ }, [paymentStatus, fetchSource, onUnlockClick])
89
+
90
+ // Auto-fetch the source once the paid card scrolls into view.
91
+ useEffect(() => {
92
+ if (!cardRef.current) return
93
+ if (paymentStatus !== 'paid' || source !== undefined) return
94
+
95
+ const observer = new IntersectionObserver(
96
+ ([entry]) => {
97
+ if (entry.isIntersecting) {
98
+ void fetchSource()
99
+ observer.disconnect()
100
+ }
101
+ },
102
+ { threshold: 1.0 }
103
+ )
104
+
105
+ observer.observe(cardRef.current)
106
+ return () => observer.disconnect()
107
+ }, [paymentStatus, source, fetchSource])
108
+
109
+ // For gallery, the per-item sources are carried by `gallery` itself, so
110
+ // the lock state is driven by paymentStatus rather than the single
111
+ // `effectiveSourceUrl`. Everything else still respects the source fetch.
112
+ const isLocked = isGallery
113
+ ? paymentStatus !== 'paid'
114
+ : effectiveSourceUrl === undefined
115
+ const sourceType = getSourceType(mimeType)
116
+
117
+ const statusBadge =
118
+ paymentStatus === 'paid' ? (
119
+ <React.Fragment>
120
+ <span className="text-xs font-medium text-black/55">&bull;</span>
121
+ <span className="text-xs font-medium text-[#008236]">Purchased</span>
122
+ <CheckCircleIcon className="size-4 text-[#008236]" weight="bold" />
123
+ </React.Fragment>
124
+ ) : amountText != null ? (
125
+ <React.Fragment>
126
+ <span className="text-xs font-medium text-black/55">&bull;</span>
127
+ <span className="text-xs font-medium text-black/55">{amountText}</span>
128
+ </React.Fragment>
129
+ ) : null
130
+
131
+ // For gallery, ReceivedCardActions falls back to a plain <button>
132
+ // (no anchor) because the gallery has no single sourceUrl to link to —
133
+ // the per-item sources live on `gallery` itself.
134
+ const actionSourceUrl = isGallery ? undefined : effectiveSourceUrl
135
+
136
+ return (
137
+ <LockedCardShell
138
+ variant="light"
139
+ rootRef={cardRef}
140
+ data-testid="locked-attachment"
141
+ >
142
+ {isGallery ? (
143
+ <GalleryThumbnail
144
+ variant="light"
145
+ gallery={gallery!}
146
+ title={title}
147
+ showLocked={isLocked}
148
+ paymentStatus={paymentStatus}
149
+ />
150
+ ) : (
151
+ <LockedThumbnail
152
+ variant="light"
153
+ mimeType={mimeType}
154
+ thumbnailUrl={thumbnailUrl}
155
+ title={title}
156
+ source={source}
157
+ showLocked={isLocked}
158
+ paymentStatus={paymentStatus}
159
+ containedImage={
160
+ !isLocked && (sourceType === 'image' || sourceType === 'document')
161
+ }
162
+ />
163
+ )}
164
+
165
+ <CardBody
166
+ variant="light"
167
+ title={title}
168
+ mimeType={mimeType}
169
+ detail={detail}
170
+ statusBadge={statusBadge}
171
+ icon={
172
+ isGallery ? (
173
+ <ImagesIcon className="size-5 shrink-0 text-black/55" />
174
+ ) : undefined
175
+ }
176
+ action={
177
+ <ReceivedCardActions
178
+ isLocked={isLocked}
179
+ isUnlocking={isUnlocking}
180
+ sourceUrl={actionSourceUrl}
181
+ redeemUrl={effectiveRedeemUrl}
182
+ onUnlockClicked={handleUnlockClick}
183
+ onDownloadClicked={onDownloadClick}
184
+ />
185
+ }
186
+ />
187
+ </LockedCardShell>
188
+ )
189
+ }
190
+
191
+ export default ReceivedCard
@@ -0,0 +1,91 @@
1
+ import { DownloadSimpleIcon, LockSimpleIcon } from '@phosphor-icons/react'
2
+ import React from 'react'
3
+
4
+ export interface ReceivedCardActionsProps {
5
+ /** Whether the card is currently locked. Drives which CTA we render. */
6
+ isLocked: boolean
7
+ /**
8
+ * When unlocked, the URL used as the Download link's `href` (opens in a new
9
+ * tab). When omitted, the Download CTA falls back to a plain `<button>`
10
+ * that just calls `onDownloadClicked` — useful for galleries where the
11
+ * single-source URL doesn't exist.
12
+ */
13
+ sourceUrl?: string
14
+ /** Optional alternate href that takes precedence over `sourceUrl`. */
15
+ redeemUrl?: string
16
+ onUnlockClicked?: () => void
17
+ onDownloadClicked?: () => void
18
+ isUnlocking?: boolean
19
+ }
20
+
21
+ 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'
23
+
24
+ /**
25
+ * Renders the primary CTA below the body of a Received card.
26
+ * - Locked + onUnlockClicked → "Unlock" button
27
+ * - Unlocked + onDownloadClicked + (redeemUrl || sourceUrl) → "Download" link
28
+ * - Unlocked + onDownloadClicked + no URL → "Download" button (e.g. gallery)
29
+ * Otherwise renders nothing.
30
+ */
31
+ const ReceivedCardActions: React.FC<ReceivedCardActionsProps> = ({
32
+ isLocked,
33
+ sourceUrl,
34
+ redeemUrl,
35
+ onUnlockClicked,
36
+ onDownloadClicked,
37
+ isUnlocking = false,
38
+ }) => {
39
+ if (isLocked && onUnlockClicked != null) {
40
+ return (
41
+ <button
42
+ type="button"
43
+ onClick={onUnlockClicked}
44
+ disabled={isUnlocking}
45
+ className={buttonClass}
46
+ >
47
+ {isUnlocking ? (
48
+ <span className="size-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
49
+ ) : (
50
+ <React.Fragment>
51
+ <LockSimpleIcon className="size-4" weight="fill" />
52
+ Unlock
53
+ </React.Fragment>
54
+ )}
55
+ </button>
56
+ )
57
+ }
58
+
59
+ if (!isLocked && onDownloadClicked != null) {
60
+ const href = redeemUrl ?? sourceUrl
61
+ if (href != null) {
62
+ return (
63
+ <a
64
+ href={href}
65
+ target="_blank"
66
+ rel="noopener noreferrer"
67
+ onClick={onDownloadClicked}
68
+ className={`${buttonClass} !text-white`}
69
+ >
70
+ <DownloadSimpleIcon className="size-4" weight="bold" />
71
+ Download
72
+ </a>
73
+ )
74
+ }
75
+
76
+ return (
77
+ <button
78
+ type="button"
79
+ onClick={onDownloadClicked}
80
+ className={buttonClass}
81
+ >
82
+ <DownloadSimpleIcon className="size-4" weight="bold" />
83
+ Download
84
+ </button>
85
+ )
86
+ }
87
+
88
+ return null
89
+ }
90
+
91
+ export default ReceivedCardActions
@@ -0,0 +1,2 @@
1
+ export { default as ReceivedCard } from './Card'
2
+ export type { ReceivedCardProps } from './Card'
@@ -0,0 +1,177 @@
1
+ import { CheckCircleIcon, ImagesIcon } 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 SentCardProps extends LockedAttachmentBaseProps {
14
+ /** Placeholder shown in the title slot when no title is set. */
15
+ placeholderTitle?: string
16
+ /** Fired the first time the sender taps the thumbnail to preview their own attachment. */
17
+ onPreviewClick?: () => void
18
+ /**
19
+ * Lazily loads the underlying source so the sender can preview the attachment.
20
+ * Called the first time the thumbnail is tapped; the returned source is cached
21
+ * and reused on subsequent toggles.
22
+ */
23
+ onFetchSource?: () => Promise<LockedAttachmentSource | void>
24
+ }
25
+
26
+ /**
27
+ * The card the sender sees in chat after a paid attachment has been posted.
28
+ * Matches the Sent column of the messaging design system in Figma.
29
+ */
30
+ const SentCard: React.FC<SentCardProps> = ({
31
+ title,
32
+ mimeType = 'application/octet-stream',
33
+ thumbnailUrl,
34
+ detail,
35
+ amountText,
36
+ placeholderTitle = 'Attachment title',
37
+ paymentStatus,
38
+ gallery,
39
+ onPreviewClick,
40
+ onFetchSource,
41
+ }) => {
42
+ const [source, setSource] = useState<LockedAttachmentSource | undefined>()
43
+ const [isPreviewVisible, setIsPreviewVisible] = useState(false)
44
+ const [isLoadingPreview, setIsLoadingPreview] = useState(false)
45
+ const fetchingRef = useRef(false)
46
+
47
+ const isGallery = (gallery?.length ?? 0) >= 2
48
+
49
+ const handleToggle = useCallback(async () => {
50
+ onPreviewClick?.()
51
+ if (isPreviewVisible) {
52
+ setIsPreviewVisible(false)
53
+ return
54
+ }
55
+ // Gallery items carry their own per-item sources on the `gallery` prop, so
56
+ // we just flip visibility — no async source fetch is needed.
57
+ if (isGallery) {
58
+ setIsPreviewVisible(true)
59
+ return
60
+ }
61
+ if (source) {
62
+ setIsPreviewVisible(true)
63
+ return
64
+ }
65
+ if (!onFetchSource) return
66
+ if (fetchingRef.current) return
67
+ fetchingRef.current = true
68
+ setIsLoadingPreview(true)
69
+ try {
70
+ const result = await onFetchSource()
71
+ if (result) {
72
+ setSource(result)
73
+ setIsPreviewVisible(true)
74
+ }
75
+ } finally {
76
+ fetchingRef.current = false
77
+ setIsLoadingPreview(false)
78
+ }
79
+ }, [isPreviewVisible, isGallery, source, onPreviewClick, onFetchSource])
80
+
81
+ // Gallery is always previewable for the sender because each item's source
82
+ // is already provided up-front via `gallery[*].sourceUrl`.
83
+ const togglePreview =
84
+ isGallery || onFetchSource || onPreviewClick ? handleToggle : undefined
85
+ const showLocked = !isPreviewVisible
86
+ const isBusy = isLoadingPreview
87
+
88
+ const statusBadge =
89
+ paymentStatus === 'paid' ? (
90
+ <React.Fragment>
91
+ <span className="text-xs font-medium text-white/55">&bull;</span>
92
+ <span className="text-xs font-medium text-[#34c759]">Sold</span>
93
+ <CheckCircleIcon className="size-4 text-[#34c759]" weight="bold" />
94
+ </React.Fragment>
95
+ ) : amountText ? (
96
+ <React.Fragment>
97
+ <span className="text-xs font-medium text-white/55">&bull;</span>
98
+ <span className="text-xs font-medium text-white/55">{amountText}</span>
99
+ </React.Fragment>
100
+ ) : null
101
+
102
+ const thumbnail = isGallery ? (
103
+ <GalleryThumbnail
104
+ variant="dark"
105
+ gallery={gallery!}
106
+ title={title}
107
+ showLocked={showLocked}
108
+ paymentStatus={paymentStatus}
109
+ />
110
+ ) : (
111
+ <LockedThumbnail
112
+ variant="dark"
113
+ mimeType={mimeType}
114
+ thumbnailUrl={thumbnailUrl}
115
+ title={title}
116
+ source={source}
117
+ showLocked={showLocked}
118
+ paymentStatus={paymentStatus}
119
+ />
120
+ )
121
+
122
+ return (
123
+ <LockedCardShell variant="dark">
124
+ {togglePreview ? (
125
+ // Uses a `<div role="button">` rather than a native `<button>` so the
126
+ // gallery's carousel arrow `<button>`s rendered inside `thumbnail`
127
+ // don't produce invalid nested interactive elements.
128
+ <div
129
+ role="button"
130
+ tabIndex={isBusy ? -1 : 0}
131
+ aria-label="Toggle preview"
132
+ aria-busy={isBusy}
133
+ aria-pressed={!showLocked}
134
+ aria-disabled={isBusy || undefined}
135
+ onClick={isBusy ? undefined : togglePreview}
136
+ onKeyDown={(e) => {
137
+ if (isBusy) return
138
+ // Only handle keys that originate on the wrapper itself, not on
139
+ // inner interactive elements (gallery carousel arrows). Without
140
+ // this guard, pressing Enter/Space on a focused child would bubble
141
+ // up and toggle the preview in addition to activating the child.
142
+ if (e.target !== e.currentTarget) return
143
+ if (e.key === 'Enter' || e.key === ' ') {
144
+ e.preventDefault()
145
+ void togglePreview()
146
+ }
147
+ }}
148
+ className={
149
+ !isBusy
150
+ ? 'block w-full cursor-pointer text-left'
151
+ : 'block w-full text-left'
152
+ }
153
+ >
154
+ {thumbnail}
155
+ </div>
156
+ ) : (
157
+ thumbnail
158
+ )}
159
+
160
+ <CardBody
161
+ variant="dark"
162
+ title={title}
163
+ placeholderTitle={placeholderTitle}
164
+ mimeType={mimeType}
165
+ detail={detail}
166
+ statusBadge={statusBadge}
167
+ icon={
168
+ isGallery ? (
169
+ <ImagesIcon className="size-5 shrink-0 text-white/55" />
170
+ ) : undefined
171
+ }
172
+ />
173
+ </LockedCardShell>
174
+ )
175
+ }
176
+
177
+ export default SentCard
@@ -0,0 +1,2 @@
1
+ export { default as SentCard } from './Card'
2
+ export type { SentCardProps } from './Card'