@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,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
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+
3
+ import { act, renderHook } from '../../../../../test/utils'
4
+
5
+ import { usePreviewToggle } from './usePreviewToggle'
6
+
7
+ const source = { sourceUrl: 'https://example.test/asset.mp4' }
8
+
9
+ describe('usePreviewToggle', () => {
10
+ it('gallery: reveals without invoking the async source fetcher', async () => {
11
+ const onFetchSource = vi.fn().mockResolvedValue(source)
12
+ const onPreviewClick = vi.fn()
13
+ const { result } = renderHook(() =>
14
+ usePreviewToggle({ isGallery: true, onFetchSource, onPreviewClick })
15
+ )
16
+
17
+ await act(async () => {
18
+ await result.current.handleToggle()
19
+ })
20
+
21
+ expect(result.current.isPreviewVisible).toBe(true)
22
+ expect(onPreviewClick).toHaveBeenCalledTimes(1)
23
+ expect(onFetchSource).not.toHaveBeenCalled()
24
+ })
25
+
26
+ it('first reveal fetches the source, then caches it on subsequent toggles', async () => {
27
+ const onFetchSource = vi.fn().mockResolvedValue(source)
28
+ const { result } = renderHook(() =>
29
+ usePreviewToggle({ isGallery: false, onFetchSource })
30
+ )
31
+
32
+ // First reveal → fetches.
33
+ await act(async () => {
34
+ await result.current.handleToggle()
35
+ })
36
+ expect(result.current.isPreviewVisible).toBe(true)
37
+ expect(result.current.source).toEqual(source)
38
+ expect(onFetchSource).toHaveBeenCalledTimes(1)
39
+
40
+ // Toggle off.
41
+ await act(async () => {
42
+ await result.current.handleToggle()
43
+ })
44
+ expect(result.current.isPreviewVisible).toBe(false)
45
+
46
+ // Toggle back on → reuses the cache, no second fetch.
47
+ await act(async () => {
48
+ await result.current.handleToggle()
49
+ })
50
+ expect(result.current.isPreviewVisible).toBe(true)
51
+ expect(onFetchSource).toHaveBeenCalledTimes(1)
52
+ })
53
+
54
+ it('stays locked when the fetcher resolves without a source', async () => {
55
+ const onFetchSource = vi.fn().mockResolvedValue(undefined)
56
+ const { result } = renderHook(() =>
57
+ usePreviewToggle({ isGallery: false, onFetchSource })
58
+ )
59
+
60
+ await act(async () => {
61
+ await result.current.handleToggle()
62
+ })
63
+
64
+ expect(result.current.isPreviewVisible).toBe(false)
65
+ expect(result.current.source).toBeUndefined()
66
+ })
67
+ })
@@ -0,0 +1,89 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+
3
+ import type { LockedAttachmentSource } from '../../types'
4
+
5
+ export interface UsePreviewToggleOptions {
6
+ /** Gallery items carry their own per-item sources on the `gallery` prop already, so toggling skips the async fetch entirely. */
7
+ isGallery: boolean
8
+ /**
9
+ * Lazily loads the underlying source; the resolved value is cached in
10
+ * `source` and reused on subsequent toggles.
11
+ */
12
+ onFetchSource?: () => Promise<LockedAttachmentSource | void>
13
+ /** Fired on every toggle tap, before any visibility/fetch logic runs. */
14
+ onPreviewClick?: () => void
15
+ }
16
+
17
+ export interface UsePreviewToggleResult {
18
+ source: LockedAttachmentSource | undefined
19
+ isPreviewVisible: boolean
20
+ isLoadingPreview: boolean
21
+ handleToggle: () => Promise<void>
22
+ /**
23
+ * `handleToggle` when the card can actually be previewed (gallery, or a
24
+ * fetch/click handler is wired), else `undefined` — pass straight to
25
+ * `PreviewToggle.onToggle` so a non-previewable card renders no
26
+ * toggle affordance.
27
+ */
28
+ togglePreview: (() => Promise<void>) | undefined
29
+ }
30
+
31
+ /**
32
+ * Shared preview-toggle state for Composer/Media isMine: flips a locked thumbnail to
33
+ * its unlocked preview, lazily fetching and caching the source on first
34
+ * reveal. Media !isMine doesn't use this — it fetches on scroll-into-view/paid
35
+ * instead of on a toggle tap.
36
+ */
37
+ export function usePreviewToggle({
38
+ isGallery,
39
+ onFetchSource,
40
+ onPreviewClick,
41
+ }: UsePreviewToggleOptions): UsePreviewToggleResult {
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 handleToggle = useCallback(async () => {
48
+ onPreviewClick?.()
49
+ if (isPreviewVisible) {
50
+ setIsPreviewVisible(false)
51
+ return
52
+ }
53
+ if (isGallery) {
54
+ setIsPreviewVisible(true)
55
+ return
56
+ }
57
+ if (source) {
58
+ setIsPreviewVisible(true)
59
+ return
60
+ }
61
+ if (!onFetchSource) return
62
+ if (fetchingRef.current) return
63
+ fetchingRef.current = true
64
+ setIsLoadingPreview(true)
65
+ try {
66
+ const result = await onFetchSource()
67
+ if (result) {
68
+ setSource(result)
69
+ setIsPreviewVisible(true)
70
+ }
71
+ } finally {
72
+ fetchingRef.current = false
73
+ setIsLoadingPreview(false)
74
+ }
75
+ }, [isPreviewVisible, isGallery, source, onPreviewClick, onFetchSource])
76
+
77
+ const togglePreview =
78
+ isGallery || onFetchSource != null || onPreviewClick != null
79
+ ? handleToggle
80
+ : undefined
81
+
82
+ return {
83
+ source,
84
+ isPreviewVisible,
85
+ isLoadingPreview,
86
+ handleToggle,
87
+ togglePreview,
88
+ }
89
+ }
@@ -0,0 +1,83 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ import type {
4
+ FetchSourceHandler,
5
+ LockedAttachmentSource,
6
+ PaymentStatus,
7
+ } from '../../types'
8
+
9
+ export interface UseReceivedSourceOptions {
10
+ paymentStatus?: PaymentStatus
11
+ onFetchSource?: FetchSourceHandler
12
+ /** Called on Unlock click while the card is still unpaid (opens checkout). */
13
+ onUnlockClick?: () => void
14
+ }
15
+
16
+ export interface UseReceivedSourceResult {
17
+ source: LockedAttachmentSource | undefined
18
+ /** Attach to the card root so the paid-source auto-fetch can observe it. */
19
+ cardRef: React.RefObject<HTMLDivElement>
20
+ /** Fetches the source when already paid, else defers to `onUnlockClick`. */
21
+ handleUnlockClick: () => void
22
+ }
23
+
24
+ /**
25
+ * Media !isMine's source-reveal logic: unlike Composer/Media isMine's
26
+ * tap-to-toggle (`usePreviewToggle`), the recipient's card reveals its source
27
+ * by fetching once — on click when already paid, or automatically the first
28
+ * time a paid card scrolls fully into view. The fetch is guarded so it runs
29
+ * at most once in flight.
30
+ */
31
+ export function useReceivedSource({
32
+ paymentStatus,
33
+ onFetchSource,
34
+ onUnlockClick,
35
+ }: UseReceivedSourceOptions): UseReceivedSourceResult {
36
+ const [source, setSource] = useState<LockedAttachmentSource | undefined>()
37
+
38
+ const cardRef = useRef<HTMLDivElement>(null)
39
+ const fetchingRef = useRef(false)
40
+
41
+ const onFetchSourceRef = useRef(onFetchSource)
42
+ onFetchSourceRef.current = onFetchSource
43
+
44
+ const fetchSource = useCallback(async (): Promise<void> => {
45
+ if (fetchingRef.current) return
46
+ fetchingRef.current = true
47
+ try {
48
+ const result = await onFetchSourceRef.current?.()
49
+ if (result) setSource(result)
50
+ } finally {
51
+ fetchingRef.current = false
52
+ }
53
+ }, [])
54
+
55
+ const handleUnlockClick = useCallback(() => {
56
+ if (paymentStatus === 'paid') {
57
+ void fetchSource()
58
+ } else {
59
+ onUnlockClick?.()
60
+ }
61
+ }, [paymentStatus, fetchSource, onUnlockClick])
62
+
63
+ // Auto-fetch the source once the paid card scrolls into view.
64
+ useEffect(() => {
65
+ if (!cardRef.current) return
66
+ if (paymentStatus !== 'paid' || source !== undefined) return
67
+
68
+ const observer = new IntersectionObserver(
69
+ ([entry]) => {
70
+ if (entry.isIntersecting) {
71
+ void fetchSource()
72
+ observer.disconnect()
73
+ }
74
+ },
75
+ { threshold: 1.0 }
76
+ )
77
+
78
+ observer.observe(cardRef.current)
79
+ return () => observer.disconnect()
80
+ }, [paymentStatus, source, fetchSource])
81
+
82
+ return { source, cardRef, handleUnlockClick }
83
+ }
@@ -0,0 +1,105 @@
1
+ import React, { Suspense } from 'react'
2
+
3
+ import type {
4
+ MediaCardDraftProps,
5
+ MediaCardOtherProps,
6
+ MediaCardOwnProps,
7
+ MediaCardPreviewProps,
8
+ } from './components/Media/types'
9
+ import type {
10
+ TextCardOtherProps,
11
+ TextCardOwnProps,
12
+ } from './components/Text/types'
13
+
14
+ const TextCardLazy = React.lazy(() => import('./components/Text/Card'))
15
+ const MediaCardLazy = React.lazy(() => import('./components/Media/Card'))
16
+
17
+ const LockedAttachmentFallback = () => (
18
+ <div
19
+ className="w-[280px] min-h-[200px] animate-pulse rounded-md bg-black/[0.06] 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)]"
20
+ aria-hidden
21
+ />
22
+ )
23
+
24
+ export type DraftProps = MediaCardDraftProps & {
25
+ /** Drafting is always the sender — doesn't apply here. */
26
+ isMine?: never
27
+ /**
28
+ * "text" renders as a chat bubble showing the real draft `text` instead of
29
+ * a media thumbnail card. Media-only fields below are ignored in text mode.
30
+ * Defaults to `"media"`.
31
+ */
32
+ contentType?: 'text' | 'media'
33
+ /** The message's own text, shown as-is (not redacted yet). Only used when `contentType` is `"text"`. */
34
+ renderedText?: React.ReactNode
35
+ }
36
+
37
+ export type PreviewProps = MediaCardPreviewProps & {
38
+ /** Drafting is always the sender — doesn't apply here. */
39
+ isMine?: never
40
+ /**
41
+ * "text" renders as a chat bubble instead of a media thumbnail card.
42
+ * Media-only fields below are ignored in text mode. Defaults to `"media"`.
43
+ */
44
+ contentType?: 'text' | 'media'
45
+ }
46
+
47
+ export type SentTextOwnProps = TextCardOwnProps & {
48
+ contentType: 'text'
49
+ /** Sender's view has no unlock flow — doesn't apply here. */
50
+ onUnlockClick?: never
51
+ isUnlocking?: never
52
+ }
53
+
54
+ export type SentTextOtherProps = TextCardOtherProps & {
55
+ contentType: 'text'
56
+ }
57
+
58
+ export type SentMediaOwnProps = MediaCardOwnProps & {
59
+ contentType?: 'media'
60
+ /** Sender's view has no unlock/download flow — doesn't apply here. */
61
+ onUnlockClick?: never
62
+ isUnlocking?: never
63
+ onDownloadClick?: never
64
+ }
65
+
66
+ export type SentMediaOtherProps = MediaCardOtherProps & {
67
+ contentType?: 'media'
68
+ }
69
+
70
+ /**
71
+ * The card for a paid attachment — a still-drafting `isDraft` card, a static
72
+ * receiver-facing `isPreview` mockup, or a sent text/media card discriminated
73
+ * by `isMine` + `contentType`. Internally lazy-loads only the one underlying
74
+ * implementation the resolved branch needs.
75
+ */
76
+ export type LockedAttachmentProps =
77
+ | DraftProps
78
+ | PreviewProps
79
+ | SentTextOwnProps
80
+ | SentTextOtherProps
81
+ | SentMediaOwnProps
82
+ | SentMediaOtherProps
83
+
84
+ const LockedAttachment: React.FC<LockedAttachmentProps> = (props) => {
85
+ return (
86
+ <Suspense fallback={<LockedAttachmentFallback />}>
87
+ {props.contentType === 'text' ? (
88
+ <TextCardLazy {...props} />
89
+ ) : (
90
+ <MediaCardLazy {...props} />
91
+ )}
92
+ </Suspense>
93
+ )
94
+ }
95
+
96
+ export default LockedAttachment
97
+ export { default as PurchaseStatusBadge } from './components/_shared/PurchaseStatusBadge'
98
+ export type { PurchaseStatusBadgeProps } from './components/_shared/PurchaseStatusBadge'
99
+ export type {
100
+ PaymentStatus,
101
+ FetchSourceHandler,
102
+ LockedAttachmentSource,
103
+ LockedAttachmentGalleryItem,
104
+ LockedAttachmentContextValue,
105
+ } from './types'
@@ -0,0 +1,44 @@
1
+ import type { Channel, LocalMessage } from 'stream-chat'
2
+
3
+ import type { PaymentStatus } from '../../../stream-custom-data'
4
+
5
+ export interface LockedAttachmentGalleryItem {
6
+ /** Drives the per-item play/lock affordance. */
7
+ mimeType: string
8
+ /** Poster image, locked or unlocked. */
9
+ thumbnailUrl?: string
10
+ /** Media source, shown only when unlocked. */
11
+ sourceUrl?: string
12
+ }
13
+
14
+ export interface LockedAttachmentSource {
15
+ /** Proxied URL for in-app playback. */
16
+ sourceUrl: string
17
+ /** Opened when the visitor clicks Download. */
18
+ redeemUrl?: string
19
+ /** Overrides the metadata thumbnail when present. */
20
+ thumbnailUrl?: string
21
+ }
22
+
23
+ export type { PaymentStatus }
24
+
25
+ /** Fetches the underlying source; `void` return means none available. */
26
+ export type FetchSourceHandler = () => Promise<LockedAttachmentSource | void>
27
+
28
+ export interface LockedAttachmentContextValue {
29
+ /** Whether the given message is mid-checkout. */
30
+ isUnlocking: (id: string) => boolean
31
+ /** Opens the checkout flow for a message. */
32
+ onUnlockClick?: (message: LocalMessage, channel: Channel) => void
33
+ /** Handles a Download click for a message. */
34
+ onDownloadClick?: (message: LocalMessage, channel: Channel) => void
35
+ /** Reveals the locked media's source. Unused for text — payment writes `message.text` directly. */
36
+ onFetchSource?: (
37
+ message: LocalMessage,
38
+ channel: Channel
39
+ ) => Promise<LockedAttachmentSource | void>
40
+ }
41
+
42
+ export const defaultLockedAttachmentContextValue: LockedAttachmentContextValue = {
43
+ isUnlocking: () => false,
44
+ }
@@ -47,6 +47,14 @@ export const isAttachmentMessage = (message: LocalMessage): boolean => {
47
47
  return message.metadata?.custom_type === 'MESSAGE_ATTACHMENT'
48
48
  }
49
49
 
50
+ export const isTextAttachmentMessage = (message: LocalMessage): boolean => {
51
+ return isAttachmentMessage(message) && message.metadata?.attachment_content_type === 'text'
52
+ }
53
+
54
+ export const isMediaAttachmentMessage = (message: LocalMessage): boolean => {
55
+ return isAttachmentMessage(message) && message.metadata?.attachment_content_type !== 'text'
56
+ }
57
+
50
58
  /** Check if a message has a tip/paid tag (both render the same) */
51
59
  export const isTipOrPaidMessage = (message: LocalMessage): boolean => {
52
60
  return isTipMessage(message) || isPaidMessage(message)
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext } from 'react'
2
2
 
3
- import { defaultLockedAttachmentContextValue, type LockedAttachmentContextValue } from '../LockedAttachment/types'
3
+ import { defaultLockedAttachmentContextValue, type LockedAttachmentContextValue } from './LockedAttachment/types'
4
4
 
5
5
  export interface CustomMessageRegistry {
6
6
  LockedAttachment: LockedAttachmentContextValue