@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.
- package/dist/Card-415OfRhw.cjs +2 -0
- package/dist/Card-415OfRhw.cjs.map +1 -0
- package/dist/Card-CyWdUMCB.cjs +2 -0
- package/dist/Card-CyWdUMCB.cjs.map +1 -0
- package/dist/Card-V5d6qhDT.js +694 -0
- package/dist/Card-V5d6qhDT.js.map +1 -0
- package/dist/Card-qS17qnN0.js +103 -0
- package/dist/Card-qS17qnN0.js.map +1 -0
- package/dist/LoadingDots-Ct3WmVlT.js +59 -0
- package/dist/LoadingDots-Ct3WmVlT.js.map +1 -0
- package/dist/LoadingDots-D9GGphgM.cjs +2 -0
- package/dist/LoadingDots-D9GGphgM.cjs.map +1 -0
- package/dist/assets/index.css +1 -1
- package/dist/index-Bx_zjNMR.cjs +2 -0
- package/dist/index-Bx_zjNMR.cjs.map +1 -0
- package/dist/{index-BxzzWK4K.js → index-DFZrAiLB.js} +1276 -1249
- package/dist/index-DFZrAiLB.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +164 -101
- package/dist/index.js +1 -1
- package/dist/testing.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/ChannelList/CustomChannelPreview.test.tsx +95 -0
- package/src/components/ChannelList/CustomChannelPreview.tsx +21 -9
- package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -1
- package/src/components/CustomMessage/LockedAttachment/LockedAttachment.stories.tsx +541 -0
- package/src/components/CustomMessage/LockedAttachment/LockedAttachment.test.tsx +117 -0
- package/src/components/CustomMessage/LockedAttachment/components/Media/Card.tsx +358 -0
- package/src/components/{LockedAttachment/components/Received → CustomMessage/LockedAttachment/components/Media}/CardActions.tsx +9 -2
- package/src/components/CustomMessage/LockedAttachment/components/Media/types.ts +79 -0
- package/src/components/CustomMessage/LockedAttachment/components/Text/Card.tsx +153 -0
- package/src/components/CustomMessage/LockedAttachment/components/Text/types.ts +58 -0
- package/src/components/{LockedAttachment → CustomMessage/LockedAttachment}/components/_shared/CardBody.tsx +8 -7
- package/src/components/CustomMessage/LockedAttachment/components/_shared/CardThumbnail.tsx +92 -0
- package/src/components/{LockedAttachment → CustomMessage/LockedAttachment}/components/_shared/GalleryThumbnail.tsx +31 -39
- package/src/components/CustomMessage/LockedAttachment/components/_shared/LoadingDots.tsx +36 -0
- package/src/components/CustomMessage/LockedAttachment/components/_shared/LockBadge.tsx +63 -0
- package/src/components/{LockedAttachment/components/_shared/LockedCardShell.tsx → CustomMessage/LockedAttachment/components/_shared/MediaShell.tsx} +8 -8
- package/src/components/CustomMessage/LockedAttachment/components/_shared/PreviewToggle.tsx +58 -0
- package/src/components/CustomMessage/LockedAttachment/components/_shared/PriceLockRow.tsx +21 -0
- package/src/components/CustomMessage/LockedAttachment/components/_shared/PurchaseStatusBadge.tsx +74 -0
- package/src/components/{LockedAttachment/components/_shared/LockedThumbnail.tsx → CustomMessage/LockedAttachment/components/_shared/SingleThumbnail.tsx} +11 -7
- package/src/components/CustomMessage/LockedAttachment/components/_shared/SkeletonTextLines.tsx +29 -0
- package/src/components/CustomMessage/LockedAttachment/components/_shared/TextShell.tsx +28 -0
- package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.test.ts +67 -0
- package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.ts +89 -0
- package/src/components/CustomMessage/LockedAttachment/components/_shared/useReceivedSource.ts +83 -0
- package/src/components/CustomMessage/LockedAttachment/index.tsx +105 -0
- package/src/components/CustomMessage/LockedAttachment/types.ts +44 -0
- package/src/components/CustomMessage/MessageTag.tsx +8 -0
- package/src/components/CustomMessage/context.tsx +1 -1
- package/src/components/CustomMessage/index.tsx +76 -42
- package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +5 -3
- package/src/index.ts +3 -8
- package/src/stream-custom-data.ts +1 -0
- package/src/styles.css +12 -0
- package/src/types.ts +1 -1
- package/dist/Card-B-L2MST4.js +0 -107
- package/dist/Card-B-L2MST4.js.map +0 -1
- package/dist/Card-B56QxJW3.cjs +0 -2
- package/dist/Card-B56QxJW3.cjs.map +0 -1
- package/dist/Card-Bqh-BQaX.js +0 -132
- package/dist/Card-Bqh-BQaX.js.map +0 -1
- package/dist/Card-ZCE6Lhfc.cjs +0 -2
- package/dist/Card-ZCE6Lhfc.cjs.map +0 -1
- package/dist/Card-g4JD-2ES.js +0 -163
- package/dist/Card-g4JD-2ES.js.map +0 -1
- package/dist/Card-wac6AMNc.cjs +0 -2
- package/dist/Card-wac6AMNc.cjs.map +0 -1
- package/dist/LockedThumbnail-BAQmoIff.js +0 -220
- package/dist/LockedThumbnail-BAQmoIff.js.map +0 -1
- package/dist/LockedThumbnail-Cr0Gqs3Y.cjs +0 -2
- package/dist/LockedThumbnail-Cr0Gqs3Y.cjs.map +0 -1
- package/dist/index-BPhZyaxT.cjs +0 -2
- package/dist/index-BPhZyaxT.cjs.map +0 -1
- package/dist/index-BxzzWK4K.js.map +0 -1
- package/src/components/LockedAttachment/LockedAttachment.stories.tsx +0 -450
- package/src/components/LockedAttachment/components/Composer/Card.tsx +0 -221
- package/src/components/LockedAttachment/components/Composer/index.ts +0 -2
- package/src/components/LockedAttachment/components/Received/Card.tsx +0 -191
- package/src/components/LockedAttachment/components/Received/index.ts +0 -2
- package/src/components/LockedAttachment/components/Sent/Card.tsx +0 -177
- package/src/components/LockedAttachment/components/Sent/index.ts +0 -2
- package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +0 -39
- package/src/components/LockedAttachment/index.tsx +0 -63
- package/src/components/LockedAttachment/types.ts +0 -51
package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.test.ts
ADDED
|
@@ -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 '
|
|
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 =
|
|
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
|
-
{
|
|
298
|
+
{isTipOnly ? (
|
|
299
|
+
<MessageTag message={message} standalone />
|
|
300
|
+
) : isTextAttachmentMessage(message) ? (
|
|
280
301
|
<div className="str-chat__message-bubble-wrapper">
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
<LockedAttachment
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
80
|
-
SentCardProps,
|
|
81
|
-
ReceivedCardProps,
|
|
79
|
+
LockedAttachmentProps,
|
|
82
80
|
LockedAttachmentGalleryItem,
|
|
83
81
|
LockedAttachmentContextValue,
|
|
84
|
-
|
|
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.
|