@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,177 @@
1
+ import { LockSimpleIcon } from '@phosphor-icons/react'
2
+ import type React from 'react'
3
+
4
+ import type { PaymentStatus } from '../../types'
5
+ import LoadingDots from '../_shared/LoadingDots'
6
+ import PriceLockRow from '../_shared/PriceLockRow'
7
+ import PurchaseStatusBadge from '../_shared/PurchaseStatusBadge'
8
+ import SkeletonTextLines from '../_shared/SkeletonTextLines'
9
+ import TextShell from '../_shared/TextShell'
10
+
11
+ interface TextCardSharedProps {
12
+ amountText?: string
13
+ paymentStatus?: PaymentStatus
14
+ /**
15
+ * The message's real, revealed content — shown instead of the skeleton
16
+ * loader once `paymentStatus` is `'paid'`. Ignored otherwise. Lets the
17
+ * caller supply its own text renderer (e.g. Stream's `MessageText`)
18
+ * without `LockedAttachment` depending on it.
19
+ */
20
+ children?: React.ReactNode
21
+ }
22
+
23
+ export type TextCardProps =
24
+ | (TextCardSharedProps & {
25
+ isMine: true
26
+ })
27
+ | (TextCardSharedProps & {
28
+ isMine: false
29
+ /**
30
+ * Called when the recipient clicks Unlock on an unpaid attachment.
31
+ * Use this to open a checkout flow. Omit to hide the Unlock button.
32
+ */
33
+ onUnlockClick?: () => void
34
+ /**
35
+ * When true, shows a loading spinner on the Unlock button. Driven by
36
+ * the LockedAttachmentContext (e.g. checkout in progress).
37
+ */
38
+ isUnlocking?: boolean
39
+ })
40
+
41
+ /**
42
+ * The sender's ('mine') view of a paid text-only message: an outgoing chat
43
+ * bubble showing a skeleton text loader in place of the redacted content (or
44
+ * `children`, once paid), plus a lock/price/status row.
45
+ */
46
+ const OwnTextCard: React.FC<TextCardSharedProps & { isMine: true }> = ({
47
+ amountText,
48
+ paymentStatus = 'pending',
49
+ children,
50
+ }) => {
51
+ const isFailed = paymentStatus === 'failed'
52
+ const isPaid = paymentStatus === 'paid'
53
+
54
+ if (isPaid && children) {
55
+ return (
56
+ <TextShell>
57
+ {children}
58
+ <span className="sr-only" aria-live="polite">
59
+ Sold.
60
+ </span>
61
+ <PriceLockRow>
62
+ {amountText != null && (
63
+ <span className="text-white/55">{amountText}</span>
64
+ )}
65
+ <PurchaseStatusBadge isMine={true} paymentStatus={paymentStatus} />
66
+ </PriceLockRow>
67
+ </TextShell>
68
+ )
69
+ }
70
+
71
+ const liveMessage = isFailed
72
+ ? 'Payment failed.'
73
+ : paymentStatus === 'refunded'
74
+ ? 'Refunded.'
75
+ : 'Payment pending.'
76
+
77
+ return (
78
+ <TextShell>
79
+ <SkeletonTextLines variant="dark" animate={paymentStatus === 'pending'} />
80
+ <span className="sr-only" aria-live="polite">
81
+ {liveMessage}
82
+ </span>
83
+ <PriceLockRow>
84
+ {amountText != null && (
85
+ <span className="text-white/55">{amountText}</span>
86
+ )}
87
+ <PurchaseStatusBadge isMine={true} paymentStatus={paymentStatus} />
88
+ </PriceLockRow>
89
+ </TextShell>
90
+ )
91
+ }
92
+
93
+ /**
94
+ * The recipient's ('other') view of a paid text-only message: a plain chat
95
+ * bubble whose content is a skeleton text loader (or `children`, once paid)
96
+ * plus a lock/price/status row. Two pre-purchase states exist — idle and
97
+ * unlocking (checkout in flight) — plus the revealed state once
98
+ * `paymentStatus` is `'paid'`.
99
+ */
100
+ const OtherTextCard: React.FC<
101
+ TextCardSharedProps & {
102
+ isMine: false
103
+ onUnlockClick?: () => void
104
+ isUnlocking?: boolean
105
+ }
106
+ > = ({
107
+ amountText,
108
+ paymentStatus,
109
+ onUnlockClick,
110
+ isUnlocking = false,
111
+ children,
112
+ }) => {
113
+ const isPaid = paymentStatus === 'paid'
114
+
115
+ if (isPaid && children) {
116
+ return (
117
+ <TextShell>
118
+ {children}
119
+ <PriceLockRow>
120
+ {amountText != null && (
121
+ <span className="opacity-55">{amountText}</span>
122
+ )}
123
+ <PurchaseStatusBadge isMine={false} paymentStatus="paid" />
124
+ </PriceLockRow>
125
+ </TextShell>
126
+ )
127
+ }
128
+
129
+ const ariaLabel = isUnlocking
130
+ ? 'Unlocking.'
131
+ : 'Locked message available to unlock.'
132
+
133
+ return (
134
+ <TextShell>
135
+ <div className="flex items-center gap-3">
136
+ <div className="flex flex-1 flex-col gap-2">
137
+ <SkeletonTextLines variant="light" animate={isUnlocking} />
138
+ <span className="sr-only" aria-live="polite">
139
+ {ariaLabel}
140
+ </span>
141
+ <PriceLockRow>
142
+ {amountText != null && (
143
+ <span className="opacity-55">{amountText}</span>
144
+ )}
145
+ </PriceLockRow>
146
+ </div>
147
+ <button
148
+ type="button"
149
+ onClick={onUnlockClick}
150
+ disabled={isUnlocking}
151
+ aria-label={isUnlocking ? 'Unlocking' : 'Unlock'}
152
+ aria-busy={isUnlocking}
153
+ title={isUnlocking ? 'Unlocking' : 'Unlock'}
154
+ className="flex size-10 shrink-0 items-center justify-center rounded-full bg-[#121110] text-white hover:bg-[#2a2928]"
155
+ >
156
+ {isUnlocking ? (
157
+ <LoadingDots />
158
+ ) : (
159
+ <LockSimpleIcon className="size-4" weight="fill" aria-hidden />
160
+ )}
161
+ </button>
162
+ </div>
163
+ </TextShell>
164
+ )
165
+ }
166
+
167
+ /**
168
+ * The card either party sees for a paid text-only message — a chat bubble
169
+ * showing a skeleton text loader (or `children`, once revealed) plus a
170
+ * lock/price/status row. `isMine` selects the sender's payment-lifecycle view
171
+ * (pending/failed/refunded/paid + Dismiss) vs. the recipient's unlock-flow
172
+ * view (idle/unlocking + Unlock CTA).
173
+ */
174
+ const TextCard: React.FC<TextCardProps> = (props) =>
175
+ props.isMine ? <OwnTextCard {...props} /> : <OtherTextCard {...props} />
176
+
177
+ export default TextCard
@@ -3,10 +3,10 @@ import React from 'react'
3
3
 
