@linktr.ee/messaging-react 3.10.0 → 3.11.0

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 (86) hide show
  1. package/dist/Card-415OfRhw.cjs +2 -0
  2. package/dist/Card-415OfRhw.cjs.map +1 -0
  3. package/dist/Card-CyWdUMCB.cjs +2 -0
  4. package/dist/Card-CyWdUMCB.cjs.map +1 -0
  5. package/dist/Card-V5d6qhDT.js +694 -0
  6. package/dist/Card-V5d6qhDT.js.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-Bx_zjNMR.cjs +2 -0
  15. package/dist/index-Bx_zjNMR.cjs.map +1 -0
  16. package/dist/{index-BxzzWK4K.js → index-DFZrAiLB.js} +1276 -1249
  17. package/dist/index-DFZrAiLB.js.map +1 -0
  18. package/dist/index.cjs +1 -1
  19. package/dist/index.d.ts +164 -101
  20. package/dist/index.js +1 -1
  21. package/dist/testing.d.ts +1 -0
  22. package/package.json +1 -1
  23. package/src/components/ChannelList/CustomChannelPreview.test.tsx +95 -0
  24. package/src/components/ChannelList/CustomChannelPreview.tsx +21 -9
  25. package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -1
  26. package/src/components/CustomMessage/LockedAttachment/LockedAttachment.stories.tsx +541 -0
  27. package/src/components/CustomMessage/LockedAttachment/LockedAttachment.test.tsx +117 -0
  28. package/src/components/CustomMessage/LockedAttachment/components/Media/Card.tsx +358 -0
  29. package/src/components/{LockedAttachment/components/Received → CustomMessage/LockedAttachment/components/Media}/CardActions.tsx +9 -2
  30. package/src/components/CustomMessage/LockedAttachment/components/Media/types.ts +79 -0
  31. package/src/components/CustomMessage/LockedAttachment/components/Text/Card.tsx +153 -0
  32. package/src/components/CustomMessage/LockedAttachment/components/Text/types.ts +58 -0
  33. package/src/components/{LockedAttachment → CustomMessage/LockedAttachment}/components/_shared/CardBody.tsx +8 -7
  34. package/src/components/CustomMessage/LockedAttachment/components/_shared/CardThumbnail.tsx +92 -0
  35. package/src/components/{LockedAttachment → CustomMessage/LockedAttachment}/components/_shared/GalleryThumbnail.tsx +31 -39
  36. package/src/components/CustomMessage/LockedAttachment/components/_shared/LoadingDots.tsx +36 -0
  37. package/src/components/CustomMessage/LockedAttachment/components/_shared/LockBadge.tsx +63 -0
  38. package/src/components/{LockedAttachment/components/_shared/LockedCardShell.tsx → CustomMessage/LockedAttachment/components/_shared/MediaShell.tsx} +8 -8
  39. package/src/components/CustomMessage/LockedAttachment/components/_shared/PreviewToggle.tsx +58 -0
  40. package/src/components/CustomMessage/LockedAttachment/components/_shared/PriceLockRow.tsx +21 -0
  41. package/src/components/CustomMessage/LockedAttachment/components/_shared/PurchaseStatusBadge.tsx +74 -0
  42. package/src/components/{LockedAttachment/components/_shared/LockedThumbnail.tsx → CustomMessage/LockedAttachment/components/_shared/SingleThumbnail.tsx} +11 -7
  43. package/src/components/CustomMessage/LockedAttachment/components/_shared/SkeletonTextLines.tsx +29 -0
  44. package/src/components/CustomMessage/LockedAttachment/components/_shared/TextShell.tsx +28 -0
  45. package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.test.ts +67 -0
  46. package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.ts +89 -0
  47. package/src/components/CustomMessage/LockedAttachment/components/_shared/useReceivedSource.ts +83 -0
  48. package/src/components/CustomMessage/LockedAttachment/index.tsx +105 -0
  49. package/src/components/CustomMessage/LockedAttachment/types.ts +44 -0
  50. package/src/components/CustomMessage/MessageTag.tsx +8 -0
  51. package/src/components/CustomMessage/context.tsx +1 -1
  52. package/src/components/CustomMessage/index.tsx +76 -42
  53. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +5 -3
  54. package/src/index.ts +3 -8
  55. package/src/stream-custom-data.ts +1 -0
  56. package/src/styles.css +12 -0
  57. package/src/types.ts +1 -1
  58. package/dist/Card-B-L2MST4.js +0 -107
  59. package/dist/Card-B-L2MST4.js.map +0 -1
  60. package/dist/Card-B56QxJW3.cjs +0 -2
  61. package/dist/Card-B56QxJW3.cjs.map +0 -1
  62. package/dist/Card-Bqh-BQaX.js +0 -132
  63. package/dist/Card-Bqh-BQaX.js.map +0 -1
  64. package/dist/Card-ZCE6Lhfc.cjs +0 -2
  65. package/dist/Card-ZCE6Lhfc.cjs.map +0 -1
  66. package/dist/Card-g4JD-2ES.js +0 -163
  67. package/dist/Card-g4JD-2ES.js.map +0 -1
  68. package/dist/Card-wac6AMNc.cjs +0 -2
  69. package/dist/Card-wac6AMNc.cjs.map +0 -1
  70. package/dist/LockedThumbnail-BAQmoIff.js +0 -220
  71. package/dist/LockedThumbnail-BAQmoIff.js.map +0 -1
  72. package/dist/LockedThumbnail-Cr0Gqs3Y.cjs +0 -2
  73. package/dist/LockedThumbnail-Cr0Gqs3Y.cjs.map +0 -1
  74. package/dist/index-BPhZyaxT.cjs +0 -2
  75. package/dist/index-BPhZyaxT.cjs.map +0 -1
  76. package/dist/index-BxzzWK4K.js.map +0 -1
  77. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +0 -450
  78. package/src/components/LockedAttachment/components/Composer/Card.tsx +0 -221
  79. package/src/components/LockedAttachment/components/Composer/index.ts +0 -2
  80. package/src/components/LockedAttachment/components/Received/Card.tsx +0 -191
  81. package/src/components/LockedAttachment/components/Received/index.ts +0 -2
  82. package/src/components/LockedAttachment/components/Sent/Card.tsx +0 -177
  83. package/src/components/LockedAttachment/components/Sent/index.ts +0 -2
  84. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +0 -39
  85. package/src/components/LockedAttachment/index.tsx +0 -63
  86. package/src/components/LockedAttachment/types.ts +0 -51
