@linktr.ee/messaging-react 3.8.1 → 3.9.0-rc-1783406511
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-BG-Mg_ss.js +221 -0
- package/dist/Card-BG-Mg_ss.js.map +1 -0
- package/dist/Card-CmRzkuPw.cjs +2 -0
- package/dist/Card-CmRzkuPw.cjs.map +1 -0
- package/dist/Card-DSG-wR8t.js +96 -0
- package/dist/Card-DSG-wR8t.js.map +1 -0
- package/dist/Card-DVEuTlf_.cjs +2 -0
- package/dist/Card-DVEuTlf_.cjs.map +1 -0
- package/dist/Card-K9ky-G-t.js +108 -0
- package/dist/Card-K9ky-G-t.js.map +1 -0
- package/dist/Card-iKTcn1wg.cjs +2 -0
- package/dist/Card-iKTcn1wg.cjs.map +1 -0
- package/dist/PurchaseStatusBadge-CJH4S-8Z.js +36 -0
- package/dist/PurchaseStatusBadge-CJH4S-8Z.js.map +1 -0
- package/dist/PurchaseStatusBadge-UjDzZhLj.cjs +2 -0
- package/dist/PurchaseStatusBadge-UjDzZhLj.cjs.map +1 -0
- package/dist/TextShell-BiqF2UB0.js +32 -0
- package/dist/TextShell-BiqF2UB0.js.map +1 -0
- package/dist/TextShell-Bnzh0T75.cjs +2 -0
- package/dist/TextShell-Bnzh0T75.cjs.map +1 -0
- package/dist/assets/index.css +1 -1
- package/dist/{index-BCgRoF_u.js → index-DO_dYsIf.js} +1154 -1113
- package/dist/index-DO_dYsIf.js.map +1 -0
- package/dist/index-DpzmKL5U.cjs +2 -0
- package/dist/index-DpzmKL5U.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +98 -55
- package/dist/index.js +1 -1
- package/dist/testing.d.ts +1 -0
- package/dist/usePreviewToggle-B5kmPELX.cjs +2 -0
- package/dist/usePreviewToggle-B5kmPELX.cjs.map +1 -0
- package/dist/usePreviewToggle-D27Xw9Xx.js +323 -0
- package/dist/usePreviewToggle-D27Xw9Xx.js.map +1 -0
- package/package.json +2 -2
- package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -1
- package/src/components/CustomMessage/MessageTag.tsx +8 -0
- package/src/components/CustomMessage/index.tsx +61 -22
- package/src/components/LockedAttachment/LockedAttachment.stories.tsx +410 -333
- package/src/components/LockedAttachment/LockedAttachment.test.tsx +104 -0
- package/src/components/LockedAttachment/components/Composer/Card.tsx +97 -120
- package/src/components/LockedAttachment/components/Media/Card.tsx +237 -0
- package/src/components/LockedAttachment/components/Text/Card.tsx +177 -0
- package/src/components/LockedAttachment/components/_shared/CardBody.tsx +3 -3
- package/src/components/LockedAttachment/components/_shared/CardThumbnail.tsx +92 -0
- package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +11 -33
- package/src/components/LockedAttachment/components/_shared/LoadingDots.tsx +36 -0
- package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +21 -11
- package/src/components/LockedAttachment/components/_shared/{LockedCardShell.tsx → MediaShell.tsx} +5 -5
- package/src/components/LockedAttachment/components/_shared/PreviewToggle.tsx +58 -0
- package/src/components/LockedAttachment/components/_shared/PriceLockRow.tsx +21 -0
- package/src/components/LockedAttachment/components/_shared/PurchaseStatusBadge.tsx +87 -0
- package/src/components/LockedAttachment/components/_shared/{LockedThumbnail.tsx → SingleThumbnail.tsx} +5 -5
- package/src/components/LockedAttachment/components/_shared/SkeletonTextLines.tsx +39 -0
- package/src/components/LockedAttachment/components/_shared/TextShell.tsx +19 -0
- package/src/components/LockedAttachment/components/_shared/usePreviewToggle.test.ts +67 -0
- package/src/components/LockedAttachment/components/_shared/usePreviewToggle.ts +89 -0
- package/src/components/LockedAttachment/components/_shared/useReceivedSource.ts +83 -0
- package/src/components/LockedAttachment/index.tsx +17 -28
- package/src/components/LockedAttachment/types.ts +28 -4
- package/src/index.ts +2 -5
- package/src/stream-custom-data.ts +1 -0
- package/src/styles.css +5 -0
- package/dist/Card-BRvcFjsq.js +0 -132
- package/dist/Card-BRvcFjsq.js.map +0 -1
- package/dist/Card-BuXkok0A.cjs +0 -2
- package/dist/Card-BuXkok0A.cjs.map +0 -1
- package/dist/Card-CvNHv3me.js +0 -107
- package/dist/Card-CvNHv3me.js.map +0 -1
- package/dist/Card-DOSuQdnk.cjs +0 -2
- package/dist/Card-DOSuQdnk.cjs.map +0 -1
- package/dist/Card-NnQw99XE.cjs +0 -2
- package/dist/Card-NnQw99XE.cjs.map +0 -1
- package/dist/Card-jGOcSN1O.js +0 -163
- package/dist/Card-jGOcSN1O.js.map +0 -1
- package/dist/LockedThumbnail-EsjaAqLR.js +0 -220
- package/dist/LockedThumbnail-EsjaAqLR.js.map +0 -1
- package/dist/LockedThumbnail-uAauUd5c.cjs +0 -2
- package/dist/LockedThumbnail-uAauUd5c.cjs.map +0 -1
- package/dist/index-BCgRoF_u.js.map +0 -1
- package/dist/index-DRurVQ0-.cjs +0 -2
- package/dist/index-DRurVQ0-.cjs.map +0 -1
- 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/{Received → Media}/CardActions.tsx +0 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { renderWithProviders, screen } from '../../test/utils'
|
|
4
|
+
|
|
5
|
+
import ComposerCard from './components/Composer/Card'
|
|
6
|
+
import MediaCard from './components/Media/Card'
|
|
7
|
+
import TextCard from './components/Text/Card'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Characterization tests — the safety net for the LockedAttachment
|
|
11
|
+
* separate-concerns refactor. They assert stable, user-visible facts only
|
|
12
|
+
* (lock affordances, CTAs, amount/status text, the preview toggle), never
|
|
13
|
+
* DOM structure or class names — exactly the things the refactor moves
|
|
14
|
+
* around. Storybook remains the pixel-level visual oracle.
|
|
15
|
+
*/
|
|
16
|
+
describe('LockedAttachment (characterization)', () => {
|
|
17
|
+
describe('Composer', () => {
|
|
18
|
+
it('media mode: shows the placeholder title when locked with no title', () => {
|
|
19
|
+
renderWithProviders(<ComposerCard />)
|
|
20
|
+
expect(screen.getByText('Attachment title')).toBeInTheDocument()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('media mode: renders a preview toggle when a source fetcher is wired', () => {
|
|
24
|
+
renderWithProviders(
|
|
25
|
+
<ComposerCard onFetchSource={vi.fn().mockResolvedValue(undefined)} />
|
|
26
|
+
)
|
|
27
|
+
expect(
|
|
28
|
+
screen.getByRole('button', { name: 'Toggle preview' })
|
|
29
|
+
).toBeInTheDocument()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('media mode: renders a dismiss control when onDismiss is provided', () => {
|
|
33
|
+
renderWithProviders(<ComposerCard onDismiss={vi.fn()} />)
|
|
34
|
+
expect(
|
|
35
|
+
screen.getByRole('button', { name: 'Dismiss attachment' })
|
|
36
|
+
).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('text mode: shows the draft text and amount', () => {
|
|
40
|
+
renderWithProviders(
|
|
41
|
+
<ComposerCard contentType="text" text="Secret draft" amountText="$5" />
|
|
42
|
+
)
|
|
43
|
+
expect(screen.getByText('Secret draft')).toBeInTheDocument()
|
|
44
|
+
expect(screen.getByText('$5')).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('Media (isMine)', () => {
|
|
49
|
+
it('shows the amount in the status badge', () => {
|
|
50
|
+
renderWithProviders(<MediaCard isMine amountText="$5" />)
|
|
51
|
+
expect(screen.getByText('$5')).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('Text (isMine)', () => {
|
|
56
|
+
it('pending: shows the Sent status and amount', () => {
|
|
57
|
+
renderWithProviders(
|
|
58
|
+
<TextCard isMine amountText="$5" paymentStatus="pending" />
|
|
59
|
+
)
|
|
60
|
+
expect(screen.getByText('Sent')).toBeInTheDocument()
|
|
61
|
+
expect(screen.getByText('$5')).toBeInTheDocument()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('paid: shows the Sold label', () => {
|
|
65
|
+
renderWithProviders(
|
|
66
|
+
<TextCard isMine amountText="$5" paymentStatus="paid" />
|
|
67
|
+
)
|
|
68
|
+
expect(screen.getByText('Sold')).toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('Media (!isMine)', () => {
|
|
73
|
+
it('renders the locked-attachment root and an Unlock CTA when locked', () => {
|
|
74
|
+
renderWithProviders(
|
|
75
|
+
<MediaCard isMine={false} amountText="$5" onUnlockClick={vi.fn()} />
|
|
76
|
+
)
|
|
77
|
+
expect(screen.getByTestId('locked-attachment')).toBeInTheDocument()
|
|
78
|
+
expect(
|
|
79
|
+
screen.getByRole('button', { name: 'Unlock' })
|
|
80
|
+
).toBeInTheDocument()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('Text (!isMine)', () => {
|
|
85
|
+
it('shows an Unlock control and the amount', () => {
|
|
86
|
+
renderWithProviders(
|
|
87
|
+
<TextCard isMine={false} amountText="$5" onUnlockClick={vi.fn()} />
|
|
88
|
+
)
|
|
89
|
+
expect(
|
|
90
|
+
screen.getByRole('button', { name: 'Unlock' })
|
|
91
|
+
).toBeInTheDocument()
|
|
92
|
+
expect(screen.getByText('$5')).toBeInTheDocument()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('unlocking: marks the Unlock control busy', () => {
|
|
96
|
+
renderWithProviders(
|
|
97
|
+
<TextCard isMine={false} isUnlocking onUnlockClick={vi.fn()} />
|
|
98
|
+
)
|
|
99
|
+
const button = screen.getByRole('button', { name: 'Unlocking' })
|
|
100
|
+
expect(button).toBeDisabled()
|
|
101
|
+
expect(button).toHaveAttribute('aria-busy', 'true')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -1,23 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import React
|
|
1
|
+
import { PencilSimpleIcon, XIcon } from '@phosphor-icons/react'
|
|
2
|
+
import React from 'react'
|
|
3
3
|
|
|
4
|
-
import type {
|
|
5
|
-
LockedAttachmentBaseProps,
|
|
6
|
-
LockedAttachmentSource,
|
|
7
|
-
} from '../../types'
|
|
4
|
+
import type { FetchSourceHandler, LockedAttachmentBaseProps } from '../../types'
|
|
8
5
|
import CardBody from '../_shared/CardBody'
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
import CardThumbnail, {
|
|
7
|
+
galleryBodyIcon,
|
|
8
|
+
isGalleryCard,
|
|
9
|
+
} from '../_shared/CardThumbnail'
|
|
10
|
+
import MediaShell from '../_shared/MediaShell'
|
|
11
|
+
import PreviewToggle from '../_shared/PreviewToggle'
|
|
12
|
+
import PriceLockRow from '../_shared/PriceLockRow'
|
|
13
|
+
import TextShell from '../_shared/TextShell'
|
|
14
|
+
import { usePreviewToggle } from '../_shared/usePreviewToggle'
|
|
12
15
|
|
|
13
16
|
export interface ComposerCardProps extends LockedAttachmentBaseProps {
|
|
14
|
-
/**
|
|
15
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The message's own text, shown as-is (not redacted yet — the message
|
|
19
|
+
* hasn't been sent). Only used when `contentType` is `"text"`.
|
|
20
|
+
*/
|
|
21
|
+
text?: string
|
|
16
22
|
/** Placeholder shown in the amount slot before one is configured. */
|
|
17
23
|
placeholderAmountText?: string
|
|
18
24
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
25
|
+
* Renders a persistent dismiss X on the thumbnail (media) or next to the
|
|
26
|
+
* text (text mode); called to remove the draft attachment. Unrelated to
|
|
27
|
+
* `TextCardProps.onDismiss`, which only appears for `isMine` after a
|
|
28
|
+
* failed payment.
|
|
21
29
|
*/
|
|
22
30
|
onDismiss?: () => void
|
|
23
31
|
/** Fired the first time the composer taps the thumbnail to preview. */
|
|
@@ -26,8 +34,9 @@ export interface ComposerCardProps extends LockedAttachmentBaseProps {
|
|
|
26
34
|
* Lazily loads the underlying source so the composer can preview the
|
|
27
35
|
* attachment they're about to send. Called the first time the thumbnail is
|
|
28
36
|
* tapped; the returned source is cached and reused on subsequent toggles.
|
|
37
|
+
* Ignored when `contentType` is `"text"`.
|
|
29
38
|
*/
|
|
30
|
-
onFetchSource?:
|
|
39
|
+
onFetchSource?: FetchSourceHandler
|
|
31
40
|
/**
|
|
32
41
|
* When provided, renders a pencil button in the body bottom-right that the
|
|
33
42
|
* composer can use to edit the attachment metadata (e.g. open the price /
|
|
@@ -37,10 +46,42 @@ export interface ComposerCardProps extends LockedAttachmentBaseProps {
|
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
49
|
+
* Composer card in text mode: a normal outgoing chat bubble showing the real
|
|
50
|
+
* draft text (it hasn't been sent/redacted yet) plus a dismiss X and a price
|
|
51
|
+
* row. Media-only props are ignored here.
|
|
52
|
+
*/
|
|
53
|
+
const ComposerTextCard: React.FC<ComposerCardProps> = ({
|
|
54
|
+
text,
|
|
55
|
+
amountText,
|
|
56
|
+
placeholderAmountText,
|
|
57
|
+
onDismiss,
|
|
58
|
+
}) => (
|
|
59
|
+
<TextShell>
|
|
60
|
+
<div className="pr-8">{text}</div>
|
|
61
|
+
<PriceLockRow>
|
|
62
|
+
<span className={amountText ? 'text-white/55' : 'text-white/30'}>
|
|
63
|
+
{amountText || placeholderAmountText}
|
|
64
|
+
</span>
|
|
65
|
+
</PriceLockRow>
|
|
66
|
+
{onDismiss && (
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={onDismiss}
|
|
70
|
+
className="absolute right-3 top-3 flex size-6 shrink-0 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/15"
|
|
71
|
+
aria-label="Dismiss attachment"
|
|
72
|
+
>
|
|
73
|
+
<XIcon className="size-3" weight="bold" />
|
|
74
|
+
</button>
|
|
75
|
+
)}
|
|
76
|
+
</TextShell>
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Composer card in media mode: the locked thumbnail (single or gallery) the
|
|
81
|
+
* composer taps to preview the attachment they're about to send, plus the
|
|
82
|
+
* title/price body with optional dismiss X and edit pencil.
|
|
42
83
|
*/
|
|
43
|
-
const
|
|
84
|
+
const ComposerMediaCard: React.FC<ComposerCardProps> = ({
|
|
44
85
|
title,
|
|
45
86
|
mimeType = 'application/octet-stream',
|
|
46
87
|
thumbnailUrl,
|
|
@@ -48,55 +89,19 @@ const ComposerCard: React.FC<ComposerCardProps> = ({
|
|
|
48
89
|
amountText,
|
|
49
90
|
placeholderTitle = 'Attachment title',
|
|
50
91
|
placeholderAmountText,
|
|
51
|
-
gallery,
|
|
92
|
+
gallery = [],
|
|
52
93
|
onDismiss,
|
|
53
94
|
onPreviewClick,
|
|
54
95
|
onFetchSource,
|
|
55
96
|
onEditClick,
|
|
56
97
|
}) => {
|
|
57
|
-
const
|
|
58
|
-
const [isPreviewVisible, setIsPreviewVisible] = useState(false)
|
|
59
|
-
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
|
|
60
|
-
const fetchingRef = useRef(false)
|
|
61
|
-
|
|
62
|
-
const isGallery = (gallery?.length ?? 0) >= 2
|
|
63
|
-
|
|
64
|
-
const handleToggle = useCallback(async () => {
|
|
65
|
-
onPreviewClick?.()
|
|
66
|
-
if (isPreviewVisible) {
|
|
67
|
-
setIsPreviewVisible(false)
|
|
68
|
-
return
|
|
69
|
-
}
|
|
70
|
-
// Gallery items carry their own per-item sources on the `gallery` prop, so
|
|
71
|
-
// we just flip visibility — no async source fetch is needed.
|
|
72
|
-
if (isGallery) {
|
|
73
|
-
setIsPreviewVisible(true)
|
|
74
|
-
return
|
|
75
|
-
}
|
|
76
|
-
if (source) {
|
|
77
|
-
setIsPreviewVisible(true)
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
if (!onFetchSource) return
|
|
81
|
-
if (fetchingRef.current) return
|
|
82
|
-
fetchingRef.current = true
|
|
83
|
-
setIsLoadingPreview(true)
|
|
84
|
-
try {
|
|
85
|
-
const result = await onFetchSource()
|
|
86
|
-
if (result) {
|
|
87
|
-
setSource(result)
|
|
88
|
-
setIsPreviewVisible(true)
|
|
89
|
-
}
|
|
90
|
-
} finally {
|
|
91
|
-
fetchingRef.current = false
|
|
92
|
-
setIsLoadingPreview(false)
|
|
93
|
-
}
|
|
94
|
-
}, [isPreviewVisible, isGallery, source, onPreviewClick, onFetchSource])
|
|
98
|
+
const isGallery = isGalleryCard(gallery)
|
|
95
99
|
|
|
96
100
|
// Gallery is always previewable in the composer because each item's source
|
|
97
101
|
// is already provided up-front via `gallery[*].sourceUrl`.
|
|
98
|
-
const togglePreview =
|
|
99
|
-
isGallery
|
|
102
|
+
const { source, isPreviewVisible, isLoadingPreview, togglePreview } =
|
|
103
|
+
usePreviewToggle({ isGallery, onFetchSource, onPreviewClick })
|
|
104
|
+
|
|
100
105
|
const showLocked = !isPreviewVisible
|
|
101
106
|
const isBusy = isLoadingPreview
|
|
102
107
|
|
|
@@ -142,63 +147,24 @@ const ComposerCard: React.FC<ComposerCardProps> = ({
|
|
|
142
147
|
</button>
|
|
143
148
|
) : undefined
|
|
144
149
|
|
|
145
|
-
const thumbnail = isGallery ? (
|
|
146
|
-
<GalleryThumbnail
|
|
147
|
-
variant="dark"
|
|
148
|
-
gallery={gallery!}
|
|
149
|
-
title={title}
|
|
150
|
-
showLocked={showLocked}
|
|
151
|
-
topRight={dismissButton}
|
|
152
|
-
/>
|
|
153
|
-
) : (
|
|
154
|
-
<LockedThumbnail
|
|
155
|
-
variant="dark"
|
|
156
|
-
mimeType={mimeType}
|
|
157
|
-
thumbnailUrl={thumbnailUrl}
|
|
158
|
-
title={title}
|
|
159
|
-
source={source}
|
|
160
|
-
showLocked={showLocked}
|
|
161
|
-
topRight={dismissButton}
|
|
162
|
-
/>
|
|
163
|
-
)
|
|
164
|
-
|
|
165
150
|
return (
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// this guard, pressing Enter/Space on a focused child would bubble
|
|
184
|
-
// up and toggle the preview in addition to activating the child.
|
|
185
|
-
if (e.target !== e.currentTarget) return
|
|
186
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
187
|
-
e.preventDefault()
|
|
188
|
-
void togglePreview()
|
|
189
|
-
}
|
|
190
|
-
}}
|
|
191
|
-
className={
|
|
192
|
-
!isBusy
|
|
193
|
-
? 'block w-full cursor-pointer text-left'
|
|
194
|
-
: 'block w-full text-left'
|
|
195
|
-
}
|
|
196
|
-
>
|
|
197
|
-
{thumbnail}
|
|
198
|
-
</div>
|
|
199
|
-
) : (
|
|
200
|
-
thumbnail
|
|
201
|
-
)}
|
|
151
|
+
<MediaShell variant="dark">
|
|
152
|
+
<PreviewToggle
|
|
153
|
+
onToggle={togglePreview}
|
|
154
|
+
isBusy={isBusy}
|
|
155
|
+
isPreviewVisible={!showLocked}
|
|
156
|
+
>
|
|
157
|
+
<CardThumbnail
|
|
158
|
+
variant="dark"
|
|
159
|
+
gallery={gallery}
|
|
160
|
+
title={title}
|
|
161
|
+
showLocked={showLocked}
|
|
162
|
+
mimeType={mimeType}
|
|
163
|
+
thumbnailUrl={thumbnailUrl}
|
|
164
|
+
source={source}
|
|
165
|
+
topRight={dismissButton}
|
|
166
|
+
/>
|
|
167
|
+
</PreviewToggle>
|
|
202
168
|
|
|
203
169
|
<CardBody
|
|
204
170
|
variant="dark"
|
|
@@ -207,15 +173,26 @@ const ComposerCard: React.FC<ComposerCardProps> = ({
|
|
|
207
173
|
mimeType={mimeType}
|
|
208
174
|
detail={detail}
|
|
209
175
|
statusBadge={statusBadge}
|
|
210
|
-
icon={
|
|
211
|
-
isGallery ? (
|
|
212
|
-
<ImagesIcon className="size-5 shrink-0 text-white/55" />
|
|
213
|
-
) : undefined
|
|
214
|
-
}
|
|
176
|
+
icon={isGallery ? galleryBodyIcon('dark') : undefined}
|
|
215
177
|
trailingAction={editButton}
|
|
216
178
|
/>
|
|
217
|
-
</
|
|
179
|
+
</MediaShell>
|
|
218
180
|
)
|
|
219
181
|
}
|
|
220
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The card the composer sees while drafting a paid attachment.
|
|
185
|
+
* Matches the Composer column of the messaging design system in Figma.
|
|
186
|
+
*
|
|
187
|
+
* When `contentType === "text"`, renders as a normal outgoing chat bubble
|
|
188
|
+
* showing the real draft text (it hasn't been sent/redacted yet) plus a
|
|
189
|
+
* dismiss X and a price row, instead of a media thumbnail card.
|
|
190
|
+
*/
|
|
191
|
+
const ComposerCard: React.FC<ComposerCardProps> = (props) =>
|
|
192
|
+
props.contentType === 'text' ? (
|
|
193
|
+
<ComposerTextCard {...props} />
|
|
194
|
+
) : (
|
|
195
|
+
<ComposerMediaCard {...props} />
|
|
196
|
+
)
|
|
197
|
+
|
|
221
198
|
export default ComposerCard
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type React from 'react'
|
|
2
|
+
|
|
3
|
+
import { getSourceType } from '../../../AttachmentCard'
|
|
4
|
+
import type {
|
|
5
|
+
FetchSourceHandler,
|
|
6
|
+
LockedAttachmentGalleryItem,
|
|
7
|
+
PaymentStatus,
|
|
8
|
+
} from '../../types'
|
|
9
|
+
import CardBody from '../_shared/CardBody'
|
|
10
|
+
import CardThumbnail, {
|
|
11
|
+
galleryBodyIcon,
|
|
12
|
+
isGalleryCard,
|
|
13
|
+
} from '../_shared/CardThumbnail'
|
|
14
|
+
import MediaShell from '../_shared/MediaShell'
|
|
15
|
+
import PreviewToggle from '../_shared/PreviewToggle'
|
|
16
|
+
import PurchaseStatusBadge from '../_shared/PurchaseStatusBadge'
|
|
17
|
+
import { usePreviewToggle } from '../_shared/usePreviewToggle'
|
|
18
|
+
import { useReceivedSource } from '../_shared/useReceivedSource'
|
|
19
|
+
|
|
20
|
+
import ReceivedCardActions from './CardActions'
|
|
21
|
+
|
|
22
|
+
interface MediaCardSharedProps {
|
|
23
|
+
title?: string
|
|
24
|
+
mimeType?: string
|
|
25
|
+
thumbnailUrl?: string
|
|
26
|
+
detail?: string
|
|
27
|
+
amountText?: string
|
|
28
|
+
paymentStatus?: PaymentStatus
|
|
29
|
+
gallery?: LockedAttachmentGalleryItem[]
|
|
30
|
+
onFetchSource?: FetchSourceHandler
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type MediaCardProps =
|
|
34
|
+
| (MediaCardSharedProps & {
|
|
35
|
+
isMine: true
|
|
36
|
+
/** Dimmed fallback shown in the title slot when `title` is unset. */
|
|
37
|
+
placeholderTitle?: string
|
|
38
|
+
/** Fired the first time the sender taps the thumbnail to preview their own attachment. */
|
|
39
|
+
onPreviewClick?: () => void
|
|
40
|
+
})
|
|
41
|
+
| (MediaCardSharedProps & {
|
|
42
|
+
isMine: false
|
|
43
|
+
/**
|
|
44
|
+
* When true, shows a loading spinner on the Unlock button. Driven by
|
|
45
|
+
* the LockedAttachmentContext (e.g. checkout in progress).
|
|
46
|
+
*/
|
|
47
|
+
isUnlocking?: boolean
|
|
48
|
+
/**
|
|
49
|
+
* Called when the recipient clicks Unlock on an unpaid attachment.
|
|
50
|
+
* Use this to open a checkout flow. Omit to hide the Unlock button.
|
|
51
|
+
*/
|
|
52
|
+
onUnlockClick?: () => void
|
|
53
|
+
/**
|
|
54
|
+
* Called when the recipient clicks Download on an unlocked card.
|
|
55
|
+
* Omit to hide the Download button.
|
|
56
|
+
*/
|
|
57
|
+
onDownloadClick?: () => void
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The sender's ('mine') view of a paid media attachment: the locked
|
|
62
|
+
* thumbnail (single or gallery) they can tap to preview their own
|
|
63
|
+
* attachment, plus the title/purchase-status body.
|
|
64
|
+
*/
|
|
65
|
+
const OwnMediaCard: React.FC<
|
|
66
|
+
MediaCardSharedProps & {
|
|
67
|
+
isMine: true
|
|
68
|
+
placeholderTitle?: string
|
|
69
|
+
onPreviewClick?: () => void
|
|
70
|
+
}
|
|
71
|
+
> = ({
|
|
72
|
+
title,
|
|
73
|
+
mimeType = 'application/octet-stream',
|
|
74
|
+
thumbnailUrl,
|
|
75
|
+
detail,
|
|
76
|
+
amountText,
|
|
77
|
+
placeholderTitle = 'Attachment title',
|
|
78
|
+
paymentStatus = 'pending',
|
|
79
|
+
gallery = [],
|
|
80
|
+
onPreviewClick,
|
|
81
|
+
onFetchSource,
|
|
82
|
+
}) => {
|
|
83
|
+
const isGallery = isGalleryCard(gallery)
|
|
84
|
+
|
|
85
|
+
// Gallery is always previewable for the sender because each item's source
|
|
86
|
+
// is already provided up-front via `gallery[*].sourceUrl`.
|
|
87
|
+
const { source, isPreviewVisible, isLoadingPreview, togglePreview } =
|
|
88
|
+
usePreviewToggle({ isGallery, onFetchSource, onPreviewClick })
|
|
89
|
+
|
|
90
|
+
const showLocked = !isPreviewVisible
|
|
91
|
+
const isBusy = isLoadingPreview
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<MediaShell variant="dark">
|
|
95
|
+
<PreviewToggle
|
|
96
|
+
onToggle={togglePreview}
|
|
97
|
+
isBusy={isBusy}
|
|
98
|
+
isPreviewVisible={!showLocked}
|
|
99
|
+
>
|
|
100
|
+
<CardThumbnail
|
|
101
|
+
variant="dark"
|
|
102
|
+
gallery={gallery}
|
|
103
|
+
title={title}
|
|
104
|
+
showLocked={showLocked}
|
|
105
|
+
paymentStatus={paymentStatus}
|
|
106
|
+
mimeType={mimeType}
|
|
107
|
+
thumbnailUrl={thumbnailUrl}
|
|
108
|
+
source={source}
|
|
109
|
+
/>
|
|
110
|
+
</PreviewToggle>
|
|
111
|
+
|
|
112
|
+
<CardBody
|
|
113
|
+
variant="dark"
|
|
114
|
+
title={title}
|
|
115
|
+
placeholderTitle={placeholderTitle}
|
|
116
|
+
mimeType={mimeType}
|
|
117
|
+
detail={detail}
|
|
118
|
+
statusBadge={
|
|
119
|
+
<PurchaseStatusBadge
|
|
120
|
+
isMine={true}
|
|
121
|
+
paymentStatus={paymentStatus}
|
|
122
|
+
amountText={amountText}
|
|
123
|
+
/>
|
|
124
|
+
}
|
|
125
|
+
icon={isGallery ? galleryBodyIcon('dark') : undefined}
|
|
126
|
+
/>
|
|
127
|
+
</MediaShell>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The recipient's ('other') view of a paid media attachment: the locked
|
|
133
|
+
* thumbnail (single or gallery) with an Unlock/Download CTA. The source is
|
|
134
|
+
* revealed by fetching once — on click when already paid, or automatically
|
|
135
|
+
* the first time a paid card scrolls into view.
|
|
136
|
+
*/
|
|
137
|
+
const OtherMediaCard: React.FC<
|
|
138
|
+
MediaCardSharedProps & {
|
|
139
|
+
isMine: false
|
|
140
|
+
onUnlockClick?: () => void
|
|
141
|
+
onDownloadClick?: () => void
|
|
142
|
+
isUnlocking?: boolean
|
|
143
|
+
}
|
|
144
|
+
> = ({
|
|
145
|
+
title,
|
|
146
|
+
amountText,
|
|
147
|
+
thumbnailUrl,
|
|
148
|
+
mimeType = 'application/octet-stream',
|
|
149
|
+
detail,
|
|
150
|
+
gallery = [],
|
|
151
|
+
onUnlockClick,
|
|
152
|
+
onFetchSource,
|
|
153
|
+
onDownloadClick,
|
|
154
|
+
paymentStatus,
|
|
155
|
+
isUnlocking = false,
|
|
156
|
+
}) => {
|
|
157
|
+
const isGallery = isGalleryCard(gallery)
|
|
158
|
+
|
|
159
|
+
const { source, cardRef, handleUnlockClick } = useReceivedSource({
|
|
160
|
+
paymentStatus,
|
|
161
|
+
onFetchSource,
|
|
162
|
+
onUnlockClick,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const effectiveSourceUrl = source?.sourceUrl
|
|
166
|
+
const effectiveRedeemUrl = source?.redeemUrl
|
|
167
|
+
|
|
168
|
+
// For gallery, the per-item sources are carried by `gallery` itself, so
|
|
169
|
+
// the lock state is driven by paymentStatus rather than the single
|
|
170
|
+
// `effectiveSourceUrl`. Everything else still respects the source fetch.
|
|
171
|
+
const isLocked = isGallery
|
|
172
|
+
? paymentStatus !== 'paid'
|
|
173
|
+
: effectiveSourceUrl === undefined
|
|
174
|
+
const sourceType = getSourceType(mimeType)
|
|
175
|
+
|
|
176
|
+
// For gallery, ReceivedCardActions falls back to a plain <button>
|
|
177
|
+
// (no anchor) because the gallery has no single sourceUrl to link to —
|
|
178
|
+
// the per-item sources live on `gallery` itself.
|
|
179
|
+
const actionSourceUrl = isGallery ? undefined : effectiveSourceUrl
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<MediaShell
|
|
183
|
+
variant="light"
|
|
184
|
+
rootRef={cardRef}
|
|
185
|
+
data-testid="locked-attachment"
|
|
186
|
+
>
|
|
187
|
+
<CardThumbnail
|
|
188
|
+
variant="light"
|
|
189
|
+
gallery={gallery}
|
|
190
|
+
title={title}
|
|
191
|
+
showLocked={isLocked}
|
|
192
|
+
paymentStatus={paymentStatus}
|
|
193
|
+
mimeType={mimeType}
|
|
194
|
+
thumbnailUrl={thumbnailUrl}
|
|
195
|
+
source={source}
|
|
196
|
+
containedImage={
|
|
197
|
+
!isLocked && (sourceType === 'image' || sourceType === 'document')
|
|
198
|
+
}
|
|
199
|
+
/>
|
|
200
|
+
|
|
201
|
+
<CardBody
|
|
202
|
+
variant="light"
|
|
203
|
+
title={title}
|
|
204
|
+
mimeType={mimeType}
|
|
205
|
+
detail={detail}
|
|
206
|
+
statusBadge={
|
|
207
|
+
<PurchaseStatusBadge
|
|
208
|
+
isMine={false}
|
|
209
|
+
paymentStatus={paymentStatus}
|
|
210
|
+
amountText={amountText}
|
|
211
|
+
/>
|
|
212
|
+
}
|
|
213
|
+
icon={isGallery ? galleryBodyIcon('light') : undefined}
|
|
214
|
+
action={
|
|
215
|
+
<ReceivedCardActions
|
|
216
|
+
isLocked={isLocked}
|
|
217
|
+
isUnlocking={isUnlocking}
|
|
218
|
+
sourceUrl={actionSourceUrl}
|
|
219
|
+
redeemUrl={effectiveRedeemUrl}
|
|
220
|
+
onUnlockClicked={handleUnlockClick}
|
|
221
|
+
onDownloadClicked={onDownloadClick}
|
|
222
|
+
/>
|
|
223
|
+
}
|
|
224
|
+
/>
|
|
225
|
+
</MediaShell>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The card either party sees for a paid media attachment. `isMine` selects
|
|
231
|
+
* the sender's preview view (tap-to-preview own draft) vs. the recipient's
|
|
232
|
+
* unlock/download view.
|
|
233
|
+
*/
|
|
234
|
+
const MediaCard: React.FC<MediaCardProps> = (props) =>
|
|
235
|
+
props.isMine ? <OwnMediaCard {...props} /> : <OtherMediaCard {...props} />
|
|
236
|
+
|
|
237
|
+
export default MediaCard
|