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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/Card-415OfRhw.cjs +2 -0
  2. package/dist/Card-415OfRhw.cjs.map +1 -0
  3. package/dist/Card-BnbNynLQ.js +694 -0
  4. package/dist/Card-BnbNynLQ.js.map +1 -0
  5. package/dist/Card-UnwKmYzt.cjs +2 -0
  6. package/dist/Card-UnwKmYzt.cjs.map +1 -0
  7. package/dist/Card-qS17qnN0.js +103 -0
  8. package/dist/Card-qS17qnN0.js.map +1 -0
  9. package/dist/LoadingDots-Ct3WmVlT.js +59 -0
  10. package/dist/LoadingDots-Ct3WmVlT.js.map +1 -0
  11. package/dist/LoadingDots-D9GGphgM.cjs +2 -0
  12. package/dist/LoadingDots-D9GGphgM.cjs.map +1 -0
  13. package/dist/assets/index.css +1 -1
  14. package/dist/index-CyHyftBi.cjs +2 -0
  15. package/dist/index-CyHyftBi.cjs.map +1 -0
  16. package/dist/{index-BxzzWK4K.js → index-DJXjS2H9.js} +1397 -1358
  17. package/dist/index-DJXjS2H9.js.map +1 -0
  18. package/dist/index.cjs +1 -1
  19. package/dist/index.d.ts +196 -101
  20. package/dist/index.js +1 -1
  21. package/dist/testing.d.ts +1 -0
  22. package/package.json +2 -2
  23. package/src/components/ChannelList/CustomChannelPreview.test.tsx +95 -0
  24. package/src/components/ChannelList/CustomChannelPreview.tsx +21 -9
  25. package/src/components/ChannelList/index.test.tsx +46 -0
  26. package/src/components/ChannelList/index.tsx +12 -0
  27. package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -1
  28. package/src/components/CustomMessage/LockedAttachment/LockedAttachment.stories.tsx +541 -0
  29. package/src/components/CustomMessage/LockedAttachment/LockedAttachment.test.tsx +117 -0
  30. package/src/components/CustomMessage/LockedAttachment/components/Media/Card.tsx +358 -0
  31. package/src/components/{LockedAttachment/components/Received → CustomMessage/LockedAttachment/components/Media}/CardActions.tsx +9 -2
  32. package/src/components/CustomMessage/LockedAttachment/components/Media/types.ts +79 -0
  33. package/src/components/CustomMessage/LockedAttachment/components/Text/Card.tsx +153 -0
  34. package/src/components/CustomMessage/LockedAttachment/components/Text/types.ts +58 -0
  35. package/src/components/{LockedAttachment → CustomMessage/LockedAttachment}/components/_shared/CardBody.tsx +8 -7
  36. package/src/components/CustomMessage/LockedAttachment/components/_shared/CardThumbnail.tsx +92 -0
  37. package/src/components/{LockedAttachment → CustomMessage/LockedAttachment}/components/_shared/GalleryThumbnail.tsx +31 -39
  38. package/src/components/CustomMessage/LockedAttachment/components/_shared/LoadingDots.tsx +36 -0
  39. package/src/components/CustomMessage/LockedAttachment/components/_shared/LockBadge.tsx +63 -0
  40. package/src/components/{LockedAttachment/components/_shared/LockedCardShell.tsx → CustomMessage/LockedAttachment/components/_shared/MediaShell.tsx} +8 -8
  41. package/src/components/CustomMessage/LockedAttachment/components/_shared/PreviewToggle.tsx +58 -0
  42. package/src/components/CustomMessage/LockedAttachment/components/_shared/PriceLockRow.tsx +21 -0
  43. package/src/components/CustomMessage/LockedAttachment/components/_shared/PurchaseStatusBadge.tsx +74 -0
  44. package/src/components/{LockedAttachment/components/_shared/LockedThumbnail.tsx → CustomMessage/LockedAttachment/components/_shared/SingleThumbnail.tsx} +11 -7
  45. package/src/components/CustomMessage/LockedAttachment/components/_shared/SkeletonTextLines.tsx +29 -0
  46. package/src/components/CustomMessage/LockedAttachment/components/_shared/TextShell.tsx +28 -0
  47. package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.test.ts +67 -0
  48. package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.ts +89 -0
  49. package/src/components/CustomMessage/LockedAttachment/components/_shared/useReceivedSource.ts +83 -0
  50. package/src/components/CustomMessage/LockedAttachment/index.tsx +105 -0
  51. package/src/components/CustomMessage/LockedAttachment/types.ts +44 -0
  52. package/src/components/CustomMessage/MessageTag.tsx +8 -0
  53. package/src/components/CustomMessage/context.tsx +1 -1
  54. package/src/components/CustomMessage/index.tsx +76 -42
  55. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +5 -3
  56. package/src/index.ts +3 -8
  57. package/src/stream-custom-data.ts +1 -0
  58. package/src/styles.css +12 -0
  59. package/src/types.ts +35 -2
  60. package/dist/Card-B-L2MST4.js +0 -107
  61. package/dist/Card-B-L2MST4.js.map +0 -1
  62. package/dist/Card-B56QxJW3.cjs +0 -2
  63. package/dist/Card-B56QxJW3.cjs.map +0 -1
  64. package/dist/Card-Bqh-BQaX.js +0 -132
  65. package/dist/Card-Bqh-BQaX.js.map +0 -1
  66. package/dist/Card-ZCE6Lhfc.cjs +0 -2
  67. package/dist/Card-ZCE6Lhfc.cjs.map +0 -1
  68. package/dist/Card-g4JD-2ES.js +0 -163
  69. package/dist/Card-g4JD-2ES.js.map +0 -1
  70. package/dist/Card-wac6AMNc.cjs +0 -2
  71. package/dist/Card-wac6AMNc.cjs.map +0 -1
  72. package/dist/LockedThumbnail-BAQmoIff.js +0 -220
  73. package/dist/LockedThumbnail-BAQmoIff.js.map +0 -1
  74. package/dist/LockedThumbnail-Cr0Gqs3Y.cjs +0 -2
  75. package/dist/LockedThumbnail-Cr0Gqs3Y.cjs.map +0 -1
  76. package/dist/index-BPhZyaxT.cjs +0 -2
  77. package/dist/index-BPhZyaxT.cjs.map +0 -1
  78. package/dist/index-BxzzWK4K.js.map +0 -1
  79. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +0 -450
  80. package/src/components/LockedAttachment/components/Composer/Card.tsx +0 -221
  81. package/src/components/LockedAttachment/components/Composer/index.ts +0 -2
  82. package/src/components/LockedAttachment/components/Received/Card.tsx +0 -191
  83. package/src/components/LockedAttachment/components/Received/index.ts +0 -2
  84. package/src/components/LockedAttachment/components/Sent/Card.tsx +0 -177
  85. package/src/components/LockedAttachment/components/Sent/index.ts +0 -2
  86. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +0 -39
  87. package/src/components/LockedAttachment/index.tsx +0 -63
  88. package/src/components/LockedAttachment/types.ts +0 -51
