@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.
Files changed (33) hide show
  1. package/dist/{Card-ClE_iExA.js → Card-BsqYzZt1.js} +55 -55
  2. package/dist/Card-BsqYzZt1.js.map +1 -0
  3. package/dist/{Card-1CQEn-OT.js → Card-Cnn9V-W7.js} +44 -44
  4. package/dist/Card-Cnn9V-W7.js.map +1 -0
  5. package/dist/assets/index.css +1 -1
  6. package/dist/index-BMfupE8K.js +3130 -0
  7. package/dist/index-BMfupE8K.js.map +1 -0
  8. package/dist/index.d.ts +19 -1
  9. package/dist/index.js +20 -2477
  10. package/dist/index.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/components/ChannelInfoDialog/index.tsx +3 -1
  13. package/src/components/ChannelView.stories.tsx +38 -0
  14. package/src/components/ChannelView.test.tsx +25 -6
  15. package/src/components/ChannelView.tsx +26 -6
  16. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +180 -0
  17. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +63 -1
  18. package/src/components/CustomMessageInput/index.tsx +24 -5
  19. package/src/components/LockedAttachment/components/Creator/Card.tsx +11 -11
  20. package/src/components/LockedAttachment/components/MediaPlayer.tsx +10 -1
  21. package/src/components/LockedAttachment/components/Visitor/Card.tsx +9 -9
  22. package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +2 -2
  23. package/src/components/MediaMessage/MediaMessage.stories.tsx +233 -0
  24. package/src/components/MediaMessage/MediaMessage.test.tsx +520 -0
  25. package/src/components/MediaMessage/index.tsx +476 -0
  26. package/src/components/MessagingShell/index.tsx +2 -0
  27. package/src/index.ts +2 -0
  28. package/src/styles.css +49 -0
  29. package/src/types.ts +13 -0
  30. package/dist/Card-1CQEn-OT.js.map +0 -1
  31. package/dist/Card-ClE_iExA.js.map +0 -1
  32. package/dist/MediaPlayer-B9Ws2NeE.js +0 -292
  33. 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\">&bull;</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\">&bull;</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\">&bull;</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\">&bull;</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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"}