@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.
- package/dist/Card-415OfRhw.cjs +2 -0
- package/dist/Card-415OfRhw.cjs.map +1 -0
- package/dist/Card-BnbNynLQ.js +694 -0
- package/dist/Card-BnbNynLQ.js.map +1 -0
- package/dist/Card-UnwKmYzt.cjs +2 -0
- package/dist/Card-UnwKmYzt.cjs.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-CyHyftBi.cjs +2 -0
- package/dist/index-CyHyftBi.cjs.map +1 -0
- package/dist/{index-BxzzWK4K.js → index-DJXjS2H9.js} +1397 -1358
- package/dist/index-DJXjS2H9.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +196 -101
- package/dist/index.js +1 -1
- package/dist/testing.d.ts +1 -0
- package/package.json +2 -2
- package/src/components/ChannelList/CustomChannelPreview.test.tsx +95 -0
- package/src/components/ChannelList/CustomChannelPreview.tsx +21 -9
- package/src/components/ChannelList/index.test.tsx +46 -0
- package/src/components/ChannelList/index.tsx +12 -0
- 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 +35 -2
- 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
|
@@ -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
|
package/src/components/CustomMessage/LockedAttachment/components/_shared/PurchaseStatusBadge.tsx
ADDED
|
@@ -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
|
+
•
|
|
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
|
+
•
|
|
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
|
+
•
|
|
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 '
|
|
7
|
+
} from '../../../../AttachmentCard'
|
|
8
8
|
import type { LockedAttachmentSource, PaymentStatus } from '../../types'
|
|
9
9
|
|
|
10
10
|
import { LockBadge } from './LockBadge'
|
|
11
|
-
import type {
|
|
11
|
+
import type { CardVariant } from './MediaShell'
|
|
12
12
|
|
|
13
|
-
export interface
|
|
14
|
-
variant:
|
|
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
|
|
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
|
|
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
|
|
132
|
+
export default SingleThumbnail
|
package/src/components/CustomMessage/LockedAttachment/components/_shared/SkeletonTextLines.tsx
ADDED
|
@@ -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
|
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
|