@@ -0,0 +1,153 @@
1
+ import { LockSimpleIcon, XIcon } from '@phosphor-icons/react'
2
+ import type React from 'react'
3
+
4
+ import LoadingDots from '../_shared/LoadingDots'
5
+ import PriceLockRow from '../_shared/PriceLockRow'
6
+ import PurchaseStatusBadge from '../_shared/PurchaseStatusBadge'
7
+ import SkeletonTextLines from '../_shared/SkeletonTextLines'
8
+ import TextShell from '../_shared/TextShell'
9
+
10
+ import type {
11
+ TextCardDraftProps,
12
+ TextCardOtherProps,
13
+ TextCardOwnProps,
14
+ TextCardPreviewProps,
15
+ TextCardProps,
16
+ } from './types'
17
+
18
+ export type { TextCardProps } from './types'
19
+
20
+ const DraftTextCard: React.FC<TextCardDraftProps> = ({
21
+ renderedText,
22
+ amountText,
23
+ placeholderAmountText,
24
+ onDismiss,
25
+ }) => (
26
+ <TextShell isMine className="pr-8">
27
+ {renderedText || <SkeletonTextLines variant="dark" />}
28
+ <PriceLockRow>
29
+ <span className={amountText ? 'text-white/55' : 'text-white/30'}>
30
+ {amountText || placeholderAmountText}
31
+ </span>
32
+ </PriceLockRow>
33
+ {onDismiss && (
34
+ <button
35
+ type="button"
36
+ onClick={onDismiss}
37
+ 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"
38
+ aria-label="Dismiss attachment"
39
+ >
40
+ <XIcon className="size-3" weight="bold" />
41
+ </button>
42
+ )}
43
+ </TextShell>
44
+ )
45
+
46
+ const PreviewTextCard: React.FC<TextCardPreviewProps> = ({
47
+ amountText,
48
+ placeholderAmountText,
49
+ }) => (
50
+ <TextShell isMine={false}>
51
+ <SkeletonTextLines variant="light" />
52
+ <PriceLockRow>
53
+ <span className="opacity-55">{amountText || placeholderAmountText}</span>
54
+ </PriceLockRow>
55
+ </TextShell>
56
+ )
57
+
58
+ const OwnTextCard: React.FC<TextCardOwnProps> = ({
59
+ renderedText,
60
+ amountText,
61
+ paymentStatus = 'pending',
62
+ }) => {
63
+ const isPaid = paymentStatus === 'paid'
64
+
65
+ return (
66
+ <TextShell isMine>
67
+ {isPaid && renderedText ? (
68
+ renderedText
69
+ ) : (
70
+ <SkeletonTextLines variant="dark" />
71
+ )}
72
+ <PriceLockRow>
73
+ {amountText && <span className="text-white/55">{amountText}</span>}
74
+ <PurchaseStatusBadge isMine paymentStatus={paymentStatus} />
75
+ </PriceLockRow>
76
+ </TextShell>
77
+ )
78
+ }
79
+
80
+ const OtherTextCard: React.FC<TextCardOtherProps> = ({
81
+ renderedText,
82
+ amountText,
83
+ paymentStatus,
84
+ onUnlockClick,
85
+ isUnlocking = false,
86
+ }) => {
87
+ const isPaid = paymentStatus === 'paid'
88
+
89
+ if (isPaid && renderedText) {
90
+ return (
91
+ <TextShell isMine={false}>
92
+ {renderedText}
93
+ <PriceLockRow>
94
+ {amountText != null && (
95
+ <span className="opacity-55">{amountText}</span>
96
+ )}
97
+ <PurchaseStatusBadge isMine={false} paymentStatus="paid" />
98
+ </PriceLockRow>
99
+ </TextShell>
100
+ )
101
+ }
102
+
103
+ const ariaLabel = isUnlocking
104
+ ? 'Unlocking message.'
105
+ : 'Unlock this message.'
106
+
107
+ return (
108
+ <TextShell isMine={false}>
109
+ <div className="flex items-center gap-3">
110
+ <div className="flex flex-1 flex-col gap-2">
111
+ <SkeletonTextLines variant="light" />
112
+ <span className="sr-only" aria-live="polite">
113
+ {ariaLabel}
114
+ </span>
115
+ <PriceLockRow>
116
+ {amountText != null && (
117
+ <span className="opacity-55">{amountText}</span>
118
+ )}
119
+ </PriceLockRow>
120
+ </div>
121
+ <button
122
+ type="button"
123
+ onClick={onUnlockClick}
124
+ disabled={isUnlocking}
125
+ aria-label={isUnlocking ? 'Unlocking message' : 'Unlock message'}
126
+ aria-busy={isUnlocking}
127
+ title={isUnlocking ? 'Unlocking message' : 'Unlock message'}
128
+ className="flex size-10 shrink-0 items-center justify-center rounded-full bg-[#121110] text-white hover:bg-[#2a2928]"
129
+ >
130
+ {isUnlocking ? (
131
+ <LoadingDots />
132
+ ) : (
133
+ <LockSimpleIcon className="size-4" weight="fill" aria-hidden />
134
+ )}
135
+ </button>
136
+ </div>
137
+ </TextShell>
138
+ )
139
+ }
140
+
141
+ const TextCard: React.FC<TextCardProps> = (props) => {
142
+ if (props.isDraft) {
143
+ return <DraftTextCard {...props} />
144
+ } else if (props.isPreview) {
145
+ return <PreviewTextCard {...props} />
146
+ } else if (props.isMine) {
147
+ return <OwnTextCard {...props} />
148
+ } else {
149
+ return <OtherTextCard {...props} />
150
+ }
151
+ }
152
+
153
+ export default TextCard
@@ -0,0 +1,58 @@
1
+ import type React from 'react'
2
+
3
+ import type { PaymentStatus } from '../../types'
4
+
5
+ interface TextCardBaseProps {
6
+ /** Amount label. */
7
+ amountText?: string
8
+ }
9
+
10
+ export interface TextCardDraftProps extends TextCardBaseProps {
11
+ /** True while composing — shows the real draft text. */
12
+ isDraft: true
13
+ isPreview?: false
14
+ /** Real draft content, not yet redacted. */
15
+ renderedText?: React.ReactNode
16
+ /** Fallback amount before a price is set. */
17
+ placeholderAmountText?: string
18
+ /** Removes the draft attachment. */
19
+ onDismiss?: () => void
20
+ }
21
+
22
+ export interface TextCardPreviewProps extends TextCardBaseProps {
23
+ /** True to render exactly as the recipient will see it before purchase — skeleton lines only. */
24
+ isDraft?: false
25
+ isPreview: true
26
+ /** Fallback amount before a price is set. */
27
+ placeholderAmountText?: string
28
+ }
29
+
30
+ interface TextCardSentBaseProps extends TextCardBaseProps {
31
+ /** False once sent. */
32
+ isDraft?: false
33
+ isPreview?: false
34
+ /** Revealed content once paid. */
35
+ renderedText?: React.ReactNode
36
+ /** Drives the lock/skeleton/badge state. */
37
+ paymentStatus?: PaymentStatus
38
+ }
39
+
40
+ export type TextCardOwnProps = TextCardSentBaseProps & {
41
+ /** Sender's own view. */
42
+ isMine: true
43
+ }
44
+
45
+ export type TextCardOtherProps = TextCardSentBaseProps & {
46
+ /** Recipient's view. */
47
+ isMine: false
48
+ /** Spinner on Unlock while checkout runs. */
49
+ isUnlocking?: boolean
50
+ /** Opens checkout on click. Omit to hide the button. */
51
+ onUnlockClick?: () => void
52
+ }
53
+
54
+ export type TextCardProps =
55
+ | TextCardDraftProps
56
+ | TextCardPreviewProps
57
+ | TextCardOwnProps
58
+ | TextCardOtherProps
@@ -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 { 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
  */
@@ -36,8 +36,8 @@ const CardBody: React.FC<CardBodyProps> = ({
36
36
  trailingAction,
37
37
  }) => {
38
38
  const isDark = variant === 'dark'
39
- const displayTitle = isDark ? (title ?? placeholderTitle) : (title ?? '')
40
- const titleDimmed = isDark && !title
39
+ const displayTitle = title || placeholderTitle
40
+ const titleDimmed = !title
41
41
 
42
42
  const typeIcon =
43
43
  icon ??
@@ -56,7 +56,8 @@ 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,
59
+ 'text-black/90': !isDark && !titleDimmed,
60
+ 'text-black/30': !isDark && titleDimmed,
60
61
  'text-white/30': isDark && titleDimmed,
61
62
  'text-white': isDark && !titleDimmed,
62
63
  })}
