@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,117 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { renderWithProviders, screen } from '../../../test/utils'
|
|
4
|
+
|
|
5
|
+
import MediaCard from './components/Media/Card'
|
|
6
|
+
import TextCard from './components/Text/Card'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Characterization tests — the safety net for the LockedAttachment
|
|
10
|
+
* separate-concerns refactor. They assert stable, user-visible facts only
|
|
11
|
+
* (lock affordances, CTAs, amount/status text, the preview toggle), never
|
|
12
|
+
* DOM structure or class names — exactly the things the refactor moves
|
|
13
|
+
* around. Storybook remains the pixel-level visual oracle.
|
|
14
|
+
*/
|
|
15
|
+
describe('LockedAttachment (characterization)', () => {
|
|
16
|
+
describe('Draft', () => {
|
|
17
|
+
it('media mode: shows the placeholder title when locked with no title', () => {
|
|
18
|
+
renderWithProviders(<MediaCard isDraft />)
|
|
19
|
+
expect(screen.getByText('Attachment title')).toBeInTheDocument()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('media mode: renders a preview toggle when a source fetcher is wired', () => {
|
|
23
|
+
renderWithProviders(
|
|
24
|
+
<MediaCard isDraft onFetchSource={vi.fn().mockResolvedValue(undefined)} />
|
|
25
|
+
)
|
|
26
|
+
expect(
|
|
27
|
+
screen.getByRole('button', { name: 'Toggle preview' })
|
|
28
|
+
).toBeInTheDocument()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('media mode: renders a dismiss control when onDismiss is provided', () => {
|
|
32
|
+
renderWithProviders(<MediaCard isDraft onDismiss={vi.fn()} />)
|
|
33
|
+
expect(
|
|
34
|
+
screen.getByRole('button', { name: 'Dismiss attachment' })
|
|
35
|
+
).toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('text mode: shows the draft text and amount', () => {
|
|
39
|
+
renderWithProviders(
|
|
40
|
+
<TextCard isDraft amountText="$5" renderedText="Secret draft" />
|
|
41
|
+
)
|
|
42
|
+
expect(screen.getByText('Secret draft')).toBeInTheDocument()
|
|
43
|
+
expect(screen.getByText('$5')).toBeInTheDocument()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('Preview', () => {
|
|
48
|
+
it('media mode: shows the placeholder title when locked with no title', () => {
|
|
49
|
+
renderWithProviders(<MediaCard isPreview amountText="$5" />)
|
|
50
|
+
expect(screen.getByText('Attachment title')).toBeInTheDocument()
|
|
51
|
+
expect(screen.getByText('$5')).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('text mode: never shows real text, only the amount', () => {
|
|
55
|
+
renderWithProviders(<TextCard isPreview amountText="$5" />)
|
|
56
|
+
expect(screen.queryByText('Secret draft')).not.toBeInTheDocument()
|
|
57
|
+
expect(screen.getByText('$5')).toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('Media (isMine)', () => {
|
|
62
|
+
it('shows the amount in the status badge', () => {
|
|
63
|
+
renderWithProviders(<MediaCard isMine amountText="$5" />)
|
|
64
|
+
expect(screen.getByText('$5')).toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('Text (isMine)', () => {
|
|
69
|
+
it('pending: shows the amount with no status label', () => {
|
|
70
|
+
renderWithProviders(
|
|
71
|
+
<TextCard isMine amountText="$5" paymentStatus="pending" />
|
|
72
|
+
)
|
|
73
|
+
expect(screen.queryByText('Sent')).not.toBeInTheDocument()
|
|
74
|
+
expect(screen.getByText('$5')).toBeInTheDocument()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('paid: shows the Sold label', () => {
|
|
78
|
+
renderWithProviders(
|
|
79
|
+
<TextCard isMine amountText="$5" paymentStatus="paid" />
|
|
80
|
+
)
|
|
81
|
+
expect(screen.getByText('Sold')).toBeInTheDocument()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('Media (!isMine)', () => {
|
|
86
|
+
it('renders the locked-attachment root and an Unlock CTA when locked', () => {
|
|
87
|
+
renderWithProviders(
|
|
88
|
+
<MediaCard isMine={false} amountText="$5" onUnlockClick={vi.fn()} />
|
|
89
|
+
)
|
|
90
|
+
expect(screen.getByTestId('locked-attachment')).toBeInTheDocument()
|
|
91
|
+
expect(
|
|
92
|
+
screen.getByRole('button', { name: 'Unlock' })
|
|
93
|
+
).toBeInTheDocument()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('Text (!isMine)', () => {
|
|
98
|
+
it('shows an Unlock control and the amount', () => {
|
|
99
|
+
renderWithProviders(
|
|
100
|
+
<TextCard isMine={false} amountText="$5" onUnlockClick={vi.fn()} />
|
|
101
|
+
)
|
|
102
|
+
expect(
|
|
103
|
+
screen.getByRole('button', { name: 'Unlock message' })
|
|
104
|
+
).toBeInTheDocument()
|
|
105
|
+
expect(screen.getByText('$5')).toBeInTheDocument()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('unlocking: marks the Unlock control busy', () => {
|
|
109
|
+
renderWithProviders(
|
|
110
|
+
<TextCard isMine={false} isUnlocking onUnlockClick={vi.fn()} />
|
|
111
|
+
)
|
|
112
|
+
const button = screen.getByRole('button', { name: 'Unlocking message' })
|
|
113
|
+
expect(button).toBeDisabled()
|
|
114
|
+
expect(button).toHaveAttribute('aria-busy', 'true')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { PencilSimpleIcon, XIcon } from '@phosphor-icons/react'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import { getSourceType } from '../../../../AttachmentCard'
|
|
5
|
+
import CardBody from '../_shared/CardBody'
|
|
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 PurchaseStatusBadge from '../_shared/PurchaseStatusBadge'
|
|
13
|
+
import { usePreviewToggle } from '../_shared/usePreviewToggle'
|
|
14
|
+
import { useReceivedSource } from '../_shared/useReceivedSource'
|
|
15
|
+
|
|
16
|
+
import ReceivedCardActions from './CardActions'
|
|
17
|
+
import type {
|
|
18
|
+
MediaCardDraftProps,
|
|
19
|
+
MediaCardOtherProps,
|
|
20
|
+
MediaCardOwnProps,
|
|
21
|
+
MediaCardPreviewProps,
|
|
22
|
+
MediaCardProps,
|
|
23
|
+
} from './types'
|
|
24
|
+
|
|
25
|
+
export type { MediaCardProps } from './types'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Media card while still drafting — the locked thumbnail (single or gallery)
|
|
29
|
+
* the composer taps to preview the attachment they're about to send, plus
|
|
30
|
+
* the title/price body with optional dismiss X and edit pencil.
|
|
31
|
+
*/
|
|
32
|
+
const DraftMediaCard: React.FC<MediaCardDraftProps> = ({
|
|
33
|
+
title,
|
|
34
|
+
mimeType = 'application/octet-stream',
|
|
35
|
+
thumbnailUrl,
|
|
36
|
+
detail,
|
|
37
|
+
amountText,
|
|
38
|
+
placeholderTitle,
|
|
39
|
+
placeholderAmountText,
|
|
40
|
+
gallery = [],
|
|
41
|
+
onDismiss,
|
|
42
|
+
onPreviewClick,
|
|
43
|
+
onFetchSource,
|
|
44
|
+
onEditClick,
|
|
45
|
+
}) => {
|
|
46
|
+
const isGallery = isGalleryCard(gallery)
|
|
47
|
+
|
|
48
|
+
// Gallery is always previewable in the composer because each item's source
|
|
49
|
+
// is already provided up-front via `gallery[*].sourceUrl`.
|
|
50
|
+
const { source, isPreviewVisible, isLoadingPreview, togglePreview } =
|
|
51
|
+
usePreviewToggle({ isGallery, onFetchSource, onPreviewClick })
|
|
52
|
+
|
|
53
|
+
const showLocked = !isPreviewVisible
|
|
54
|
+
const isBusy = isLoadingPreview
|
|
55
|
+
|
|
56
|
+
const statusBadge = (
|
|
57
|
+
<React.Fragment>
|
|
58
|
+
<span className="text-xs font-medium text-white/55">•</span>
|
|
59
|
+
<span
|
|
60
|
+
className={
|
|
61
|
+
amountText
|
|
62
|
+
? 'text-xs font-medium text-white/55'
|
|
63
|
+
: 'text-xs font-medium text-white/30'
|
|
64
|
+
}
|
|
65
|
+
>
|
|
66
|
+
{amountText || placeholderAmountText}
|
|
67
|
+
</span>
|
|
68
|
+
</React.Fragment>
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const dismissButton = onDismiss ? (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={(e) => {
|
|
75
|
+
// Stop the click from bubbling up into the outer preview-toggle
|
|
76
|
+
// wrapper when both are wired up at the same time.
|
|
77
|
+
e.stopPropagation()
|
|
78
|
+
onDismiss()
|
|
79
|
+
}}
|
|
80
|
+
className="flex size-6 items-center justify-center rounded-full bg-[#121110] text-white"
|
|
81
|
+
aria-label="Dismiss attachment"
|
|
82
|
+
>
|
|
83
|
+
<XIcon className="size-3" weight="bold" />
|
|
84
|
+
</button>
|
|
85
|
+
) : undefined
|
|
86
|
+
|
|
87
|
+
const editButton = onEditClick ? (
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={onEditClick}
|
|
91
|
+
aria-label="Edit attachment"
|
|
92
|
+
className="flex size-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/15"
|
|
93
|
+
>
|
|
94
|
+
<PencilSimpleIcon className="size-5" weight="regular" />
|
|
95
|
+
</button>
|
|
96
|
+
) : undefined
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<MediaShell variant="dark">
|
|
100
|
+
<PreviewToggle
|
|
101
|
+
onToggle={togglePreview}
|
|
102
|
+
isBusy={isBusy}
|
|
103
|
+
isPreviewVisible={!showLocked}
|
|
104
|
+
>
|
|
105
|
+
<CardThumbnail
|
|
106
|
+
variant="dark"
|
|
107
|
+
gallery={gallery}
|
|
108
|
+
title={title}
|
|
109
|
+
showLocked={showLocked}
|
|
110
|
+
mimeType={mimeType}
|
|
111
|
+
thumbnailUrl={thumbnailUrl}
|
|
112
|
+
source={source}
|
|
113
|
+
topRight={dismissButton}
|
|
114
|
+
/>
|
|
115
|
+
</PreviewToggle>
|
|
116
|
+
|
|
117
|
+
<CardBody
|
|
118
|
+
variant="dark"
|
|
119
|
+
title={title}
|
|
120
|
+
placeholderTitle={placeholderTitle}
|
|
121
|
+
mimeType={mimeType}
|
|
122
|
+
detail={detail}
|
|
123
|
+
statusBadge={statusBadge}
|
|
124
|
+
icon={isGallery ? galleryBodyIcon('dark') : undefined}
|
|
125
|
+
trailingAction={editButton}
|
|
126
|
+
/>
|
|
127
|
+
</MediaShell>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Static mockup of what the recipient will see before purchase — always
|
|
133
|
+
* locked/blurred (the lock badge renders from `showLocked` alone, no toggle
|
|
134
|
+
* wiring needed), no interactivity, no real content revealed.
|
|
135
|
+
*/
|
|
136
|
+
const PreviewMediaCard: React.FC<MediaCardPreviewProps> = ({
|
|
137
|
+
title,
|
|
138
|
+
mimeType = 'application/octet-stream',
|
|
139
|
+
thumbnailUrl,
|
|
140
|
+
detail,
|
|
141
|
+
amountText,
|
|
142
|
+
placeholderTitle,
|
|
143
|
+
placeholderAmountText,
|
|
144
|
+
gallery = [],
|
|
145
|
+
}) => {
|
|
146
|
+
const isGallery = isGalleryCard(gallery)
|
|
147
|
+
|
|
148
|
+
const statusBadge = (
|
|
149
|
+
<React.Fragment>
|
|
150
|
+
<span className="text-xs font-medium text-black/55">•</span>
|
|
151
|
+
<span
|
|
152
|
+
className={
|
|
153
|
+
amountText
|
|
154
|
+
? 'text-xs font-medium text-black/55'
|
|
155
|
+
: 'text-xs font-medium text-black/30'
|
|
156
|
+
}
|
|
157
|
+
>
|
|
158
|
+
{amountText || placeholderAmountText}
|
|
159
|
+
</span>
|
|
160
|
+
</React.Fragment>
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<MediaShell variant="light">
|
|
165
|
+
<CardThumbnail
|
|
166
|
+
variant="light"
|
|
167
|
+
gallery={gallery}
|
|
168
|
+
title={title}
|
|
169
|
+
showLocked
|
|
170
|
+
mimeType={mimeType}
|
|
171
|
+
thumbnailUrl={thumbnailUrl}
|
|
172
|
+
/>
|
|
173
|
+
|
|
174
|
+
<CardBody
|
|
175
|
+
variant="light"
|
|
176
|
+
title={title}
|
|
177
|
+
placeholderTitle={placeholderTitle}
|
|
178
|
+
mimeType={mimeType}
|
|
179
|
+
detail={detail}
|
|
180
|
+
statusBadge={statusBadge}
|
|
181
|
+
icon={isGallery ? galleryBodyIcon('light') : undefined}
|
|
182
|
+
/>
|
|
183
|
+
</MediaShell>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The sender's ('mine') view of a paid media attachment: the locked
|
|
188
|
+
* thumbnail (single or gallery) they can tap to preview their own
|
|
189
|
+
* attachment, plus the title/purchase-status body.
|
|
190
|
+
*/
|
|
191
|
+
const OwnMediaCard: React.FC<MediaCardOwnProps> = ({
|
|
192
|
+
title,
|
|
193
|
+
mimeType = 'application/octet-stream',
|
|
194
|
+
thumbnailUrl,
|
|
195
|
+
detail,
|
|
196
|
+
amountText,
|
|
197
|
+
placeholderTitle,
|
|
198
|
+
paymentStatus = 'pending',
|
|
199
|
+
gallery = [],
|
|
200
|
+
onPreviewClick,
|
|
201
|
+
onFetchSource,
|
|
202
|
+
}) => {
|
|
203
|
+
const isGallery = isGalleryCard(gallery)
|
|
204
|
+
|
|
205
|
+
// Gallery is always previewable for the sender because each item's source
|
|
206
|
+
// is already provided up-front via `gallery[*].sourceUrl`.
|
|
207
|
+
const { source, isPreviewVisible, isLoadingPreview, togglePreview } =
|
|
208
|
+
usePreviewToggle({ isGallery, onFetchSource, onPreviewClick })
|
|
209
|
+
|
|
210
|
+
const showLocked = !isPreviewVisible
|
|
211
|
+
const isBusy = isLoadingPreview
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<MediaShell variant="dark">
|
|
215
|
+
<PreviewToggle
|
|
216
|
+
onToggle={togglePreview}
|
|
217
|
+
isBusy={isBusy}
|
|
218
|
+
isPreviewVisible={!showLocked}
|
|
219
|
+
>
|
|
220
|
+
<CardThumbnail
|
|
221
|
+
variant="dark"
|
|
222
|
+
gallery={gallery}
|
|
223
|
+
title={title}
|
|
224
|
+
showLocked={showLocked}
|
|
225
|
+
paymentStatus={paymentStatus}
|
|
226
|
+
mimeType={mimeType}
|
|
227
|
+
thumbnailUrl={thumbnailUrl}
|
|
228
|
+
source={source}
|
|
229
|
+
/>
|
|
230
|
+
</PreviewToggle>
|
|
231
|
+
|
|
232
|
+
<CardBody
|
|
233
|
+
variant="dark"
|
|
234
|
+
title={title}
|
|
235
|
+
placeholderTitle={placeholderTitle}
|
|
236
|
+
mimeType={mimeType}
|
|
237
|
+
detail={detail}
|
|
238
|
+
statusBadge={
|
|
239
|
+
<PurchaseStatusBadge
|
|
240
|
+
isMine={true}
|
|
241
|
+
paymentStatus={paymentStatus}
|
|
242
|
+
amountText={amountText}
|
|
243
|
+
/>
|
|
244
|
+
}
|
|
245
|
+
icon={isGallery ? galleryBodyIcon('dark') : undefined}
|
|
246
|
+
/>
|
|
247
|
+
</MediaShell>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The recipient's ('other') view of a paid media attachment: the locked
|
|
253
|
+
* thumbnail (single or gallery) with an Unlock/Download CTA. The source is
|
|
254
|
+
* revealed by fetching once — on click when already paid, or automatically
|
|
255
|
+
* the first time a paid card scrolls into view.
|
|
256
|
+
*/
|
|
257
|
+
const OtherMediaCard: React.FC<MediaCardOtherProps> = ({
|
|
258
|
+
title,
|
|
259
|
+
amountText,
|
|
260
|
+
thumbnailUrl,
|
|
261
|
+
mimeType = 'application/octet-stream',
|
|
262
|
+
detail,
|
|
263
|
+
gallery = [],
|
|
264
|
+
onUnlockClick,
|
|
265
|
+
onFetchSource,
|
|
266
|
+
onDownloadClick,
|
|
267
|
+
paymentStatus,
|
|
268
|
+
isUnlocking = false,
|
|
269
|
+
}) => {
|
|
270
|
+
const isGallery = isGalleryCard(gallery)
|
|
271
|
+
|
|
272
|
+
const { source, cardRef, handleUnlockClick } = useReceivedSource({
|
|
273
|
+
paymentStatus,
|
|
274
|
+
onFetchSource,
|
|
275
|
+
onUnlockClick,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const effectiveSourceUrl = source?.sourceUrl
|
|
279
|
+
const effectiveRedeemUrl = source?.redeemUrl
|
|
280
|
+
|
|
281
|
+
// For gallery, the per-item sources are carried by `gallery` itself, so
|
|
282
|
+
// the lock state is driven by paymentStatus rather than the single
|
|
283
|
+
// `effectiveSourceUrl`. Everything else still respects the source fetch.
|
|
284
|
+
const isLocked = isGallery
|
|
285
|
+
? paymentStatus !== 'paid'
|
|
286
|
+
: effectiveSourceUrl === undefined
|
|
287
|
+
const sourceType = getSourceType(mimeType)
|
|
288
|
+
|
|
289
|
+
// For gallery, ReceivedCardActions falls back to a plain <button>
|
|
290
|
+
// (no anchor) because the gallery has no single sourceUrl to link to —
|
|
291
|
+
// the per-item sources live on `gallery` itself.
|
|
292
|
+
const actionSourceUrl = isGallery ? undefined : effectiveSourceUrl
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<MediaShell
|
|
296
|
+
variant="light"
|
|
297
|
+
rootRef={cardRef}
|
|
298
|
+
data-testid="locked-attachment"
|
|
299
|
+
>
|
|
300
|
+
<CardThumbnail
|
|
301
|
+
variant="light"
|
|
302
|
+
gallery={gallery}
|
|
303
|
+
title={title}
|
|
304
|
+
showLocked={isLocked}
|
|
305
|
+
paymentStatus={paymentStatus}
|
|
306
|
+
mimeType={mimeType}
|
|
307
|
+
thumbnailUrl={thumbnailUrl}
|
|
308
|
+
source={source}
|
|
309
|
+
containedImage={!isLocked && sourceType === 'document'}
|
|
310
|
+
/>
|
|
311
|
+
|
|
312
|
+
<CardBody
|
|
313
|
+
variant="light"
|
|
314
|
+
title={title}
|
|
315
|
+
mimeType={mimeType}
|
|
316
|
+
detail={detail}
|
|
317
|
+
statusBadge={
|
|
318
|
+
<PurchaseStatusBadge
|
|
319
|
+
isMine={false}
|
|
320
|
+
paymentStatus={paymentStatus}
|
|
321
|
+
amountText={amountText}
|
|
322
|
+
/>
|
|
323
|
+
}
|
|
324
|
+
icon={isGallery ? galleryBodyIcon('light') : undefined}
|
|
325
|
+
action={
|
|
326
|
+
<ReceivedCardActions
|
|
327
|
+
isLocked={isLocked}
|
|
328
|
+
isUnlocking={isUnlocking}
|
|
329
|
+
sourceUrl={actionSourceUrl}
|
|
330
|
+
redeemUrl={effectiveRedeemUrl}
|
|
331
|
+
onUnlockClicked={handleUnlockClick}
|
|
332
|
+
onDownloadClicked={onDownloadClick}
|
|
333
|
+
/>
|
|
334
|
+
}
|
|
335
|
+
/>
|
|
336
|
+
</MediaShell>
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The card either party sees for a paid media attachment. `isDraft`/`isPreview`
|
|
342
|
+
* select the composer's own drafting view / a static receiver-side mockup;
|
|
343
|
+
* otherwise `isMine` selects the sender's sent view vs. the recipient's
|
|
344
|
+
* unlock/download view.
|
|
345
|
+
*/
|
|
346
|
+
const MediaCard: React.FC<MediaCardProps> = (props) => {
|
|
347
|
+
if (props.isDraft) {
|
|
348
|
+
return <DraftMediaCard {...props} />
|
|
349
|
+
} else if (props.isPreview) {
|
|
350
|
+
return <PreviewMediaCard {...props} />
|
|
351
|
+
} else if (props.isMine) {
|
|
352
|
+
return <OwnMediaCard {...props} />
|
|
353
|
+
} else {
|
|
354
|
+
return <OtherMediaCard {...props} />
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export default MediaCard
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { DownloadSimpleIcon, LockSimpleIcon } from '@phosphor-icons/react'
|
|
2
2
|
import React from 'react'
|
|
3
3
|
|
|
4
|
+
import LoadingDots from '../_shared/LoadingDots'
|
|
5
|
+
|
|
4
6
|
export interface ReceivedCardActionsProps {
|
|
5
7
|
/** Whether the card is currently locked. Drives which CTA we render. */
|
|
6
8
|
isLocked: boolean
|
|
@@ -19,7 +21,7 @@ export interface ReceivedCardActionsProps {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const buttonClass =
|
|
22
|
-
'mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full bg-[#121110] px-4 text-sm font-medium leading-none text-white hover:bg-[#2a2928]
|
|
24
|
+
'mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full bg-[#121110] px-4 text-sm font-medium leading-none text-white hover:bg-[#2a2928]'
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Renders the primary CTA below the body of a Received card.
|
|
@@ -42,10 +44,13 @@ const ReceivedCardActions: React.FC<ReceivedCardActionsProps> = ({
|
|
|
42
44
|
type="button"
|
|
43
45
|
onClick={onUnlockClicked}
|
|
44
46
|
disabled={isUnlocking}
|
|
47
|
+
aria-label={isUnlocking ? 'Unlocking' : 'Unlock'}
|
|
48
|
+
aria-busy={isUnlocking}
|
|
49
|
+
title={isUnlocking ? 'Unlocking' : 'Unlock'}
|
|
45
50
|
className={buttonClass}
|
|
46
51
|
>
|
|
47
52
|
{isUnlocking ? (
|
|
48
|
-
<
|
|
53
|
+
<LoadingDots />
|
|
49
54
|
) : (
|
|
50
55
|
<React.Fragment>
|
|
51
56
|
<LockSimpleIcon className="size-4" weight="fill" />
|
|
@@ -62,6 +67,7 @@ const ReceivedCardActions: React.FC<ReceivedCardActionsProps> = ({
|
|
|
62
67
|
return (
|
|
63
68
|
<a
|
|
64
69
|
href={href}
|
|
70
|
+
aria-label="Download"
|
|
65
71
|
target="_blank"
|
|
66
72
|
rel="noopener noreferrer"
|
|
67
73
|
onClick={onDownloadClicked}
|
|
@@ -77,6 +83,7 @@ const ReceivedCardActions: React.FC<ReceivedCardActionsProps> = ({
|
|
|
77
83
|
<button
|
|
78
84
|
type="button"
|
|
79
85
|
onClick={onDownloadClicked}
|
|
86
|
+
aria-label="Download"
|
|
80
87
|
className={buttonClass}
|
|
81
88
|
>
|
|
82
89
|
<DownloadSimpleIcon className="size-4" weight="bold" />
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FetchSourceHandler,
|
|
3
|
+
LockedAttachmentGalleryItem,
|
|
4
|
+
PaymentStatus,
|
|
5
|
+
} from '../../types'
|
|
6
|
+
|
|
7
|
+
interface MediaCardBaseProps {
|
|
8
|
+
/** Attachment title. */
|
|
9
|
+
title?: string
|
|
10
|
+
/** Fallback shown when `title` is unset. */
|
|
11
|
+
placeholderTitle?: string
|
|
12
|
+
/** Drives icon/thumbnail rendering. */
|
|
13
|
+
mimeType?: string
|
|
14
|
+
/** Poster image, locked or unlocked. */
|
|
15
|
+
thumbnailUrl?: string
|
|
16
|
+
/** Secondary description text. */
|
|
17
|
+
detail?: string
|
|
18
|
+
/** Amount label. */
|
|
19
|
+
amountText?: string
|
|
20
|
+
/** 2+ items renders as a carousel instead of a single thumbnail. */
|
|
21
|
+
gallery?: LockedAttachmentGalleryItem[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MediaCardDraftProps extends MediaCardBaseProps {
|
|
25
|
+
/** True while composing — shows the real (unredacted) thumbnail. */
|
|
26
|
+
isDraft: true
|
|
27
|
+
isPreview?: false
|
|
28
|
+
/** Fallback amount before a price is set. */
|
|
29
|
+
placeholderAmountText?: string
|
|
30
|
+
/** Lazily fetches the source on first tap; cached and reused after. */
|
|
31
|
+
onFetchSource?: FetchSourceHandler
|
|
32
|
+
/** Fired on first tap to preview. */
|
|
33
|
+
onPreviewClick?: () => void
|
|
34
|
+
/** Removes the draft attachment. */
|
|
35
|
+
onDismiss?: () => void
|
|
36
|
+
/** Renders a pencil button to edit the attachment metadata. */
|
|
37
|
+
onEditClick?: () => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MediaCardPreviewProps extends MediaCardBaseProps {
|
|
41
|
+
/** True to render exactly as the recipient will see it before purchase — always locked/blurred. */
|
|
42
|
+
isDraft?: false
|
|
43
|
+
isPreview: true
|
|
44
|
+
/** Fallback amount before a price is set. */
|
|
45
|
+
placeholderAmountText?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface MediaCardSentBaseProps extends MediaCardBaseProps {
|
|
49
|
+
/** False once sent. */
|
|
50
|
+
isDraft?: false
|
|
51
|
+
isPreview?: false
|
|
52
|
+
/** Drives the lock/skeleton/badge state. */
|
|
53
|
+
paymentStatus?: PaymentStatus
|
|
54
|
+
onFetchSource?: FetchSourceHandler
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type MediaCardOwnProps = MediaCardSentBaseProps & {
|
|
58
|
+
/** Sender's own view. */
|
|
59
|
+
isMine: true
|
|
60
|
+
/** Fired on first tap to preview own attachment. */
|
|
61
|
+
onPreviewClick?: () => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type MediaCardOtherProps = MediaCardSentBaseProps & {
|
|
65
|
+
/** Recipient's view. */
|
|
66
|
+
isMine: false
|
|
67
|
+
/** Opens checkout on click. Omit to hide the button. */
|
|
68
|
+
onUnlockClick?: () => void
|
|
69
|
+
/** Omit to hide the Download button. */
|
|
70
|
+
onDownloadClick?: () => void
|
|
71
|
+
/** Spinner on Unlock while checkout runs. */
|
|
72
|
+
isUnlocking?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type MediaCardProps =
|
|
76
|
+
| MediaCardDraftProps
|
|
77
|
+
| MediaCardPreviewProps
|
|
78
|
+
| MediaCardOwnProps
|
|
79
|
+
| MediaCardOtherProps
|