@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,153 @@
|
|
|
1
|
+
import { LockSimpleIcon, XIcon } from '@phosphor-icons/react'
|
|
2
|
+
import type React from 'react'
|
|
3
|
+
|
|
4
|
+
import LoadingDots from '../_shared/LoadingDots'
|
|
5
|
+
import PriceLockRow from '../_shared/PriceLockRow'
|
|
6
|
+
import PurchaseStatusBadge from '../_shared/PurchaseStatusBadge'
|
|
7
|
+
import SkeletonTextLines from '../_shared/SkeletonTextLines'
|
|
8
|
+
import TextShell from '../_shared/TextShell'
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
TextCardDraftProps,
|
|
12
|
+
TextCardOtherProps,
|
|
13
|
+
TextCardOwnProps,
|
|
14
|
+
TextCardPreviewProps,
|
|
15
|
+
TextCardProps,
|
|
16
|
+
} from './types'
|
|
17
|
+
|
|
18
|
+
export type { TextCardProps } from './types'
|
|
19
|
+
|
|
20
|
+
const DraftTextCard: React.FC<TextCardDraftProps> = ({
|
|
21
|
+
renderedText,
|
|
22
|
+
amountText,
|
|
23
|
+
placeholderAmountText,
|
|
24
|
+
onDismiss,
|
|
25
|
+
}) => (
|
|
26
|
+
<TextShell isMine className="pr-8">
|
|
27
|
+
{renderedText || <SkeletonTextLines variant="dark" />}
|
|
28
|
+
<PriceLockRow>
|
|
29
|
+
<span className={amountText ? 'text-white/55' : 'text-white/30'}>
|
|
30
|
+
{amountText || placeholderAmountText}
|
|
31
|
+
</span>
|
|
32
|
+
</PriceLockRow>
|
|
33
|
+
{onDismiss && (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
onClick={onDismiss}
|
|
37
|
+
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"
|
|
38
|
+
aria-label="Dismiss attachment"
|
|
39
|
+
>
|
|
40
|
+
<XIcon className="size-3" weight="bold" />
|
|
41
|
+
</button>
|
|
42
|
+
)}
|
|
43
|
+
</TextShell>
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const PreviewTextCard: React.FC<TextCardPreviewProps> = ({
|
|
47
|
+
amountText,
|
|
48
|
+
placeholderAmountText,
|
|
49
|
+
}) => (
|
|
50
|
+
<TextShell isMine={false}>
|
|
51
|
+
<SkeletonTextLines variant="light" />
|
|
52
|
+
<PriceLockRow>
|
|
53
|
+
<span className="opacity-55">{amountText || placeholderAmountText}</span>
|
|
54
|
+
</PriceLockRow>
|
|
55
|
+
</TextShell>
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const OwnTextCard: React.FC<TextCardOwnProps> = ({
|
|
59
|
+
renderedText,
|
|
60
|
+
amountText,
|
|
61
|
+
paymentStatus = 'pending',
|
|
62
|
+
}) => {
|
|
63
|
+
const isPaid = paymentStatus === 'paid'
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<TextShell isMine>
|
|
67
|
+
{isPaid && renderedText ? (
|
|
68
|
+
renderedText
|
|
69
|
+
) : (
|
|
70
|
+
<SkeletonTextLines variant="dark" />
|
|
71
|
+
)}
|
|
72
|
+
<PriceLockRow>
|
|
73
|
+
{amountText && <span className="text-white/55">{amountText}</span>}
|
|
74
|
+
<PurchaseStatusBadge isMine paymentStatus={paymentStatus} />
|
|
75
|
+
</PriceLockRow>
|
|
76
|
+
</TextShell>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const OtherTextCard: React.FC<TextCardOtherProps> = ({
|
|
81
|
+
renderedText,
|
|
82
|
+
amountText,
|
|
83
|
+
paymentStatus,
|
|
84
|
+
onUnlockClick,
|
|
85
|
+
isUnlocking = false,
|
|
86
|
+
}) => {
|
|
87
|
+
const isPaid = paymentStatus === 'paid'
|
|
88
|
+
|
|
89
|
+
if (isPaid && renderedText) {
|
|
90
|
+
return (
|
|
91
|
+
<TextShell isMine={false}>
|
|
92
|
+
{renderedText}
|
|
93
|
+
<PriceLockRow>
|
|
94
|
+
{amountText != null && (
|
|
95
|
+
<span className="opacity-55">{amountText}</span>
|
|
96
|
+
)}
|
|
97
|
+
<PurchaseStatusBadge isMine={false} paymentStatus="paid" />
|
|
98
|
+
</PriceLockRow>
|
|
99
|
+
</TextShell>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const ariaLabel = isUnlocking
|
|
104
|
+
? 'Unlocking message.'
|
|
105
|
+
: 'Unlock this message.'
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<TextShell isMine={false}>
|
|
109
|
+
<div className="flex items-center gap-3">
|
|
110
|
+
<div className="flex flex-1 flex-col gap-2">
|
|
111
|
+
<SkeletonTextLines variant="light" />
|
|
112
|
+
<span className="sr-only" aria-live="polite">
|
|
113
|
+
{ariaLabel}
|
|
114
|
+
</span>
|
|
115
|
+
<PriceLockRow>
|
|
116
|
+
{amountText != null && (
|
|
117
|
+
<span className="opacity-55">{amountText}</span>
|
|
118
|
+
)}
|
|
119
|
+
</PriceLockRow>
|
|
120
|
+
</div>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={onUnlockClick}
|
|
124
|
+
disabled={isUnlocking}
|
|
125
|
+
aria-label={isUnlocking ? 'Unlocking message' : 'Unlock message'}
|
|
126
|
+
aria-busy={isUnlocking}
|
|
127
|
+
title={isUnlocking ? 'Unlocking message' : 'Unlock message'}
|
|
128
|
+
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-[#121110] text-white hover:bg-[#2a2928]"
|
|
129
|
+
>
|
|
130
|
+
{isUnlocking ? (
|
|
131
|
+
<LoadingDots />
|
|
132
|
+
) : (
|
|
133
|
+
<LockSimpleIcon className="size-4" weight="fill" aria-hidden />
|
|
134
|
+
)}
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
</TextShell>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const TextCard: React.FC<TextCardProps> = (props) => {
|
|
142
|
+
if (props.isDraft) {
|
|
143
|
+
return <DraftTextCard {...props} />
|
|
144
|
+
} else if (props.isPreview) {
|
|
145
|
+
return <PreviewTextCard {...props} />
|
|
146
|
+
} else if (props.isMine) {
|
|
147
|
+
return <OwnTextCard {...props} />
|
|
148
|
+
} else {
|
|
149
|
+
return <OtherTextCard {...props} />
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export default TextCard
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type React from 'react'
|
|
2
|
+
|
|
3
|
+
import type { PaymentStatus } from '../../types'
|
|
4
|
+
|
|
5
|
+
interface TextCardBaseProps {
|
|
6
|
+
/** Amount label. */
|
|
7
|
+
amountText?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TextCardDraftProps extends TextCardBaseProps {
|
|
11
|
+
/** True while composing — shows the real draft text. */
|
|
12
|
+
isDraft: true
|
|
13
|
+
isPreview?: false
|
|
14
|
+
/** Real draft content, not yet redacted. */
|
|
15
|
+
renderedText?: React.ReactNode
|
|
16
|
+
/** Fallback amount before a price is set. */
|
|
17
|
+
placeholderAmountText?: string
|
|
18
|
+
/** Removes the draft attachment. */
|
|
19
|
+
onDismiss?: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TextCardPreviewProps extends TextCardBaseProps {
|
|
23
|
+
/** True to render exactly as the recipient will see it before purchase — skeleton lines only. */
|
|
24
|
+
isDraft?: false
|
|
25
|
+
isPreview: true
|
|
26
|
+
/** Fallback amount before a price is set. */
|
|
27
|
+
placeholderAmountText?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TextCardSentBaseProps extends TextCardBaseProps {
|
|
31
|
+
/** False once sent. */
|
|
32
|
+
isDraft?: false
|
|
33
|
+
isPreview?: false
|
|
34
|
+
/** Revealed content once paid. */
|
|
35
|
+
renderedText?: React.ReactNode
|
|
36
|
+
/** Drives the lock/skeleton/badge state. */
|
|
37
|
+
paymentStatus?: PaymentStatus
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type TextCardOwnProps = TextCardSentBaseProps & {
|
|
41
|
+
/** Sender's own view. */
|
|
42
|
+
isMine: true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type TextCardOtherProps = TextCardSentBaseProps & {
|
|
46
|
+
/** Recipient's view. */
|
|
47
|
+
isMine: false
|
|
48
|
+
/** Spinner on Unlock while checkout runs. */
|
|
49
|
+
isUnlocking?: boolean
|
|
50
|
+
/** Opens checkout on click. Omit to hide the button. */
|
|
51
|
+
onUnlockClick?: () => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type TextCardProps =
|
|
55
|
+
| TextCardDraftProps
|
|
56
|
+
| TextCardPreviewProps
|
|
57
|
+
| TextCardOwnProps
|
|
58
|
+
| TextCardOtherProps
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import classNames from 'classnames'
|
|
2
2
|
import React from 'react'
|
|
3
3
|
|
|
4
|
-
import { renderTypeIcon } from '
|
|
4
|
+
import { renderTypeIcon } from '../../../../AttachmentCard'
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type { CardVariant } from './MediaShell'
|
|
7
7
|
|
|
8
8
|
export interface CardBodyProps {
|
|
9
|
-
variant:
|
|
9
|
+
variant: CardVariant
|
|
10
10
|
title?: string
|
|
11
11
|
placeholderTitle?: string
|
|
12
12
|
mimeType: string
|
|
@@ -20,7 +20,7 @@ export interface CardBodyProps {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Title + status row layout shared by Composer
|
|
23
|
+
* Title + status row layout shared by Composer and `LockedAttachment.Media`.
|
|
24
24
|
* Layout matches the Figma `Container > Labels` group (16px h-padding, 12px v-padding,
|
|
25
25
|
* 4px gap between title and status row, 4px gap inside status row).
|
|
26
26
|
*/
|
|
@@ -36,8 +36,8 @@ const CardBody: React.FC<CardBodyProps> = ({
|
|
|
36
36
|
trailingAction,
|
|
37
37
|
}) => {
|
|
38
38
|
const isDark = variant === 'dark'
|
|
39
|
-
const displayTitle =
|
|
40
|
-
const titleDimmed =
|
|
39
|
+
const displayTitle = title || placeholderTitle
|
|
40
|
+
const titleDimmed = !title
|
|
41
41
|
|
|
42
42
|
const typeIcon =
|
|
43
43
|
icon ??
|
|
@@ -56,7 +56,8 @@ const CardBody: React.FC<CardBodyProps> = ({
|
|
|
56
56
|
{displayTitle.trim() !== '' && (
|
|
57
57
|
<p
|
|
58
58
|
className={classNames('truncate text-base font-medium leading-6', {
|
|
59
|
-
'text-black/90': !isDark,
|
|
59
|
+
'text-black/90': !isDark && !titleDimmed,
|
|
60
|
+
'text-black/30': !isDark && titleDimmed,
|
|
60
61
|
'text-white/30': isDark && titleDimmed,
|
|
61
62
|
'text-white': isDark && !titleDimmed,
|
|
62
63
|
})}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ImagesIcon } from '@phosphor-icons/react'
|
|
2
|
+
import type React from 'react'
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
LockedAttachmentGalleryItem,
|
|
6
|
+
LockedAttachmentSource,
|
|
7
|
+
PaymentStatus,
|
|
8
|
+
} from '../../types'
|
|
9
|
+
|
|
10
|
+
import GalleryThumbnail from './GalleryThumbnail'
|
|
11
|
+
import type { CardVariant } from './MediaShell'
|
|
12
|
+
import SingleThumbnail from './SingleThumbnail'
|
|
13
|
+
|
|
14
|
+
export interface CardThumbnailProps {
|
|
15
|
+
variant: CardVariant
|
|
16
|
+
gallery: LockedAttachmentGalleryItem[]
|
|
17
|
+
title?: string
|
|
18
|
+
showLocked: boolean
|
|
19
|
+
paymentStatus?: PaymentStatus
|
|
20
|
+
/** Single-asset props — ignored when the card renders as a gallery. */
|
|
21
|
+
mimeType: string
|
|
22
|
+
thumbnailUrl?: string
|
|
23
|
+
source?: LockedAttachmentSource
|
|
24
|
+
containedImage?: boolean
|
|
25
|
+
/** Top-right overlay (e.g. Composer dismiss X). Rendered inside the thumbnail. */
|
|
26
|
+
topRight?: React.ReactNode
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True when the card should render as a mixed-media carousel (2+ items). */
|
|
30
|
+
export const isGalleryCard = (
|
|
31
|
+
gallery: LockedAttachmentGalleryItem[]
|
|
32
|
+
): boolean => gallery.length >= 2
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The body-row icon a card shows when it renders as a gallery — `CardBody`
|
|
36
|
+
* otherwise auto-detects the icon from `mimeType`. Returned as a ready node so
|
|
37
|
+
* every card shares the same size/tone treatment.
|
|
38
|
+
*/
|
|
39
|
+
export const galleryBodyIcon = (
|
|
40
|
+
variant: CardVariant
|
|
41
|
+
): React.ReactNode => (
|
|
42
|
+
<ImagesIcon
|
|
43
|
+
className={
|
|
44
|
+
variant === 'dark'
|
|
45
|
+
? 'size-5 shrink-0 text-white/55'
|
|
46
|
+
: 'size-5 shrink-0 text-black/55'
|
|
47
|
+
}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Owns the single-vs-gallery thumbnail decision shared by Composer /
|
|
53
|
+
* `LockedAttachment.Media` cards: renders `GalleryThumbnail` for a 2+ item `gallery`,
|
|
54
|
+
* else `SingleThumbnail` for the single asset. Callers pass the superset of
|
|
55
|
+
* props; the unused half is ignored by whichever branch renders.
|
|
56
|
+
*/
|
|
57
|
+
const CardThumbnail: React.FC<CardThumbnailProps> = ({
|
|
58
|
+
variant,
|
|
59
|
+
gallery,
|
|
60
|
+
title,
|
|
61
|
+
showLocked,
|
|
62
|
+
paymentStatus,
|
|
63
|
+
mimeType,
|
|
64
|
+
thumbnailUrl,
|
|
65
|
+
source,
|
|
66
|
+
containedImage,
|
|
67
|
+
topRight,
|
|
68
|
+
}) =>
|
|
69
|
+
isGalleryCard(gallery) ? (
|
|
70
|
+
<GalleryThumbnail
|
|
71
|
+
variant={variant}
|
|
72
|
+
gallery={gallery}
|
|
73
|
+
title={title}
|
|
74
|
+
showLocked={showLocked}
|
|
75
|
+
paymentStatus={paymentStatus}
|
|
76
|
+
topRight={topRight}
|
|
77
|
+
/>
|
|
78
|
+
) : (
|
|
79
|
+
<SingleThumbnail
|
|
80
|
+
variant={variant}
|
|
81
|
+
mimeType={mimeType}
|
|
82
|
+
thumbnailUrl={thumbnailUrl}
|
|
83
|
+
title={title}
|
|
84
|
+
source={source}
|
|
85
|
+
showLocked={showLocked}
|
|
86
|
+
paymentStatus={paymentStatus}
|
|
87
|
+
topRight={topRight}
|
|
88
|
+
containedImage={containedImage}
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
export default CardThumbnail
|
|
@@ -1,23 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CaretLeftIcon,
|
|
3
|
-
CaretRightIcon,
|
|
4
|
-
LockOpenIcon,
|
|
5
|
-
LockSimpleIcon,
|
|
6
|
-
PlayIcon,
|
|
7
|
-
} from '@phosphor-icons/react'
|
|
1
|
+
import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
|
|
8
2
|
import classNames from 'classnames'
|
|
9
3
|
import React, { useState } from 'react'
|
|
10
4
|
|
|
11
|
-
import { AttachmentThumbnail, getSourceType } from '
|
|
5
|
+
import { AttachmentThumbnail, getSourceType } from '../../../../AttachmentCard'
|
|
12
6
|
import type {
|
|
13
7
|
LockedAttachmentGalleryItem,
|
|
14
8
|
PaymentStatus,
|
|
15
9
|
} from '../../types'
|
|
16
10
|
|
|
17
|
-
import
|
|
11
|
+
import { LockBadge } from './LockBadge'
|
|
12
|
+
import type { CardVariant } from './MediaShell'
|
|
18
13
|
|
|
19
14
|
export interface GalleryThumbnailProps {
|
|
20
|
-
variant:
|
|
15
|
+
variant: CardVariant
|
|
21
16
|
gallery: LockedAttachmentGalleryItem[]
|
|
22
17
|
title?: string
|
|
23
18
|
/** When true, all items are shown blurred + the locked/play badge is overlaid. */
|
|
@@ -52,24 +47,8 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
|
|
|
52
47
|
const current = gallery[safeIndex]
|
|
53
48
|
const isVideoItem = isPlayableItem(current)
|
|
54
49
|
|
|
55
|
-
// Center badge: PLAY for video items, LOCK for non-video locked items.
|
|
56
|
-
// Unlocked image items show no center badge — the image is the content.
|
|
57
|
-
const centerBadge = (() => {
|
|
58
|
-
if (isVideoItem) {
|
|
59
|
-
return <PlayIcon className="size-6" weight="fill" />
|
|
60
|
-
}
|
|
61
|
-
if (showLocked) {
|
|
62
|
-
return paymentStatus === 'paid' ? (
|
|
63
|
-
<LockOpenIcon className="size-6" weight="fill" />
|
|
64
|
-
) : (
|
|
65
|
-
<LockSimpleIcon className="size-6" weight="fill" />
|
|
66
|
-
)
|
|
67
|
-
}
|
|
68
|
-
return null
|
|
69
|
-
})()
|
|
70
|
-
|
|
71
50
|
// Stop propagation so arrow taps don't bubble up to the outer preview-toggle
|
|
72
|
-
// wrapper on
|
|
51
|
+
// wrapper on PreviewMediaCard / OwnMediaCard.
|
|
73
52
|
const goPrev = (e: React.MouseEvent) => {
|
|
74
53
|
e.stopPropagation()
|
|
75
54
|
setIndex((i) => Math.max(0, i - 1))
|
|
@@ -86,14 +65,22 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
|
|
|
86
65
|
// Locked + unlocked render entirely separate trees so the locked state
|
|
87
66
|
// doesn't end up stacking a blurred `<img>` on top of an underlying
|
|
88
67
|
// `AttachmentThumbnail` (whose `bg-white/10` poster shell otherwise
|
|
89
|
-
// shows through as a visible horizontal seam
|
|
90
|
-
//
|
|
68
|
+
// shows through as a visible horizontal seam).
|
|
69
|
+
//
|
|
70
|
+
// The locked placeholder is absolutely positioned, so it needs the fixed
|
|
71
|
+
// 337/386 box for height; unlocked content (video, natural-sized image)
|
|
72
|
+
// manages its own height and can expand/shrink per item.
|
|
91
73
|
//
|
|
92
74
|
// The locked branch uses `filter: blur` on a single full-size `<img>`
|
|
93
75
|
// — not `backdrop-filter` — so the rounded corners are clipped
|
|
94
|
-
// normally without any clip-path workarounds (see
|
|
76
|
+
// normally without any clip-path workarounds (see SingleThumbnail).
|
|
95
77
|
return (
|
|
96
|
-
<div
|
|
78
|
+
<div
|
|
79
|
+
className={classNames(
|
|
80
|
+
'group relative overflow-hidden rounded-t-md',
|
|
81
|
+
showLocked ? 'aspect-[337/386]' : undefined
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
97
84
|
{showLocked ? (
|
|
98
85
|
<div
|
|
99
86
|
className={classNames(
|
|
@@ -115,20 +102,25 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
|
|
|
115
102
|
</div>
|
|
116
103
|
) : (
|
|
117
104
|
<AttachmentThumbnail
|
|
105
|
+
key={safeIndex}
|
|
118
106
|
mimeType={current.mimeType}
|
|
119
107
|
sourceUrl={current.sourceUrl}
|
|
120
108
|
thumbnailUrl={current.thumbnailUrl}
|
|
121
109
|
title={title}
|
|
122
110
|
variant={variant}
|
|
111
|
+
mediaPlayerProps={{ autoPlay: true }}
|
|
123
112
|
/>
|
|
124
113
|
)}
|
|
125
114
|
|
|
126
|
-
{
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
115
|
+
{/* LOCK while locked. Once unlocked, video/audio autoplays via
|
|
116
|
+
`mediaPlayerProps` and MediaPlayer already has its own inline
|
|
117
|
+
controls, so no separate Play affordance is shown. */}
|
|
118
|
+
{showLocked && (
|
|
119
|
+
<LockBadge
|
|
120
|
+
paymentStatus={paymentStatus}
|
|
121
|
+
isPlayable={isVideoItem}
|
|
122
|
+
isLocked={showLocked}
|
|
123
|
+
/>
|
|
132
124
|
)}
|
|
133
125
|
|
|
134
126
|
{total > 1 && (
|
|
@@ -158,8 +150,8 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
|
|
|
158
150
|
<CaretRightIcon className="size-5" weight="bold" />
|
|
159
151
|
</button>
|
|
160
152
|
|
|
161
|
-
<div className="pointer-events-none absolute bottom-
|
|
162
|
-
<span className="text-xs font-medium leading-
|
|
153
|
+
<div className="pointer-events-none absolute bottom-10 left-1/2 flex size-8 -translate-x-1/2 items-center justify-center rounded-full bg-black/30">
|
|
154
|
+
<span className="text-xs font-medium leading-none text-white">
|
|
163
155
|
{safeIndex + 1}/{total}
|
|
164
156
|
</span>
|
|
165
157
|
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
const Dot = ({ cx, index }: { cx: string; index: number }) => (
|
|
4
|
+
<circle cx={cx} cy="6.15" r="2.4" fill="currentColor">
|
|
5
|
+
<animateTransform
|
|
6
|
+
attributeName="transform"
|
|
7
|
+
type="translate"
|
|
8
|
+
values="0 0; 0 -2.25; 0 0;"
|
|
9
|
+
dur="900ms"
|
|
10
|
+
begin={`${120 * index}ms`}
|
|
11
|
+
repeatCount="indefinite"
|
|
12
|
+
/>
|
|
13
|
+
</circle>
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Three bouncing dots, inline-sized for use inside buttons/labels (e.g. the
|
|
18
|
+
* Unlock CTA while a purchase is in flight). Mirrors the typing indicator's
|
|
19
|
+
* animation but uses `currentColor` so it adopts the surrounding text color.
|
|
20
|
+
*/
|
|
21
|
+
const LoadingDots: React.FC<{ className?: string }> = ({ className }) => (
|
|
22
|
+
<svg
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
className={className}
|
|
25
|
+
viewBox="0 0 20 12.3"
|
|
26
|
+
width="20"
|
|
27
|
+
height="13"
|
|
28
|
+
overflow="visible"
|
|
29
|
+
>
|
|
30
|
+
<Dot cx="3" index={0} />
|
|
31
|
+
<Dot cx="10" index={1} />
|
|
32
|
+
<Dot cx="17" index={2} />
|
|
33
|
+
</svg>
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
export default LoadingDots
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { LockOpenIcon, LockSimpleIcon, PlayIcon } from '@phosphor-icons/react'
|
|
2
|
+
import classNames from 'classnames'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
import { getSourceType } from '../../../../AttachmentCard'
|
|
6
|
+
import type { PaymentStatus } from '../../types'
|
|
7
|
+
|
|
8
|
+
export interface LockBadgeProps {
|
|
9
|
+
paymentStatus?: PaymentStatus
|
|
10
|
+
mimeType?: string
|
|
11
|
+
/**
|
|
12
|
+
* Overrides the `mimeType`-derived playable check. Lets a caller that
|
|
13
|
+
* already knows per-item playability (e.g. `GalleryThumbnail`, checking a
|
|
14
|
+
* carousel item's own mimeType rather than the card's single `mimeType`)
|
|
15
|
+
* skip re-deriving it.
|
|
16
|
+
*/
|
|
17
|
+
isPlayable?: boolean
|
|
18
|
+
/** Whether the item is still locked. Play only ever shows once unlocked —
|
|
19
|
+
* a locked item always shows the lock, even for otherwise-playable media. */
|
|
20
|
+
isLocked: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Centered 48x48 lock badge that overlays the thumbnail of any locked attachment.
|
|
25
|
+
* Uses `bg-black/30`. Stays `pointer-events-none` so clicks pass through to
|
|
26
|
+
* the underlying player/toggle; the play state highlights via `group-hover`
|
|
27
|
+
* (same `bg-black/40` as the gallery's prev/next arrows) — the ancestor
|
|
28
|
+
* providing `group` must wrap the actual clickable/hoverable player area.
|
|
29
|
+
*
|
|
30
|
+
* For playable media (video/audio) that's unlocked, the badge shows a play
|
|
31
|
+
* icon instead of a lock — matching the convention used by `GalleryThumbnail`.
|
|
32
|
+
*/
|
|
33
|
+
export const LockBadge: React.FC<LockBadgeProps> = ({
|
|
34
|
+
paymentStatus,
|
|
35
|
+
mimeType,
|
|
36
|
+
isPlayable,
|
|
37
|
+
isLocked,
|
|
38
|
+
}) => {
|
|
39
|
+
const sourceType = mimeType ? getSourceType(mimeType) : undefined
|
|
40
|
+
const resolvedIsPlayable =
|
|
41
|
+
isPlayable ?? (sourceType === 'video' || sourceType === 'audio')
|
|
42
|
+
|
|
43
|
+
const Icon =
|
|
44
|
+
resolvedIsPlayable && !isLocked
|
|
45
|
+
? PlayIcon
|
|
46
|
+
: paymentStatus === 'paid'
|
|
47
|
+
? LockOpenIcon
|
|
48
|
+
: LockSimpleIcon
|
|
49
|
+
return (
|
|
50
|
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
51
|
+
<div
|
|
52
|
+
className={classNames(
|
|
53
|
+
'flex size-12 items-center justify-center rounded-full bg-black/30 text-white',
|
|
54
|
+
Icon === PlayIcon && 'group-hover:bg-black/40'
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<Icon className="size-6" weight="fill" />
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default LockBadge
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import classNames from 'classnames'
|
|
2
2
|
import React from 'react'
|
|
3
3
|
|
|
4
|
-
export type
|
|
4
|
+
export type CardVariant = 'dark' | 'light'
|
|
5
5
|
|
|
6
|
-
export interface
|
|
7
|
-
variant:
|
|
6
|
+
export interface MediaShellProps {
|
|
7
|
+
variant: CardVariant
|
|
8
8
|
children: React.ReactNode
|
|
9
9
|
rootRef?: React.Ref<HTMLDivElement>
|
|
10
10
|
'data-testid'?: string
|
|
@@ -12,9 +12,9 @@ export interface LockedCardShellProps {
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Outer chrome for every LockedAttachment card.
|
|
15
|
-
* Mirrors the
|
|
15
|
+
* Mirrors the 16px radius treatment from the Figma design system.
|
|
16
16
|
*/
|
|
17
|
-
const
|
|
17
|
+
const MediaShell: React.FC<MediaShellProps> = ({
|
|
18
18
|
variant,
|
|
19
19
|
children,
|
|
20
20
|
rootRef,
|
|
@@ -25,12 +25,12 @@ const LockedCardShell: React.FC<LockedCardShellProps> = ({
|
|
|
25
25
|
data-testid={dataTestId}
|
|
26
26
|
className={classNames(
|
|
27
27
|
'relative w-[280px] select-none overflow-hidden rounded-md',
|
|
28
|
-
'
|
|
29
|
-
|
|
28
|
+
variant === 'dark' ? 'bg-[#1e2330]' : 'bg-white',
|
|
29
|
+
'shadow-[0_1px_2px_rgba(0,0,0,0.08)]'
|
|
30
30
|
)}
|
|
31
31
|
>
|
|
32
32
|
{children}
|
|
33
33
|
</div>
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
-
export default
|
|
36
|
+
export default MediaShell
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
export interface PreviewToggleProps {
|
|
4
|
+
/** Toggles the preview; omit to render `children` with no wrapper at all (nothing to toggle). */
|
|
5
|
+
onToggle: (() => void | Promise<void>) | undefined
|
|
6
|
+
isBusy: boolean
|
|
7
|
+
isPreviewVisible: boolean
|
|
8
|
+
children: React.ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Makes a locked thumbnail clickable to toggle its preview. Uses a
|
|
13
|
+
* `role="button"` div rather than a native `<button>` because the thumbnail
|
|
14
|
+
* it wraps can itself contain interactive children (gallery carousel arrows,
|
|
15
|
+
* a Composer dismiss X) — nesting a real `<button>` around those would
|
|
16
|
+
* produce invalid, inaccessible nested-interactive-element markup.
|
|
17
|
+
*/
|
|
18
|
+
const PreviewToggle: React.FC<PreviewToggleProps> = ({
|
|
19
|
+
onToggle,
|
|
20
|
+
isBusy,
|
|
21
|
+
isPreviewVisible,
|
|
22
|
+
children,
|
|
23
|
+
}) => {
|
|
24
|
+
if (!onToggle) return <React.Fragment>{children}</React.Fragment>
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
role="button"
|
|
29
|
+
tabIndex={isBusy ? -1 : 0}
|
|
30
|
+
aria-label="Toggle preview"
|
|
31
|
+
aria-busy={isBusy}
|
|
32
|
+
aria-pressed={isPreviewVisible}
|
|
33
|
+
aria-disabled={isBusy || undefined}
|
|
34
|
+
onClick={isBusy ? undefined : onToggle}
|
|
35
|
+
onKeyDown={(e) => {
|
|
36
|
+
if (isBusy) return
|
|
37
|
+
// Only handle keys that originate on the wrapper itself, not on
|
|
38
|
+
// inner interactive elements (dismiss X, carousel arrows). Without
|
|
39
|
+
// this guard, pressing Enter/Space on a focused child would bubble
|
|
40
|
+
// up and toggle the preview in addition to activating the child.
|
|
41
|
+
if (e.target !== e.currentTarget) return
|
|
42
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
43
|
+
e.preventDefault()
|
|
44
|
+
void onToggle()
|
|
45
|
+
}
|
|
46
|
+
}}
|
|
47
|
+
className={
|
|
48
|
+
!isBusy
|
|
49
|
+
? 'block w-full cursor-pointer text-left'
|
|
50
|
+
: 'block w-full text-left'
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default PreviewToggle
|