@@ -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
@@ -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,74 @@
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
+ if (paymentStatus === 'paid') {
27
+ return (
28
+ <React.Fragment>
29
+ <span className={classNames('text-xs font-medium', labelColour)}>
30
+ &bull;
31
+ </span>
32
+ <span className={classNames('text-xs font-medium', successColour)}>
33
+ {isMine ? 'Sold' : 'Purchased'}
34
+ </span>
35
+ <CheckCircleIcon
36
+ className={classNames('shrink-0 size-3.5', successColour)}
37
+ weight="bold"
38
+ aria-hidden
39
+ />
40
+ </React.Fragment>
41
+ )
42
+ }
43
+
44
+ if (amountText != null) {
45
+ return (
46
+ <React.Fragment>
47
+ <span className={classNames('text-xs font-medium', labelColour)}>
48
+ &bull;
49
+ </span>
50
+ <span className={classNames('text-xs font-medium', labelColour)}>
51
+ {amountText}
52
+ </span>
53
+ </React.Fragment>
54
+ )
55
+ }
56
+
57
+
58
+ if (paymentStatus === 'failed' || paymentStatus === 'refunded') {
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
+ {paymentStatus === 'failed' ? 'Payment failed' : 'Refunded'}
66
+ </span>
67
+ </React.Fragment>
68
+ )
69
+ }
70
+
71
+ return null
72
+ }
73
+
74
+ export default PurchaseStatusBadge
@@ -4,14 +4,14 @@ import React from 'react'
4
4
  import {
5
5
  AttachmentThumbnail,
6
6
  renderTypeIcon,
7
- } from '../../../AttachmentCard'
7
+ } from '../../../../AttachmentCard'
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,
@@ -98,7 +98,11 @@ const LockedThumbnail: React.FC<LockedThumbnailProps> = ({
98
98
  })}
