@linktr.ee/messaging-react 3.9.0-rc-1783580190 → 3.9.1-rc-1783576271

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-CyBRO0Ex.cjs +2 -0
  2. package/dist/Card-CyBRO0Ex.cjs.map +1 -0
  3. package/dist/Card-DYNxwKX6.js +132 -0
  4. package/dist/Card-DYNxwKX6.js.map +1 -0
  5. package/dist/Card-DmfP-4oF.cjs +2 -0
  6. package/dist/Card-DmfP-4oF.cjs.map +1 -0
  7. package/dist/Card-PBfzY620.cjs +2 -0
  8. package/dist/Card-PBfzY620.cjs.map +1 -0
  9. package/dist/Card-dZkL-om-.js +163 -0
  10. package/dist/Card-dZkL-om-.js.map +1 -0
  11. package/dist/Card-xR3OTXRB.js +107 -0
  12. package/dist/Card-xR3OTXRB.js.map +1 -0
  13. package/dist/LockedThumbnail-BblFdDJp.js +220 -0
  14. package/dist/LockedThumbnail-BblFdDJp.js.map +1 -0
  15. package/dist/LockedThumbnail-eE8ljauK.cjs +2 -0
  16. package/dist/LockedThumbnail-eE8ljauK.cjs.map +1 -0
  17. package/dist/assets/index.css +1 -1
  18. package/dist/index-Bxezkdl6.cjs +2 -0
  19. package/dist/index-Bxezkdl6.cjs.map +1 -0
  20. package/dist/{index-CmVOs9Jy.js → index-FY6hmphi.js} +1198 -1226
  21. package/dist/index-FY6hmphi.js.map +1 -0
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.d.ts +98 -161
  24. package/dist/index.js +1 -1
  25. package/dist/testing.d.ts +0 -1
  26. package/package.json +2 -2
  27. package/src/components/ChannelList/CustomChannelPreview.test.tsx +0 -95
  28. package/src/components/ChannelList/CustomChannelPreview.tsx +9 -21
  29. package/src/components/CustomMessage/CustomMessage.stories.tsx +1 -54
  30. package/src/components/CustomMessage/MessageTag.tsx +0 -8
  31. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +29 -11
  32. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +22 -26
  33. package/src/components/CustomMessage/context.tsx +1 -1
  34. package/src/components/CustomMessage/index.tsx +42 -76
  35. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +3 -5
  36. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +450 -0
  37. package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
  38. package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
  39. package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
  40. package/src/components/{CustomMessage/LockedAttachment/components/Media → LockedAttachment/components/Received}/CardActions.tsx +2 -9
  41. package/src/components/LockedAttachment/components/Received/index.ts +2 -0
  42. package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
  43. package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
  44. package/src/components/{CustomMessage/LockedAttachment → LockedAttachment}/components/_shared/CardBody.tsx +7 -8
  45. package/src/components/{CustomMessage/LockedAttachment → LockedAttachment}/components/_shared/GalleryThumbnail.tsx +39 -31
  46. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
  47. package/src/components/{CustomMessage/LockedAttachment/components/_shared/MediaShell.tsx → LockedAttachment/components/_shared/LockedCardShell.tsx} +8 -8
  48. package/src/components/{CustomMessage/LockedAttachment/components/_shared/SingleThumbnail.tsx → LockedAttachment/components/_shared/LockedThumbnail.tsx} +7 -11
  49. package/src/components/LockedAttachment/index.tsx +63 -0
  50. package/src/components/LockedAttachment/types.ts +51 -0
  51. package/src/index.ts +8 -3
  52. package/src/stream-custom-data.ts +0 -1
  53. package/src/styles.css +0 -12
  54. package/src/types.ts +1 -1
  55. package/dist/Card-415OfRhw.cjs +0 -2
  56. package/dist/Card-415OfRhw.cjs.map +0 -1
  57. package/dist/Card-Q9e8pbMP.js +0 -694
  58. package/dist/Card-Q9e8pbMP.js.map +0 -1
  59. package/dist/Card-dg4NGPoC.cjs +0 -2
  60. package/dist/Card-dg4NGPoC.cjs.map +0 -1
  61. package/dist/Card-qS17qnN0.js +0 -103
  62. package/dist/Card-qS17qnN0.js.map +0 -1
  63. package/dist/LoadingDots-Ct3WmVlT.js +0 -59
  64. package/dist/LoadingDots-Ct3WmVlT.js.map +0 -1
  65. package/dist/LoadingDots-D9GGphgM.cjs +0 -2
  66. package/dist/LoadingDots-D9GGphgM.cjs.map +0 -1
  67. package/dist/index-ChRwg_g6.cjs +0 -2
  68. package/dist/index-ChRwg_g6.cjs.map +0 -1
  69. package/dist/index-CmVOs9Jy.js.map +0 -1
  70. package/src/components/CustomMessage/LockedAttachment/LockedAttachment.stories.tsx +0 -541
  71. package/src/components/CustomMessage/LockedAttachment/LockedAttachment.test.tsx +0 -117
  72. package/src/components/CustomMessage/LockedAttachment/components/Media/Card.tsx +0 -358
  73. package/src/components/CustomMessage/LockedAttachment/components/Media/types.ts +0 -79
  74. package/src/components/CustomMessage/LockedAttachment/components/Text/Card.tsx +0 -153
  75. package/src/components/CustomMessage/LockedAttachment/components/Text/types.ts +0 -58
  76. package/src/components/CustomMessage/LockedAttachment/components/_shared/CardThumbnail.tsx +0 -92
  77. package/src/components/CustomMessage/LockedAttachment/components/_shared/LoadingDots.tsx +0 -36
  78. package/src/components/CustomMessage/LockedAttachment/components/_shared/LockBadge.tsx +0 -63
  79. package/src/components/CustomMessage/LockedAttachment/components/_shared/PreviewToggle.tsx +0 -58
  80. package/src/components/CustomMessage/LockedAttachment/components/_shared/PriceLockRow.tsx +0 -21
  81. package/src/components/CustomMessage/LockedAttachment/components/_shared/PurchaseStatusBadge.tsx +0 -74
  82. package/src/components/CustomMessage/LockedAttachment/components/_shared/SkeletonTextLines.tsx +0 -29
  83. package/src/components/CustomMessage/LockedAttachment/components/_shared/TextShell.tsx +0 -28
  84. package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.test.ts +0 -67
  85. package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.ts +0 -89
  86. package/src/components/CustomMessage/LockedAttachment/components/_shared/useReceivedSource.ts +0 -83
  87. package/src/components/CustomMessage/LockedAttachment/index.tsx +0 -105
  88. package/src/components/CustomMessage/LockedAttachment/types.ts +0 -44
