@linktr.ee/messaging-react 1.37.0 → 1.38.0-rc-1777617124

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 (29) hide show
  1. package/dist/Card-DwgUtqsA.js +127 -0
  2. package/dist/Card-DwgUtqsA.js.map +1 -0
  3. package/dist/Card-RgHsp9x1.js +138 -0
  4. package/dist/Card-RgHsp9x1.js.map +1 -0
  5. package/dist/{index-DOsC03ZN.js → index-B_4pciGp.js} +1411 -1358
  6. package/dist/index-B_4pciGp.js.map +1 -0
  7. package/dist/index.d.ts +21 -1
  8. package/dist/index.js +15 -13
  9. package/package.json +1 -1
  10. package/src/components/{LockedAttachment/components → AttachmentCard}/MediaPlayer.tsx +4 -3
  11. package/src/components/AttachmentCard/Thumbnail.tsx +150 -0
  12. package/src/components/AttachmentCard/index.tsx +114 -0
  13. package/src/components/LockedAttachment/components/Creator/Card.tsx +123 -113
  14. package/src/components/LockedAttachment/components/Visitor/Card.tsx +43 -42
  15. package/src/components/LockedAttachment/components/Visitor/LockBadge.tsx +12 -0
  16. package/src/components/MediaMessage/MediaMessage.stories.tsx +45 -4
  17. package/src/components/MediaMessage/MediaMessage.test.tsx +151 -153
  18. package/src/components/MediaMessage/index.tsx +239 -349
  19. package/src/index.ts +7 -3
  20. package/dist/Card-BHrnmHeu.js +0 -167
  21. package/dist/Card-BHrnmHeu.js.map +0 -1
  22. package/dist/Card-D4vEgqWt.js +0 -195
  23. package/dist/Card-D4vEgqWt.js.map +0 -1
  24. package/dist/index-DOsC03ZN.js.map +0 -1
  25. package/src/components/LockedAttachment/components/Creator/CardThumbnail.tsx +0 -114
  26. package/src/components/LockedAttachment/components/Visitor/CardThumbnail.tsx +0 -81
  27. /package/src/components/{LockedAttachment → AttachmentCard}/utils/icons.ts +0 -0
  28. /package/src/components/{LockedAttachment → AttachmentCard}/utils/mimeType.test.ts +0 -0
  29. /package/src/components/{LockedAttachment → AttachmentCard}/utils/mimeType.ts +0 -0
@@ -1,11 +1,12 @@
1
- import { CircleNotchIcon, DownloadSimpleIcon, LinkIcon, XIcon } from '@phosphor-icons/react'
2
- import React, { useEffect, useRef, useState } from 'react'
1
+ import { CircleNotchIcon, DownloadSimpleIcon, LinkIcon } from '@phosphor-icons/react'
2
+ import React, { useState } from 'react'
3
3
  import type { Attachment, LocalMessage } from 'stream-chat'
4
4
 
5
+ import AttachmentCard, {
6
+ AttachmentThumbnail,
7
+ getSourceType,
8
+ } from '../AttachmentCard'
5
9
  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
 
