@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
@@ -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
@@ -32,17 +32,17 @@ import {
32
32
  import { useMessageVote } from '../../hooks/useMessageVote'
33
33
  import { getMessageDisplayText } from '../../utils/getMessageDisplayText'
34
34
  import { Avatar } from '../Avatar'
35
- import LockedAttachment from '../LockedAttachment'
36
35
  import { isLinkAttachment } from '../MediaMessage'
37
- import {
38
- bubbleGroupPositionFromStream as messageAttachmentGroupPositionFromStream,
39
- } from '../MessageAttachment'
36
+ import { bubbleGroupPositionFromStream as messageAttachmentGroupPositionFromStream } from '../MessageAttachment'
40
37
 
41
38
  import { useCustomMessage } from './context'
39
+ import LockedAttachment from './LockedAttachment'
42
40
  import {
43
41
  MessageTag,
44
42
  isAttachmentMessage,
45
43
  isChatbotMessage,
44
+ isMediaAttachmentMessage,
45
+ isTextAttachmentMessage,
46
46
  isTipOnlyMessage,
47
47
  } from './MessageTag'
48
48
  import { MessageVoteButtons } from './MessageVoteButtons'
@@ -145,13 +145,12 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
145
145
  (total, segment) => total + segment.attachments.length,
146
146
  0
147
147
  ) === (finalAttachments?.length ?? 0)
148
- const streamAttachmentGroupPosition = messageAttachmentGroupPositionFromStream(
149
- {
148
+ const streamAttachmentGroupPosition =
149
+ messageAttachmentGroupPositionFromStream({
150
150
  endOfGroup,
151
151
  firstOfGroup,
152
152
  groupedByUser,
153
- }
154
- )
153
+ })
155
154
 