@@ -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
@@ -1,8 +1,6 @@
1
1
  import { DownloadSimpleIcon, LockSimpleIcon } from '@phosphor-icons/react'
2
2
  import React from 'react'
3
3
 
4
- import LoadingDots from '../_shared/LoadingDots'
5
-
6
4
  export interface ReceivedCardActionsProps {
7
5
  /** Whether the card is currently locked. Drives which CTA we render. */
8
6
  isLocked: boolean
@@ -21,7 +19,7 @@ export interface ReceivedCardActionsProps {
21
19
  }
22
20
 
23
21
  const buttonClass =
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]'
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'
25
23
 
26
24
  /**
27
25
  * Renders the primary CTA below the body of a Received card.
@@ -44,13 +42,10 @@ const ReceivedCardActions: React.FC<ReceivedCardActionsProps> = ({
44
42
  type="button"
45
43
  onClick={onUnlockClicked}
46
44
  disabled={isUnlocking}
47
- aria-label={isUnlocking ? 'Unlocking' : 'Unlock'}
48
- aria-busy={isUnlocking}
49
- title={isUnlocking ? 'Unlocking' : 'Unlock'}
50
45
  className={buttonClass}
51
46
  >
52
47
  {isUnlocking ? (
53
- <LoadingDots />
48
+ <span className="size-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
54
49
  ) : (
55
50
  <React.Fragment>
56
51
  <LockSimpleIcon className="size-4" weight="fill" />
@@ -67,7 +62,6 @@ const ReceivedCardActions: React.FC<ReceivedCardActionsProps> = ({
67
62
  return (
68
63
  <a
69
64
  href={href}
70
- aria-label="Download"
71
65
  target="_blank"
72
66
  rel="noopener noreferrer"
73
67
  onClick={onDownloadClicked}
@@ -83,7 +77,6 @@ const ReceivedCardActions: React.FC<ReceivedCardActionsProps> = ({
83
77
  <button
84
78
  type="button"
85
79
  onClick={onDownloadClicked}
86
- aria-label="Download"
87
80
  className={buttonClass}
88
81
  >
89
82
  <DownloadSimpleIcon className="size-4" weight="bold" />
@@ -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'
@@ -1,12 +1,12 @@
1
1
  import classNames from 'classnames'
2
2
  import React from 'react'
3
3
 
4
- import { renderTypeIcon } from '../../../../AttachmentCard'
4
+ import { renderTypeIcon } from '../../../AttachmentCard'
5
5
 
6
- import type { CardVariant } from './MediaShell'
6
+ import type { LockedCardVariant } from './LockedCardShell'
7
7
 
8
8
  export interface CardBodyProps {
9
- variant: CardVariant
9
+ variant: LockedCardVariant
10
10
  title?: string
11
11
  placeholderTitle?: string
12
12
  mimeType: string
@@ -20,7 +20,7 @@ export interface CardBodyProps {
20
20
  }
21
21
 
22
22
  /**
23
- * Title + status row layout shared by Composer and `LockedAttachment.Media`.
23
+ * Title + status row layout shared by Composer / Sent / Received cards.
24
24
  * Layout matches the Figma `Container > Labels` group (16px h-padding, 12px v-padding,
25
25
  * 4px gap between title and status row, 4px gap inside status row).
26
26
  */
