@linktr.ee/messaging-react 1.26.1 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Creator-B6M8dB0U.js +87 -0
- package/dist/Creator-B6M8dB0U.js.map +1 -0
- package/dist/MediaPlayer-DsjlYGGH.js +539 -0
- package/dist/MediaPlayer-DsjlYGGH.js.map +1 -0
- package/dist/Preview-DqAv16NS.js +87 -0
- package/dist/Preview-DqAv16NS.js.map +1 -0
- package/dist/Visitor-CpmFZRGO.js +175 -0
- package/dist/Visitor-CpmFZRGO.js.map +1 -0
- package/dist/dash.all.min-Duv4lvGS.js +18858 -0
- package/dist/dash.all.min-Duv4lvGS.js.map +1 -0
- package/dist/hls-Bogc7CBn.js +21710 -0
- package/dist/hls-Bogc7CBn.js.map +1 -0
- package/dist/index-Da-xN4Yq.js +16142 -0
- package/dist/index-Da-xN4Yq.js.map +1 -0
- package/dist/index-Dj9rqWcU.js +69 -0
- package/dist/index-Dj9rqWcU.js.map +1 -0
- package/dist/index.d.ts +73 -10
- package/dist/index.js +979 -934
- package/dist/index.js.map +1 -1
- package/dist/mixin-B6jYfIcp.js +808 -0
- package/dist/mixin-B6jYfIcp.js.map +1 -0
- package/dist/react-BxlQMOfz.js +419 -0
- package/dist/react-BxlQMOfz.js.map +1 -0
- package/dist/react-COAP-MIW.js +377 -0
- package/dist/react-COAP-MIW.js.map +1 -0
- package/dist/react-Cn4WlMcl.js +3108 -0
- package/dist/react-Cn4WlMcl.js.map +1 -0
- package/dist/react-CwTJArKY.js +459 -0
- package/dist/react-CwTJArKY.js.map +1 -0
- package/dist/react-DkfS_atT.js +373 -0
- package/dist/react-DkfS_atT.js.map +1 -0
- package/dist/react-Pea5fum1.js +286 -0
- package/dist/react-Pea5fum1.js.map +1 -0
- package/dist/react-RiBbsUDd.js +534 -0
- package/dist/react-RiBbsUDd.js.map +1 -0
- package/dist/react-dS1WBxxz.js +238 -0
- package/dist/react-dS1WBxxz.js.map +1 -0
- package/package.json +2 -1
- package/src/components/ChannelView.tsx +12 -2
- package/src/components/CustomMessage/CustomMessage.stories.tsx +173 -41
- package/src/components/CustomMessage/MessageTag.tsx +5 -0
- package/src/components/CustomMessage/index.tsx +43 -4
- package/src/components/LockedAttachment/LockedAttachment.stories.tsx +249 -0
- package/src/components/LockedAttachment/components/Creator.tsx +171 -0
- package/src/components/LockedAttachment/components/MediaPlayer.tsx +299 -0
- package/src/components/LockedAttachment/components/Visitor.tsx +293 -0
- package/src/components/LockedAttachment/index.tsx +39 -0
- package/src/components/LockedAttachment/types.ts +18 -0
- package/src/components/LockedAttachment/utils/icons.ts +52 -0
- package/src/components/LockedAttachment/utils/mimeType.test.ts +97 -0
- package/src/components/LockedAttachment/utils/mimeType.ts +35 -0
- package/src/components/ParticipantPicker/index.tsx +8 -1
- package/src/hooks/useParticipants.ts +3 -2
- package/src/index.ts +4 -0
- package/src/stories/decorators/storyUser.tsx +37 -0
- package/src/stream-custom-data.ts +9 -3
- package/src/types.ts +21 -1
- package/src/utils/isDevBuild.ts +10 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CircleNotchIcon,
|
|
3
|
+
PauseIcon,
|
|
4
|
+
PlayIcon,
|
|
5
|
+
} from '@phosphor-icons/react'
|
|
6
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
7
|
+
import ReactPlayer from 'react-player'
|
|
8
|
+
|
|
9
|
+
import { isDevBuild } from '../../../utils/isDevBuild'
|
|
10
|
+
import { renderTypeIcon } from '../utils/icons'
|
|
11
|
+
import { getSourceType, type AttachmentSourceType } from '../utils/mimeType'
|
|
12
|
+
|
|
13
|
+
const getPlayerBg = (sourceType: AttachmentSourceType, poster?: string) =>
|
|
14
|
+
sourceType === 'audio' && !poster ? 'bg-black/5' : 'bg-black'
|
|
15
|
+
|
|
16
|
+
function getClientXFromEvent(
|
|
17
|
+
e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
|
|
18
|
+
): number {
|
|
19
|
+
if ('touches' in e) {
|
|
20
|
+
return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0
|
|
21
|
+
}
|
|
22
|
+
return e.clientX
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MediaPlayerProps {
|
|
26
|
+
source: string
|
|
27
|
+
mimeType: string
|
|
28
|
+
poster?: string
|
|
29
|
+
autoPlay?: boolean
|
|
30
|
+
loop?: boolean
|
|
31
|
+
controls?: boolean
|
|
32
|
+
showProgress?: boolean
|
|
33
|
+
onContainerClick?: () => void
|
|
34
|
+
/** When true, requests muted playback (helps autoplay policies on video). */
|
|
35
|
+
muted?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MediaPlayer: React.FC<MediaPlayerProps> = ({
|
|
39
|
+
source,
|
|
40
|
+
mimeType,
|
|
41
|
+
poster,
|
|
42
|
+
autoPlay = false,
|
|
43
|
+
loop = false,
|
|
44
|
+
controls = true,
|
|
45
|
+
showProgress = false,
|
|
46
|
+
onContainerClick,
|
|
47
|
+
muted = false,
|
|
48
|
+
}) => {
|
|
49
|
+
const sourceType = getSourceType(mimeType)
|
|
50
|
+
const [playing, setPlaying] = useState(autoPlay)
|
|
51
|
+
const [played, setPlayed] = useState(0)
|
|
52
|
+
const [seeking, setSeeking] = useState(false)
|
|
53
|
+
const [scrubberHovered, setScrubberHovered] = useState(false)
|
|
54
|
+
const [videoAspect, setVideoAspect] = useState<number | null>(null)
|
|
55
|
+
const [buffering, setBuffering] = useState(false)
|
|
56
|
+
/** Set when autoplay/play() was rejected so user can start via gesture (no controls UI). */
|
|
57
|
+
const [manualPlayRequired, setManualPlayRequired] = useState(false)
|
|
58
|
+
const playerRef = useRef<HTMLVideoElement>(null)
|
|
59
|
+
const trackRef = useRef<HTMLDivElement>(null)
|
|
60
|
+
const rafRef = useRef<number | null>(null)
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setManualPlayRequired(false)
|
|
64
|
+
}, [source])
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!playing) {
|
|
68
|
+
if (rafRef.current !== null) {
|
|
69
|
+
cancelAnimationFrame(rafRef.current)
|
|
70
|
+
rafRef.current = null
|
|
71
|
+
}
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const tick = () => {
|
|
75
|
+
const el = playerRef.current
|
|
76
|
+
if (el && el.duration && !seeking) setPlayed(el.currentTime / el.duration)
|
|
77
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
78
|
+
}
|
|
79
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
80
|
+
return () => {
|
|
81
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
|
82
|
+
}
|
|
83
|
+
}, [playing, seeking])
|
|
84
|
+
|
|
85
|
+
// ReactPlayer v3 uses native HTML media elements and does not support a
|
|
86
|
+
// declarative `playing` prop — playback must be driven imperatively.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const el = playerRef.current
|
|
89
|
+
if (!el) return
|
|
90
|
+
if (playing) {
|
|
91
|
+
void el.play().catch((err) => {
|
|
92
|
+
setPlaying(false)
|
|
93
|
+
setManualPlayRequired(true)
|
|
94
|
+
if (isDevBuild()) {
|
|
95
|
+
console.debug('[MediaPlayer] play() failed', err)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
} else {
|
|
99
|
+
el.pause()
|
|
100
|
+
}
|
|
101
|
+
}, [playing])
|
|
102
|
+
|
|
103
|
+
const startPlaybackFromGesture = useCallback(() => {
|
|
104
|
+
setManualPlayRequired(false)
|
|
105
|
+
setPlaying(true)
|
|
106
|
+
}, [])
|
|
107
|
+
|
|
108
|
+
const getFraction = useCallback(
|
|
109
|
+
(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => {
|
|
110
|
+
const track = trackRef.current
|
|
111
|
+
if (!track) return 0
|
|
112
|
+
const rect = track.getBoundingClientRect()
|
|
113
|
+
return Math.max(
|
|
114
|
+
0,
|
|
115
|
+
Math.min(1, (getClientXFromEvent(e) - rect.left) / rect.width)
|
|
116
|
+
)
|
|
117
|
+
},
|
|
118
|
+
[]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const seekTo = useCallback((fraction: number) => {
|
|
122
|
+
const el = playerRef.current
|
|
123
|
+
if (el && el.duration) el.currentTime = fraction * el.duration
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
const handleTrackPointerDown = (e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
|
|
127
|
+
e.stopPropagation()
|
|
128
|
+
setSeeking(true)
|
|
129
|
+
const fraction = getFraction(e)
|
|
130
|
+
setPlayed(fraction)
|
|
131
|
+
seekTo(fraction)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!seeking) return
|
|
136
|
+
const onMove = (e: MouseEvent | TouchEvent) => setPlayed(getFraction(e))
|
|
137
|
+
const onUp = (e: MouseEvent | TouchEvent) => {
|
|
138
|
+
setSeeking(false)
|
|
139
|
+
seekTo(getFraction(e))
|
|
140
|
+
}
|
|
141
|
+
window.addEventListener('mousemove', onMove)
|
|
142
|
+
window.addEventListener('mouseup', onUp)
|
|
143
|
+
window.addEventListener('touchmove', onMove, { passive: true })
|
|
144
|
+
window.addEventListener('touchend', onUp)
|
|
145
|
+
return () => {
|
|
146
|
+
window.removeEventListener('mousemove', onMove)
|
|
147
|
+
window.removeEventListener('mouseup', onUp)
|
|
148
|
+
window.removeEventListener('touchmove', onMove)
|
|
149
|
+
window.removeEventListener('touchend', onUp)
|
|
150
|
+
}
|
|
151
|
+
}, [seeking, getFraction, seekTo])
|
|
152
|
+
|
|
153
|
+
// Use natural aspect ratio once metadata loads, fall back to 16:9 before then.
|
|
154
|
+
const aspectStyle = videoAspect ? { aspectRatio: String(videoAspect) } : undefined
|
|
155
|
+
const aspectClass = !videoAspect ? ' aspect-video' : ''
|
|
156
|
+
const scrubberPercent = Math.round(played * 100)
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
role="button"
|
|
161
|
+
tabIndex={0}
|
|
162
|
+
className={`relative cursor-pointer overflow-hidden ${getPlayerBg(sourceType, poster)}${aspectClass}`}
|
|
163
|
+
style={aspectStyle}
|
|
164
|
+
onClick={() => {
|
|
165
|
+
if (manualPlayRequired) return
|
|
166
|
+
if (onContainerClick) { onContainerClick(); return }
|
|
167
|
+
if (controls) setPlaying((p) => !p)
|
|
168
|
+
}}
|
|
169
|
+
onKeyDown={(e) => {
|
|
170
|
+
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
171
|
+
e.preventDefault()
|
|
172
|
+
if (manualPlayRequired) return
|
|
173
|
+
if (onContainerClick) {
|
|
174
|
+
onContainerClick()
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (controls) setPlaying((p) => !p)
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
{poster && (
|
|
181
|
+
<img src={poster} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
|
182
|
+
)}
|
|
183
|
+
{!poster && (
|
|
184
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
185
|
+
{renderTypeIcon(mimeType, { className: 'size-12 text-black/20', weight: 'regular' })}
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
<div className="absolute inset-0">
|
|
189
|
+
<ReactPlayer
|
|
190
|
+
ref={playerRef}
|
|
191
|
+
src={source}
|
|
192
|
+
poster={poster}
|
|
193
|
+
loop={loop}
|
|
194
|
+
muted={muted}
|
|
195
|
+
playsInline
|
|
196
|
+
width="100%"
|
|
197
|
+
height="100%"
|
|
198
|
+
onLoadStart={() => setBuffering(true)}
|
|
199
|
+
onCanPlay={() => setBuffering(false)}
|
|
200
|
+
onWaiting={() => setBuffering(true)}
|
|
201
|
+
onPlay={() => setManualPlayRequired(false)}
|
|
202
|
+
onLoadedMetadata={() => {
|
|
203
|
+
const el = playerRef.current
|
|
204
|
+
if (el && el.videoWidth && el.videoHeight) {
|
|
205
|
+
setVideoAspect(el.videoWidth / el.videoHeight)
|
|
206
|
+
}
|
|
207
|
+
}}
|
|
208
|
+
onEnded={() => {
|
|
209
|
+
if (!loop) {
|
|
210
|
+
setPlaying(false)
|
|
211
|
+
setPlayed(0)
|
|
212
|
+
}
|
|
213
|
+
}}
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{buffering && !manualPlayRequired && (
|
|
218
|
+
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
|
219
|
+
<CircleNotchIcon className="size-8 animate-spin text-white/80" weight="bold" />
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
{manualPlayRequired && !controls && (
|
|
224
|
+
<div
|
|
225
|
+
className="absolute inset-0 z-30 flex cursor-pointer items-center justify-center bg-black/35"
|
|
226
|
+
role="button"
|
|
227
|
+
tabIndex={0}
|
|
228
|
+
aria-label="Play preview"
|
|
229
|
+
onClick={(e) => {
|
|
230
|
+
e.stopPropagation()
|
|
231
|
+
startPlaybackFromGesture()
|
|
232
|
+
}}
|
|
233
|
+
onKeyDown={(e) => {
|
|
234
|
+
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
235
|
+
e.preventDefault()
|
|
236
|
+
e.stopPropagation()
|
|
237
|
+
startPlaybackFromGesture()
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
<span className="flex size-16 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm">
|
|
241
|
+
<PlayIcon className="size-9 translate-x-0.5" weight="fill" />
|
|
242
|
+
</span>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{showProgress && !controls && (
|
|
247
|
+
<div className="absolute inset-x-0 bottom-0 px-3 pb-2.5 pt-6 bg-gradient-to-t from-black/40 to-transparent pointer-events-none">
|
|
248
|
+
<div className="h-1 w-full overflow-hidden rounded-full bg-white/30">
|
|
249
|
+
<div className="h-full rounded-full bg-white" style={{ width: `${scrubberPercent}%` }} />
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
|
|
254
|
+
{controls && <div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 transition-all duration-200">
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={(e) => { e.stopPropagation(); setPlaying((p) => !p) }}
|
|
258
|
+
className="shrink-0 text-white"
|
|
259
|
+
aria-label={playing ? 'Pause' : 'Play'}
|
|
260
|
+
>
|
|
261
|
+
{playing
|
|
262
|
+
? <PauseIcon className="size-5" weight="fill" />
|
|
263
|
+
: <PlayIcon className="size-5 translate-x-px" weight="fill" />
|
|
264
|
+
}
|
|
265
|
+
</button>
|
|
266
|
+
|
|
267
|
+
<div
|
|
268
|
+
role="slider"
|
|
269
|
+
aria-label="Playback position"
|
|
270
|
+
aria-valuenow={scrubberPercent}
|
|
271
|
+
aria-valuemin={0}
|
|
272
|
+
aria-valuemax={100}
|
|
273
|
+
tabIndex={0}
|
|
274
|
+
ref={trackRef}
|
|
275
|
+
className="relative flex h-4 w-full cursor-pointer items-center"
|
|
276
|
+
onMouseDown={handleTrackPointerDown}
|
|
277
|
+
onTouchStart={handleTrackPointerDown}
|
|
278
|
+
onClick={(e) => e.stopPropagation()}
|
|
279
|
+
onMouseEnter={() => setScrubberHovered(true)}
|
|
280
|
+
onMouseLeave={() => setScrubberHovered(false)}
|
|
281
|
+
onKeyDown={(e) => {
|
|
282
|
+
if (e.key === 'ArrowRight') seekTo(Math.min(1, played + 0.05))
|
|
283
|
+
if (e.key === 'ArrowLeft') seekTo(Math.max(0, played - 0.05))
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<div className={`w-full overflow-hidden rounded-full bg-white/30 transition-all duration-200 ${scrubberHovered || seeking ? 'h-1.5' : 'h-1'}`}>
|
|
287
|
+
<div className="h-full rounded-full bg-white" style={{ width: `${scrubberPercent}%` }} />
|
|
288
|
+
</div>
|
|
289
|
+
<div
|
|
290
|
+
className={`absolute size-3 -translate-x-1/2 rounded-full bg-white shadow transition-[opacity,transform] duration-200 ${scrubberHovered || seeking ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
|
291
|
+
style={{ left: `${scrubberPercent}%` }}
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
</div>}
|
|
295
|
+
</div>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export default MediaPlayer
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CheckCircleIcon,
|
|
3
|
+
DownloadSimpleIcon,
|
|
4
|
+
LockSimpleIcon,
|
|
5
|
+
LockSimpleOpenIcon,
|
|
6
|
+
} from '@phosphor-icons/react'
|
|
7
|
+
import React, { useEffect, useState } from 'react'
|
|
8
|
+
|
|
9
|
+
import { isDevBuild } from '../../../utils/isDevBuild'
|
|
10
|
+
import type { LockedAttachmentBaseProps, LockedAttachmentSource, PaymentStatus } from '../types'
|
|
11
|
+
import { renderTypeIcon } from '../utils/icons'
|
|
12
|
+
import { getSourceType } from '../utils/mimeType'
|
|
13
|
+
|
|
14
|
+
import MediaPlayer from './MediaPlayer'
|
|
15
|
+
|
|
16
|
+
export interface VisitorCardProps extends LockedAttachmentBaseProps {
|
|
17
|
+
title?: string
|
|
18
|
+
/**
|
|
19
|
+
* Called when the visitor clicks Unlock. Return the resolved source and optional poster.
|
|
20
|
+
* The component manages loading state and sets source/poster internally on resolution.
|
|
21
|
+
* Omit to hide the Unlock button.
|
|
22
|
+
*/
|
|
23
|
+
onUnlock?: () => Promise<LockedAttachmentSource>
|
|
24
|
+
/** Called when the visitor clicks Download on an unlocked card. */
|
|
25
|
+
onDownload?: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const getLockIcon = (paymentStatus?: PaymentStatus): React.ElementType => {
|
|
29
|
+
return paymentStatus === 'paid' ? LockSimpleOpenIcon : LockSimpleIcon
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ThumbnailOrIcon: React.FC<{ src?: string; mimeType: string }> = ({
|
|
33
|
+
src,
|
|
34
|
+
mimeType,
|
|
35
|
+
}) => {
|
|
36
|
+
if (src) {
|
|
37
|
+
return (
|
|
38
|
+
<img
|
|
39
|
+
src={src}
|
|
40
|
+
alt=""
|
|
41
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
48
|
+
{renderTypeIcon(mimeType, { className: 'size-12 text-black/20', weight: 'regular' })}
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const LockOverlay: React.FC<{ icon: React.ElementType }> = ({ icon: Icon }) => (
|
|
54
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
|
55
|
+
<div className="flex size-12 items-center justify-center rounded-full bg-black/60">
|
|
56
|
+
<Icon className="size-6 text-white" weight="regular" />
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
interface LockedPreviewProps {
|
|
62
|
+
thumbnail?: string
|
|
63
|
+
mimeType: string
|
|
64
|
+
LockIcon: React.ElementType
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const LockedPreview: React.FC<LockedPreviewProps> = ({
|
|
68
|
+
thumbnail,
|
|
69
|
+
mimeType,
|
|
70
|
+
LockIcon,
|
|
71
|
+
}) => (
|
|
72
|
+
<div className="relative aspect-video overflow-hidden bg-black/5">
|
|
73
|
+
<ThumbnailOrIcon src={thumbnail} mimeType={mimeType} />
|
|
74
|
+
<LockOverlay icon={LockIcon} />
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
interface CardActionsProps {
|
|
79
|
+
isLocked: boolean
|
|
80
|
+
loading: boolean
|
|
81
|
+
paymentStatus?: PaymentStatus
|
|
82
|
+
source?: string
|
|
83
|
+
LockIcon: React.ElementType
|
|
84
|
+
onUnlock?: () => void
|
|
85
|
+
onDownload?: () => void
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const CardActions: React.FC<CardActionsProps> = ({
|
|
89
|
+
isLocked,
|
|
90
|
+
loading,
|
|
91
|
+
paymentStatus,
|
|
92
|
+
source,
|
|
93
|
+
LockIcon,
|
|
94
|
+
onUnlock,
|
|
95
|
+
onDownload,
|
|
96
|
+
}) => {
|
|
97
|
+
if (isLocked && onUnlock) {
|
|
98
|
+
return (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={onUnlock}
|
|
102
|
+
disabled={loading}
|
|
103
|
+
className="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 disabled:opacity-70"
|
|
104
|
+
>
|
|
105
|
+
{loading ? (
|
|
106
|
+
<span className="flex items-center gap-1">
|
|
107
|
+
<span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.3s]" />
|
|
108
|
+
<span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.15s]" />
|
|
109
|
+
<span className="size-1 rounded-full bg-white animate-bounce" />
|
|
110
|
+
</span>
|
|
111
|
+
) : (
|
|
112
|
+
<>
|
|
113
|
+
<LockIcon className="size-4" weight="fill" />
|
|
114
|
+
{paymentStatus === 'paid' ? 'Open' : 'Unlock'}
|
|
115
|
+
</>
|
|
116
|
+
)}
|
|
117
|
+
</button>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
if (!isLocked && onDownload && source) {
|
|
121
|
+
return (
|
|
122
|
+
<a
|
|
123
|
+
href={source}
|
|
124
|
+
target="_blank"
|
|
125
|
+
rel="noopener noreferrer"
|
|
126
|
+
onClick={onDownload}
|
|
127
|
+
className="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"
|
|
128
|
+
>
|
|
129
|
+
<DownloadSimpleIcon className="size-4" weight="bold" />
|
|
130
|
+
Download
|
|
131
|
+
</a>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface VisitorCardMetaProps {
|
|
138
|
+
mimeType: string
|
|
139
|
+
detail?: string
|
|
140
|
+
paymentStatus?: PaymentStatus
|
|
141
|
+
amountText?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const VisitorCardMeta: React.FC<VisitorCardMetaProps> = ({
|
|
145
|
+
mimeType,
|
|
146
|
+
detail,
|
|
147
|
+
paymentStatus,
|
|
148
|
+
amountText,
|
|
149
|
+
}) => {
|
|
150
|
+
return (
|
|
151
|
+
<div className="flex items-center gap-1">
|
|
152
|
+
{renderTypeIcon(mimeType, { className: 'size-5 shrink-0 text-black/55', weight: 'regular' })}
|
|
153
|
+
{detail && (
|
|
154
|
+
<span className="text-xs font-medium text-black/55">{detail}</span>
|
|
155
|
+
)}
|
|
156
|
+
{paymentStatus === 'paid' ? (
|
|
157
|
+
<>
|
|
158
|
+
<span className="text-xs font-medium text-black/55">•</span>
|
|
159
|
+
<span className="text-xs font-medium text-[#008236]">Purchased</span>
|
|
160
|
+
<CheckCircleIcon className="size-4 text-[#008236]" weight="bold" />
|
|
161
|
+
</>
|
|
162
|
+
) : (
|
|
163
|
+
amountText && (
|
|
164
|
+
<>
|
|
165
|
+
<span className="text-xs font-medium text-black/55">•</span>
|
|
166
|
+
<span className="text-xs font-medium text-black/55">
|
|
167
|
+
{amountText}
|
|
168
|
+
</span>
|
|
169
|
+
</>
|
|
170
|
+
)
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const VisitorCard: React.FC<VisitorCardProps> = ({
|
|
177
|
+
title,
|
|
178
|
+
amountText,
|
|
179
|
+
thumbnail,
|
|
180
|
+
poster: posterProp,
|
|
181
|
+
source: sourceProp,
|
|
182
|
+
mimeType = 'application/octet-stream',
|
|
183
|
+
detail,
|
|
184
|
+
onUnlock,
|
|
185
|
+
onDownload,
|
|
186
|
+
paymentStatus,
|
|
187
|
+
}) => {
|
|
188
|
+
const [source, setSource] = useState(sourceProp)
|
|
189
|
+
const [poster, setPoster] = useState(posterProp)
|
|
190
|
+
const [loading, setLoading] = useState(false)
|
|
191
|
+
const [sourceReady, setSourceReady] = useState(false)
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (sourceProp !== undefined) setSource(sourceProp)
|
|
195
|
+
}, [sourceProp])
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (posterProp !== undefined) setPoster(posterProp)
|
|
199
|
+
}, [posterProp])
|
|
200
|
+
|
|
201
|
+
const isLocked = source === undefined
|
|
202
|
+
const LockIcon = getLockIcon(paymentStatus)
|
|
203
|
+
const sourceType = getSourceType(mimeType)
|
|
204
|
+
|
|
205
|
+
const handleUnlock = async () => {
|
|
206
|
+
if (!onUnlock) return
|
|
207
|
+
setLoading(true)
|
|
208
|
+
try {
|
|
209
|
+
const result = await onUnlock()
|
|
210
|
+
setSource(result.source)
|
|
211
|
+
if (result.poster) setPoster(result.poster)
|
|
212
|
+
} catch (err) {
|
|
213
|
+
// Avoid unhandled rejection from async onClick; host may still surface UI in onUnlock.
|
|
214
|
+
if (isDevBuild()) {
|
|
215
|
+
console.debug('[LockedAttachment] onUnlock failed', err)
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
setLoading(false)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let mediaPreview: React.ReactNode
|
|
223
|
+
if (sourceType === 'image') {
|
|
224
|
+
mediaPreview = isLocked ? (
|
|
225
|
+
<LockedPreview
|
|
226
|
+
thumbnail={thumbnail}
|
|
227
|
+
mimeType={mimeType}
|
|
228
|
+
LockIcon={LockIcon}
|
|
229
|
+
/>
|
|
230
|
+
) : (
|
|
231
|
+
<div className="relative overflow-hidden bg-black/5">
|
|
232
|
+
<img
|
|
233
|
+
src={source}
|
|
234
|
+
alt={title}
|
|
235
|
+
className={`block w-full transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}
|
|
236
|
+
onLoad={() => setSourceReady(true)}
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
)
|
|
240
|
+
} else if (sourceType === 'document') {
|
|
241
|
+
mediaPreview = (
|
|
242
|
+
<div className="relative aspect-video overflow-hidden bg-black/5">
|
|
243
|
+
<ThumbnailOrIcon
|
|
244
|
+
src={isLocked ? thumbnail : poster}
|
|
245
|
+
mimeType={mimeType}
|
|
246
|
+
/>
|
|
247
|
+
{isLocked && <LockOverlay icon={LockIcon} />}
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
} else {
|
|
251
|
+
mediaPreview = isLocked ? (
|
|
252
|
+
<LockedPreview
|
|
253
|
+
thumbnail={thumbnail}
|
|
254
|
+
mimeType={mimeType}
|
|
255
|
+
LockIcon={LockIcon}
|
|
256
|
+
/>
|
|
257
|
+
) : (
|
|
258
|
+
<MediaPlayer
|
|
259
|
+
source={source}
|
|
260
|
+
mimeType={mimeType}
|
|
261
|
+
poster={poster ?? thumbnail}
|
|
262
|
+
/>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div className="w-[280px] overflow-hidden rounded-3xl bg-white shadow-[0px_0px_0px_1px_rgba(0,0,0,0.04),0px_1px_2px_0px_rgba(0,0,0,0.04),0px_8px_32px_0px_rgba(0,0,0,0.1)]">
|
|
268
|
+
{mediaPreview}
|
|
269
|
+
<div className="px-4 pb-3 pt-3">
|
|
270
|
+
<p className="mb-1.5 truncate text-base font-medium text-black">
|
|
271
|
+
{title}
|
|
272
|
+
</p>
|
|
273
|
+
<VisitorCardMeta
|
|
274
|
+
mimeType={mimeType}
|
|
275
|
+
detail={detail}
|
|
276
|
+
paymentStatus={paymentStatus}
|
|
277
|
+
amountText={amountText}
|
|
278
|
+
/>
|
|
279
|
+
<CardActions
|
|
280
|
+
isLocked={isLocked}
|
|
281
|
+
loading={loading}
|
|
282
|
+
paymentStatus={paymentStatus}
|
|
283
|
+
source={source}
|
|
284
|
+
LockIcon={LockIcon}
|
|
285
|
+
onUnlock={onUnlock ? handleUnlock : undefined}
|
|
286
|
+
onDownload={onDownload}
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export default VisitorCard
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React, { Suspense } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { CreatorCardProps } from './components/Creator'
|
|
4
|
+
import type { VisitorCardProps } from './components/Visitor'
|
|
5
|
+
|
|
6
|
+
const CreatorCardLazy = React.lazy(() => import('./components/Creator'))
|
|
7
|
+
const VisitorCardLazy = React.lazy(() => import('./components/Visitor'))
|
|
8
|
+
|
|
9
|
+
const LockedAttachmentFallback = () => (
|
|
10
|
+
<div
|
|
11
|
+
className="w-[280px] min-h-[200px] animate-pulse rounded-3xl bg-black/[0.06] shadow-[0px_0px_0px_1px_rgba(0,0,0,0.04),0px_1px_2px_0px_rgba(0,0,0,0.04)]"
|
|
12
|
+
aria-hidden
|
|
13
|
+
/>
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
export type LockedAttachmentProps =
|
|
17
|
+
| ({ isCreator: true } & CreatorCardProps)
|
|
18
|
+
| ({ isCreator?: false } & VisitorCardProps)
|
|
19
|
+
|
|
20
|
+
const LockedAttachment = (props: LockedAttachmentProps) => {
|
|
21
|
+
if (props.isCreator) {
|
|
22
|
+
const { isCreator: _, ...rest } = props
|
|
23
|
+
return (
|
|
24
|
+
<Suspense fallback={<LockedAttachmentFallback />}>
|
|
25
|
+
<CreatorCardLazy {...rest} />
|
|
26
|
+
</Suspense>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
const { isCreator: _, ...rest } = props
|
|
30
|
+
return (
|
|
31
|
+
<Suspense fallback={<LockedAttachmentFallback />}>
|
|
32
|
+
<VisitorCardLazy {...rest} />
|
|
33
|
+
</Suspense>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default LockedAttachment
|
|
38
|
+
export type { CreatorCardProps, VisitorCardProps }
|
|
39
|
+
export type { PaymentStatus, LockedAttachmentSource } from './types'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PaymentStatus } from '../../stream-custom-data'
|
|
2
|
+
|
|
3
|
+
/** Shared fields for creator and visitor locked-attachment cards (internal). */
|
|
4
|
+
export interface LockedAttachmentBaseProps {
|
|
5
|
+
mimeType?: string
|
|
6
|
+
/** Blurred preview image shown in the locked/collapsed state. */
|
|
7
|
+
thumbnail?: string
|
|
8
|
+
/** Clean poster image passed to the media player. Falls back to thumbnail. */
|
|
9
|
+
poster?: string
|
|
10
|
+
/** Unlocked media URL. Undefined while locked or pending unlock. */
|
|
11
|
+
source?: string
|
|
12
|
+
detail?: string
|
|
13
|
+
amountText?: string
|
|
14
|
+
paymentStatus?: PaymentStatus
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type { PaymentStatus }
|
|
18
|
+
export type { LockedAttachmentSource } from '../../types'
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FileIcon,
|
|
3
|
+
FileCsvIcon,
|
|
4
|
+
FileDocIcon,
|
|
5
|
+
FileMdIcon,
|
|
6
|
+
FilePdfIcon,
|
|
7
|
+
FilePptIcon,
|
|
8
|
+
FileTextIcon,
|
|
9
|
+
FileXlsIcon,
|
|
10
|
+
FileZipIcon,
|
|
11
|
+
ImageIcon,
|
|
12
|
+
SpeakerHighIcon,
|
|
13
|
+
VideoCameraIcon,
|
|
14
|
+
} from '@phosphor-icons/react'
|
|
15
|
+
import React from 'react'
|
|
16
|
+
|
|
17
|
+
import { getDocumentIconType, getSourceType } from './mimeType'
|
|
18
|
+
import type { AttachmentSourceType } from './mimeType'
|
|
19
|
+
|
|
20
|
+
export const MEDIA_TYPE_ICON: Record<AttachmentSourceType, React.ElementType> =
|
|
21
|
+
{
|
|
22
|
+
video: VideoCameraIcon,
|
|
23
|
+
audio: SpeakerHighIcon,
|
|
24
|
+
image: ImageIcon,
|
|
25
|
+
document: FileIcon,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DOCUMENT_ICON_COMPONENT = {
|
|
29
|
+
pdf: FilePdfIcon,
|
|
30
|
+
doc: FileDocIcon,
|
|
31
|
+
xls: FileXlsIcon,
|
|
32
|
+
csv: FileCsvIcon,
|
|
33
|
+
ppt: FilePptIcon,
|
|
34
|
+
zip: FileZipIcon,
|
|
35
|
+
text: FileTextIcon,
|
|
36
|
+
markdown: FileMdIcon,
|
|
37
|
+
generic: FileIcon,
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
export function getTypeIcon(mimeType: string): React.ElementType {
|
|
41
|
+
const sourceType = getSourceType(mimeType)
|
|
42
|
+
if (sourceType !== 'document') return MEDIA_TYPE_ICON[sourceType]
|
|
43
|
+
return DOCUMENT_ICON_COMPONENT[getDocumentIconType(mimeType)]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Use instead of `<TypeIcon />` where TypeIcon = getTypeIcon(mime) to satisfy react-hooks/static-components. */
|
|
47
|
+
export function renderTypeIcon(
|
|
48
|
+
mimeType: string,
|
|
49
|
+
props: { className?: string; weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' }
|
|
50
|
+
): React.ReactElement {
|
|
51
|
+
return React.createElement(getTypeIcon(mimeType), props)
|
|
52
|
+
}
|