99
99
  </div>
100
100
  )}
101
- <LockBadge paymentStatus={paymentStatus} mimeType={mimeType} />
101
+ <LockBadge
102
+ paymentStatus={paymentStatus}
103
+ mimeType={mimeType}
104
+ isLocked={showLocked}
105
+ />
102
106
  </div>
103
107
  ) : (
104
108
  <AttachmentThumbnail
@@ -125,4 +129,4 @@ const LockedThumbnail: React.FC<LockedThumbnailProps> = ({
125
129
  )
126
130
  }
127
131
 
128
- export default LockedThumbnail
132
+ export default SingleThumbnail
@@ -0,0 +1,29 @@
1
+ import classNames from 'classnames'
2
+ import React from 'react'
3
+
4
+ export interface SkeletonTextLinesProps {
5
+ /** Matches the bubble it sits in — "dark" for the sender's own bubble. */
6
+ variant: 'light' | 'dark'
7
+ }
8
+
9
+ /**
10
+ * Two skeleton bars standing in for the redacted text of a paid text-only
11
+ * message — the real content is never sent to the client until purchase, so
12
+ * there's nothing to render yet.
13
+ *
14
+ * Uses explicit black/white (not `bg-current`) because Tailwind doesn't
15
+ * support opacity modifiers on the `current` color — `bg-current/30` silently
16
+ * produces no CSS rule at all.
17
+ */
18
+ const SkeletonTextLines: React.FC<SkeletonTextLinesProps> = ({ variant = 'dark' }) => {
19
+ const colour = variant === 'dark' ? 'bg-white/25' : 'bg-black/15'
20
+
21
+ return (
22
+ <div className="flex flex-col gap-2 str-chat__message-text my-1 animate-pulse" aria-hidden>
23
+ <div className={classNames('h-3 w-40 rounded-full', colour)} />
24
+ <div className={classNames('h-3 w-24 rounded-full', colour)} />
25
+ </div>
26
+ )
27
+ }
28
+
29
+ export default SkeletonTextLines
@@ -0,0 +1,28 @@
1
+ import classNames from 'classnames'
2
+ import React from 'react'
3
+
4
+ /**
5
+ * Chat-bubble chrome for a paid text-only message/preview. Deliberately
6
+ * doesn't lean on `.str-chat`'s CSS variables (`--str-chat__message-bubble-*`)
7
+ * — this renders in the Composer preview too, which sits outside any
8
+ * `.str-chat` ancestor, so every value here (radius, colour, type scale) is
9
+ * hardcoded to match the Figma spec directly.
10
+ */
11
+ const TextShell: React.FC<
12
+ React.PropsWithChildren<{ isMine: boolean; className?: string }>
13
+ > = ({ isMine, children, className }) => (
14
+ <div
15
+ className={classNames(
16
+ 'message-locked-attachment-text',
17
+ 'relative flex min-w-[225px] max-w-[280px] flex-col gap-1 rounded-[1.5rem] px-4 py-3',
18
+ 'text-[0.875rem] leading-[1.3125rem] tracking-[0.14px]',
19
+ isMine ? 'bg-[#1e2330] text-white' : 'bg-[#f2f1ef] text-black',
20
+ 'shadow-[0_1px_2px_rgba(0,0,0,0.08)]',
21
+ className
22
+ )}
23
+ >
24
+ {children}
25
+ </div>
26
+ )
27
+
28
+ export default TextShell