156
155
  if (isDateSeparatorMessage(message)) {
157
156
  return null
@@ -226,6 +225,26 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
226
225
  !isAIGenerated &&
227
226
  !isChatbot
228
227
 
228
+ const sharedLockedTextProps = {
229
+ contentType: 'text' as const,
230
+ amountText: message.metadata?.amount_text,
231
+ paymentStatus: message.metadata?.payment_status,
232
+ renderedText: message.text && (
233
+ <MessageText message={displayMessage} renderText={renderText} />
234
+ ),
235
+ }
236
+ const sharedLockedMediaProps = {
237
+ contentType: 'media' as const,
238
+ title: message.metadata?.attachment_title,
239
+ mimeType: message.metadata?.attachment_mime_type,
240
+ thumbnailUrl: message.metadata?.attachment_thumbnail,
241
+ amountText: message.metadata?.amount_text,
242
+ detail: message.metadata?.attachment_detail,
243
+ paymentStatus: message.metadata?.payment_status,
244
+ onFetchSource: onFetchSource
245
+ ? () => onFetchSource(message, channel)
246
+ : undefined,
247
+ }
229
248
  return (
230
249
  <>
231
250
  {editing && (
@@ -276,43 +295,61 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
276
295
  marginInlineStart: 0,
277
296
  }}
278
297
  >
279
- {isAttachment ? (
298
+ {isTipOnly ? (
299
+ <MessageTag message={message} standalone />
300
+ ) : isTextAttachmentMessage(message) ? (
280
301
  <div className="str-chat__message-bubble-wrapper">
281
- {isMine ? (
282
- <div className="flex items-center gap-2">
283
- {MessageActions && <MessageActions />}
284
- <LockedAttachment.Sent
285
- title={message.metadata?.attachment_title}
286
- mimeType={message.metadata?.attachment_mime_type}
287
- thumbnailUrl={message.metadata?.attachment_thumbnail}
288
- amountText={message.metadata?.amount_text}
289
- detail={message.metadata?.attachment_detail}
290
- paymentStatus={message.metadata?.payment_status}
291
- onPreviewClick={() => onUnlockClick?.(message, channel)}
292
- onFetchSource={async () =>
293
- await onFetchSource?.(message, channel)
294
- }
302
+ <div className="flex items-center gap-2">
303
+ {isMine && MessageActions && <MessageActions />}
304
+ {isMine ? (
305
+ <LockedAttachment
306
+ {...sharedLockedTextProps}
307
+ isMine={true}
295
308
  />
296
- </div>
297
- ) : (
298
- <div className="flex items-center gap-2">
299
- <LockedAttachment.Received
300
- title={message.metadata?.attachment_title}
301
- mimeType={message.metadata?.attachment_mime_type}
302
- thumbnailUrl={message.metadata?.attachment_thumbnail}
303
- amountText={message.metadata?.amount_text}
304
- detail={message.metadata?.attachment_detail}
305
- paymentStatus={message.metadata?.payment_status}
309
+ ) : (
310
+ <LockedAttachment
311
+ {...sharedLockedTextProps}
312
+ isMine={false}
306
313
  isUnlocking={isUnlocking(message.id)}
307
314
  onUnlockClick={() => onUnlockClick?.(message, channel)}
308
- onFetchSource={async () =>
309
- await onFetchSource?.(message, channel)
315
+ />
316
+ )}
317
+ {!isMine && MessageActions && <MessageActions />}
318
+ </div>
319
+ </div>
320
+ ) : isMediaAttachmentMessage(message) ? (
321
+ <div className="str-chat__message-bubble-wrapper">
322
+ <div className="flex items-center gap-2">
323
+ {isMine && MessageActions && <MessageActions />}
324
+ {isMine ? (
325
+ <LockedAttachment
326
+ {...sharedLockedMediaProps}
327
+ isMine={true}
328
+ onPreviewClick={() =>
329
+ onUnlockClick
330
+ ? onUnlockClick(message, channel)
331
+ : undefined
310
332
  }
311
- onDownloadClick={() => onDownloadClick?.(message, channel)}
312
333
  />
313
- {MessageActions && <MessageActions />}
314
- </div>
315
- )}
334
+ ) : (
335
+ <LockedAttachment
336
+ {...sharedLockedMediaProps}
337
+ isMine={false}
338
+ isUnlocking={isUnlocking(message.id)}
339
+ onUnlockClick={() =>
340
+ onUnlockClick
341
+ ? onUnlockClick(message, channel)
342
+ : undefined
343
+ }
344
+ onDownloadClick={() =>
345
+ onDownloadClick
346
+ ? onDownloadClick(message, channel)
347
+ : undefined
348
+ }
349
+ />
350
+ )}
351
+ {!isMine && MessageActions && <MessageActions />}
352
+ </div>
316
353
  {message.text && (
317
354
  <div className="str-chat__message-bubble">
318
355
  <MessageText
@@ -322,9 +359,6 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
322
359
  </div>
323
360
  )}
324
361
  </div>
325
- ) : isTipOnly ? (
326
- /* Tip-only messages render as a standalone bubble */
327
- <MessageTag message={message} standalone />
328
362
  ) : canRenderAttachmentsInToolkit ? (
329
363
  <StreamAttachmentMessage
330
364
  groupPosition={streamAttachmentGroupPosition}
@@ -5,7 +5,7 @@ import { QueryChannelAPIResponse, StreamChat } from 'stream-chat'
5
5
  import { Channel, Chat, type SendButtonProps } from 'stream-chat-react'
6
6
 
7
7
  import { createMockStreamChatClient } from '../../testing/createMockStreamChatClient'
8
- import LockedAttachment from '../LockedAttachment'
8
+ import LockedAttachment from '../CustomMessage/LockedAttachment'
9
9
 
10
10
  import { CustomMessageInput } from '.'
11
11
 
@@ -229,7 +229,9 @@ WithAttachments.parameters = {
229
229
  export const WithPaidAttachment: StoryFn<WrapperProps> = (args) => <Wrapper {...args} />
230
230
  WithPaidAttachment.args = {
231
231
  attachmentPreviewList: () => (
232
- <LockedAttachment.Composer
232
+ <LockedAttachment
233
+ isPreview
234
+ contentType="media"
233
235
  title="Exclusive Content"
234
236
  mimeType="image/jpeg"
235
237
  thumbnailUrl="https://picsum.photos/seed/paid/320/180"
@@ -266,7 +268,7 @@ WithPaidAttachment.parameters = {
266
268
  docs: {
267
269
  description: {
268
270
  story:
269
- "Message input with a paid attachment staged inside the input bubble. Uses `Channel`'s `AttachmentPreviewList` override to render a `LockedAttachment.Composer` card at the top of the input. `onDismiss` removes it; `onEditClick` reopens the paid content wizard.",
271
+ "Message input with a paid attachment staged inside the input bubble. Uses `Channel`'s `AttachmentPreviewList` override to render a `LockedAttachment isPreview` card at the top of the input. `onDismiss` removes it; `onEditClick` reopens the paid content wizard.",
270
272
  },
271
273
  },
272
274
  }
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ export { MessagingShell } from './components/MessagingShell'
6
6
  export { ChannelList } from './components/ChannelList'
7
7
  export { ChannelView } from './components/ChannelView'
8
8
  export { default as ActionButton } from './components/ActionButton'
9
- export { default as LockedAttachment } from './components/LockedAttachment'
9
+ export { default as LockedAttachment } from './components/CustomMessage/LockedAttachment'
10
10
  export { default as LinkAttachment } from './components/LinkAttachment'
11
11
  export { default as MessageAttachment } from './components/MessageAttachment'
12
12
  export { Avatar } from './components/Avatar'
@@ -76,15 +76,10 @@ export type {
76
76
  } from './components/MessageBubble'
77
77
  export type { ActionButtonProps } from './components/ActionButton'
78
78
  export type {
79
- ComposerCardProps,
80
- SentCardProps,
81
- ReceivedCardProps,
79
+ LockedAttachmentProps,
82
80
  LockedAttachmentGalleryItem,
83
81
  LockedAttachmentContextValue,
84
- // Deprecated aliases preserved for backwards compatibility:
85
- CreatorCardProps,
86
- VisitorCardProps,
87
- } from './components/LockedAttachment'
82
+ } from './components/CustomMessage/LockedAttachment'
88
83
  export type {
89
84
  ComposerCardProps as LinkAttachmentComposerCardProps,
90
85
  SentCardProps as LinkAttachmentSentCardProps,
@@ -41,6 +41,7 @@ export interface MessageMetadata {
41
41
  amount_text?: string
42
42
  payment_status?: PaymentStatus
43
43
  attachment_title?: string
44
+ attachment_content_type?: 'text' | 'media'
44
45
  attachment_mime_type?: string
45
46
  attachment_thumbnail?: string
46
47
  attachment_detail?: string
package/src/styles.css CHANGED
@@ -831,3 +831,15 @@
831
831
  color: #016e1a;
832
832
  background-color: #dbf0e0;
833
833
  }
834
+
835
+ /** Remove padding from the text inside the locked attachment text shell,
836
+ and inherit TextShell's explicit colour — stream-chat-react's own
837
+ `MessageText` sets its own (black) colour that only gets overridden by
838
+ `.str-chat`'s CSS vars, which the Composer preview doesn't have. */
839
+ .message-locked-attachment-text .str-chat__message-text {
840
+ padding: 0rem !important;
841
+ }
842
+
843
+ .message-locked-attachment-text .str-chat__message-text-inner {
844
+ color: inherit;
845
+ }
package/src/types.ts CHANGED
@@ -20,7 +20,7 @@ import type {
20
20
 
21
21
  import type { MessageMetadata } from './stream-custom-data'
22
22
 
23
- export type { LockedAttachmentSource } from './components/LockedAttachment'
23
+ export type { LockedAttachmentSource } from './components/CustomMessage/LockedAttachment'
24
24
 
25
25
  /**
26
26
  * Callback invoked when a user clicks a link inside the channel view.