@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.
Files changed (58) hide show
  1. package/dist/Creator-B6M8dB0U.js +87 -0
  2. package/dist/Creator-B6M8dB0U.js.map +1 -0
  3. package/dist/MediaPlayer-DsjlYGGH.js +539 -0
  4. package/dist/MediaPlayer-DsjlYGGH.js.map +1 -0
  5. package/dist/Preview-DqAv16NS.js +87 -0
  6. package/dist/Preview-DqAv16NS.js.map +1 -0
  7. package/dist/Visitor-CpmFZRGO.js +175 -0
  8. package/dist/Visitor-CpmFZRGO.js.map +1 -0
  9. package/dist/dash.all.min-Duv4lvGS.js +18858 -0
  10. package/dist/dash.all.min-Duv4lvGS.js.map +1 -0
  11. package/dist/hls-Bogc7CBn.js +21710 -0
  12. package/dist/hls-Bogc7CBn.js.map +1 -0
  13. package/dist/index-Da-xN4Yq.js +16142 -0
  14. package/dist/index-Da-xN4Yq.js.map +1 -0
  15. package/dist/index-Dj9rqWcU.js +69 -0
  16. package/dist/index-Dj9rqWcU.js.map +1 -0
  17. package/dist/index.d.ts +73 -10
  18. package/dist/index.js +979 -934
  19. package/dist/index.js.map +1 -1
  20. package/dist/mixin-B6jYfIcp.js +808 -0
  21. package/dist/mixin-B6jYfIcp.js.map +1 -0
  22. package/dist/react-BxlQMOfz.js +419 -0
  23. package/dist/react-BxlQMOfz.js.map +1 -0
  24. package/dist/react-COAP-MIW.js +377 -0
  25. package/dist/react-COAP-MIW.js.map +1 -0
  26. package/dist/react-Cn4WlMcl.js +3108 -0
  27. package/dist/react-Cn4WlMcl.js.map +1 -0
  28. package/dist/react-CwTJArKY.js +459 -0
  29. package/dist/react-CwTJArKY.js.map +1 -0
  30. package/dist/react-DkfS_atT.js +373 -0
  31. package/dist/react-DkfS_atT.js.map +1 -0
  32. package/dist/react-Pea5fum1.js +286 -0
  33. package/dist/react-Pea5fum1.js.map +1 -0
  34. package/dist/react-RiBbsUDd.js +534 -0
  35. package/dist/react-RiBbsUDd.js.map +1 -0
  36. package/dist/react-dS1WBxxz.js +238 -0
  37. package/dist/react-dS1WBxxz.js.map +1 -0
  38. package/package.json +2 -1
  39. package/src/components/ChannelView.tsx +12 -2
  40. package/src/components/CustomMessage/CustomMessage.stories.tsx +173 -41
  41. package/src/components/CustomMessage/MessageTag.tsx +5 -0
  42. package/src/components/CustomMessage/index.tsx +43 -4
  43. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +249 -0
  44. package/src/components/LockedAttachment/components/Creator.tsx +171 -0
  45. package/src/components/LockedAttachment/components/MediaPlayer.tsx +299 -0
  46. package/src/components/LockedAttachment/components/Visitor.tsx +293 -0
  47. package/src/components/LockedAttachment/index.tsx +39 -0
  48. package/src/components/LockedAttachment/types.ts +18 -0
  49. package/src/components/LockedAttachment/utils/icons.ts +52 -0
  50. package/src/components/LockedAttachment/utils/mimeType.test.ts +97 -0
  51. package/src/components/LockedAttachment/utils/mimeType.ts +35 -0
  52. package/src/components/ParticipantPicker/index.tsx +8 -1
  53. package/src/hooks/useParticipants.ts +3 -2
  54. package/src/index.ts +4 -0
  55. package/src/stories/decorators/storyUser.tsx +37 -0
  56. package/src/stream-custom-data.ts +9 -3
  57. package/src/types.ts +21 -1
  58. 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
+ }