@@ -36,8 +36,8 @@ const CardBody: React.FC<CardBodyProps> = ({
36
36
  trailingAction,
37
37
  }) => {
38
38
  const isDark = variant === 'dark'
39
- const displayTitle = title || placeholderTitle
40
- const titleDimmed = !title
39
+ const displayTitle = isDark ? (title ?? placeholderTitle) : (title ?? '')
40
+ const titleDimmed = isDark && !title
41
41
 
42
42
  const typeIcon =
43
43
  icon ??
@@ -56,8 +56,7 @@ const CardBody: React.FC<CardBodyProps> = ({
56
56
  {displayTitle.trim() !== '' && (
57
57
  <p
58
58
  className={classNames('truncate text-base font-medium leading-6', {
59
- 'text-black/90': !isDark && !titleDimmed,
60
- 'text-black/30': !isDark && titleDimmed,
59
+ 'text-black/90': !isDark,
61
60
  'text-white/30': isDark && titleDimmed,
62
61
  'text-white': isDark && !titleDimmed,
63
62
  })}
@@ -1,18 +1,23 @@
1
- import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
1
+ import {
2
+ CaretLeftIcon,
3
+ CaretRightIcon,
4
+ LockOpenIcon,
5
+ LockSimpleIcon,
6
+ PlayIcon,
7
+ } from '@phosphor-icons/react'
2
8
  import classNames from 'classnames'
3
9
  import React, { useState } from 'react'
4
10
 
5
- import { AttachmentThumbnail, getSourceType } from '../../../../AttachmentCard'
11
+ import { AttachmentThumbnail, getSourceType } from '../../../AttachmentCard'
6
12
  import type {
7
13
  LockedAttachmentGalleryItem,
8
14
  PaymentStatus,
9
15
  } from '../../types'
10
16
 
11
- import { LockBadge } from './LockBadge'
12
- import type { CardVariant } from './MediaShell'
17
+ import type { LockedCardVariant } from './LockedCardShell'
13
18
 
14
19
  export interface GalleryThumbnailProps {
15
- variant: CardVariant
20
+ variant: LockedCardVariant
16
21
  gallery: LockedAttachmentGalleryItem[]
17
22
  title?: string
18
23
  /** When true, all items are shown blurred + the locked/play badge is overlaid. */
@@ -47,8 +52,24 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
47
52
  const current = gallery[safeIndex]
48
53
  const isVideoItem = isPlayableItem(current)
49
54
 
55
+ // Center badge: PLAY for video items, LOCK for non-video locked items.
56
+ // Unlocked image items show no center badge — the image is the content.
57
+ const centerBadge = (() => {
58
+ if (isVideoItem) {
59
+ return <PlayIcon className="size-6" weight="fill" />
60
+ }
61
+ if (showLocked) {
62
+ return paymentStatus === 'paid' ? (
63
+ <LockOpenIcon className="size-6" weight="fill" />
64
+ ) : (
65
+ <LockSimpleIcon className="size-6" weight="fill" />
66
+ )
67
+ }
68
+ return null
69
+ })()
70
+
50
71
  // Stop propagation so arrow taps don't bubble up to the outer preview-toggle
51
- // wrapper on PreviewMediaCard / OwnMediaCard.
72
+ // wrapper on ComposerCard / SentCard.
52
73
  const goPrev = (e: React.MouseEvent) => {
53
74
  e.stopPropagation()
54
75
  setIndex((i) => Math.max(0, i - 1))
@@ -65,22 +86,14 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
65
86
  // Locked + unlocked render entirely separate trees so the locked state
66
87
  // doesn't end up stacking a blurred `<img>` on top of an underlying
67
88
  // `AttachmentThumbnail` (whose `bg-white/10` poster shell otherwise
68
- // shows through as a visible horizontal seam).
69
- //
70
- // The locked placeholder is absolutely positioned, so it needs the fixed
71
- // 337/386 box for height; unlocked content (video, natural-sized image)
72
- // manages its own height and can expand/shrink per item.
89
+ // shows through as a visible horizontal seam at the 16:9 boundary
90
+ // inside the taller 337/386 gallery aspect).
73
91
  //
74
92
  // The locked branch uses `filter: blur` on a single full-size `<img>`
75
93
  // — not `backdrop-filter` — so the rounded corners are clipped
76
- // normally without any clip-path workarounds (see SingleThumbnail).
94
+ // normally without any clip-path workarounds (see LockedThumbnail).
77
95
  return (
78
- <div
79
- className={classNames(
80
- 'group relative overflow-hidden rounded-t-md',
81
- showLocked ? 'aspect-[337/386]' : undefined
82
- )}
83
- >
96
+ <div className="relative aspect-[337/386] overflow-hidden rounded-t-md">
84
97
  {showLocked ? (
85
98
  <div
86
99
  className={classNames(
@@ -102,25 +115,20 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
102
115
  </div>
103
116
  ) : (
104
117
  <AttachmentThumbnail
105
- key={safeIndex}
106
118
  mimeType={current.mimeType}
107
119
  sourceUrl={current.sourceUrl}
108
120
  thumbnailUrl={current.thumbnailUrl}
109
121
  title={title}
110
122
  variant={variant}
111
- mediaPlayerProps={{ autoPlay: true }}
112
123
  />
113
124
  )}
114
125
 
115
- {/* LOCK while locked. Once unlocked, video/audio autoplays via
116
- `mediaPlayerProps` and MediaPlayer already has its own inline
117
- controls, so no separate Play affordance is shown. */}
118
- {showLocked && (
119
- <LockBadge
120
- paymentStatus={paymentStatus}
121
- isPlayable={isVideoItem}
122
- isLocked={showLocked}
123
- />
126
+ {centerBadge && (
127
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
128
+ <div className="flex size-12 items-center justify-center rounded-full bg-black/25 text-white">
129
+ {centerBadge}
130
+ </div>
131
+ </div>
124
132
  )}
125
133
 
126
134
  {total > 1 && (
@@ -150,8 +158,8 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
150
158
  <CaretRightIcon className="size-5" weight="bold" />
151
159
  </button>
152
160
 
153
- <div className="pointer-events-none absolute bottom-10 left-1/2 flex size-8 -translate-x-1/2 items-center justify-center rounded-full bg-black/30">
154
- <span className="text-xs font-medium leading-none text-white">
161
+ <div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2 rounded-lg bg-black/30 px-2 py-1">
162
+ <span className="text-xs font-medium leading-4 text-white">
155
163
  {safeIndex + 1}/{total}
156
164
  </span>
157
165
  </div>
@@ -0,0 +1,39 @@
1
+ import {
2
+ LockOpenIcon,
3
+ LockSimpleIcon,
4
+ PlayIcon,
5
+ } from '@phosphor-icons/react'
6
+ import React from 'react'
7
+
8
+ import { getSourceType } from '../../../AttachmentCard'
9
+ import type { PaymentStatus } from '../../types'
10
+
11
+ /**
12
+ * Centered 48x48 lock badge that overlays the thumbnail of any locked attachment.
13
+ * Uses `bg-black/25` from the design system's `alpha-black-25` token.
14
+ *
15
+ * For playable media (video/audio), the badge shows a play icon instead of a
16
+ * lock — matching the convention used by `GalleryThumbnail`.
17
+ */
18
+ export const LockBadge: React.FC<{
19
+ paymentStatus?: PaymentStatus
20
+ mimeType?: string
21
+ }> = ({ paymentStatus, mimeType }) => {
22
+ const sourceType = mimeType ? getSourceType(mimeType) : undefined
23
+ const isPlayable = sourceType === 'video' || sourceType === 'audio'
24
+
25
+ const Icon = isPlayable
26
+ ? PlayIcon
27
+ : paymentStatus === 'paid'
28
+ ? LockOpenIcon
29
+ : LockSimpleIcon
30
+ return (
31
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
32
+ <div className="flex size-12 items-center justify-center rounded-full bg-black/25 text-white">
33
+ <Icon className="size-6" weight="fill" />
34
+ </div>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ export default LockBadge
@@ -1,10 +1,10 @@
1
1
  import classNames from 'classnames'
2
2
  import React from 'react'
3
3
 
4
- export type CardVariant = 'dark' | 'light'
4
+ export type LockedCardVariant = 'dark' | 'light'
5
5
 
6
- export interface MediaShellProps {
7
- variant: CardVariant
6
+ export interface LockedCardShellProps {
7
+ variant: LockedCardVariant
8
8
  children: React.ReactNode
9
9
  rootRef?: React.Ref<HTMLDivElement>
10
10
  'data-testid'?: string
@@ -12,9 +12,9 @@ export interface MediaShellProps {
12
12
 
13
13
  /**
14
14
  * Outer chrome for every LockedAttachment card.
15
- * Mirrors the 16px radius treatment from the Figma design system.
15
+ * Mirrors the `shadow-400` + 16px radius treatment from the Figma design system.
16
16
  */
17
- const MediaShell: React.FC<MediaShellProps> = ({
17
+ const LockedCardShell: React.FC<LockedCardShellProps> = ({
18
18
  variant,
19
19
  children,
20
20
  rootRef,
@@ -25,12 +25,12 @@ const MediaShell: React.FC<MediaShellProps> = ({
25
25
  data-testid={dataTestId}
26
26
  className={classNames(
27
27
  'relative w-[280px] select-none overflow-hidden rounded-md',
28
- variant === 'dark' ? 'bg-[#1e2330]' : 'bg-white',
29
- 'shadow-[0_1px_2px_rgba(0,0,0,0.08)]'
28
+ 'shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_1px_2px_rgba(0,0,0,0.04),0_8px_32px_rgba(0,0,0,0.1)]',
29
+ variant === 'dark' ? 'bg-[#1e2330]' : 'bg-white'
30
30
  )}
31
31
  >
32
32
  {children}
33
33
  </div>
34
34
  )
35
35
 
36
- export default MediaShell
36
+ export default LockedCardShell