10
11
  function formatBytes(bytes: number): string {
11
12
  if (bytes < 1024) return `${bytes} B`
@@ -13,38 +14,94 @@ function formatBytes(bytes: number): string {
13
14
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
14
15
  }
15
16
 
16
- function cardClass(isMyMessage: boolean) {
17
+ function linkCardShellClass(isMyMessage: boolean) {
17
18
  const bg = isMyMessage ? 'bg-[#121110]' : 'bg-[#F3F3F1]'
18
19
  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
  }
20
21
 
21
- function primaryText(isMyMessage: boolean) {
22
+ function linkThumbnailBg(isMyMessage: boolean) {
23
+ return isMyMessage ? 'bg-white/10' : 'bg-black/5'
24
+ }
25
+
26
+ function linkPrimaryText(isMyMessage: boolean) {
22
27
  return isMyMessage ? 'text-white' : 'text-black'
23
28
  }
24
29
 
25
- function secondaryText(isMyMessage: boolean) {
30
+ function linkSecondaryText(isMyMessage: boolean) {
26
31
  return isMyMessage ? 'text-white/55' : 'text-black/55'
27
32
  }
28
33
 
29
- function tertiaryText(isMyMessage: boolean) {
34
+ function linkTertiaryText(isMyMessage: boolean) {
30
35
  return isMyMessage ? 'text-white/40' : 'text-black/40'
31
36
  }
32
37
 
33
- function thumbnailBg(isMyMessage: boolean) {
34
- return isMyMessage ? 'bg-white/10' : 'bg-black/5'
35
- }
36
-
37
- function iconColor(isMyMessage: boolean) {
38
+ function linkIconColor(isMyMessage: boolean) {
38
39
  return isMyMessage ? 'text-white/20' : 'text-black/20'
39
40
  }
40
41
 
41
- function buttonBg(isMyMessage: boolean) {
42
- return isMyMessage ? 'bg-white/10 hover:bg-white/15' : 'bg-black/[0.06] hover:bg-black/10'
42
+ /** Link preview (OG) — no download; opens in a new tab. */
43
+ const LinkCard: React.FC<{
44
+ attachment: Attachment
45
+ isMyMessage: boolean
46
+ }> = ({ attachment, isMyMessage }) => {
47
+ const { title, text, image_url, og_scrape_url, title_link } = attachment
48
+ const rawUrl = og_scrape_url ?? title_link
49
+ const url =
50
+ typeof rawUrl === 'string' && rawUrl.trim() !== '' ? rawUrl : undefined
51
+
52
+ const body = (
53
+ <React.Fragment>
54
+ <div className="p-2">
55
+ {image_url ? (
56
+ <img
57
+ src={image_url}
58
+ alt={title ?? ''}
59
+ className="aspect-video w-full rounded-[20px] object-cover"
60
+ />
61
+ ) : (
62
+ <div
63
+ className={`aspect-video w-full rounded-[20px] ${linkThumbnailBg(isMyMessage)} flex items-center justify-center`}
64
+ >
65
+ <LinkIcon className={`size-12 ${linkIconColor(isMyMessage)}`} />
66
+ </div>
67
+ )}
68
+ </div>
69
+ <div className="px-3 pb-3">
70
+ {title && (
71
+ <p className={`truncate text-[14px] font-medium leading-5 ${linkPrimaryText(isMyMessage)}`}>
72
+ {title}
73
+ </p>
74
+ )}
75
+ {text && (
76
+ <p className={`truncate text-[12px] leading-4 ${linkSecondaryText(isMyMessage)}`}>{text}</p>
77
+ )}
78
+ {url && (
79
+ <p className={`mt-1 truncate text-[12px] leading-4 ${linkTertiaryText(isMyMessage)}`}>
80
+ {url}
81
+ </p>
82
+ )}
83
+ </div>
84
+ </React.Fragment>
85
+ )
86
+
87
+ if (url) {
88
+ return (
89
+ <a href={url} target="_blank" rel="noopener noreferrer" className="block no-underline">
90
+ {body}
91
+ </a>
92
+ )
93
+ }
94
+
95
+ return <div className="block">{body}</div>
43
96
  }
44
97
 
45
- // ---------------------------------------------------------------------------
46
- // Download button (shared across all media types)
47
- // ---------------------------------------------------------------------------
98
+ export function resolveLinkAttachment(
99
+ message: LocalMessage
100
+ ): Attachment | undefined {
101
+ return message.attachments?.find(
102
+ (a) => a.type === 'link' || (!!a.og_scrape_url && !a.asset_url)
103
+ )
104
+ }
48
105
 
49
106
  async function triggerDownload(url: string, filename?: string): Promise<void> {
50
107
  let name: string
@@ -64,16 +121,14 @@ async function triggerDownload(url: string, filename?: string): Promise<void> {
64
121
  URL.revokeObjectURL(objectUrl)
65
122
  }
66
123
 
67
- const DownloadButton: React.FC<{ url: string; filename?: string; isMyMessage: boolean }> = ({
124
+ const DownloadAction: React.FC<{ url: string; filename?: string }> = ({
68
125
  url,
69
126
  filename,
70
- isMyMessage,
71
127
  }) => {
72
128
  const [busy, setBusy] = useState(false)
73
129
 
74
130
  const handleClick = (e: React.MouseEvent) => {
75
131
  e.stopPropagation()
76
- // Open synchronously so the browser treats it as a user gesture (prevents popup blocking)
77
132
  const fallback = window.open('', '_blank', 'noopener,noreferrer')
78
133
  setBusy(true)
79
134
  triggerDownload(url, filename)
@@ -87,245 +142,31 @@ const DownloadButton: React.FC<{ url: string; filename?: string; isMyMessage: bo
87
142
  type="button"
88
143
  onClick={handleClick}
89
144
  disabled={busy}
90
- className={`flex size-8 items-center justify-center rounded-full ${buttonBg(isMyMessage)} disabled:opacity-50`}
91
- aria-label="Download"
145
+ 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"
92
146
  >
93
147
  {busy ? (
94
- <CircleNotchIcon className={`size-4 animate-spin ${secondaryText(isMyMessage)}`} weight="bold" />
148
+ <CircleNotchIcon className="size-4 animate-spin text-white" weight="bold" />
95
149
  ) : (
96
- <DownloadSimpleIcon className={`size-4 ${secondaryText(isMyMessage)}`} weight="bold" />
150
+ <React.Fragment>
151
+ <DownloadSimpleIcon className="size-4 text-white" weight="bold" />
152
+ Download
153
+ </React.Fragment>
97
154
  )}
98
155
  </button>
99
156
  )
100
157
  }
101
158
 
102
- // ---------------------------------------------------------------------------
103
- // Chin: title + file size + download
104
- // ---------------------------------------------------------------------------
105
-
106
- const MediaMeta: React.FC<{
107
- mimeType: string
159
+ export interface MediaMessageResolved {
160
+ resolvedUrl: string
161
+ resolvedType: string
108
162
  title?: string
109
163
  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={`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
- )
164
+ thumbnailUrl?: string
309
165
  }
310
166
 
311
- // ---------------------------------------------------------------------------
312
- // MediaMessage
313
- // ---------------------------------------------------------------------------
314
-
315
- export interface MediaMessageProps {
167
+ export function resolveMediaFromMessage(
316
168
  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
- )
169
+ ): MediaMessageResolved | null {
329
170
  const videoAttachment = message.attachments?.find(
330
171
  (a) => a.type === 'video' && a.asset_url
331
172
  )
@@ -348,6 +189,8 @@ export const MediaMessage: React.FC<MediaMessageProps> = ({
348
189
  audioAttachment?.asset_url ??
349
190
  fileAttachment?.asset_url
350
191
 
192
+ if (!resolvedUrl) return null
193
+
351
194
  const resolvedType =
352
195
  activeAttachment?.mime_type ??
353
196
  (activeAttachment?.type === 'image'
@@ -358,119 +201,166 @@ export const MediaMessage: React.FC<MediaMessageProps> = ({
358
201
  ? 'audio/mpeg'
359
202
  : 'application/octet-stream')
360
203
 
361
- if (!linkAttachment && !resolvedUrl) return null
362
-
363
- const sourceType = resolvedUrl ? getSourceType(resolvedType) : null
364
204
  const title = (activeAttachment as { title?: string } | undefined)?.title
365
205
  const fileSize = (activeAttachment as { file_size?: number } | undefined)?.file_size
366
206
  const thumbnailUrl = (videoAttachment as { thumb_url?: string } | undefined)?.thumb_url
367
- const isPdf = resolvedType === 'application/pdf'
207
+
208
+ return {
209
+ resolvedUrl,
210
+ resolvedType,
211
+ title,
212
+ fileSize,
213
+ thumbnailUrl,
214
+ }
215
+ }
216
+
217
+ /** Dark card (sent / own message) — matches LockedAttachment.Creator preview without toggle. */
218
+ const Creator: React.FC<MediaMessageResolved> = ({
219
+ resolvedUrl,
220
+ resolvedType,
221
+ title,
222
+ fileSize,
223
+ thumbnailUrl,
224
+ }) => {
225
+ const detail = fileSize !== undefined ? formatBytes(fileSize) : undefined
226
+
227
+ return (
228
+ <AttachmentCard
229
+ variant="dark"
230
+ title={title}
231
+ placeholderTitle=""
232
+ mimeType={resolvedType}
233
+ detail={detail}
234
+ thumbnail={
235
+ <AttachmentThumbnail
236
+ mimeType={resolvedType}
237
+ sourceUrl={resolvedUrl}
238
+ thumbnailUrl={thumbnailUrl}
239
+ title={title}
240
+ variant="dark"
241
+ />
242
+ }
243
+ />
244
+ )
245
+ }
246
+
247
+ /** Light card (received) — matches LockedAttachment.Visitor unlocked without Purchased copy. */
248
+ const Visitor: React.FC<MediaMessageResolved> = ({
249
+ resolvedUrl,
250
+ resolvedType,
251
+ title,
252
+ fileSize,
253
+ thumbnailUrl,
254
+ }) => {
255
+ const sourceType = getSourceType(resolvedType)
256
+ const detail = fileSize !== undefined ? formatBytes(fileSize) : undefined
257
+
258
+ return (
259
+ <AttachmentCard
260
+ variant="light"
261
+ title={title}
262
+ mimeType={resolvedType}
263
+ detail={detail}
264
+ thumbnail={
265
+ <AttachmentThumbnail
266
+ mimeType={resolvedType}
267
+ sourceUrl={resolvedUrl}
268
+ thumbnailUrl={thumbnailUrl}
269
+ title={title}
270
+ variant="light"
271
+ containedImage={sourceType === 'image' || sourceType === 'document'}
272
+ />
273
+ }
274
+ action={<DownloadAction url={resolvedUrl} filename={title} />}
275
+ />
276
+ )
277
+ }
278
+
279
+ export interface MediaMessageProps {
280
+ message: LocalMessage
281
+ isMyMessage?: boolean
282
+ }
283
+
284
+ const MediaMessageRoot: React.FC<MediaMessageProps> = ({
285
+ message,
286
+ isMyMessage = false,
287
+ }) => {
288
+ const linkAttachment = resolveLinkAttachment(message)
289
+ const resolved = resolveMediaFromMessage(message)
290
+ if (!linkAttachment && !resolved) return null
368
291
 
369
292
  const messageClass = isMyMessage
370
293
  ? 'str-chat__message str-chat__message-simple str-chat__message--me str-chat__message-simple--me'
371
294
  : 'str-chat__message str-chat__message-simple str-chat__message--other'
372
295
 
373
- // Only images, video (from poster), and PDFs support the full-screen viewer
374
- const canView = sourceType === 'image' || sourceType === 'video' || isPdf
375
-
376
296
  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
- )}
297
+ <div className={messageClass}>
298
+ {!isMyMessage && message.user && (
299
+ <Avatar
300
+ className="str-chat__avatar str-chat__message-sender-avatar"
301
+ id={message.user.id}
302
+ image={message.user.image}
303
+ name={message.user.name ?? message.user.id}
304
+ />
305
+ )}
306
+ <div
307
+ className="str-chat__message-inner"
308
+ style={{ marginInlineEnd: 0, marginInlineStart: 0 }}
309
+ >
310
+ <div className="str-chat__message-bubble-wrapper">
311
+ <div
312
+ className="str-chat__message-bubble"
313
+ style={{ padding: 0, borderRadius: 0, overflow: 'visible', background: 'transparent' }}
314
+ >
315
+ {linkAttachment ? (
316
+ <div className={linkCardShellClass(isMyMessage)}>
317
+ <LinkCard attachment={linkAttachment} isMyMessage={isMyMessage} />
459
318
  </div>
460
- </div>
319
+ ) : isMyMessage ? (
320
+ <Creator {...resolved!} />
321
+ ) : (
322
+ <Visitor {...resolved!} />
323
+ )}
461
324
  </div>
462
325
  </div>
463
326
  </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
- </>
327
+ </div>
475
328
  )
476
329
  }
330
+
331
+ const MediaMessageCreatorEntry: React.FC<{ message: LocalMessage }> = ({
332
+ message,
333
+ }) => {
334
+ const linkAttachment = resolveLinkAttachment(message)
335
+ if (linkAttachment) {
336
+ return (
337
+ <div className={linkCardShellClass(true)}>
338
+ <LinkCard attachment={linkAttachment} isMyMessage={true} />
339
+ </div>
340
+ )
341
+ }
342
+ const resolved = resolveMediaFromMessage(message)
343
+ if (!resolved) return null
344
+ return <Creator {...resolved} />
345
+ }
346
+
347
+ const MediaMessageVisitorEntry: React.FC<{ message: LocalMessage }> = ({
348
+ message,
349
+ }) => {
350
+ const linkAttachment = resolveLinkAttachment(message)
351
+ if (linkAttachment) {
352
+ return (
353
+ <div className={linkCardShellClass(false)}>
354
+ <LinkCard attachment={linkAttachment} isMyMessage={false} />
355
+ </div>
356
+ )
357
+ }
358
+ const resolved = resolveMediaFromMessage(message)
359
+ if (!resolved) return null
360
+ return <Visitor {...resolved} />
361
+ }
362
+
363
+ export const MediaMessage = Object.assign(MediaMessageRoot, {
364
+ Creator: MediaMessageCreatorEntry,
365
+ Visitor: MediaMessageVisitorEntry,
366
+ })