@linktr.ee/messaging-react 1.32.1 → 1.33.0-rc-1777504230
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-ClE_iExA.js → Card-BsqYzZt1.js} +55 -55
- package/dist/Card-BsqYzZt1.js.map +1 -0
- package/dist/{Card-1CQEn-OT.js → Card-Cnn9V-W7.js} +44 -44
- package/dist/Card-Cnn9V-W7.js.map +1 -0
- package/dist/assets/index.css +1 -1
- package/dist/index-BMfupE8K.js +3130 -0
- package/dist/index-BMfupE8K.js.map +1 -0
- package/dist/index.d.ts +19 -1
- package/dist/index.js +20 -2477
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ChannelInfoDialog/index.tsx +3 -1
- package/src/components/ChannelView.stories.tsx +38 -0
- package/src/components/ChannelView.test.tsx +25 -6
- package/src/components/ChannelView.tsx +26 -6
- package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +180 -0
- package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +63 -1
- package/src/components/CustomMessageInput/index.tsx +24 -5
- package/src/components/LockedAttachment/components/Creator/Card.tsx +11 -11
- package/src/components/LockedAttachment/components/MediaPlayer.tsx +10 -1
- package/src/components/LockedAttachment/components/Visitor/Card.tsx +9 -9
- package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +2 -2
- package/src/components/MediaMessage/MediaMessage.stories.tsx +233 -0
- package/src/components/MediaMessage/MediaMessage.test.tsx +520 -0
- package/src/components/MediaMessage/index.tsx +476 -0
- package/src/components/MessagingShell/index.tsx +2 -0
- package/src/index.ts +2 -0
- package/src/styles.css +49 -0
- package/src/types.ts +13 -0
- package/dist/Card-1CQEn-OT.js.map +0 -1
- package/dist/Card-ClE_iExA.js.map +0 -1
- package/dist/MediaPlayer-B9Ws2NeE.js +0 -292
- package/dist/MediaPlayer-B9Ws2NeE.js.map +0 -1
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import { CircleNotchIcon, DownloadSimpleIcon, LinkIcon, XIcon } from '@phosphor-icons/react'
|
|
2
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import type { Attachment, LocalMessage } from 'stream-chat'
|
|
4
|
+
|
|
5
|
+
import { Avatar } from '../Avatar'
|
|
6
|
+
import MediaPlayer from '../LockedAttachment/components/MediaPlayer'
|
|
7
|
+
import { renderTypeIcon } from '../LockedAttachment/utils/icons'
|
|
8
|
+
import { getSourceType } from '../LockedAttachment/utils/mimeType'
|
|
9
|
+
|
|
10
|
+
function formatBytes(bytes: number): string {
|
|
11
|
+
if (bytes < 1024) return `${bytes} B`
|
|
12
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
13
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function cardClass(isMyMessage: boolean) {
|
|
17
|
+
const bg = isMyMessage ? 'bg-[#121110]' : 'bg-[#F3F3F1]'
|
|
18
|
+
return `w-[280px] select-none overflow-hidden rounded-[24px] ${bg} shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_4px_8px_rgba(0,0,0,0.06)]`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function primaryText(isMyMessage: boolean) {
|
|
22
|
+
return isMyMessage ? 'text-white' : 'text-black'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function secondaryText(isMyMessage: boolean) {
|
|
26
|
+
return isMyMessage ? 'text-white/55' : 'text-black/55'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tertiaryText(isMyMessage: boolean) {
|
|
30
|
+
return isMyMessage ? 'text-white/40' : 'text-black/40'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function thumbnailBg(isMyMessage: boolean) {
|
|
34
|
+
return isMyMessage ? 'bg-white/10' : 'bg-black/5'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function iconColor(isMyMessage: boolean) {
|
|
38
|
+
return isMyMessage ? 'text-white/20' : 'text-black/20'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buttonBg(isMyMessage: boolean) {
|
|
42
|
+
return isMyMessage ? 'bg-white/10 hover:bg-white/15' : 'bg-black/[0.06] hover:bg-black/10'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Download button (shared across all media types)
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
async function triggerDownload(url: string, filename?: string): Promise<void> {
|
|
50
|
+
let name: string
|
|
51
|
+
try { name = filename ?? new URL(url).pathname.split('/').pop() ?? 'download' }
|
|
52
|
+
catch { name = filename ?? 'download' }
|
|
53
|
+
const res = await fetch(url, { mode: 'cors' })
|
|
54
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
55
|
+
const blob = await res.blob()
|
|
56
|
+
const objectUrl = URL.createObjectURL(blob)
|
|
57
|
+
const a = document.createElement('a')
|
|
58
|
+
a.href = objectUrl
|
|
59
|
+
a.download = name
|
|
60
|
+
a.style.display = 'none'
|
|
61
|
+
document.body.appendChild(a)
|
|
62
|
+
a.click()
|
|
63
|
+
document.body.removeChild(a)
|
|
64
|
+
URL.revokeObjectURL(objectUrl)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DownloadButton: React.FC<{ url: string; filename?: string; isMyMessage: boolean }> = ({
|
|
68
|
+
url,
|
|
69
|
+
filename,
|
|
70
|
+
isMyMessage,
|
|
71
|
+
}) => {
|
|
72
|
+
const [busy, setBusy] = useState(false)
|
|
73
|
+
|
|
74
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
75
|
+
e.stopPropagation()
|
|
76
|
+
// Open synchronously so the browser treats it as a user gesture (prevents popup blocking)
|
|
77
|
+
const fallback = window.open('', '_blank', 'noopener,noreferrer')
|
|
78
|
+
setBusy(true)
|
|
79
|
+
triggerDownload(url, filename)
|
|
80
|
+
.then(() => { fallback?.close() })
|
|
81
|
+
.catch(() => { if (fallback) fallback.location.href = url })
|
|
82
|
+
.finally(() => setBusy(false))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={handleClick}
|
|
89
|
+
disabled={busy}
|
|
90
|
+
className={`flex size-8 items-center justify-center rounded-full ${buttonBg(isMyMessage)} disabled:opacity-50`}
|
|
91
|
+
aria-label="Download"
|
|
92
|
+
>
|
|
93
|
+
{busy ? (
|
|
94
|
+
<CircleNotchIcon className={`size-4 animate-spin ${secondaryText(isMyMessage)}`} weight="bold" />
|
|
95
|
+
) : (
|
|
96
|
+
<DownloadSimpleIcon className={`size-4 ${secondaryText(isMyMessage)}`} weight="bold" />
|
|
97
|
+
)}
|
|
98
|
+
</button>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Chin: title + file size + download
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
const MediaMeta: React.FC<{
|
|
107
|
+
mimeType: string
|
|
108
|
+
title?: string
|
|
109
|
+
fileSize?: number
|
|
110
|
+
url: string
|
|
111
|
+
isMyMessage: boolean
|
|
112
|
+
}> = ({ mimeType, title, fileSize, url, isMyMessage }) => (
|
|
113
|
+
<div className="flex items-start gap-2 px-4 pb-3 pt-3">
|
|
114
|
+
<div className="min-w-0 flex-1">
|
|
115
|
+
{title && (
|
|
116
|
+
<p className={`mb-1.5 truncate text-base font-medium ${primaryText(isMyMessage)}`}>
|
|
117
|
+
{title}
|
|
118
|
+
</p>
|
|
119
|
+
)}
|
|
120
|
+
{fileSize !== undefined && (
|
|
121
|
+
<div className="flex items-center gap-1">
|
|
122
|
+
{renderTypeIcon(mimeType, {
|
|
123
|
+
className: `size-5 shrink-0 ${secondaryText(isMyMessage)}`,
|
|
124
|
+
weight: 'regular',
|
|
125
|
+
})}
|
|
126
|
+
<span className={`text-xs font-medium ${secondaryText(isMyMessage)}`}>
|
|
127
|
+
{formatBytes(fileSize)}
|
|
128
|
+
</span>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
<div className="flex shrink-0 items-center gap-1 pt-0.5">
|
|
133
|
+
<DownloadButton url={url} filename={title} isMyMessage={isMyMessage} />
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Document card: PDF (click → viewer) or unknown (link only)
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const FileCard: React.FC<{
|
|
143
|
+
url: string
|
|
144
|
+
mimeType: string
|
|
145
|
+
title?: string
|
|
146
|
+
fileSize?: number
|
|
147
|
+
isMyMessage: boolean
|
|
148
|
+
onExpand?: () => void
|
|
149
|
+
}> = ({ url, mimeType, title, fileSize, isMyMessage, onExpand }) => {
|
|
150
|
+
const thumbnailContent = (
|
|
151
|
+
<div
|
|
152
|
+
className={`aspect-video w-full ${thumbnailBg(isMyMessage)} flex items-center justify-center`}
|
|
153
|
+
>
|
|
154
|
+
{renderTypeIcon(mimeType, {
|
|
155
|
+
className: `size-12 ${iconColor(isMyMessage)}`,
|
|
156
|
+
weight: 'regular',
|
|
157
|
+
})}
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div>
|
|
163
|
+
{onExpand ? (
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
onClick={onExpand}
|
|
167
|
+
className="block w-full"
|
|
168
|
+
aria-label="Open PDF viewer"
|
|
169
|
+
>
|
|
170
|
+
{thumbnailContent}
|
|
171
|
+
</button>
|
|
172
|
+
) : (
|
|
173
|
+
<a href={url} target="_blank" rel="noopener noreferrer" className="block no-underline">
|
|
174
|
+
{thumbnailContent}
|
|
175
|
+
</a>
|
|
176
|
+
)}
|
|
177
|
+
<MediaMeta
|
|
178
|
+
mimeType={mimeType}
|
|
179
|
+
title={title}
|
|
180
|
+
fileSize={fileSize}
|
|
181
|
+
url={url}
|
|
182
|
+
isMyMessage={isMyMessage}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Link preview card (no download / no viewer)
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
const LinkCard: React.FC<{
|
|
193
|
+
attachment: Attachment
|
|
194
|
+
isMyMessage: boolean
|
|
195
|
+
}> = ({ attachment, isMyMessage }) => {
|
|
196
|
+
const { title, text, image_url, og_scrape_url, title_link } = attachment
|
|
197
|
+
const url = og_scrape_url ?? title_link
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<a href={url} target="_blank" rel="noopener noreferrer" className="block no-underline">
|
|
201
|
+
<div className="p-2">
|
|
202
|
+
{image_url ? (
|
|
203
|
+
<img
|
|
204
|
+
src={image_url}
|
|
205
|
+
alt={title ?? ''}
|
|
206
|
+
className="aspect-video w-full rounded-[20px] object-cover"
|
|
207
|
+
/>
|
|
208
|
+
) : (
|
|
209
|
+
<div
|
|
210
|
+
className={`aspect-video w-full rounded-[20px] ${thumbnailBg(isMyMessage)} flex items-center justify-center`}
|
|
211
|
+
>
|
|
212
|
+
<LinkIcon className={`size-12 ${iconColor(isMyMessage)}`} />
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
<div className="px-3 pb-3">
|
|
217
|
+
{title && (
|
|
218
|
+
<p className={`truncate text-[14px] font-medium leading-5 ${primaryText(isMyMessage)}`}>
|
|
219
|
+
{title}
|
|
220
|
+
</p>
|
|
221
|
+
)}
|
|
222
|
+
{text && (
|
|
223
|
+
<p className={`truncate text-[12px] leading-4 ${secondaryText(isMyMessage)}`}>{text}</p>
|
|
224
|
+
)}
|
|
225
|
+
{url && (
|
|
226
|
+
<p className={`mt-1 truncate text-[12px] leading-4 ${tertiaryText(isMyMessage)}`}>
|
|
227
|
+
{url}
|
|
228
|
+
</p>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
</a>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Full-screen media viewer (image, video, PDF)
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
const MediaViewer: React.FC<{
|
|
240
|
+
sourceType: string
|
|
241
|
+
url: string
|
|
242
|
+
mimeType: string
|
|
243
|
+
title?: string
|
|
244
|
+
poster?: string
|
|
245
|
+
onClose: () => void
|
|
246
|
+
}> = ({ sourceType, url, mimeType, title, poster, onClose }) => {
|
|
247
|
+
const dialogRef = useRef<HTMLDialogElement>(null)
|
|
248
|
+
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
const el = dialogRef.current
|
|
251
|
+
el?.showModal()
|
|
252
|
+
return () => {
|
|
253
|
+
if (el?.open) el.close()
|
|
254
|
+
}
|
|
255
|
+
}, [])
|
|
256
|
+
|
|
257
|
+
const handleClick = (e: React.MouseEvent<HTMLDialogElement>) => {
|
|
258
|
+
if (e.target === dialogRef.current) onClose()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const isPdf = mimeType === 'application/pdf'
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
|
265
|
+
<dialog
|
|
266
|
+
ref={dialogRef}
|
|
267
|
+
className="mes-media-viewer"
|
|
268
|
+
onClose={onClose}
|
|
269
|
+
onClick={handleClick}
|
|
270
|
+
>
|
|
271
|
+
<div className="relative flex h-full w-full items-center justify-center p-6">
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
onClick={onClose}
|
|
275
|
+
className="absolute right-4 top-4 z-10 flex size-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
|
276
|
+
aria-label="Close"
|
|
277
|
+
>
|
|
278
|
+
<XIcon className="size-5" weight="bold" />
|
|
279
|
+
</button>
|
|
280
|
+
|
|
281
|
+
{sourceType === 'image' ? (
|
|
282
|
+
<img
|
|
283
|
+
src={url}
|
|
284
|
+
alt={title ?? ''}
|
|
285
|
+
className="max-h-[90vh] max-w-[90vw] rounded-2xl object-contain"
|
|
286
|
+
/>
|
|
287
|
+
) : isPdf ? (
|
|
288
|
+
<div className="flex h-full w-full max-w-4xl flex-col pt-14">
|
|
289
|
+
{title && (
|
|
290
|
+
<p className="mb-2 shrink-0 truncate text-sm font-medium text-white/70">{title}</p>
|
|
291
|
+
)}
|
|
292
|
+
<iframe
|
|
293
|
+
src={url}
|
|
294
|
+
title={title ?? 'Document'}
|
|
295
|
+
className="w-full flex-1 rounded-xl"
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
) : (
|
|
299
|
+
<div className="w-full max-w-2xl">
|
|
300
|
+
{title && (
|
|
301
|
+
<p className="mb-4 text-center text-sm font-medium text-white/70">{title}</p>
|
|
302
|
+
)}
|
|
303
|
+
<MediaPlayer source={url} mimeType={mimeType} poster={poster} controls />
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
</dialog>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// MediaMessage
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
export interface MediaMessageProps {
|
|
316
|
+
message: LocalMessage
|
|
317
|
+
isMyMessage?: boolean
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export const MediaMessage: React.FC<MediaMessageProps> = ({
|
|
321
|
+
message,
|
|
322
|
+
isMyMessage = false,
|
|
323
|
+
}) => {
|
|
324
|
+
const [viewerOpen, setViewerOpen] = useState(false)
|
|
325
|
+
|
|
326
|
+
const linkAttachment = message.attachments?.find(
|
|
327
|
+
(a) => a.type === 'link' || (a.og_scrape_url && !a.asset_url)
|
|
328
|
+
)
|
|
329
|
+
const videoAttachment = message.attachments?.find(
|
|
330
|
+
(a) => a.type === 'video' && a.asset_url
|
|
331
|
+
)
|
|
332
|
+
const imageAttachment = message.attachments?.find(
|
|
333
|
+
(a) => a.type === 'image' && (a as { image_url?: string }).image_url
|
|
334
|
+
)
|
|
335
|
+
const audioAttachment = message.attachments?.find(
|
|
336
|
+
(a) => a.type === 'audio' && a.asset_url
|
|
337
|
+
)
|
|
338
|
+
const fileAttachment = message.attachments?.find(
|
|
339
|
+
(a) => a.type === 'file' && a.asset_url
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
const activeAttachment =
|
|
343
|
+
videoAttachment ?? imageAttachment ?? audioAttachment ?? fileAttachment
|
|
344
|
+
|
|
345
|
+
const resolvedUrl =
|
|
346
|
+
videoAttachment?.asset_url ??
|
|
347
|
+
(imageAttachment as { image_url?: string } | undefined)?.image_url ??
|
|
348
|
+
audioAttachment?.asset_url ??
|
|
349
|
+
fileAttachment?.asset_url
|
|
350
|
+
|
|
351
|
+
const resolvedType =
|
|
352
|
+
activeAttachment?.mime_type ??
|
|
353
|
+
(activeAttachment?.type === 'image'
|
|
354
|
+
? 'image/jpeg'
|
|
355
|
+
: activeAttachment?.type === 'video'
|
|
356
|
+
? 'video/mp4'
|
|
357
|
+
: activeAttachment?.type === 'audio'
|
|
358
|
+
? 'audio/mpeg'
|
|
359
|
+
: 'application/octet-stream')
|
|
360
|
+
|
|
361
|
+
if (!linkAttachment && !resolvedUrl) return null
|
|
362
|
+
|
|
363
|
+
const sourceType = resolvedUrl ? getSourceType(resolvedType) : null
|
|
364
|
+
const title = (activeAttachment as { title?: string } | undefined)?.title
|
|
365
|
+
const fileSize = (activeAttachment as { file_size?: number } | undefined)?.file_size
|
|
366
|
+
const thumbnailUrl = (videoAttachment as { thumb_url?: string } | undefined)?.thumb_url
|
|
367
|
+
const isPdf = resolvedType === 'application/pdf'
|
|
368
|
+
|
|
369
|
+
const messageClass = isMyMessage
|
|
370
|
+
? 'str-chat__message str-chat__message-simple str-chat__message--me str-chat__message-simple--me'
|
|
371
|
+
: 'str-chat__message str-chat__message-simple str-chat__message--other'
|
|
372
|
+
|
|
373
|
+
// Only images, video (from poster), and PDFs support the full-screen viewer
|
|
374
|
+
const canView = sourceType === 'image' || sourceType === 'video' || isPdf
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<>
|
|
378
|
+
<div className={messageClass}>
|
|
379
|
+
{!isMyMessage && message.user && (
|
|
380
|
+
<Avatar
|
|
381
|
+
className="str-chat__avatar str-chat__message-sender-avatar"
|
|
382
|
+
id={message.user.id}
|
|
383
|
+
image={message.user.image}
|
|
384
|
+
name={message.user.name ?? message.user.id}
|
|
385
|
+
/>
|
|
386
|
+
)}
|
|
387
|
+
<div
|
|
388
|
+
className="str-chat__message-inner"
|
|
389
|
+
style={{ marginInlineEnd: 0, marginInlineStart: 0 }}
|
|
390
|
+
>
|
|
391
|
+
<div className="str-chat__message-bubble-wrapper">
|
|
392
|
+
<div
|
|
393
|
+
className="str-chat__message-bubble"
|
|
394
|
+
style={{ padding: 0, borderRadius: 0, overflow: 'visible', background: 'transparent' }}
|
|
395
|
+
>
|
|
396
|
+
<div className={cardClass(isMyMessage)}>
|
|
397
|
+
{linkAttachment ? (
|
|
398
|
+
<LinkCard attachment={linkAttachment} isMyMessage={isMyMessage} />
|
|
399
|
+
) : sourceType === 'image' ? (
|
|
400
|
+
<>
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
onClick={() => setViewerOpen(true)}
|
|
404
|
+
className="block w-full cursor-zoom-in"
|
|
405
|
+
>
|
|
406
|
+
<img src={resolvedUrl!} alt={title ?? ''} className="block w-full" />
|
|
407
|
+
</button>
|
|
408
|
+
<MediaMeta
|
|
409
|
+
mimeType={resolvedType}
|
|
410
|
+
title={title}
|
|
411
|
+
fileSize={fileSize}
|
|
412
|
+
url={resolvedUrl!}
|
|
413
|
+
isMyMessage={isMyMessage}
|
|
414
|
+
/>
|
|
415
|
+
</>
|
|
416
|
+
) : sourceType === 'video' ? (
|
|
417
|
+
<>
|
|
418
|
+
<MediaPlayer
|
|
419
|
+
source={resolvedUrl!}
|
|
420
|
+
mimeType={resolvedType}
|
|
421
|
+
poster={thumbnailUrl}
|
|
422
|
+
controls
|
|
423
|
+
onContainerClick={() => setViewerOpen(true)}
|
|
424
|
+
/>
|
|
425
|
+
<MediaMeta
|
|
426
|
+
mimeType={resolvedType}
|
|
427
|
+
title={title}
|
|
428
|
+
fileSize={fileSize}
|
|
429
|
+
url={resolvedUrl!}
|
|
430
|
+
isMyMessage={isMyMessage}
|
|
431
|
+
/>
|
|
432
|
+
</>
|
|
433
|
+
) : sourceType === 'audio' ? (
|
|
434
|
+
<>
|
|
435
|
+
<MediaPlayer
|
|
436
|
+
source={resolvedUrl!}
|
|
437
|
+
mimeType={resolvedType}
|
|
438
|
+
controls
|
|
439
|
+
/>
|
|
440
|
+
<MediaMeta
|
|
441
|
+
mimeType={resolvedType}
|
|
442
|
+
title={title}
|
|
443
|
+
fileSize={fileSize}
|
|
444
|
+
url={resolvedUrl!}
|
|
445
|
+
isMyMessage={isMyMessage}
|
|
446
|
+
/>
|
|
447
|
+
</>
|
|
448
|
+
) : (
|
|
449
|
+
// document: PDF gets viewer, unknown gets link-only thumbnail
|
|
450
|
+
<FileCard
|
|
451
|
+
url={resolvedUrl!}
|
|
452
|
+
mimeType={resolvedType}
|
|
453
|
+
title={title}
|
|
454
|
+
fileSize={fileSize}
|
|
455
|
+
isMyMessage={isMyMessage}
|
|
456
|
+
onExpand={isPdf ? () => setViewerOpen(true) : undefined}
|
|
457
|
+
/>
|
|
458
|
+
)}
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
{viewerOpen && canView && resolvedUrl && (
|
|
465
|
+
<MediaViewer
|
|
466
|
+
sourceType={sourceType!}
|
|
467
|
+
url={resolvedUrl}
|
|
468
|
+
mimeType={resolvedType}
|
|
469
|
+
title={title}
|
|
470
|
+
poster={thumbnailUrl}
|
|
471
|
+
onClose={() => setViewerOpen(false)}
|
|
472
|
+
/>
|
|
473
|
+
)}
|
|
474
|
+
</>
|
|
475
|
+
)
|
|
476
|
+
}
|
|
@@ -42,6 +42,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
42
42
|
customProfileContent,
|
|
43
43
|
customChannelActions,
|
|
44
44
|
renderMessage,
|
|
45
|
+
sendButton,
|
|
45
46
|
}) => {
|
|
46
47
|
const {
|
|
47
48
|
service,
|
|
@@ -505,6 +506,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
505
506
|
customProfileContent={customProfileContent}
|
|
506
507
|
customChannelActions={customChannelActions}
|
|
507
508
|
renderMessage={renderMessage}
|
|
509
|
+
sendButton={sendButton}
|
|
508
510
|
/>
|
|
509
511
|
</div>
|
|
510
512
|
) : initialParticipantFilter ? (
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ export { FaqList } from './components/FaqList'
|
|
|
13
13
|
export { FaqListItem } from './components/FaqList/FaqListItem'
|
|
14
14
|
export { ChannelEmptyState } from './components/MessagingShell/ChannelEmptyState'
|
|
15
15
|
export { MessageVoteButtons } from './components/CustomMessage/MessageVoteButtons'
|
|
16
|
+
export { MediaMessage } from './components/MediaMessage'
|
|
17
|
+
export type { MediaMessageProps } from './components/MediaMessage'
|
|
16
18
|
|
|
17
19
|
// Providers
|
|
18
20
|
export { MessagingProvider } from './providers/MessagingProvider'
|
package/src/styles.css
CHANGED
|
@@ -64,6 +64,55 @@
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/* Full-screen media viewer dialog */
|
|
68
|
+
.mes-media-viewer {
|
|
69
|
+
--transition-duration: 0.15s;
|
|
70
|
+
|
|
71
|
+
border: none;
|
|
72
|
+
padding: 0;
|
|
73
|
+
margin: 0;
|
|
74
|
+
background: transparent;
|
|
75
|
+
width: 100vw;
|
|
76
|
+
height: 100dvh;
|
|
77
|
+
max-width: 100vw;
|
|
78
|
+
max-height: 100dvh;
|
|
79
|
+
position: fixed;
|
|
80
|
+
inset: 0;
|
|
81
|
+
|
|
82
|
+
transition:
|
|
83
|
+
opacity var(--transition-duration) ease,
|
|
84
|
+
overlay var(--transition-duration) allow-discrete,
|
|
85
|
+
display var(--transition-duration) allow-discrete;
|
|
86
|
+
|
|
87
|
+
opacity: 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.mes-media-viewer[open] {
|
|
91
|
+
opacity: 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.mes-media-viewer::backdrop {
|
|
95
|
+
transition:
|
|
96
|
+
display var(--transition-duration) allow-discrete,
|
|
97
|
+
overlay var(--transition-duration) allow-discrete,
|
|
98
|
+
background-color var(--transition-duration) linear;
|
|
99
|
+
background-color: rgba(0, 0, 0, 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.mes-media-viewer[open]::backdrop {
|
|
103
|
+
background-color: rgba(0, 0, 0, 0.92);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@starting-style {
|
|
107
|
+
.mes-media-viewer[open] {
|
|
108
|
+
opacity: 0;
|
|
109
|
+
|
|
110
|
+
&::backdrop {
|
|
111
|
+
background-color: rgba(0, 0, 0, 0);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
67
116
|
/* Textarea composer focus styles */
|
|
68
117
|
.mes-textarea-composer-container:has(.mes-textarea-composer:focus) {
|
|
69
118
|
outline: 2px solid black;
|
package/src/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
MessagingUser,
|
|
3
3
|
StreamChatServiceConfig,
|
|
4
4
|
} from '@linktr.ee/messaging-core'
|
|
5
|
+
import type { ComponentType } from 'react'
|
|
5
6
|
import type {
|
|
6
7
|
Channel,
|
|
7
8
|
ChannelFilters,
|
|
@@ -234,6 +235,17 @@ export interface ChannelViewProps {
|
|
|
234
235
|
message: LocalMessage
|
|
235
236
|
) => React.ReactNode
|
|
236
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Passed to Stream `Channel` as `SendButton`. Required for hosts that replace
|
|
240
|
+
* the send control: `Channel` merges this into `ComponentContext` and an
|
|
241
|
+
* explicit `SendButton: undefined` would otherwise override outer
|
|
242
|
+
* `WithComponents` overrides.
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* sendButton={MediaSendButton}
|
|
246
|
+
*/
|
|
247
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
248
|
+
sendButton?: ComponentType<any>
|
|
237
249
|
}
|
|
238
250
|
|
|
239
251
|
/**
|
|
@@ -257,6 +269,7 @@ export type ChannelViewPassthroughProps = Pick<
|
|
|
257
269
|
| 'customProfileContent'
|
|
258
270
|
| 'customChannelActions'
|
|
259
271
|
| 'renderMessage'
|
|
272
|
+
| 'sendButton'
|
|
260
273
|
>
|
|
261
274
|
|
|
262
275
|
/**
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Card-1CQEn-OT.js","sources":["../src/components/LockedAttachment/components/Creator/CardThumbnail.tsx","../src/components/LockedAttachment/components/Creator/Card.tsx"],"sourcesContent":["import classNames from 'classnames'\nimport React from 'react'\n\nimport { renderTypeIcon } from '../../utils/icons'\nimport { getSourceType } from '../../utils/mimeType'\nimport MediaPlayer from '../MediaPlayer'\n\ninterface CardThumbnailProps {\n title?: string\n sourceUrl?: string\n thumbnailUrl?: string\n mimeType: string\n onToggle?: () => void\n}\n\nconst CardThumbnail: React.FC<CardThumbnailProps> = ({\n title,\n sourceUrl,\n thumbnailUrl,\n mimeType,\n onToggle,\n}) => {\n const isExpanded = onToggle && sourceUrl && thumbnailUrl\n\n return (\n <button\n type=\"button\"\n disabled={!onToggle}\n className={classNames(\n 'relative block w-full overflow-hidden border-0 bg-black/5 p-0 text-left appearance-none',\n { 'cursor-pointer': !!onToggle, 'cursor-default': !onToggle }\n )}\n onClick={onToggle}\n aria-label={onToggle ? 'Toggle preview' : undefined}\n >\n {isExpanded ? (\n <ThumbnailMedia\n sourceUrl={sourceUrl}\n thumbnailUrl={thumbnailUrl}\n mimeType={mimeType}\n />\n ) : thumbnailUrl ? (\n <div className=\"aspect-video overflow-hidden\">\n <img\n src={thumbnailUrl}\n alt={title}\n draggable={false}\n className=\"absolute inset-0 h-full w-full object-cover\"\n />\n </div>\n ) : (\n <div className=\"aspect-video flex items-center justify-center\">\n {renderTypeIcon(mimeType, {\n className: 'size-12 text-black/20',\n weight: 'regular',\n })}\n </div>\n )}\n\n {!isExpanded && (\n <div className=\"pointer-events-none absolute inset-0 bg-black/30\" />\n )}\n </button>\n )\n}\n\ninterface ThumbnailMediaProps {\n sourceUrl: string\n thumbnailUrl: string\n mimeType: string\n}\n\nconst ThumbnailMedia: React.FC<ThumbnailMediaProps> = ({\n sourceUrl,\n thumbnailUrl,\n mimeType,\n}) => {\n const sourceType = getSourceType(mimeType)\n\n if (sourceType === 'video' || sourceType === 'audio') {\n return (\n <MediaPlayer\n mimeType={mimeType}\n source={sourceUrl}\n poster={thumbnailUrl}\n autoPlay={true}\n loop={true}\n controls={true}\n muted={false}\n />\n )\n }\n\n if (sourceType === 'image') {\n return (\n <img src={sourceUrl} alt=\"\" className=\"block w-full\" draggable={false} />\n )\n }\n\n if (sourceType === 'document') {\n return (\n <img\n src={thumbnailUrl}\n alt=\"\"\n className=\"block w-full\"\n draggable={false}\n />\n )\n }\n\n return null\n}\n\nexport default CardThumbnail\n","import {\n CheckCircleIcon,\n EyeIcon,\n EyeSlashIcon,\n LockIcon,\n LockOpenIcon,\n XIcon,\n} from '@phosphor-icons/react'\nimport classNames from 'classnames'\nimport React, { useCallback, useState } from 'react'\n\nimport type {\n LockedAttachmentBaseProps,\n LockedAttachmentSource,\n PaymentStatus,\n} from '../../types'\nimport { renderTypeIcon } from '../../utils/icons'\n\nimport CardThumbnail from './CardThumbnail'\n\nexport interface CreatorCardProps extends LockedAttachmentBaseProps {\n placeholderTitle?: string\n placeholderAmountText?: string\n onDismiss?: () => void\n onPreviewClick?: () => LockedAttachmentSource\n}\n\nconst CreatorCard: React.FC<CreatorCardProps> = ({\n title,\n mimeType = 'application/octet-stream',\n thumbnailUrl,\n detail,\n amountText,\n placeholderTitle = 'Attachment title',\n placeholderAmountText,\n paymentStatus,\n onDismiss,\n onPreviewClick,\n}) => {\n const [source, setSource] = useState<LockedAttachmentSource | undefined>()\n\n const effectiveSourceUrl = source?.sourceUrl\n const effectiveThumbnailUrl = source?.thumbnailUrl ?? thumbnailUrl\n\n const handleToggle = useCallback(() => {\n if (source) {\n setSource(undefined)\n } else if (onPreviewClick) {\n setSource(onPreviewClick())\n }\n }, [source, onPreviewClick])\n\n return (\n <div className=\"relative w-[280px] select-none overflow-hidden rounded-[24px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_4px_8px_rgba(0,0,0,0.06)]\">\n <CardHeader\n onDismiss={onDismiss}\n onPreviewClick={onPreviewClick}\n sourceUrl={source?.sourceUrl}\n paymentStatus={paymentStatus}\n />\n\n <CardThumbnail\n title={title}\n sourceUrl={effectiveSourceUrl}\n thumbnailUrl={effectiveThumbnailUrl}\n mimeType={mimeType}\n onToggle={onPreviewClick ? handleToggle : undefined}\n />\n\n <div className=\"px-4 pb-3 pt-3\">\n <p\n className={classNames('mb-1.5 truncate text-base font-medium', {\n 'text-black/30': !title,\n 'text-black': !!title,\n })}\n >\n {title || placeholderTitle}\n </p>\n\n <div className=\"flex items-center gap-1\">\n {renderTypeIcon(mimeType, {\n className: 'size-5 shrink-0 text-black/55',\n weight: 'regular',\n })}\n\n {detail && (\n <span className=\"text-xs font-medium text-black/55\">{detail}</span>\n )}\n\n {paymentStatus === 'paid' ? (\n <React.Fragment>\n <span className=\"text-xs font-medium text-black/55\">•</span>\n <span className=\"text-xs font-medium text-[#008236]\">Sold</span>\n <CheckCircleIcon\n className=\"size-4 text-[#008236]\"\n weight=\"bold\"\n />\n </React.Fragment>\n ) : (\n <React.Fragment>\n <span className=\"text-xs font-medium text-black/55\">•</span>\n <span\n className={classNames('text-xs font-medium', {\n 'text-black/30': !amountText,\n 'text-black/55': !!amountText,\n })}\n >\n {amountText || placeholderAmountText}\n </span>\n </React.Fragment>\n )}\n </div>\n </div>\n </div>\n )\n}\n\ninterface CardHeaderProps {\n onDismiss?: () => void\n onPreviewClick?: () => void\n sourceUrl?: string\n paymentStatus?: PaymentStatus\n}\n\nconst CardHeader: React.FC<CardHeaderProps> = ({\n onDismiss,\n onPreviewClick,\n sourceUrl,\n paymentStatus,\n}) => {\n if (onDismiss) {\n return (\n <button\n type=\"button\"\n onClick={onDismiss}\n className=\"absolute top-3 z-50 flex size-8 items-center justify-center rounded-full bg-black/60 text-white right-3\"\n aria-label=\"Dismiss attachment\"\n >\n <XIcon className=\"size-4\" weight=\"bold\" />\n </button>\n )\n }\n\n const Icon = onPreviewClick\n ? sourceUrl\n ? EyeIcon\n : EyeSlashIcon\n : paymentStatus === 'paid'\n ? LockOpenIcon\n : LockIcon\n\n return (\n <div className=\"absolute top-3 z-50 flex size-8 items-center justify-center rounded-full bg-black/60 text-white left-3\">\n <Icon className=\"size-4\" weight=\"fill\" />\n </div>\n )\n}\n\nexport default CreatorCard\n"],"names":["CardThumbnail","title","sourceUrl","thumbnailUrl","mimeType","onToggle","isExpanded","jsxs","classNames","jsx","ThumbnailMedia","sourceType","getSourceType","MediaPlayer","CreatorCard","detail","amountText","placeholderTitle","placeholderAmountText","paymentStatus","onDismiss","onPreviewClick","source","setSource","useState","effectiveSourceUrl","effectiveThumbnailUrl","handleToggle","useCallback","CardHeader","renderTypeIcon","React","CheckCircleIcon","XIcon","EyeIcon","EyeSlashIcon","LockOpenIcon","LockIcon"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Card-ClE_iExA.js","sources":["../src/components/LockedAttachment/components/Visitor/CardActions.tsx","../src/components/LockedAttachment/components/Visitor/CardThumbnail.tsx","../src/components/LockedAttachment/components/Visitor/Card.tsx"],"sourcesContent":["import { DownloadSimpleIcon, LockSimpleIcon } from '@phosphor-icons/react'\nimport React from 'react'\n\ninterface CardActionsProps {\n sourceUrl?: string\n redeemUrl?: string\n onUnlockClicked?: () => void\n onDownloadClicked?: () => void\n isUnlocking?: boolean\n}\n\nconst CardActions: React.FC<CardActionsProps> = (props) => {\n const {\n isUnlocking = false,\n sourceUrl,\n redeemUrl,\n onUnlockClicked,\n onDownloadClicked,\n } = props\n\n const isLocked = sourceUrl === undefined\n\n if (isLocked && onUnlockClicked != null) {\n return (\n <button\n type=\"button\"\n onClick={onUnlockClicked}\n disabled={isUnlocking}\n 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 hover:bg-[#2a2928] disabled:opacity-70\"\n >\n {isUnlocking ? (\n <LoadingDots />\n ) : (\n <React.Fragment>\n <LockSimpleIcon className=\"size-4\" weight=\"fill\" />\n Unlock\n </React.Fragment>\n )}\n </button>\n )\n }\n\n if (!isLocked && onDownloadClicked != null && sourceUrl != null) {\n return (\n <a\n href={redeemUrl ?? sourceUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={onDownloadClicked}\n 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 hover:bg-[#2a2928]\"\n >\n <DownloadSimpleIcon className=\"size-4\" weight=\"bold\" />\n Download\n </a>\n )\n }\n\n return null\n}\n\nconst LoadingDots: React.FC = () => {\n return (\n <span className=\"flex items-center gap-1\">\n <span className=\"size-1 rounded-full bg-white animate-bounce [animation-delay:-0.3s]\" />\n <span className=\"size-1 rounded-full bg-white animate-bounce [animation-delay:-0.15s]\" />\n <span className=\"size-1 rounded-full bg-white animate-bounce\" />\n </span>\n )\n}\n\nexport default CardActions\n","import { LockOpenIcon, LockSimpleIcon } from '@phosphor-icons/react'\nimport React, { useState } from 'react'\n\nimport { PaymentStatus } from '../../types'\nimport { renderTypeIcon } from '../../utils/icons'\nimport { getSourceType } from '../../utils/mimeType'\nimport MediaPlayer from '../MediaPlayer'\n\ninterface CardThumbnailProps {\n title?: string\n sourceUrl?: string\n thumbnailUrl?: string\n mimeType: string\n paymentStatus?: PaymentStatus\n}\n\nconst CardThumbnail: React.FC<CardThumbnailProps> = ({\n title,\n sourceUrl,\n thumbnailUrl,\n mimeType,\n paymentStatus,\n}) => {\n const [sourceReady, setSourceReady] = useState(false)\n\n const isLocked = sourceUrl === undefined\n const sourceType = getSourceType(mimeType)\n\n if (!isLocked) {\n if (sourceType === 'audio' || sourceType === 'video') {\n return (\n <MediaPlayer\n source={sourceUrl}\n poster={thumbnailUrl}\n mimeType={mimeType}\n />\n )\n }\n\n return (\n <div className=\"relative overflow-hidden bg-black/5\">\n <img\n src={sourceType === 'document' ? thumbnailUrl : sourceUrl}\n alt={title}\n className={`block w-full transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}\n draggable={false}\n onLoad={() => setSourceReady(true)}\n />\n </div>\n )\n }\n\n return (\n <div className=\"relative aspect-video overflow-hidden bg-black/5\">\n {thumbnailUrl != null ? (\n <img\n src={thumbnailUrl}\n alt=\"\"\n className=\"absolute inset-0 h-full w-full object-cover\"\n draggable={false}\n />\n ) : (\n <div className=\"absolute inset-0 flex items-center justify-center\">\n {renderTypeIcon(mimeType, {\n className: 'size-12 text-black/20',\n weight: 'regular',\n })}\n </div>\n )}\n {isLocked && (\n <div className=\"absolute inset-0 bg-black/30\">\n <div className=\"absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60 text-white\">\n {paymentStatus === 'paid' ? <LockOpenIcon /> : <LockSimpleIcon />}\n </div>\n </div>\n )}\n </div>\n )\n}\n\nexport default CardThumbnail\n","import { CheckCircleIcon } from '@phosphor-icons/react'\nimport React, { useCallback, useEffect, useRef, useState } from 'react'\n\nimport type {\n LockedAttachmentBaseProps,\n LockedAttachmentSource,\n} from '../../types'\nimport { renderTypeIcon } from '../../utils/icons'\n\nimport CardActions from './CardActions'\nimport CardThumbnail from './CardThumbnail'\n\nexport interface VisitorCardProps extends LockedAttachmentBaseProps {\n /**\n * Called when the visitor clicks Unlock on an unpaid attachment.\n * Use this to open a checkout flow. Omit to hide the Unlock button.\n */\n onUnlockClick?: () => void\n /**\n * Called to fetch the attachment source — fired automatically when\n * paymentStatus transitions to 'paid', or immediately on click when\n * paymentStatus is already 'paid'. Return a LockedAttachmentSource to\n * unlock the card.\n */\n onFetchSource?: () => Promise<LockedAttachmentSource | void>\n /**\n * Called when the visitor clicks Download on an unlocked card.\n * Omit to hide the Download button.\n */\n onDownloadClick?: () => void\n /**\n * When true, shows loading dots on the Unlock button.\n * Driven by the LockedAttachmentContext (e.g. checkout in progress, payment processing).\n */\n isUnlocking?: boolean\n}\n\nconst VisitorCard: React.FC<VisitorCardProps> = ({\n title,\n amountText,\n thumbnailUrl,\n mimeType = 'application/octet-stream',\n detail,\n onUnlockClick,\n onFetchSource,\n onDownloadClick,\n paymentStatus,\n isUnlocking = false,\n}) => {\n const [source, setSource] = useState<LockedAttachmentSource | undefined>()\n\n const cardRef = useRef<HTMLDivElement>(null)\n const fetchingRef = useRef(false)\n\n const onFetchSourceRef = useRef(onFetchSource)\n onFetchSourceRef.current = onFetchSource\n\n const effectiveSourceUrl = source?.sourceUrl\n const effectiveThumbnail = source?.thumbnailUrl ?? thumbnailUrl\n const effectiveRedeemUrl = source?.redeemUrl\n\n const fetchSource = useCallback(async (): Promise<void> => {\n if (fetchingRef.current) return\n fetchingRef.current = true\n try {\n const result = await onFetchSourceRef.current?.()\n if (result) setSource(result)\n } finally {\n fetchingRef.current = false\n }\n }, [])\n\n const handleUnlockClick = useCallback(() => {\n if (paymentStatus === 'paid') {\n void fetchSource()\n } else {\n onUnlockClick?.()\n }\n }, [paymentStatus, fetchSource, onUnlockClick])\n\n // Fetch source when card is in viewport\n useEffect(() => {\n if (!cardRef.current) return\n if (paymentStatus !== 'paid' || source !== undefined) return\n\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting) {\n void fetchSource()\n observer.disconnect()\n }\n },\n { threshold: 1.0 }\n )\n\n observer.observe(cardRef.current)\n return () => observer.disconnect()\n }, [paymentStatus, source, fetchSource])\n\n return (\n <div\n ref={cardRef}\n data-testid=\"locked-attachment\"\n className=\"w-[280px] select-none overflow-hidden rounded-[24px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_4px_8px_rgba(0,0,0,0.06)]\"\n >\n <CardThumbnail\n title={title}\n sourceUrl={effectiveSourceUrl}\n thumbnailUrl={effectiveThumbnail}\n mimeType={mimeType}\n paymentStatus={paymentStatus}\n />\n\n <div className=\"px-4 pb-3 pt-3\">\n <p className=\"mb-1.5 truncate text-base font-medium text-black\">\n {title}\n </p>\n <div className=\"flex items-center gap-1\">\n {renderTypeIcon(mimeType, {\n className: 'size-5 shrink-0 text-black/55',\n weight: 'regular',\n })}\n\n {detail && (\n <span className=\"text-xs font-medium text-black/55\">{detail}</span>\n )}\n\n {paymentStatus === 'paid' ? (\n <React.Fragment>\n <span className=\"text-xs font-medium text-black/55\">•</span>\n <span className=\"text-xs font-medium text-[#008236]\">\n Purchased\n </span>\n <CheckCircleIcon\n className=\"size-4 text-[#008236]\"\n weight=\"bold\"\n />\n </React.Fragment>\n ) : amountText != null ? (\n <React.Fragment>\n <span className=\"text-xs font-medium text-black/55\">•</span>\n <span className=\"text-xs font-medium text-black/55\">\n {amountText}\n </span>\n </React.Fragment>\n ) : null}\n </div>\n\n <CardActions\n isUnlocking={isUnlocking}\n sourceUrl={effectiveSourceUrl}\n redeemUrl={effectiveRedeemUrl}\n onUnlockClicked={handleUnlockClick}\n onDownloadClicked={onDownloadClick}\n />\n </div>\n </div>\n )\n}\n\nexport default VisitorCard\n"],"names":["CardActions","props","isUnlocking","sourceUrl","redeemUrl","onUnlockClicked","onDownloadClicked","isLocked","jsx","LoadingDots","jsxs","React","LockSimpleIcon","DownloadSimpleIcon","CardThumbnail","title","thumbnailUrl","mimeType","paymentStatus","sourceReady","setSourceReady","useState","sourceType","getSourceType","LockOpenIcon","MediaPlayer","VisitorCard","amountText","detail","onUnlockClick","onFetchSource","onDownloadClick","source","setSource","cardRef","useRef","fetchingRef","onFetchSourceRef","effectiveSourceUrl","effectiveThumbnail","effectiveRedeemUrl","fetchSource","useCallback","result","_a","handleUnlockClick","useEffect","observer","entry","renderTypeIcon","CheckCircleIcon"],"mappings":"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"}
|