@linktr.ee/messaging-react 1.26.1 → 1.28.0-rc-1776225927
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-D38dWn2X.js +318 -0
- package/dist/Creator-D38dWn2X.js.map +1 -0
- package/dist/MediaPlayer-DE9MC6k6.js +599 -0
- package/dist/MediaPlayer-DE9MC6k6.js.map +1 -0
- package/dist/Preview-DqAv16NS.js +87 -0
- package/dist/Preview-DqAv16NS.js.map +1 -0
- package/dist/Visitor-BG-9-3HU.js +199 -0
- package/dist/Visitor-BG-9-3HU.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 +74 -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 +343 -0
- package/src/components/LockedAttachment/components/Creator.tsx +469 -0
- package/src/components/LockedAttachment/components/MediaPlayer.tsx +359 -0
- package/src/components/LockedAttachment/components/Visitor.tsx +356 -0
- package/src/components/LockedAttachment/index.tsx +39 -0
- package/src/components/LockedAttachment/types.ts +17 -0
- package/src/components/LockedAttachment/utils/icons.ts +53 -0
- package/src/components/LockedAttachment/utils/mimeType.test.ts +119 -0
- package/src/components/LockedAttachment/utils/mimeType.ts +37 -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 +20 -1
- package/src/utils/isDevBuild.ts +10 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { CircleNotchIcon, PauseIcon, PlayIcon } from '@phosphor-icons/react'
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
+
import ReactPlayer from 'react-player'
|
|
4
|
+
|
|
5
|
+
import { isDevBuild } from '../../../utils/isDevBuild'
|
|
6
|
+
import { renderTypeIcon } from '../utils/icons'
|
|
7
|
+
import { getSourceType, type AttachmentSourceType } from '../utils/mimeType'
|
|
8
|
+
|
|
9
|
+
const getPlayerBg = (sourceType: AttachmentSourceType, poster?: string) =>
|
|
10
|
+
sourceType === 'audio' && !poster ? 'bg-black/5' : 'bg-black'
|
|
11
|
+
|
|
12
|
+
function getClientXFromEvent(
|
|
13
|
+
e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
|
|
14
|
+
): number {
|
|
15
|
+
if ('touches' in e) {
|
|
16
|
+
return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0
|
|
17
|
+
}
|
|
18
|
+
return e.clientX
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MediaPlayerProps {
|
|
22
|
+
source: string
|
|
23
|
+
mimeType: string
|
|
24
|
+
poster?: string
|
|
25
|
+
autoPlay?: boolean
|
|
26
|
+
/** Controlled playing state. When provided, syncs to internal play/pause. */
|
|
27
|
+
playing?: boolean
|
|
28
|
+
loop?: boolean
|
|
29
|
+
controls?: boolean
|
|
30
|
+
showProgress?: boolean
|
|
31
|
+
onContainerClick?: () => void
|
|
32
|
+
/** When true, requests muted playback (helps autoplay policies on video). */
|
|
33
|
+
muted?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MediaPlayer: React.FC<MediaPlayerProps> = ({
|
|
37
|
+
source,
|
|
38
|
+
mimeType,
|
|
39
|
+
poster,
|
|
40
|
+
autoPlay = false,
|
|
41
|
+
playing: playingProp,
|
|
42
|
+
loop = false,
|
|
43
|
+
controls = true,
|
|
44
|
+
showProgress = false,
|
|
45
|
+
onContainerClick,
|
|
46
|
+
muted = false,
|
|
47
|
+
}) => {
|
|
48
|
+
const sourceType = getSourceType(mimeType)
|
|
49
|
+
const [playing, setPlaying] = useState(autoPlay)
|
|
50
|
+
|
|
51
|
+
// Sync controlled playing prop to internal state
|
|
52
|
+
const prevPlayingPropRef = useRef(playingProp)
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (
|
|
55
|
+
playingProp !== undefined &&
|
|
56
|
+
playingProp !== prevPlayingPropRef.current
|
|
57
|
+
) {
|
|
58
|
+
prevPlayingPropRef.current = playingProp
|
|
59
|
+
setPlaying(playingProp)
|
|
60
|
+
}
|
|
61
|
+
}, [playingProp])
|
|
62
|
+
const [played, setPlayed] = useState(0)
|
|
63
|
+
const [seeking, setSeeking] = useState(false)
|
|
64
|
+
const [scrubberHovered, setScrubberHovered] = useState(false)
|
|
65
|
+
const [videoAspect, setVideoAspect] = useState<number | null>(null)
|
|
66
|
+
const [buffering, setBuffering] = useState(false)
|
|
67
|
+
/** Set when autoplay/play() was rejected so user can start via gesture (no controls UI). */
|
|
68
|
+
const [manualPlayRequired, setManualPlayRequired] = useState(false)
|
|
69
|
+
const playerRef = useRef<HTMLVideoElement>(null)
|
|
70
|
+
const trackRef = useRef<HTMLDivElement>(null)
|
|
71
|
+
const rafRef = useRef<number | null>(null)
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
setManualPlayRequired(false)
|
|
75
|
+
}, [source])
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!playing) {
|
|
79
|
+
if (rafRef.current !== null) {
|
|
80
|
+
cancelAnimationFrame(rafRef.current)
|
|
81
|
+
rafRef.current = null
|
|
82
|
+
}
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
const tick = () => {
|
|
86
|
+
const el = playerRef.current
|
|
87
|
+
if (el && el.duration && !seeking) setPlayed(el.currentTime / el.duration)
|
|
88
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
89
|
+
}
|
|
90
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
91
|
+
return () => {
|
|
92
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
|
93
|
+
}
|
|
94
|
+
}, [playing, seeking])
|
|
95
|
+
|
|
96
|
+
// ReactPlayer v3 uses native HTML media elements and does not support a
|
|
97
|
+
// declarative `playing` prop — playback must be driven imperatively.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const el = playerRef.current
|
|
100
|
+
if (!el) return
|
|
101
|
+
if (playing) {
|
|
102
|
+
void el.play().catch((err) => {
|
|
103
|
+
setPlaying(false)
|
|
104
|
+
setManualPlayRequired(true)
|
|
105
|
+
if (isDevBuild()) {
|
|
106
|
+
console.debug('[MediaPlayer] play() failed', err)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
} else {
|
|
110
|
+
el.pause()
|
|
111
|
+
}
|
|
112
|
+
}, [playing])
|
|
113
|
+
|
|
114
|
+
const startPlaybackFromGesture = useCallback(() => {
|
|
115
|
+
setManualPlayRequired(false)
|
|
116
|
+
setPlaying(true)
|
|
117
|
+
}, [])
|
|
118
|
+
|
|
119
|
+
const getFraction = useCallback(
|
|
120
|
+
(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => {
|
|
121
|
+
const track = trackRef.current
|
|
122
|
+
if (!track) return 0
|
|
123
|
+
const rect = track.getBoundingClientRect()
|
|
124
|
+
return Math.max(
|
|
125
|
+
0,
|
|
126
|
+
Math.min(1, (getClientXFromEvent(e) - rect.left) / rect.width)
|
|
127
|
+
)
|
|
128
|
+
},
|
|
129
|
+
[]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const seekTo = useCallback((fraction: number) => {
|
|
133
|
+
const el = playerRef.current
|
|
134
|
+
if (el && el.duration) el.currentTime = fraction * el.duration
|
|
135
|
+
}, [])
|
|
136
|
+
|
|
137
|
+
const handleTrackPointerDown = (
|
|
138
|
+
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
|
|
139
|
+
) => {
|
|
140
|
+
e.stopPropagation()
|
|
141
|
+
setSeeking(true)
|
|
142
|
+
const fraction = getFraction(e)
|
|
143
|
+
setPlayed(fraction)
|
|
144
|
+
seekTo(fraction)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!seeking) return
|
|
149
|
+
const onMove = (e: MouseEvent | TouchEvent) => setPlayed(getFraction(e))
|
|
150
|
+
const onUp = (e: MouseEvent | TouchEvent) => {
|
|
151
|
+
setSeeking(false)
|
|
152
|
+
seekTo(getFraction(e))
|
|
153
|
+
}
|
|
154
|
+
window.addEventListener('mousemove', onMove)
|
|
155
|
+
window.addEventListener('mouseup', onUp)
|
|
156
|
+
window.addEventListener('touchmove', onMove, { passive: true })
|
|
157
|
+
window.addEventListener('touchend', onUp)
|
|
158
|
+
return () => {
|
|
159
|
+
window.removeEventListener('mousemove', onMove)
|
|
160
|
+
window.removeEventListener('mouseup', onUp)
|
|
161
|
+
window.removeEventListener('touchmove', onMove)
|
|
162
|
+
window.removeEventListener('touchend', onUp)
|
|
163
|
+
}
|
|
164
|
+
}, [seeking, getFraction, seekTo])
|
|
165
|
+
|
|
166
|
+
// Use natural aspect ratio once metadata loads, fall back to 16:9 before then.
|
|
167
|
+
const aspectStyle = videoAspect
|
|
168
|
+
? { aspectRatio: String(videoAspect) }
|
|
169
|
+
: undefined
|
|
170
|
+
const aspectClass = !videoAspect ? ' aspect-video' : ''
|
|
171
|
+
const scrubberPercent = Math.round(played * 100)
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div
|
|
175
|
+
role="button"
|
|
176
|
+
tabIndex={0}
|
|
177
|
+
className={`relative cursor-pointer overflow-hidden ${getPlayerBg(sourceType, poster)}${aspectClass}`}
|
|
178
|
+
style={aspectStyle}
|
|
179
|
+
onClick={() => {
|
|
180
|
+
if (manualPlayRequired) return
|
|
181
|
+
if (onContainerClick) {
|
|
182
|
+
onContainerClick()
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (controls) setPlaying((p) => !p)
|
|
186
|
+
}}
|
|
187
|
+
onKeyDown={(e) => {
|
|
188
|
+
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
189
|
+
e.preventDefault()
|
|
190
|
+
if (manualPlayRequired) return
|
|
191
|
+
if (onContainerClick) {
|
|
192
|
+
onContainerClick()
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
if (controls) setPlaying((p) => !p)
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
{poster && (
|
|
199
|
+
<img
|
|
200
|
+
src={poster}
|
|
201
|
+
alt=""
|
|
202
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
{!poster && (
|
|
206
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
207
|
+
{renderTypeIcon(mimeType, {
|
|
208
|
+
className: 'size-12 text-black/20',
|
|
209
|
+
weight: 'regular',
|
|
210
|
+
})}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
<div className="absolute inset-0">
|
|
214
|
+
<ReactPlayer
|
|
215
|
+
ref={playerRef}
|
|
216
|
+
src={source}
|
|
217
|
+
poster={poster}
|
|
218
|
+
loop={loop}
|
|
219
|
+
muted={muted}
|
|
220
|
+
playsInline
|
|
221
|
+
width="100%"
|
|
222
|
+
height="100%"
|
|
223
|
+
onLoadStart={() => setBuffering(true)}
|
|
224
|
+
onCanPlay={() => setBuffering(false)}
|
|
225
|
+
onWaiting={() => setBuffering(true)}
|
|
226
|
+
onPlay={() => setManualPlayRequired(false)}
|
|
227
|
+
onLoadedMetadata={() => {
|
|
228
|
+
const el = playerRef.current
|
|
229
|
+
if (el && el.videoWidth && el.videoHeight) {
|
|
230
|
+
setVideoAspect(el.videoWidth / el.videoHeight)
|
|
231
|
+
}
|
|
232
|
+
}}
|
|
233
|
+
onEnded={() => {
|
|
234
|
+
if (!loop) {
|
|
235
|
+
setPlaying(false)
|
|
236
|
+
setPlayed(0)
|
|
237
|
+
}
|
|
238
|
+
}}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{buffering && !manualPlayRequired && (
|
|
243
|
+
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
|
244
|
+
<CircleNotchIcon
|
|
245
|
+
className="size-8 animate-spin text-white/80"
|
|
246
|
+
weight="bold"
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{manualPlayRequired && !controls && (
|
|
252
|
+
<div
|
|
253
|
+
className="absolute inset-0 z-30 flex cursor-pointer items-center justify-center bg-black/35"
|
|
254
|
+
role="button"
|
|
255
|
+
tabIndex={0}
|
|
256
|
+
aria-label="Play preview"
|
|
257
|
+
onClick={(e) => {
|
|
258
|
+
e.stopPropagation()
|
|
259
|
+
startPlaybackFromGesture()
|
|
260
|
+
}}
|
|
261
|
+
onKeyDown={(e) => {
|
|
262
|
+
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
263
|
+
e.preventDefault()
|
|
264
|
+
e.stopPropagation()
|
|
265
|
+
startPlaybackFromGesture()
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
<span className="flex size-16 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm">
|
|
269
|
+
<PlayIcon className="size-9 translate-x-0.5" weight="fill" />
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
{showProgress && !controls && (
|
|
275
|
+
<div className="absolute inset-x-0 bottom-0 px-3 pb-2.5 pt-6 bg-gradient-to-t from-black/40 to-transparent">
|
|
276
|
+
<div
|
|
277
|
+
role="slider"
|
|
278
|
+
aria-label="Playback position"
|
|
279
|
+
aria-valuenow={scrubberPercent}
|
|
280
|
+
aria-valuemin={0}
|
|
281
|
+
aria-valuemax={100}
|
|
282
|
+
tabIndex={0}
|
|
283
|
+
ref={trackRef}
|
|
284
|
+
className="relative flex h-4 w-full cursor-pointer items-center"
|
|
285
|
+
onMouseDown={handleTrackPointerDown}
|
|
286
|
+
onTouchStart={handleTrackPointerDown}
|
|
287
|
+
onClick={(e) => e.stopPropagation()}
|
|
288
|
+
onKeyDown={(e) => {
|
|
289
|
+
if (e.key === 'ArrowRight') seekTo(Math.min(1, played + 0.05))
|
|
290
|
+
if (e.key === 'ArrowLeft') seekTo(Math.max(0, played - 0.05))
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
<div className="w-full overflow-hidden rounded-full bg-white/30 h-1">
|
|
294
|
+
<div
|
|
295
|
+
className="h-full rounded-full bg-white"
|
|
296
|
+
style={{ width: `${scrubberPercent}%` }}
|
|
297
|
+
/>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{controls && (
|
|
304
|
+
<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">
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
onClick={(e) => {
|
|
308
|
+
e.stopPropagation()
|
|
309
|
+
setPlaying((p) => !p)
|
|
310
|
+
}}
|
|
311
|
+
className="shrink-0 text-white"
|
|
312
|
+
aria-label={playing ? 'Pause' : 'Play'}
|
|
313
|
+
>
|
|
314
|
+
{playing ? (
|
|
315
|
+
<PauseIcon className="size-5" weight="fill" />
|
|
316
|
+
) : (
|
|
317
|
+
<PlayIcon className="size-5 translate-x-px" weight="fill" />
|
|
318
|
+
)}
|
|
319
|
+
</button>
|
|
320
|
+
|
|
321
|
+
<div
|
|
322
|
+
role="slider"
|
|
323
|
+
aria-label="Playback position"
|
|
324
|
+
aria-valuenow={scrubberPercent}
|
|
325
|
+
aria-valuemin={0}
|
|
326
|
+
aria-valuemax={100}
|
|
327
|
+
tabIndex={0}
|
|
328
|
+
ref={trackRef}
|
|
329
|
+
className="relative flex h-4 w-full cursor-pointer items-center"
|
|
330
|
+
onMouseDown={handleTrackPointerDown}
|
|
331
|
+
onTouchStart={handleTrackPointerDown}
|
|
332
|
+
onClick={(e) => e.stopPropagation()}
|
|
333
|
+
onMouseEnter={() => setScrubberHovered(true)}
|
|
334
|
+
onMouseLeave={() => setScrubberHovered(false)}
|
|
335
|
+
onKeyDown={(e) => {
|
|
336
|
+
if (e.key === 'ArrowRight') seekTo(Math.min(1, played + 0.05))
|
|
337
|
+
if (e.key === 'ArrowLeft') seekTo(Math.max(0, played - 0.05))
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
<div
|
|
341
|
+
className={`w-full overflow-hidden rounded-full bg-white/30 transition-all duration-200 ${scrubberHovered || seeking ? 'h-1.5' : 'h-1'}`}
|
|
342
|
+
>
|
|
343
|
+
<div
|
|
344
|
+
className="h-full rounded-full bg-white"
|
|
345
|
+
style={{ width: `${scrubberPercent}%` }}
|
|
346
|
+
/>
|
|
347
|
+
</div>
|
|
348
|
+
<div
|
|
349
|
+
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'}`}
|
|
350
|
+
style={{ left: `${scrubberPercent}%` }}
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
)}
|
|
355
|
+
</div>
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export default MediaPlayer
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CheckCircleIcon,
|
|
3
|
+
DownloadSimpleIcon,
|
|
4
|
+
LockOpenIcon,
|
|
5
|
+
LockSimpleIcon,
|
|
6
|
+
} from '@phosphor-icons/react'
|
|
7
|
+
import React, { useEffect, useState } from 'react'
|
|
8
|
+
|
|
9
|
+
import { isDevBuild } from '../../../utils/isDevBuild'
|
|
10
|
+
import type {
|
|
11
|
+
LockedAttachmentBaseProps,
|
|
12
|
+
LockedAttachmentSource,
|
|
13
|
+
PaymentStatus,
|
|
14
|
+
} from '../types'
|
|
15
|
+
import { renderTypeIcon } from '../utils/icons'
|
|
16
|
+
import { getSourceType } from '../utils/mimeType'
|
|
17
|
+
|
|
18
|
+
import MediaPlayer from './MediaPlayer'
|
|
19
|
+
|
|
20
|
+
export interface VisitorCardProps extends LockedAttachmentBaseProps {
|
|
21
|
+
title?: string
|
|
22
|
+
/**
|
|
23
|
+
* Called when the visitor clicks Unlock. Return the resolved source URL.
|
|
24
|
+
* The component manages loading state and sets source internally on resolution.
|
|
25
|
+
* Omit to hide the Unlock button.
|
|
26
|
+
*/
|
|
27
|
+
onUnlock?: () => Promise<LockedAttachmentSource>
|
|
28
|
+
/** Called when the visitor clicks Download on an unlocked card. */
|
|
29
|
+
onDownload?: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const getLockIcon = (paymentStatus?: PaymentStatus): React.ElementType =>
|
|
33
|
+
paymentStatus === 'paid' ? LockOpenIcon : LockSimpleIcon
|
|
34
|
+
|
|
35
|
+
// ─── Shared primitives ────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
interface LockOverlayProps {
|
|
38
|
+
icon: React.ElementType
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const LockOverlay: React.FC<LockOverlayProps> = (props) => {
|
|
42
|
+
const { icon: Icon } = props
|
|
43
|
+
return (
|
|
44
|
+
<div className="absolute inset-0 bg-black/30">
|
|
45
|
+
<div className="absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60">
|
|
46
|
+
<Icon className="size-4 text-white" weight="fill" />
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface LockedPreviewProps {
|
|
53
|
+
thumbnail?: string
|
|
54
|
+
mimeType: string
|
|
55
|
+
LockIcon: React.ElementType
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const LockedPreview: React.FC<LockedPreviewProps> = (props) => {
|
|
59
|
+
const { thumbnail, mimeType, LockIcon } = props
|
|
60
|
+
return (
|
|
61
|
+
<div className="relative aspect-video overflow-hidden bg-black/5">
|
|
62
|
+
{thumbnail ? (
|
|
63
|
+
<img
|
|
64
|
+
src={thumbnail}
|
|
65
|
+
alt=""
|
|
66
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
67
|
+
/>
|
|
68
|
+
) : (
|
|
69
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
70
|
+
{renderTypeIcon(mimeType, {
|
|
71
|
+
className: 'size-12 text-black/20',
|
|
72
|
+
weight: 'regular',
|
|
73
|
+
})}
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
<LockOverlay icon={LockIcon} />
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Per-type preview components ─────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
interface ImagePreviewProps {
|
|
84
|
+
source?: string
|
|
85
|
+
thumbnail?: string
|
|
86
|
+
mimeType: string
|
|
87
|
+
title?: string
|
|
88
|
+
paymentStatus?: PaymentStatus
|
|
89
|
+
isLocked: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const ImagePreview: React.FC<ImagePreviewProps> = (props) => {
|
|
93
|
+
const { source, thumbnail, mimeType, title, paymentStatus, isLocked } = props
|
|
94
|
+
const [sourceReady, setSourceReady] = useState(false)
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
setSourceReady(false)
|
|
98
|
+
}, [source])
|
|
99
|
+
|
|
100
|
+
if (isLocked) {
|
|
101
|
+
return (
|
|
102
|
+
<LockedPreview
|
|
103
|
+
thumbnail={thumbnail}
|
|
104
|
+
mimeType={mimeType}
|
|
105
|
+
LockIcon={getLockIcon(paymentStatus)}
|
|
106
|
+
/>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="relative overflow-hidden bg-black/5">
|
|
112
|
+
<img
|
|
113
|
+
src={source}
|
|
114
|
+
alt={title}
|
|
115
|
+
className={`block w-full transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}
|
|
116
|
+
onLoad={() => setSourceReady(true)}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface DocumentPreviewProps {
|
|
123
|
+
thumbnail?: string
|
|
124
|
+
mimeType: string
|
|
125
|
+
paymentStatus?: PaymentStatus
|
|
126
|
+
isLocked: boolean
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const DocumentPreview: React.FC<DocumentPreviewProps> = (props) => {
|
|
130
|
+
const { thumbnail, mimeType, paymentStatus, isLocked } = props
|
|
131
|
+
return (
|
|
132
|
+
<div className="relative aspect-video overflow-hidden bg-black/5">
|
|
133
|
+
{thumbnail ? (
|
|
134
|
+
<img
|
|
135
|
+
src={thumbnail}
|
|
136
|
+
alt=""
|
|
137
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
138
|
+
/>
|
|
139
|
+
) : (
|
|
140
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
141
|
+
{renderTypeIcon(mimeType, {
|
|
142
|
+
className: 'size-12 text-black/20',
|
|
143
|
+
weight: 'regular',
|
|
144
|
+
})}
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
{isLocked && <LockOverlay icon={getLockIcon(paymentStatus)} />}
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface MediaPreviewProps {
|
|
153
|
+
source?: string
|
|
154
|
+
thumbnail?: string
|
|
155
|
+
mimeType: string
|
|
156
|
+
paymentStatus?: PaymentStatus
|
|
157
|
+
isLocked: boolean
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const MediaPreview: React.FC<MediaPreviewProps> = (props) => {
|
|
161
|
+
const { source, thumbnail, mimeType, paymentStatus, isLocked } = props
|
|
162
|
+
if (isLocked) {
|
|
163
|
+
return (
|
|
164
|
+
<LockedPreview
|
|
165
|
+
thumbnail={thumbnail}
|
|
166
|
+
mimeType={mimeType}
|
|
167
|
+
LockIcon={getLockIcon(paymentStatus)}
|
|
168
|
+
/>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
return <MediaPlayer source={source!} mimeType={mimeType} poster={thumbnail} />
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Actions ─────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
interface CardActionsProps {
|
|
177
|
+
isLocked: boolean
|
|
178
|
+
loading: boolean
|
|
179
|
+
paymentStatus?: PaymentStatus
|
|
180
|
+
source?: string
|
|
181
|
+
onUnlock?: () => void
|
|
182
|
+
onDownload?: () => void
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const CardActions: React.FC<CardActionsProps> = (props) => {
|
|
186
|
+
const { isLocked, loading, paymentStatus, source, onUnlock, onDownload } =
|
|
187
|
+
props
|
|
188
|
+
const LockIcon = getLockIcon(paymentStatus)
|
|
189
|
+
|
|
190
|
+
if (isLocked && onUnlock) {
|
|
191
|
+
return (
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={onUnlock}
|
|
195
|
+
disabled={loading}
|
|
196
|
+
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"
|
|
197
|
+
>
|
|
198
|
+
{loading ? (
|
|
199
|
+
<span className="flex items-center gap-1">
|
|
200
|
+
<span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.3s]" />
|
|
201
|
+
<span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.15s]" />
|
|
202
|
+
<span className="size-1 rounded-full bg-white animate-bounce" />
|
|
203
|
+
</span>
|
|
204
|
+
) : (
|
|
205
|
+
<>
|
|
206
|
+
{paymentStatus === 'paid' ? (
|
|
207
|
+
<LockOpenIcon className="size-4" weight="fill" />
|
|
208
|
+
) : (
|
|
209
|
+
<LockIcon className="size-4" weight="fill" />
|
|
210
|
+
)}
|
|
211
|
+
{paymentStatus === 'paid' ? 'Open' : 'Unlock'}
|
|
212
|
+
</>
|
|
213
|
+
)}
|
|
214
|
+
</button>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!isLocked && onDownload && source) {
|
|
219
|
+
return (
|
|
220
|
+
<a
|
|
221
|
+
href={source}
|
|
222
|
+
target="_blank"
|
|
223
|
+
rel="noopener noreferrer"
|
|
224
|
+
onClick={onDownload}
|
|
225
|
+
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"
|
|
226
|
+
>
|
|
227
|
+
<DownloadSimpleIcon className="size-4" weight="bold" />
|
|
228
|
+
Download
|
|
229
|
+
</a>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Card shell ───────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
const VisitorCard: React.FC<VisitorCardProps> = (props) => {
|
|
239
|
+
const {
|
|
240
|
+
title,
|
|
241
|
+
amountText,
|
|
242
|
+
thumbnail,
|
|
243
|
+
source: sourceProp,
|
|
244
|
+
mimeType = 'application/octet-stream',
|
|
245
|
+
detail,
|
|
246
|
+
onUnlock,
|
|
247
|
+
onDownload,
|
|
248
|
+
paymentStatus,
|
|
249
|
+
} = props
|
|
250
|
+
const [source, setSource] = useState(sourceProp)
|
|
251
|
+
const [loading, setLoading] = useState(false)
|
|
252
|
+
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (sourceProp !== undefined) setSource(sourceProp)
|
|
255
|
+
}, [sourceProp])
|
|
256
|
+
|
|
257
|
+
const isLocked = source === undefined
|
|
258
|
+
const sourceType = getSourceType(mimeType)
|
|
259
|
+
|
|
260
|
+
const handleUnlock = async () => {
|
|
261
|
+
if (!onUnlock) return
|
|
262
|
+
setLoading(true)
|
|
263
|
+
try {
|
|
264
|
+
const result = await onUnlock()
|
|
265
|
+
setSource(result.source)
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (isDevBuild()) console.debug('[LockedAttachment] onUnlock failed', err)
|
|
268
|
+
} finally {
|
|
269
|
+
setLoading(false)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let mediaPreview: React.ReactNode
|
|
274
|
+
if (sourceType === 'image') {
|
|
275
|
+
mediaPreview = (
|
|
276
|
+
<ImagePreview
|
|
277
|
+
source={source}
|
|
278
|
+
thumbnail={thumbnail}
|
|
279
|
+
mimeType={mimeType}
|
|
280
|
+
title={title}
|
|
281
|
+
paymentStatus={paymentStatus}
|
|
282
|
+
isLocked={isLocked}
|
|
283
|
+
/>
|
|
284
|
+
)
|
|
285
|
+
} else if (sourceType === 'document') {
|
|
286
|
+
mediaPreview = (
|
|
287
|
+
<DocumentPreview
|
|
288
|
+
thumbnail={thumbnail}
|
|
289
|
+
mimeType={mimeType}
|
|
290
|
+
paymentStatus={paymentStatus}
|
|
291
|
+
isLocked={isLocked}
|
|
292
|
+
/>
|
|
293
|
+
)
|
|
294
|
+
} else {
|
|
295
|
+
mediaPreview = (
|
|
296
|
+
<MediaPreview
|
|
297
|
+
source={source}
|
|
298
|
+
thumbnail={thumbnail}
|
|
299
|
+
mimeType={mimeType}
|
|
300
|
+
paymentStatus={paymentStatus}
|
|
301
|
+
isLocked={isLocked}
|
|
302
|
+
/>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<div className="w-[280px] select-none overflow-hidden rounded-3xl bg-white shadow-card">
|
|
308
|
+
{mediaPreview}
|
|
309
|
+
<div className="px-4 pb-3 pt-3">
|
|
310
|
+
<p className="mb-1.5 truncate text-base font-medium text-black">
|
|
311
|
+
{title}
|
|
312
|
+
</p>
|
|
313
|
+
<div className="flex items-center gap-1">
|
|
314
|
+
{renderTypeIcon(mimeType, {
|
|
315
|
+
className: 'size-5 shrink-0 text-black/55',
|
|
316
|
+
weight: 'regular',
|
|
317
|
+
})}
|
|
318
|
+
{detail && (
|
|
319
|
+
<span className="text-xs font-medium text-black/55">{detail}</span>
|
|
320
|
+
)}
|
|
321
|
+
{paymentStatus === 'paid' ? (
|
|
322
|
+
<>
|
|
323
|
+
<span className="text-xs font-medium text-black/55">•</span>
|
|
324
|
+
<span className="text-xs font-medium text-[#008236]">
|
|
325
|
+
Purchased
|
|
326
|
+
</span>
|
|
327
|
+
<CheckCircleIcon
|
|
328
|
+
className="size-4 text-[#008236]"
|
|
329
|
+
weight="bold"
|
|
330
|
+
/>
|
|
331
|
+
</>
|
|
332
|
+
) : (
|
|
333
|
+
amountText && (
|
|
334
|
+
<>
|
|
335
|
+
<span className="text-xs font-medium text-black/55">•</span>
|
|
336
|
+
<span className="text-xs font-medium text-black/55">
|
|
337
|
+
{amountText}
|
|
338
|
+
</span>
|
|
339
|
+
</>
|
|
340
|
+
)
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
<CardActions
|
|
344
|
+
isLocked={isLocked}
|
|
345
|
+
loading={loading}
|
|
346
|
+
paymentStatus={paymentStatus}
|
|
347
|
+
source={source}
|
|
348
|
+
onUnlock={onUnlock ? handleUnlock : undefined}
|
|
349
|
+
onDownload={onDownload}
|
|
350
|
+
/>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export default VisitorCard
|