@@ -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,23 +1,18 @@
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
 
11
- import { AttachmentThumbnail, getSourceType } from '../../../AttachmentCard'
5
+ import { AttachmentThumbnail, getSourceType } from '../../../../AttachmentCard'
12
6
  import type {
13
7
  LockedAttachmentGalleryItem,
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 PreviewMediaCard / OwnMediaCard.
73
52
  const goPrev = (e: React.MouseEvent) => {
74
53
  e.stopPropagation()
75
54
  setIndex((i) => Math.max(0, i - 1))
@@ -86,14 +65,22 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
86
65
  // Locked + unlocked render entirely separate trees so the locked state
87
66
  // doesn't end up stacking a blurred `<img>` on top of an underlying
88
67
  // `AttachmentThumbnail` (whose `bg-white/10` poster shell otherwise
89
- // shows through as a visible horizontal seam at the 16:9 boundary
90
- // inside the taller 337/386 gallery aspect).
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.
91
73
  //
92
74
  // The locked branch uses `filter: blur` on a single full-size `<img>`
93
75
  // — not `backdrop-filter` — so the rounded corners are clipped
94
- // normally without any clip-path workarounds (see LockedThumbnail).
76
+ // normally without any clip-path workarounds (see SingleThumbnail).
95
77
  return (
96
- <div className="relative aspect-[337/386] overflow-hidden rounded-t-md">
78
+ <div
79
+ className={classNames(
80
+ 'group relative overflow-hidden rounded-t-md',
81
+ showLocked ? 'aspect-[337/386]' : undefined
82
+ )}
83
+ >
97
84
  {showLocked ? (
98
85
  <div
99
86
  className={classNames(
@@ -115,20 +102,25 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
115
102
  </div>
116
103
  ) : (
117
104
  <AttachmentThumbnail
105
+ key={safeIndex}
118
106
  mimeType={current.mimeType}
119
107
  sourceUrl={current.sourceUrl}
120
108
  thumbnailUrl={current.thumbnailUrl}
121
109
  title={title}
122
110
  variant={variant}
111
+ mediaPlayerProps={{ autoPlay: true }}
123
112
  />
124
113
  )}
125
114
 
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>
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
+ />
132
124
  )}
133
125
 
134
126
  {total > 1 && (
@@ -158,8 +150,8 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
158
150
  <CaretRightIcon className="size-5" weight="bold" />
159
151
  </button>
160
152
 
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">
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">
163
155
  {safeIndex + 1}/{total}
164
156
  </span>
165
157
  </div>
@@ -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
@@ -0,0 +1,63 @@
1
+ import { LockOpenIcon, LockSimpleIcon, PlayIcon } from '@phosphor-icons/react'
2
+ import classNames from 'classnames'
3
+ import React from 'react'
4
+
5
+ import { getSourceType } from '../../../../AttachmentCard'
6
+ import type { PaymentStatus } from '../../types'
7
+
8
+ export interface LockBadgeProps {
9
+ paymentStatus?: PaymentStatus
10
+ mimeType?: string
11
+ /**
12
+ * Overrides the `mimeType`-derived playable check. Lets a caller that
13
+ * already knows per-item playability (e.g. `GalleryThumbnail`, checking a
14
+ * carousel item's own mimeType rather than the card's single `mimeType`)
15
+ * skip re-deriving it.
16
+ */
17
+ isPlayable?: boolean
18
+ /** Whether the item is still locked. Play only ever shows once unlocked —
19
+ * a locked item always shows the lock, even for otherwise-playable media. */
20
+ isLocked: boolean
21
+ }
22
+
23
+ /**
24
+ * Centered 48x48 lock badge that overlays the thumbnail of any locked attachment.
25
+ * Uses `bg-black/30`. Stays `pointer-events-none` so clicks pass through to
26
+ * the underlying player/toggle; the play state highlights via `group-hover`
27
+ * (same `bg-black/40` as the gallery's prev/next arrows) — the ancestor
28
+ * providing `group` must wrap the actual clickable/hoverable player area.
29
+ *
30
+ * For playable media (video/audio) that's unlocked, the badge shows a play
31
+ * icon instead of a lock — matching the convention used by `GalleryThumbnail`.
32
+ */
33
+ export const LockBadge: React.FC<LockBadgeProps> = ({
34
+ paymentStatus,
35
+ mimeType,
36
+ isPlayable,
37
+ isLocked,
38
+ }) => {
39
+ const sourceType = mimeType ? getSourceType(mimeType) : undefined
40
+ const resolvedIsPlayable =
41
+ isPlayable ?? (sourceType === 'video' || sourceType === 'audio')
42
+
43
+ const Icon =
44
+ resolvedIsPlayable && !isLocked
45
+ ? PlayIcon
46
+ : paymentStatus === 'paid'
47
+ ? LockOpenIcon
48
+ : LockSimpleIcon
49
+ return (
50
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
51
+ <div
52
+ className={classNames(
53
+ 'flex size-12 items-center justify-center rounded-full bg-black/30 text-white',
54
+ Icon === PlayIcon && 'group-hover:bg-black/40'
55
+ )}
56
+ >
57
+ <Icon className="size-6" weight="fill" />
58
+ </div>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ export default LockBadge
@@ -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
@@ -12,9 +12,9 @@ export interface LockedCardShellProps {
12
12
 
13
13
  /**
14
14
  * Outer chrome for every LockedAttachment card.
15
- * Mirrors the `shadow-400` + 16px radius treatment from the Figma design system.
15
+ * Mirrors the 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,
@@ -25,12 +25,12 @@ const LockedCardShell: React.FC<LockedCardShellProps> = ({
25
25
  data-testid={dataTestId}
26
26
  className={classNames(
27
27
  'relative w-[280px] select-none overflow-hidden rounded-md',
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'
28
+ variant === 'dark' ? 'bg-[#1e2330]' : 'bg-white',
29
+ 'shadow-[0_1px_2px_rgba(0,0,0,0.08)]'
30
30
  )}
31
31
  >
32
32
  {children}
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