@linktr.ee/messaging-react 1.33.1 → 1.33.2-rc-1777444067

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/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as e, a as t, C as i, b as n, c as o, d as g, F as r, e as m, L as u, f as M, h as c, i as l, j as h, P as C, k as P, u as d, l as L, m as p, n as v } from "./index-BePLvyvi.js";
1
+ import { A as e, a as t, C as i, b as n, c as o, d as g, F as r, e as m, L as u, f as M, h as c, i as l, j as h, P as C, k as P, u as d, l as L, m as p, n as v } from "./index-CahzrNJz.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "1.33.1",
3
+ "version": "1.33.2-rc-1777444067",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,6 +31,12 @@ export interface MediaPlayerProps {
31
31
  showProgress?: boolean
32
32
  /** When true, requests muted playback (helps autoplay policies on video). */
33
33
  muted?: boolean
34
+ /**
35
+ * When provided, overrides the default click-to-play-toggle behaviour on the
36
+ * player container. The play/pause button (which calls stopPropagation) is
37
+ * unaffected, so inline playback still works.
38
+ */
39
+ onContainerClick?: (e: React.MouseEvent) => void
34
40
  }
35
41
 
36
42
  const MediaPlayer: React.FC<MediaPlayerProps> = ({
@@ -43,6 +49,7 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
43
49
  controls = true,
44
50
  showProgress = false,
45
51
  muted = false,
52
+ onContainerClick,
46
53
  }) => {
47
54
  // --- Derived ---
48
55
  const sourceType = getSourceType(mimeType)
@@ -185,13 +192,15 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
185
192
  tabIndex={0}
186
193
  className={`relative cursor-pointer overflow-hidden bg-black ${aspectClass}`}
187
194
  style={aspectStyle}
188
- onClick={() => {
195
+ onClick={(e) => {
196
+ if (onContainerClick) { onContainerClick(e); return }
189
197
  if (manualPlayRequired) return
190
198
  if (controls) setPlaying((p) => !p)
191
199
  }}
192
200
  onKeyDown={(e) => {
193
201
  if (e.key !== 'Enter' && e.key !== ' ') return
194
202
  e.preventDefault()
203
+ if (onContainerClick) { onContainerClick(e as unknown as React.MouseEvent); return }
195
204
  if (manualPlayRequired) return
196
205
  if (controls) setPlaying((p) => !p)
197
206
  }}
@@ -84,6 +84,43 @@ const VARIANTS = [
84
84
  },
85
85
  ] as const
86
86
 
87
+ const LINK_VARIANTS = [
88
+ {
89
+ label: 'With image',
90
+ attachment: {
91
+ type: 'link',
92
+ og_scrape_url: 'https://linktr.ee/brieparsons',
93
+ title: 'World Famous 3 Bottle Grey Wash Set',
94
+ text: 'When its time to shade, the World Famous Grey Wash set has you covered.',
95
+ image_url: 'https://picsum.photos/seed/linkcard/560/315',
96
+ },
97
+ },
98
+ {
99
+ label: 'No image',
100
+ attachment: {
101
+ type: 'link',
102
+ og_scrape_url: 'https://linktr.ee/brieparsons',
103
+ title: 'World Famous 3 Bottle Grey Wash Set',
104
+ text: 'When its time to shade, the World Famous Grey Wash set has you covered.',
105
+ },
106
+ },
107
+ {
108
+ label: 'Title only',
109
+ attachment: {
110
+ type: 'link',
111
+ og_scrape_url: 'https://linktr.ee/someone',
112
+ title: 'Check out my Linktree',
113
+ },
114
+ },
115
+ {
116
+ label: 'URL only',
117
+ attachment: {
118
+ type: 'link',
119
+ og_scrape_url: 'https://linktr.ee/someone',
120
+ },
121
+ },
122
+ ] as const
123
+
87
124
  // ---------------------------------------------------------------------------
88
125
  // Layout primitives
89
126
  // ---------------------------------------------------------------------------
@@ -94,11 +131,10 @@ const GridTable: React.FC<{ children: React.ReactNode }> = ({ children }) => (
94
131
  </div>
95
132
  )
96
133
 
97
- const GridHead = () => (
134
+ const GridHead: React.FC<{ labels: readonly string[] }> = ({ labels }) => (
98
135
  <thead>
99
136
  <tr>
100
- <th className="text-left text-xs font-medium text-black/40 pb-2 w-16" />
101
- {VARIANTS.map(({ label }) => (
137
+ {labels.map((label) => (
102
138
  <th key={label} className="text-left text-xs font-medium text-black/40 pb-2">
103
139
  {label}
104
140
  </th>
@@ -107,22 +143,15 @@ const GridHead = () => (
107
143
  </thead>
108
144
  )
109
145
 
110
- const RowLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
111
- <td className="text-xs text-right font-medium text-black/40 pr-4 align-top pt-2">
112
- {children}
113
- </td>
114
- )
115
-
116
146
  // ---------------------------------------------------------------------------
117
147
  // Stories
118
148
  // ---------------------------------------------------------------------------
119
149
 
120
- export const Visitor: StoryFn = () => (
150
+ export const Received: StoryFn = () => (
121
151
  <GridTable>
122
- <GridHead />
152
+ <GridHead labels={VARIANTS.map((v) => v.label)} />
123
153
  <tbody>
124
154
  <tr>
125
- <RowLabel>Sent</RowLabel>
126
155
  {VARIANTS.map(({ label, attachment }) => (
127
156
  <td key={label} className="align-top">
128
157
  <MediaMessage
@@ -135,20 +164,19 @@ export const Visitor: StoryFn = () => (
135
164
  </tbody>
136
165
  </GridTable>
137
166
  )
138
- Visitor.parameters = {
167
+ Received.parameters = {
139
168
  docs: {
140
169
  description: {
141
- story: 'Visitor perspectivemessages from the creator, left-aligned with avatar.',
170
+ story: 'Received messages — left-aligned with avatar, light gray card background.',
142
171
  },
143
172
  },
144
173
  }
145
174
 
146
- export const Creator: StoryFn = () => (
175
+ export const Sent: StoryFn = () => (
147
176
  <GridTable>
148
- <GridHead />
177
+ <GridHead labels={VARIANTS.map((v) => v.label)} />
149
178
  <tbody>
150
179
  <tr>
151
- <RowLabel>Sent</RowLabel>
152
180
  {VARIANTS.map(({ label, attachment }) => (
153
181
  <td key={label} className="align-top">
154
182
  <MediaMessage
@@ -161,10 +189,45 @@ export const Creator: StoryFn = () => (
161
189
  </tbody>
162
190
  </GridTable>
163
191
  )
164
- Creator.parameters = {
192
+ Sent.parameters = {
193
+ docs: {
194
+ description: {
195
+ story: 'Sent messages — right-aligned, no avatar, black card background.',
196
+ },
197
+ },
198
+ }
199
+
200
+ export const Links: StoryFn = () => (
201
+ <GridTable>
202
+ <GridHead labels={LINK_VARIANTS.map((v) => v.label)} />
203
+ <tbody>
204
+ <tr>
205
+ {LINK_VARIANTS.map(({ label, attachment }) => (
206
+ <td key={label} className="align-top">
207
+ <MediaMessage
208
+ isMyMessage={false}
209
+ message={base({ user: SENDER, attachments: [attachment as LocalMessage['attachments'][number]] })}
210
+ />
211
+ </td>
212
+ ))}
213
+ </tr>
214
+ <tr>
215
+ {LINK_VARIANTS.map(({ label, attachment }) => (
216
+ <td key={label} className="align-top">
217
+ <MediaMessage
218
+ isMyMessage={true}
219
+ message={base({ user: ME, attachments: [attachment as LocalMessage['attachments'][number]] })}
220
+ />
221
+ </td>
222
+ ))}
223
+ </tr>
224
+ </tbody>
225
+ </GridTable>
226
+ )
227
+ Links.parameters = {
165
228
  docs: {
166
229
  description: {
167
- story: 'Creator perspectiveown messages, right-aligned, no avatar.',
230
+ story: 'Link preview cards top row received, bottom row sent. Shows thumbnail, title, description, and URL with image fallback.',
168
231
  },
169
232
  },
170
233
  }
@@ -1,11 +1,20 @@
1
1
  import React from 'react'
2
2
  import type { LocalMessage } from 'stream-chat'
3
- import { describe, expect, it, vi } from 'vitest'
3
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
4
4
 
5
- import { renderWithProviders, screen } from '../../test/utils'
5
+ import { renderWithProviders, screen, fireEvent } from '../../test/utils'
6
6
 
7
7
  import { MediaMessage } from '.'
8
8
 
9
+ beforeAll(() => {
10
+ HTMLDialogElement.prototype.showModal = vi.fn(function (this: HTMLDialogElement) {
11
+ this.setAttribute('open', '')
12
+ })
13
+ HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
14
+ this.removeAttribute('open')
15
+ })
16
+ })
17
+
9
18
  vi.mock('../Avatar', () => ({
10
19
  Avatar: ({ id }: { id: string }) => (
11
20
  <div data-testid="avatar" data-user-id={id} />
@@ -16,14 +25,20 @@ vi.mock('../LockedAttachment/components/MediaPlayer', () => ({
16
25
  default: ({
17
26
  source,
18
27
  mimeType,
28
+ onContainerClick,
19
29
  }: {
20
30
  source: string
21
31
  mimeType: string
32
+ onContainerClick?: (e: React.MouseEvent) => void
22
33
  }) => (
23
34
  <div
35
+ role="button"
36
+ tabIndex={0}
24
37
  data-testid="media-player"
25
38
  data-source={source}
26
39
  data-mime-type={mimeType}
40
+ onClick={onContainerClick}
41
+ onKeyDown={undefined}
27
42
  />
28
43
  ),
29
44
  }))
@@ -41,7 +56,6 @@ vi.mock('../LockedAttachment/utils/mimeType', () => ({
41
56
  },
42
57
  }))
43
58
 
44
- // Cast through unknown to avoid satisfying every optional field of LocalMessage
45
59
  const msg = (overrides: Record<string, unknown> = {}): LocalMessage =>
46
60
  ({
47
61
  id: 'msg-1',
@@ -63,7 +77,7 @@ describe('MediaMessage', () => {
63
77
  expect(container.firstChild).toBeNull()
64
78
  })
65
79
 
66
- it('renders MediaPlayer for a video attachment', () => {
80
+ it('renders MediaPlayer immediately for a video attachment', () => {
67
81
  renderWithProviders(
68
82
  <MediaMessage
69
83
  message={msg({
@@ -72,6 +86,7 @@ describe('MediaMessage', () => {
72
86
  type: 'video',
73
87
  asset_url: 'https://cdn.example.com/clip.mp4',
74
88
  mime_type: 'video/mp4',
89
+ title: 'My Clip',
75
90
  },
76
91
  ],
77
92
  })}
@@ -80,11 +95,28 @@ describe('MediaMessage', () => {
80
95
 
81
96
  const player = screen.getByTestId('media-player')
82
97
  expect(player).toBeInTheDocument()
83
- expect(player).toHaveAttribute(
84
- 'data-source',
85
- 'https://cdn.example.com/clip.mp4'
98
+ expect(player).toHaveAttribute('data-source', 'https://cdn.example.com/clip.mp4')
99
+ })
100
+
101
+ it('opens viewer when the video player container is clicked', () => {
102
+ renderWithProviders(
103
+ <MediaMessage
104
+ message={msg({
105
+ attachments: [
106
+ {
107
+ type: 'video',
108
+ asset_url: 'https://cdn.example.com/clip.mp4',
109
+ mime_type: 'video/mp4',
110
+ title: 'My Clip',
111
+ },
112
+ ],
113
+ })}
114
+ />
86
115
  )
87
- expect(player).toHaveAttribute('data-mime-type', 'video/mp4')
116
+
117
+ fireEvent.click(screen.getByTestId('media-player'))
118
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled()
119
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
88
120
  })
89
121
 
90
122
  it('renders MediaPlayer for an audio attachment', () => {
@@ -108,7 +140,27 @@ describe('MediaMessage', () => {
108
140
  )
109
141
  })
110
142
 
111
- it('renders an img for an image attachment', () => {
143
+ it('does not show viewer or expand button for audio', () => {
144
+ renderWithProviders(
145
+ <MediaMessage
146
+ message={msg({
147
+ attachments: [
148
+ {
149
+ type: 'audio',
150
+ asset_url: 'https://cdn.example.com/track.mp3',
151
+ mime_type: 'audio/mpeg',
152
+ title: 'My Track',
153
+ },
154
+ ],
155
+ })}
156
+ />
157
+ )
158
+
159
+ expect(screen.queryByRole('button', { name: 'View full screen' })).not.toBeInTheDocument()
160
+ expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
161
+ })
162
+
163
+ it('renders an img for an image attachment and opens viewer on click', () => {
112
164
  renderWithProviders(
113
165
  <MediaMessage
114
166
  message={msg({
@@ -126,9 +178,36 @@ describe('MediaMessage', () => {
126
178
 
127
179
  const image = screen.getByRole('img', { name: 'My Photo' })
128
180
  expect(image).toHaveAttribute('src', 'https://cdn.example.com/photo.jpg')
181
+
182
+ // Clicking the image button opens the viewer
183
+ fireEvent.click(screen.getByRole('button', { name: 'My Photo' }))
184
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled()
185
+ const viewerImages = screen.getAllByRole('img', { name: 'My Photo' })
186
+ expect(viewerImages.some((img) => img.classList.contains('rounded-2xl'))).toBe(true)
129
187
  })
130
188
 
131
- it('renders a download link for a document attachment', () => {
189
+ it('does not show expand button in chin for images, only download', () => {
190
+ renderWithProviders(
191
+ <MediaMessage
192
+ message={msg({
193
+ attachments: [
194
+ {
195
+ type: 'image',
196
+ image_url: 'https://cdn.example.com/photo.jpg',
197
+ mime_type: 'image/jpeg',
198
+ title: 'My Photo',
199
+ },
200
+ ],
201
+ })}
202
+ />
203
+ )
204
+
205
+ // Thumbnail button + download button — no separate expand/fullscreen button
206
+ expect(screen.queryByRole('button', { name: 'View full screen' })).not.toBeInTheDocument()
207
+ expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument()
208
+ })
209
+
210
+ it('renders PDF card with a viewer button', () => {
132
211
  renderWithProviders(
133
212
  <MediaMessage
134
213
  message={msg({
@@ -144,13 +223,56 @@ describe('MediaMessage', () => {
144
223
  />
145
224
  )
146
225
 
147
- const link = screen.getByRole('link')
148
- expect(link).toHaveAttribute('href', 'https://cdn.example.com/report.pdf')
226
+ expect(screen.getByRole('button', { name: 'Open PDF viewer' })).toBeInTheDocument()
149
227
  expect(screen.getByText('Annual Report')).toBeInTheDocument()
150
- expect(screen.getByTestId('type-icon')).toBeInTheDocument()
151
228
  })
152
229
 
153
- it('shows title and file size below video', () => {
230
+ it('opens PDF viewer when PDF thumbnail is clicked', () => {
231
+ renderWithProviders(
232
+ <MediaMessage
233
+ message={msg({
234
+ attachments: [
235
+ {
236
+ type: 'file',
237
+ asset_url: 'https://cdn.example.com/report.pdf',
238
+ mime_type: 'application/pdf',
239
+ title: 'Annual Report',
240
+ },
241
+ ],
242
+ })}
243
+ />
244
+ )
245
+
246
+ fireEvent.click(screen.getByRole('button', { name: 'Open PDF viewer' }))
247
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled()
248
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
249
+ })
250
+
251
+ it('renders unknown file as a link with no viewer button', () => {
252
+ renderWithProviders(
253
+ <MediaMessage
254
+ message={msg({
255
+ attachments: [
256
+ {
257
+ type: 'file',
258
+ asset_url: 'https://cdn.example.com/data.bin',
259
+ mime_type: 'application/octet-stream',
260
+ title: 'Unknown File',
261
+ },
262
+ ],
263
+ })}
264
+ />
265
+ )
266
+
267
+ // No viewer button for unknown files
268
+ expect(screen.queryByRole('button', { name: 'Open PDF viewer' })).not.toBeInTheDocument()
269
+ // Thumbnail is a plain link (opens in new tab)
270
+ const links = screen.getAllByRole('link')
271
+ expect(links.some((l) => l.getAttribute('href') === 'https://cdn.example.com/data.bin')).toBe(true)
272
+ expect(screen.getByText('Unknown File')).toBeInTheDocument()
273
+ })
274
+
275
+ it('shows title and file size in chin', () => {
154
276
  renderWithProviders(
155
277
  <MediaMessage
156
278
  message={msg({
@@ -188,10 +310,7 @@ describe('MediaMessage', () => {
188
310
  )
189
311
 
190
312
  expect(screen.getByTestId('avatar')).toBeInTheDocument()
191
- expect(screen.getByTestId('avatar')).toHaveAttribute(
192
- 'data-user-id',
193
- 'user-1'
194
- )
313
+ expect(screen.getByTestId('avatar')).toHaveAttribute('data-user-id', 'user-1')
195
314
  })
196
315
 
197
316
  it('does not render Avatar for own messages', () => {
@@ -258,4 +377,144 @@ describe('MediaMessage', () => {
258
377
 
259
378
  expect(container.firstChild).toBeNull()
260
379
  })
380
+
381
+ it('renders a link card for a link attachment', () => {
382
+ renderWithProviders(
383
+ <MediaMessage
384
+ message={msg({
385
+ attachments: [
386
+ {
387
+ type: 'link',
388
+ og_scrape_url: 'https://linktr.ee/someone',
389
+ title: 'My Linktree',
390
+ text: 'Check out my links',
391
+ image_url: 'https://cdn.example.com/thumb.jpg',
392
+ },
393
+ ],
394
+ })}
395
+ />
396
+ )
397
+
398
+ const link = screen.getByRole('link')
399
+ expect(link).toHaveAttribute('href', 'https://linktr.ee/someone')
400
+ expect(screen.getByText('My Linktree')).toBeInTheDocument()
401
+ expect(screen.getByText('Check out my links')).toBeInTheDocument()
402
+ expect(screen.getByText('https://linktr.ee/someone')).toBeInTheDocument()
403
+ const img = screen.getByRole('img', { name: 'My Linktree' })
404
+ expect(img).toHaveAttribute('src', 'https://cdn.example.com/thumb.jpg')
405
+ })
406
+
407
+ it('renders a link fallback icon when link has no image', () => {
408
+ renderWithProviders(
409
+ <MediaMessage
410
+ message={msg({
411
+ attachments: [
412
+ {
413
+ type: 'link',
414
+ og_scrape_url: 'https://linktr.ee/someone',
415
+ title: 'My Linktree',
416
+ },
417
+ ],
418
+ })}
419
+ />
420
+ )
421
+
422
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
423
+ expect(screen.getByText('My Linktree')).toBeInTheDocument()
424
+ })
425
+
426
+ it('uses dark card background for sent messages', () => {
427
+ const { container } = renderWithProviders(
428
+ <MediaMessage
429
+ isMyMessage={true}
430
+ message={msg({
431
+ attachments: [
432
+ {
433
+ type: 'image',
434
+ image_url: 'https://cdn.example.com/photo.jpg',
435
+ mime_type: 'image/jpeg',
436
+ },
437
+ ],
438
+ })}
439
+ />
440
+ )
441
+
442
+ expect(container.querySelector('.bg-\\[\\#121110\\]')).toBeInTheDocument()
443
+ })
444
+
445
+ it('uses light card background for received messages', () => {
446
+ const { container } = renderWithProviders(
447
+ <MediaMessage
448
+ isMyMessage={false}
449
+ message={msg({
450
+ attachments: [
451
+ {
452
+ type: 'image',
453
+ image_url: 'https://cdn.example.com/photo.jpg',
454
+ mime_type: 'image/jpeg',
455
+ },
456
+ ],
457
+ })}
458
+ />
459
+ )
460
+
461
+ expect(container.querySelector('.bg-\\[\\#F3F3F1\\]')).toBeInTheDocument()
462
+ })
463
+
464
+ it('shows download button for image attachment', () => {
465
+ renderWithProviders(
466
+ <MediaMessage
467
+ message={msg({
468
+ attachments: [
469
+ {
470
+ type: 'image',
471
+ image_url: 'https://cdn.example.com/photo.jpg',
472
+ mime_type: 'image/jpeg',
473
+ title: 'My Photo',
474
+ },
475
+ ],
476
+ })}
477
+ />
478
+ )
479
+
480
+ expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument()
481
+ })
482
+
483
+ it('shows download button for audio attachment', () => {
484
+ renderWithProviders(
485
+ <MediaMessage
486
+ message={msg({
487
+ attachments: [
488
+ {
489
+ type: 'audio',
490
+ asset_url: 'https://cdn.example.com/track.mp3',
491
+ mime_type: 'audio/mpeg',
492
+ title: 'My Track',
493
+ },
494
+ ],
495
+ })}
496
+ />
497
+ )
498
+
499
+ expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument()
500
+ })
501
+
502
+ it('does not show download or expand buttons for link cards', () => {
503
+ renderWithProviders(
504
+ <MediaMessage
505
+ message={msg({
506
+ attachments: [
507
+ {
508
+ type: 'link',
509
+ og_scrape_url: 'https://linktr.ee/someone',
510
+ title: 'My Linktree',
511
+ },
512
+ ],
513
+ })}
514
+ />
515
+ )
516
+
517
+ expect(screen.queryByRole('button', { name: 'Download' })).not.toBeInTheDocument()
518
+ expect(screen.queryByRole('button', { name: 'View full screen' })).not.toBeInTheDocument()
519
+ })
261
520
  })