@linktr.ee/messaging-react 1.33.2 → 1.33.3-rc-1777507218

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.
@@ -1,5 +1,6 @@
1
- import React from 'react'
2
- import type { LocalMessage } from 'stream-chat'
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'
3
4
 
4
5
  import { Avatar } from '../Avatar'
5
6
  import MediaPlayer from '../LockedAttachment/components/MediaPlayer'
@@ -12,53 +13,304 @@ function formatBytes(bytes: number): string {
12
13
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
13
14
  }
14
15
 
15
- const CARD_CLASS =
16
- '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)]'
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
+ }
17
66
 
18
- const MediaMeta: React.FC<{ mimeType: string; title?: string; fileSize?: number }> = ({
19
- mimeType,
20
- title,
21
- fileSize,
67
+ const DownloadButton: React.FC<{ url: string; filename?: string; isMyMessage: boolean }> = ({
68
+ url,
69
+ filename,
70
+ isMyMessage,
22
71
  }) => {
23
- if (!title && fileSize === undefined) return null
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
+
24
85
  return (
25
- <div className="px-4 pb-3 pt-3">
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">
26
115
  {title && (
27
- <p className="mb-1.5 truncate text-base font-medium text-black">{title}</p>
116
+ <p className={`mb-1.5 truncate text-base font-medium ${primaryText(isMyMessage)}`}>
117
+ {title}
118
+ </p>
28
119
  )}
29
120
  {fileSize !== undefined && (
30
121
  <div className="flex items-center gap-1">
31
- {renderTypeIcon(mimeType, { className: 'size-5 shrink-0 text-black/55', weight: 'regular' })}
32
- <span className="text-xs font-medium text-black/55">{formatBytes(fileSize)}</span>
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>
33
129
  </div>
34
130
  )}
35
131
  </div>
36
- )
37
- }
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
+ // ---------------------------------------------------------------------------
38
141
 
39
142
  const FileCard: React.FC<{
40
143
  url: string
41
144
  mimeType: string
42
145
  title?: string
43
146
  fileSize?: number
44
- }> = ({ url, mimeType, title, fileSize }) => (
45
- <a href={url} target="_blank" rel="noopener noreferrer" className="block no-underline">
46
- <div className="aspect-video w-full bg-black/5 flex items-center justify-center">
47
- {renderTypeIcon(mimeType, { className: 'size-12 text-black/20', weight: 'regular' })}
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
+ })}
48
158
  </div>
49
- <div className="px-4 pb-3 pt-3">
50
- {title && (
51
- <p className="mb-1.5 truncate text-base font-medium text-black">{title}</p>
52
- )}
53
- {fileSize !== undefined && (
54
- <div className="flex items-center gap-1">
55
- {renderTypeIcon(mimeType, { className: 'size-5 shrink-0 text-black/55', weight: 'regular' })}
56
- <span className="text-xs font-medium text-black/55">{formatBytes(fileSize)}</span>
57
- </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>
58
176
  )}
177
+ <MediaMeta
178
+ mimeType={mimeType}
179
+ title={title}
180
+ fileSize={fileSize}
181
+ url={url}
182
+ isMyMessage={isMyMessage}
183
+ />
59
184
  </div>
60
- </a>
61
- )
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
+ // ---------------------------------------------------------------------------
62
314
 
63
315
  export interface MediaMessageProps {
64
316
  message: LocalMessage
@@ -69,6 +321,11 @@ export const MediaMessage: React.FC<MediaMessageProps> = ({
69
321
  message,
70
322
  isMyMessage = false,
71
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
+ )
72
329
  const videoAttachment = message.attachments?.find(
73
330
  (a) => a.type === 'video' && a.asset_url
74
331
  )
@@ -93,73 +350,127 @@ export const MediaMessage: React.FC<MediaMessageProps> = ({
93
350
 
94
351
  const resolvedType =
95
352
  activeAttachment?.mime_type ??
96
- (imageAttachment
353
+ (activeAttachment?.type === 'image'
97
354
  ? 'image/jpeg'
98
- : videoAttachment
355
+ : activeAttachment?.type === 'video'
99
356
  ? 'video/mp4'
100
- : audioAttachment
357
+ : activeAttachment?.type === 'audio'
101
358
  ? 'audio/mpeg'
102
359
  : 'application/octet-stream')
103
360
 
104
- if (!resolvedUrl) return null
361
+ if (!linkAttachment && !resolvedUrl) return null
105
362
 
106
- const sourceType = getSourceType(resolvedType)
363
+ const sourceType = resolvedUrl ? getSourceType(resolvedType) : null
107
364
  const title = (activeAttachment as { title?: string } | undefined)?.title
108
365
  const fileSize = (activeAttachment as { file_size?: number } | undefined)?.file_size
109
366
  const thumbnailUrl = (videoAttachment as { thumb_url?: string } | undefined)?.thumb_url
367
+ const isPdf = resolvedType === 'application/pdf'
110
368
 
111
369
  const messageClass = isMyMessage
112
370
  ? 'str-chat__message str-chat__message-simple str-chat__message--me str-chat__message-simple--me'
113
371
  : 'str-chat__message str-chat__message-simple str-chat__message--other'
114
372
 
373
+ // Only images, video (from poster), and PDFs support the full-screen viewer
374
+ const canView = sourceType === 'image' || sourceType === 'video' || isPdf
375
+
115
376
  return (
116
- <div className={messageClass}>
117
- {!isMyMessage && message.user && (
118
- <Avatar
119
- className="str-chat__avatar str-chat__message-sender-avatar"
120
- id={message.user.id}
121
- image={message.user.image}
122
- name={message.user.name ?? message.user.id}
123
- />
124
- )}
125
- <div
126
- className="str-chat__message-inner"
127
- style={{ marginInlineEnd: 0, marginInlineStart: 0 }}
128
- >
129
- <div className="str-chat__message-bubble-wrapper">
130
- <div className="str-chat__message-bubble" style={{ padding: 0, borderRadius: 0, overflow: 'visible', background: 'transparent' }}>
131
- <div className={CARD_CLASS}>
132
- {sourceType === 'image' ? (
133
- <>
134
- <img
135
- src={resolvedUrl}
136
- alt={title ?? ''}
137
- className="block w-full"
138
- />
139
- <MediaMeta mimeType={resolvedType} title={title} fileSize={fileSize} />
140
- </>
141
- ) : sourceType === 'document' ? (
142
- <FileCard
143
- url={resolvedUrl}
144
- mimeType={resolvedType}
145
- title={title}
146
- fileSize={fileSize}
147
- />
148
- ) : (
149
- <>
150
- <MediaPlayer
151
- source={resolvedUrl}
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!}
152
452
  mimeType={resolvedType}
153
- poster={thumbnailUrl}
154
- controls
453
+ title={title}
454
+ fileSize={fileSize}
455
+ isMyMessage={isMyMessage}
456
+ onExpand={isPdf ? () => setViewerOpen(true) : undefined}
155
457
  />
156
- <MediaMeta mimeType={resolvedType} title={title} fileSize={fileSize} />
157
- </>
158
- )}
458
+ )}
459
+ </div>
159
460
  </div>
160
461
  </div>
161
462
  </div>
162
463
  </div>
163
- </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
+ </>
164
475
  )
165
476
  }
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;