4
4
  import { renderTypeIcon } from '../../../AttachmentCard'
5
5
 
6
- import type { LockedCardVariant } from './LockedCardShell'
6
+ import type { CardVariant } from './MediaShell'
7
7
 
8
8
  export interface CardBodyProps {
9
- variant: LockedCardVariant
9
+ variant: CardVariant
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 / Sent / Received cards.
23
+ * Title + status row layout shared by Composer and `LockedAttachment.Media`.
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
  */
@@ -0,0 +1,92 @@
1
+ import { ImagesIcon } from '@phosphor-icons/react'
2
+ import type React from 'react'
3
+
4
+ import type {
5
+ LockedAttachmentGalleryItem,
6
+ LockedAttachmentSource,
7
+ PaymentStatus,
8
+ } from '../../types'
9
+
10
+ import GalleryThumbnail from './GalleryThumbnail'
11
+ import type { CardVariant } from './MediaShell'
12
+ import SingleThumbnail from './SingleThumbnail'
13
+
14
+ export interface CardThumbnailProps {
15
+ variant: CardVariant
16
+ gallery: LockedAttachmentGalleryItem[]
17
+ title?: string
18
+ showLocked: boolean
19
+ paymentStatus?: PaymentStatus
20
+ /** Single-asset props — ignored when the card renders as a gallery. */
21
+ mimeType: string
22
+ thumbnailUrl?: string
23
+ source?: LockedAttachmentSource
24
+ containedImage?: boolean
25
+ /** Top-right overlay (e.g. Composer dismiss X). Rendered inside the thumbnail. */
26
+ topRight?: React.ReactNode
27
+ }
28
+
29
+ /** True when the card should render as a mixed-media carousel (2+ items). */
30
+ export const isGalleryCard = (
31
+ gallery: LockedAttachmentGalleryItem[]
32
+ ): boolean => gallery.length >= 2
33
+
34
+ /**
35
+ * The body-row icon a card shows when it renders as a gallery — `CardBody`
36
+ * otherwise auto-detects the icon from `mimeType`. Returned as a ready node so
37
+ * every card shares the same size/tone treatment.
38
+ */
39
+ export const galleryBodyIcon = (
40
+ variant: CardVariant
41
+ ): React.ReactNode => (
42
+ <ImagesIcon
43
+ className={
44
+ variant === 'dark'
45
+ ? 'size-5 shrink-0 text-white/55'
46
+ : 'size-5 shrink-0 text-black/55'
47
+ }
48
+ />
49
+ )
50
+
51
+ /**
52
+ * Owns the single-vs-gallery thumbnail decision shared by Composer /
53
+ * `LockedAttachment.Media` cards: renders `GalleryThumbnail` for a 2+ item `gallery`,
54
+ * else `SingleThumbnail` for the single asset. Callers pass the superset of
55
+ * props; the unused half is ignored by whichever branch renders.
56
+ */
57
+ const CardThumbnail: React.FC<CardThumbnailProps> = ({
58
+ variant,
59
+ gallery,
60
+ title,
61
+ showLocked,
62
+ paymentStatus,
63
+ mimeType,
64
+ thumbnailUrl,
65
+ source,
66
+ containedImage,
67
+ topRight,
68
+ }) =>
69
+ isGalleryCard(gallery) ? (
70
+ <GalleryThumbnail
71
+ variant={variant}
72
+ gallery={gallery}
73
+ title={title}
74
+ showLocked={showLocked}
75
+ paymentStatus={paymentStatus}
76
+ topRight={topRight}
77
+ />
78
+ ) : (
79
+ <SingleThumbnail
80
+ variant={variant}
81
+ mimeType={mimeType}
82
+ thumbnailUrl={thumbnailUrl}
83
+ title={title}
84
+ source={source}
85
+ showLocked={showLocked}
86
+ paymentStatus={paymentStatus}
87
+ topRight={topRight}
88
+ containedImage={containedImage}
89
+ />
90
+ )
91
+
92
+ export default CardThumbnail
@@ -1,10 +1,4 @@
1
- import {
2
- CaretLeftIcon,
3
- CaretRightIcon,
4
- LockOpenIcon,
5
- LockSimpleIcon,
6
- PlayIcon,
7
- } from '@phosphor-icons/react'
1
+ import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
8
2
  import classNames from 'classnames'
9
3
  import React, { useState } from 'react'
10
4
 
@@ -14,10 +8,11 @@ import type {
14
8
  PaymentStatus,
15
9
  } from '../../types'
16
10
 
17
- import type { LockedCardVariant } from './LockedCardShell'
11
+ import { LockBadge } from './LockBadge'
12
+ import type { CardVariant } from './MediaShell'
18
13
 
19
14
  export interface GalleryThumbnailProps {
20
- variant: LockedCardVariant
15
+ variant: CardVariant
21
16
  gallery: LockedAttachmentGalleryItem[]
22
17
  title?: string
23
18
  /** When true, all items are shown blurred + the locked/play badge is overlaid. */
@@ -52,24 +47,8 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
52
47
  const current = gallery[safeIndex]
53
48
  const isVideoItem = isPlayableItem(current)
54
49
 
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
-
71
50
  // Stop propagation so arrow taps don't bubble up to the outer preview-toggle
72
- // wrapper on ComposerCard / SentCard.
51
+ // wrapper on ComposerCard / OwnMediaCard.
73
52
  const goPrev = (e: React.MouseEvent) => {
74
53
  e.stopPropagation()
75
54
  setIndex((i) => Math.max(0, i - 1))
@@ -91,7 +70,7 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
91
70
  //
92
71
  // The locked branch uses `filter: blur` on a single full-size `<img>`
93
72
  // — not `backdrop-filter` — so the rounded corners are clipped
94
- // normally without any clip-path workarounds (see LockedThumbnail).
73
+ // normally without any clip-path workarounds (see SingleThumbnail).
95
74
  return (
96
75
  <div className="relative aspect-[337/386] overflow-hidden rounded-t-md">
97
76
  {showLocked ? (
@@ -123,12 +102,11 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
123
102
  />
124
103
  )}
125
104
 
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>
105
+ {/* PLAY for video items (locked or not — video needs a play affordance
106
+ either way); LOCK for non-video locked items. Unlocked non-video
107
+ items show no badge the image is the content. */}
108
+ {(isVideoItem || showLocked) && (
109
+ <LockBadge paymentStatus={paymentStatus} isPlayable={isVideoItem} />
132
110
  )}
133
111
 
134
112
  {total > 1 && (
@@ -0,0 +1,36 @@
1
+ import React from 'react'
2
+
3
+ const Dot = ({ cx, index }: { cx: string; index: number }) => (
4
+ <circle cx={cx} cy="6.15" r="2.4" fill="currentColor">
5
+ <animateTransform
6
+ attributeName="transform"
7
+ type="translate"
8
+ values="0 0; 0 -2.25; 0 0;"
9
+ dur="900ms"
10
+ begin={`${120 * index}ms`}
11
+ repeatCount="indefinite"
12
+ />
13
+ </circle>
14
+ )
15
+
16
+ /**
17
+ * Three bouncing dots, inline-sized for use inside buttons/labels (e.g. the
18
+ * Unlock CTA while a purchase is in flight). Mirrors the typing indicator's
19
+ * animation but uses `currentColor` so it adopts the surrounding text color.
20
+ */
21
+ const LoadingDots: React.FC<{ className?: string }> = ({ className }) => (
22
+ <svg
23
+ aria-hidden="true"
24
+ className={className}
25
+ viewBox="0 0 20 12.3"
26
+ width="20"
27
+ height="13"
28
+ overflow="visible"
29
+ >
30
+ <Dot cx="3" index={0} />
31
+ <Dot cx="10" index={1} />
32
+ <Dot cx="17" index={2} />
33
+ </svg>
34
+ )
35
+
36
+ export default LoadingDots
@@ -1,13 +1,21 @@
1
- import {
2
- LockOpenIcon,
3
- LockSimpleIcon,
4
- PlayIcon,
5
- } from '@phosphor-icons/react'
1
+ import { LockOpenIcon, LockSimpleIcon, PlayIcon } from '@phosphor-icons/react'
6
2
  import React from 'react'
7
3
 
8
4
  import { getSourceType } from '../../../AttachmentCard'
9
5
  import type { PaymentStatus } from '../../types'
10
6
 
7
+ export interface LockBadgeProps {
8
+ paymentStatus?: PaymentStatus
9
+ mimeType?: string
10
+ /**
11
+ * Overrides the `mimeType`-derived playable check. Lets a caller that
12
+ * already knows per-item playability (e.g. `GalleryThumbnail`, checking a
13
+ * carousel item's own mimeType rather than the card's single `mimeType`)
14
+ * skip re-deriving it.
15
+ */
16
+ isPlayable?: boolean
17
+ }
18
+
11
19
  /**
12
20
  * Centered 48x48 lock badge that overlays the thumbnail of any locked attachment.
13
21
  * Uses `bg-black/25` from the design system's `alpha-black-25` token.
@@ -15,14 +23,16 @@ import type { PaymentStatus } from '../../types'
15
23
  * For playable media (video/audio), the badge shows a play icon instead of a
16
24
  * lock — matching the convention used by `GalleryThumbnail`.
17
25
  */
18
- export const LockBadge: React.FC<{
19
- paymentStatus?: PaymentStatus
20
- mimeType?: string
21
- }> = ({ paymentStatus, mimeType }) => {
26
+ export const LockBadge: React.FC<LockBadgeProps> = ({
27
+ paymentStatus,
28
+ mimeType,
29
+ isPlayable,
30
+ }) => {
22
31
  const sourceType = mimeType ? getSourceType(mimeType) : undefined
23
- const isPlayable = sourceType === 'video' || sourceType === 'audio'
32
+ const resolvedIsPlayable =
33
+ isPlayable ?? (sourceType === 'video' || sourceType === 'audio')
24
34
 
25
- const Icon = isPlayable
35
+ const Icon = resolvedIsPlayable
26
36
  ? PlayIcon
27
37
  : paymentStatus === 'paid'
28
38
  ? LockOpenIcon
@@ -1,10 +1,10 @@
1
1
  import classNames from 'classnames'
2
2
  import React from 'react'
3
3
 
4
- export type LockedCardVariant = 'dark' | 'light'
4
+ export type CardVariant = 'dark' | 'light'
5
5
 
6
- export interface LockedCardShellProps {
7
- variant: LockedCardVariant
6
+ export interface MediaShellProps {
7
+ variant: CardVariant
8
8
  children: React.ReactNode
9
9
  rootRef?: React.Ref<HTMLDivElement>
10
10
  'data-testid'?: string
@@ -14,7 +14,7 @@ export interface LockedCardShellProps {
14
14
  * Outer chrome for every LockedAttachment card.
15
15
  * Mirrors the `shadow-400` + 16px radius treatment from the Figma design system.
16
16
  */
17
- const LockedCardShell: React.FC<LockedCardShellProps> = ({
17
+ const MediaShell: React.FC<MediaShellProps> = ({
18
18
  variant,
19
19
  children,
20
20
  rootRef,
@@ -33,4 +33,4 @@ const LockedCardShell: React.FC<LockedCardShellProps> = ({
33
33
  </div>
34
34
  )
35
35
 
36
- export default LockedCardShell
36
+ export default MediaShell
@@ -0,0 +1,58 @@
1
+ import React from 'react'
2
+
3
+ export interface PreviewToggleProps {
4
+ /** Toggles the preview; omit to render `children` with no wrapper at all (nothing to toggle). */
5
+ onToggle: (() => void | Promise<void>) | undefined
6
+ isBusy: boolean
7
+ isPreviewVisible: boolean
8
+ children: React.ReactNode
9
+ }
10
+
11
+ /**
12
+ * Makes a locked thumbnail clickable to toggle its preview. Uses a
13
+ * `role="button"` div rather than a native `<button>` because the thumbnail
14
+ * it wraps can itself contain interactive children (gallery carousel arrows,
15
+ * a Composer dismiss X) — nesting a real `<button>` around those would
16
+ * produce invalid, inaccessible nested-interactive-element markup.
17
+ */
18
+ const PreviewToggle: React.FC<PreviewToggleProps> = ({
19
+ onToggle,
20
+ isBusy,
21
+ isPreviewVisible,
22
+ children,
23
+ }) => {
24
+ if (!onToggle) return <React.Fragment>{children}</React.Fragment>
25
+
26
+ return (
27
+ <div
28
+ role="button"
29
+ tabIndex={isBusy ? -1 : 0}
30
+ aria-label="Toggle preview"
31
+ aria-busy={isBusy}
32
+ aria-pressed={isPreviewVisible}
33
+ aria-disabled={isBusy || undefined}
34
+ onClick={isBusy ? undefined : onToggle}
35
+ onKeyDown={(e) => {
36
+ if (isBusy) return
37
+ // Only handle keys that originate on the wrapper itself, not on
38
+ // inner interactive elements (dismiss X, carousel arrows). Without
39
+ // this guard, pressing Enter/Space on a focused child would bubble
40
+ // up and toggle the preview in addition to activating the child.
41
+ if (e.target !== e.currentTarget) return
42
+ if (e.key === 'Enter' || e.key === ' ') {
43
+ e.preventDefault()
44
+ void onToggle()
45
+ }
46
+ }}
47
+ className={
48
+ !isBusy
49
+ ? 'block w-full cursor-pointer text-left'
50
+ : 'block w-full text-left'
51
+ }
52
+ >
53
+ {children}
54
+ </div>
55
+ )
56
+ }
57
+
58
+ export default PreviewToggle
@@ -0,0 +1,21 @@
1
+ import { LockSimpleIcon } from '@phosphor-icons/react'
2
+ import type React from 'react'
3
+
4
+ /**
5
+ * The lock-icon + amount/status row shared by every LockedAttachment text-mode
6
+ * card. Only the wrapper and the leading lock icon are shared — the amount and
7
+ * any trailing status live in `children`, since they genuinely differ per card
8
+ * (placeholder fallback vs. paid badge vs. bare amount).
9
+ */
10
+ const PriceLockRow: React.FC<React.PropsWithChildren> = ({ children }) => (
11
+ <div className="flex items-center gap-1 text-xs font-medium">
12
+ <LockSimpleIcon
13
+ className="size-3.5 shrink-0 opacity-55"
14
+ weight="fill"
15
+ aria-hidden
16
+ />
17
+ {children}
18
+ </div>
19
+ )
20
+
21
+ export default PriceLockRow
@@ -0,0 +1,87 @@
1
+ import { CheckCircleIcon } from '@phosphor-icons/react'
2
+ import classNames from 'classnames'
3
+ import React from 'react'
4
+
5
+ import type { PaymentStatus } from '../../types'
6
+
7
+ export interface PurchaseStatusBadgeProps {
8
+ /** 'own' matches the sender's dark/own-message context; 'other' matches the recipient's light/other-message context. */
9
+ isMine: boolean
10
+ /** 'paid' renders the "Sold"/"Purchased" + checkmark variant; otherwise falls back to plain amount text, or nothing. */
11
+ paymentStatus?: PaymentStatus
12
+ amountText?: string
13
+ }
14
+
15
+ /**
16
+ * Bullet + amount/label + checkmark row for a paid attachment or message.
17
+ * Shared by `LockedAttachment.Media`/`.Text`'s status badges.
18
+ */
19
+ const PurchaseStatusBadge: React.FC<PurchaseStatusBadgeProps> = ({
20
+ isMine,
21
+ paymentStatus,
22
+ amountText,
23
+ }) => {
24
+ const labelColour = isMine ? 'text-white/55' : 'text-black/55'
25
+ const successColour = isMine ? 'text-[#34c759]' : 'text-[#008236]'
26
+
27
+ if (paymentStatus === 'paid') {
28
+ return (
29
+ <React.Fragment>
30
+ <span className={classNames('text-xs font-medium', labelColour)}>
31
+ &bull;
32
+ </span>
33
+ <span className={classNames('text-xs font-medium', successColour)}>
34
+ {isMine ? 'Sold' : 'Purchased'}
35
+ </span>
36
+ <CheckCircleIcon
37
+ className={classNames('shrink-0 size-3.5', successColour)}
38
+ weight="bold"
39
+ aria-hidden
40
+ />
41
+ </React.Fragment>
42
+ )
43
+ }
44
+
45
+ if (amountText != null) {
46
+ return (
47
+ <React.Fragment>
48
+ <span className={classNames('text-xs font-medium', labelColour)}>
49
+ &bull;
50
+ </span>
51
+ <span className={classNames('text-xs font-medium', labelColour)}>
52
+ {amountText}
53
+ </span>
54
+ </React.Fragment>
55
+ )
56
+ }
57
+
58
+ if (paymentStatus === 'pending') {
59
+ return (
60
+ <React.Fragment>
61
+ <span className={classNames('text-xs font-medium', labelColour)}>
62
+ &bull;
63
+ </span>
64
+ <span className={classNames('text-xs font-medium', labelColour)}>
65
+ Sent
66
+ </span>
67
+ </React.Fragment>
68
+ )
69
+ }
70
+
71
+ if (paymentStatus === 'failed' || paymentStatus === 'refunded') {
72
+ return (
73
+ <React.Fragment>
74
+ <span className={classNames('text-xs font-medium', labelColour)}>
75
+ &bull;
76
+ </span>
77
+ <span className={classNames('text-xs font-medium', labelColour)}>
78
+ {paymentStatus === 'failed' ? 'Payment failed' : 'Refunded'}
79
+ </span>
80
+ </React.Fragment>
81
+ )
82
+ }
83
+
84
+ return null
85
+ }
86
+
87
+ export default PurchaseStatusBadge
@@ -8,10 +8,10 @@ import {
8
8
  import type { LockedAttachmentSource, PaymentStatus } from '../../types'
9
9
 
10
10
  import { LockBadge } from './LockBadge'
11
- import type { LockedCardVariant } from './LockedCardShell'
11
+ import type { CardVariant } from './MediaShell'
12
12
 
13
- export interface LockedThumbnailProps {
14
- variant: LockedCardVariant
13
+ export interface SingleThumbnailProps {
14
+ variant: CardVariant
15
15
  mimeType: string
16
16
  thumbnailUrl?: string
17
17
  title?: string
@@ -40,7 +40,7 @@ export interface LockedThumbnailProps {
40
40
  * - preview/paid: clear thumbnail (the underlying source)
41
41
  * - empty: type-icon placeholder
42
42
  */
43
- const LockedThumbnail: React.FC<LockedThumbnailProps> = ({
43
+ const SingleThumbnail: React.FC<SingleThumbnailProps> = ({
44
44
  variant,
45
45
  mimeType,
46
46
  thumbnailUrl,
@@ -125,4 +125,4 @@ const LockedThumbnail: React.FC<LockedThumbnailProps> = ({
125
125
  )
126
126
  }
127
127
 
128
- export default LockedThumbnail
128
+ export default SingleThumbnail