@linktr.ee/messaging-react 4.4.7 → 4.5.0-rc-1788297811

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 (54) hide show
  1. package/dist/assets/index.css +1 -1
  2. package/dist/index.cjs +2 -2
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +58 -43
  5. package/dist/index.js +1167 -1033
  6. package/dist/index.js.map +1 -1
  7. package/package.json +3 -3
  8. package/src/components/AttachmentCard/MediaPlayer.test.tsx +9 -5
  9. package/src/components/AttachmentCard/Thumbnail.test.tsx +4 -0
  10. package/src/components/ChannelActionsMenu/ChannelActionsMenu.test.tsx +18 -10
  11. package/src/components/ChannelList/ChannelListContext.test.tsx +7 -5
  12. package/src/components/ChannelList/CustomChannelPreview.test.tsx +7 -5
  13. package/src/components/ChannelList/index.test.tsx +1 -0
  14. package/src/components/ChannelView.test.tsx +20 -13
  15. package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +4 -3
  16. package/src/components/CustomMessage/CustomMessage.test.tsx +6 -5
  17. package/src/components/CustomMessage/MessageTag.test.tsx +6 -0
  18. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +19 -9
  19. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +45 -5
  20. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +6 -0
  21. package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +3 -0
  22. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +16 -7
  23. package/src/components/FaqList/FaqList.test.tsx +20 -12
  24. package/src/components/LinkAttachment/LinkAttachment.test.tsx +27 -14
  25. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +1 -0
  26. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +6 -6
  27. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +8 -5
  28. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +7 -5
  29. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +3 -8
  30. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +165 -58
  31. package/src/components/MessageAttachment/Audio/WaveformAudioRow.test.tsx +155 -0
  32. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +455 -0
  33. package/src/components/MessageAttachment/Audio/audioRowRequester.test.ts +24 -0
  34. package/src/components/MessageAttachment/Audio/audioRowRequester.ts +16 -0
  35. package/src/components/MessageAttachment/Audio/index.tsx +83 -79
  36. package/src/components/MessageAttachment/MessageAttachment.behavior.test.tsx +22 -15
  37. package/src/components/MessageAttachment/MessageAttachment.test.tsx +279 -134
  38. package/src/components/MessageAttachment/_shared/Bubble.test.tsx +2 -0
  39. package/src/components/MessageAttachment/_shared/CarouselNav.test.tsx +5 -4
  40. package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +4 -3
  41. package/src/components/MessageAttachment/_shared/DismissButton.test.tsx +4 -3
  42. package/src/components/MessageAttachment/_shared/DownloadAction.test.tsx +8 -5
  43. package/src/components/MessageAttachment/_shared/ImageViewer.test.tsx +6 -3
  44. package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +11 -4
  45. package/src/components/MessageAttachment/_shared/PdfViewer.test.tsx +5 -3
  46. package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +12 -3
  47. package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +13 -6
  48. package/src/components/MessageAttachment/_shared/useCarousel.test.tsx +10 -4
  49. package/src/components/MessageAttachment/_shared/useIsClient.ts +23 -0
  50. package/src/components/MessageAttachment/index.tsx +25 -9
  51. package/src/components/MessageAttachment/types.ts +19 -9
  52. package/src/components/MessageBubble/MessageBubble.test.tsx +8 -0
  53. package/src/components/RichCard/RichCard.test.tsx +10 -2
  54. package/src/styles.css +120 -0
@@ -1,4 +1,9 @@
1
1
  import React from 'react'
2
+ import {
3
+ ChatProvider,
4
+ WithAudioPlayback,
5
+ type ChatContextValue,
6
+ } from 'stream-chat-react'
2
7
  import { beforeEach, describe, expect, it, vi } from 'vitest'
3
8
 
4
9
  import {
@@ -6,12 +11,28 @@ import {
6
11
  renderWithProviders,
7
12
  screen,
8
13
  within,
14
+ userEvent,
9
15
  } from '../../test/utils'
10
16
 
11
17
  import { triggerDownload } from './_shared/triggerDownload'
12
18
 
13
19
  import MessageAttachment, { bubbleGroupPositionFromStream } from '.'
14
20
 
21
+ // `MessageAttachment.Audio`'s playback comes from `stream-chat-react`'s
22
+ // pooled `AudioPlayer`, which `<Channel>` mounts in production. Mounting
23
+ // the pool directly is the same contract without needing a channel that
24
+ // can `watch()`. The player reads only `client` off the chat context —
25
+ // optional-chained, for error notifications — so a stub is enough; its
26
+ // presence is what stops `useChatContext` warning.
27
+ const AUDIO_CHAT_CONTEXT = {} as ChatContextValue
28
+
29
+ const renderAudio = (ui: React.ReactElement) =>
30
+ renderWithProviders(
31
+ <ChatProvider value={AUDIO_CHAT_CONTEXT}>
32
+ <WithAudioPlayback>{ui}</WithAudioPlayback>
33
+ </ChatProvider>
34
+ )
35
+
15
36
  vi.mock('./_shared/triggerDownload', () => ({
16
37
  triggerDownload: vi.fn(() => Promise.resolve()),
17
38
  }))
@@ -38,7 +59,8 @@ const expectViewerOpen = (testId: string) => {
38
59
  }
39
60
 
40
61
  describe('MessageAttachment.Image', () => {
41
- it('optimizes Linktree-hosted composer previews without changing the viewer source', () => {
62
+ it('optimizes Linktree-hosted composer previews without changing the viewer source', async () => {
63
+ const user = userEvent.setup()
42
64
  renderWithProviders(
43
65
  <MessageAttachment.Image.Composer
44
66
  src="https://assets.linktr.ee/photo.jpg"
@@ -51,8 +73,9 @@ describe('MessageAttachment.Image', () => {
51
73
  'https://assets.linktr.ee/photo.jpg?io=true&size=messaging-attachment-v1_0'
52
74
  )
53
75
 
54
- fireEvent.click(screen.getByLabelText('Open image'))
76
+ await user.click(screen.getByLabelText('Open image'))
55
77
  const viewer = expectViewerOpen('image-viewer')
78
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
56
79
  expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
57
80
  'https://assets.linktr.ee/photo.jpg'
58
81
  )
@@ -69,12 +92,14 @@ describe('MessageAttachment.Image', () => {
69
92
  const bubble = screen.getByTestId('image-attachment')
70
93
  expect(bubble).toBeInTheDocument()
71
94
  expect(screen.getByText('Here is the image')).toBeInTheDocument()
95
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
72
96
  expect(bubble.querySelector('img')?.getAttribute('src')).toBe(
73
97
  'https://cdn.example.com/photo.jpg'
74
98
  )
75
99
  })
76
100
 
77
- it('opens the ImageViewer lightbox when clicked', () => {
101
+ it('opens the ImageViewer lightbox when clicked', async () => {
102
+ const user = userEvent.setup()
78
103
  renderWithProviders(
79
104
  <MessageAttachment.Image.Received
80
105
  src="https://cdn.example.com/photo.jpg"
@@ -85,7 +110,7 @@ describe('MessageAttachment.Image', () => {
85
110
 
86
111
  expectViewerClosed('image-viewer')
87
112
 
88
- fireEvent.click(screen.getByLabelText('Open image 1 of 1'))
113
+ await user.click(screen.getByLabelText('Open image 1 of 1'))
89
114
 
90
115
  expectViewerOpen('image-viewer')
91
116
  })
@@ -121,7 +146,8 @@ describe('MessageAttachment.Image', () => {
121
146
  expect(screen.getByLabelText('Open image 3 of 3')).toBeInTheDocument()
122
147
  })
123
148
 
124
- it('renders a download button inside the image viewer', () => {
149
+ it('renders a download button inside the image viewer', async () => {
150
+ const user = userEvent.setup()
125
151
  renderWithProviders(
126
152
  <MessageAttachment.Image.Received
127
153
  src="https://cdn.example.com/photo.jpg"
@@ -129,30 +155,33 @@ describe('MessageAttachment.Image', () => {
129
155
  filename="photo.jpg"
130
156
  />
131
157
  )
132
- fireEvent.click(screen.getByLabelText('Open image 1 of 1'))
158
+ await user.click(screen.getByLabelText('Open image 1 of 1'))
133
159
  const viewer = expectViewerOpen('image-viewer')
134
160
  // The viewer chrome carries its own download action — separate
135
161
  // from any sibling download button on the bubble surface — so the
136
162
  // user can grab the image without leaving the lightbox.
137
163
  expect(
164
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
138
165
  viewer.querySelector('button[aria-label="Download photo.jpg"]')
139
166
  ).not.toBeNull()
140
167
  })
141
168
 
142
- it('does not render carousel nav controls for a single image', () => {
169
+ it('does not render carousel nav controls for a single image', async () => {
170
+ const user = userEvent.setup()
143
171
  renderWithProviders(
144
172
  <MessageAttachment.Image.Received
145
173
  src="https://cdn.example.com/photo.jpg"
146
174
  filename="photo.jpg"
147
175
  />
148
176
  )
149
- fireEvent.click(screen.getByLabelText('Open image 1 of 1'))
177
+ await user.click(screen.getByLabelText('Open image 1 of 1'))
150
178
  expectViewerOpen('image-viewer')
151
179
  expect(screen.queryByLabelText('Next image')).toBeNull()
152
180
  expect(screen.queryByLabelText('Previous image')).toBeNull()
153
181
  })
154
182
 
155
- it('renders the stacked images as a carousel inside the viewer', () => {
183
+ it('renders the stacked images as a carousel inside the viewer', async () => {
184
+ const user = userEvent.setup()
156
185
  renderWithProviders(
157
186
  <MessageAttachment.Image.Sent
158
187
  filename="album"
@@ -165,8 +194,9 @@ describe('MessageAttachment.Image', () => {
165
194
  )
166
195
 
167
196
  // Open the second tile — the viewer should land on image 2.
168
- fireEvent.click(screen.getByLabelText('Open image 2 of 3'))
197
+ await user.click(screen.getByLabelText('Open image 2 of 3'))
169
198
  const viewer = expectViewerOpen('image-viewer')
199
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
170
200
  expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
171
201
  'https://cdn.example.com/b.jpg'
172
202
  )
@@ -174,26 +204,30 @@ describe('MessageAttachment.Image', () => {
174
204
  expect(viewer.textContent).toContain('2 / 3')
175
205
 
176
206
  // Next button advances by one.
177
- fireEvent.click(screen.getByLabelText('Next image'))
207
+ await user.click(screen.getByLabelText('Next image'))
208
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
178
209
  expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
179
210
  'https://cdn.example.com/c.jpg'
180
211
  )
181
212
  expect(viewer.textContent).toContain('3 / 3')
182
213
 
183
214
  // Next wraps from the last item back to the first.
184
- fireEvent.click(screen.getByLabelText('Next image'))
215
+ await user.click(screen.getByLabelText('Next image'))
216
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
185
217
  expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
186
218
  'https://cdn.example.com/a.jpg'
187
219
  )
188
220
 
189
221
  // Previous wraps from the first item to the last.
190
- fireEvent.click(screen.getByLabelText('Previous image'))
222
+ await user.click(screen.getByLabelText('Previous image'))
223
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
191
224
  expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
192
225
  'https://cdn.example.com/c.jpg'
193
226
  )
194
227
  })
195
228
 
196
- it('uses ArrowRight / ArrowLeft to navigate stacked images', () => {
229
+ it('uses ArrowRight / ArrowLeft to navigate stacked images', async () => {
230
+ const user = userEvent.setup()
197
231
  renderWithProviders(
198
232
  <MessageAttachment.Image.Sent
199
233
  items={[
@@ -202,18 +236,21 @@ describe('MessageAttachment.Image', () => {
202
236
  ]}
203
237
  />
204
238
  )
205
- fireEvent.click(screen.getByLabelText('Open image 1 of 2'))
239
+ await user.click(screen.getByLabelText('Open image 1 of 2'))
206
240
  const viewer = expectViewerOpen('image-viewer')
241
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
207
242
  expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
208
243
  'https://cdn.example.com/a.jpg'
209
244
  )
210
245
 
211
246
  fireEvent.keyDown(window, { key: 'ArrowRight' })
247
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
212
248
  expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
213
249
  'https://cdn.example.com/b.jpg'
214
250
  )
215
251
 
216
252
  fireEvent.keyDown(window, { key: 'ArrowLeft' })
253
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
217
254
  expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
218
255
  'https://cdn.example.com/a.jpg'
219
256
  )
@@ -242,6 +279,7 @@ describe('MessageAttachment.Image single-image aspect ratio', () => {
242
279
  const WIDTH_CAP_WEB_PX = 326
243
280
 
244
281
  const mediaBox = (label: string) =>
282
+ // eslint-disable-next-line testing-library/no-node-access -- the wrapper class is a deliberate DOM contract with no semantic query.
245
283
  screen.getByLabelText(label).parentElement as HTMLElement
246
284
 
247
285
  /** The `min(var(--…), Npx)` term the height cap contributes. */
@@ -319,6 +357,7 @@ describe('MessageAttachment.Image single-image aspect ratio', () => {
319
357
 
320
358
  // Scoped to the tile: the always-mounted `ImageViewer` renders a
321
359
  // second `<img>` for the same source.
360
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
322
361
  const img = mediaBox('Open image 1 of 1').querySelector(
323
362
  'img'
324
363
  ) as HTMLImageElement
@@ -405,7 +444,8 @@ describe('MessageAttachment.Pdf', () => {
405
444
  expect(screen.getByText('PDF · 379.5 KB')).toBeInTheDocument()
406
445
  })
407
446
 
408
- it('opens the PdfViewer when clicked', () => {
447
+ it('opens the PdfViewer when clicked', async () => {
448
+ const user = userEvent.setup()
409
449
  renderWithProviders(
410
450
  <MessageAttachment.Pdf.Sent
411
451
  src="https://cdn.example.com/doc.pdf"
@@ -413,11 +453,12 @@ describe('MessageAttachment.Pdf', () => {
413
453
  />
414
454
  )
415
455
  expectViewerClosed('pdf-viewer')
416
- fireEvent.click(screen.getByLabelText('Open doc.pdf'))
456
+ await user.click(screen.getByLabelText('Open doc.pdf'))
417
457
  expectViewerOpen('pdf-viewer')
418
458
  })
419
459
 
420
- it('forwards the onClick handler before opening the viewer', () => {
460
+ it('forwards the onClick handler before opening the viewer', async () => {
461
+ const user = userEvent.setup()
421
462
  const onClick = vi.fn()
422
463
  renderWithProviders(
423
464
  <MessageAttachment.Pdf.Received
@@ -426,7 +467,7 @@ describe('MessageAttachment.Pdf', () => {
426
467
  onClick={onClick}
427
468
  />
428
469
  )
429
- fireEvent.click(screen.getByLabelText('Open doc.pdf'))
470
+ await user.click(screen.getByLabelText('Open doc.pdf'))
430
471
  expect(onClick).toHaveBeenCalledTimes(1)
431
472
  })
432
473
 
@@ -457,7 +498,8 @@ describe('MessageAttachment.Pdf', () => {
457
498
  expect(screen.getByLabelText('Open c.pdf')).toBeInTheDocument()
458
499
  })
459
500
 
460
- it('opens the viewer on the activated row in a stacked PDF bubble', () => {
501
+ it('opens the viewer on the activated row in a stacked PDF bubble', async () => {
502
+ const user = userEvent.setup()
461
503
  renderWithProviders(
462
504
  <MessageAttachment.Pdf.Received
463
505
  items={[
@@ -467,7 +509,7 @@ describe('MessageAttachment.Pdf', () => {
467
509
  />
468
510
  )
469
511
  expectViewerClosed('pdf-viewer')
470
- fireEvent.click(screen.getByLabelText('Open b.pdf'))
512
+ await user.click(screen.getByLabelText('Open b.pdf'))
471
513
  const viewer = expectViewerOpen('pdf-viewer')
472
514
  // The active filename surfaces as the dialog's accessible name —
473
515
  // the lightbox chrome is intentionally just close + carousel chrome
@@ -479,7 +521,8 @@ describe('MessageAttachment.Pdf', () => {
479
521
  expect(viewer.textContent).toContain('2 / 2')
480
522
  })
481
523
 
482
- it('navigates between stacked PDFs via the carousel controls', () => {
524
+ it('navigates between stacked PDFs via the carousel controls', async () => {
525
+ const user = userEvent.setup()
483
526
  renderWithProviders(
484
527
  <MessageAttachment.Pdf.Received
485
528
  items={[
@@ -489,18 +532,18 @@ describe('MessageAttachment.Pdf', () => {
489
532
  ]}
490
533
  />
491
534
  )
492
- fireEvent.click(screen.getByLabelText('Open a.pdf'))
535
+ await user.click(screen.getByLabelText('Open a.pdf'))
493
536
  const viewer = expectViewerOpen('pdf-viewer')
494
537
  expect(viewer.getAttribute('aria-label')).toBe('a.pdf')
495
538
 
496
- fireEvent.click(screen.getByLabelText('Next document'))
539
+ await user.click(screen.getByLabelText('Next document'))
497
540
  expect(viewer.getAttribute('aria-label')).toBe('b.pdf')
498
541
 
499
- fireEvent.click(screen.getByLabelText('Next document'))
542
+ await user.click(screen.getByLabelText('Next document'))
500
543
  expect(viewer.getAttribute('aria-label')).toBe('c.pdf')
501
544
 
502
545
  // Wraps back to the start.
503
- fireEvent.click(screen.getByLabelText('Next document'))
546
+ await user.click(screen.getByLabelText('Next document'))
504
547
  expect(viewer.getAttribute('aria-label')).toBe('a.pdf')
505
548
  })
506
549
 
@@ -537,7 +580,8 @@ describe('MessageAttachment.Pdf', () => {
537
580
  expect(screen.getByLabelText('Download c.pdf')).toBeInTheDocument()
538
581
  })
539
582
 
540
- it('does not open the viewer when the Download button is clicked', () => {
583
+ it('does not open the viewer when the Download button is clicked', async () => {
584
+ const user = userEvent.setup()
541
585
  renderWithProviders(
542
586
  <MessageAttachment.Pdf.Sent
543
587
  src="https://cdn.example.com/notes.pdf"
@@ -545,7 +589,7 @@ describe('MessageAttachment.Pdf', () => {
545
589
  />
546
590
  )
547
591
  expectViewerClosed('pdf-viewer')
548
- fireEvent.click(screen.getByLabelText('Download notes.pdf'))
592
+ await user.click(screen.getByLabelText('Download notes.pdf'))
549
593
  // Download should fire on its own without bubbling up to the row
550
594
  // activation that opens the viewer.
551
595
  expectViewerClosed('pdf-viewer')
@@ -563,7 +607,8 @@ describe('MessageAttachment.Pdf', () => {
563
607
  expect(screen.getByLabelText('Dismiss attachment')).toBeInTheDocument()
564
608
  })
565
609
 
566
- it('does not crash when items shrinks after the viewer opens (P1 regression)', () => {
610
+ it('does not crash when items shrinks after the viewer opens (P1 regression)', async () => {
611
+ const user = userEvent.setup()
567
612
  // Regression test for the PR #151 P1 reviewer finding:
568
613
  // `viewerIndex` was dereferenced directly against `resolvedItems`,
569
614
  // so a re-render with fewer items than the open index would crash
@@ -578,7 +623,7 @@ describe('MessageAttachment.Pdf', () => {
578
623
  ]}
579
624
  />
580
625
  )
581
- fireEvent.click(screen.getByLabelText('Open c.pdf'))
626
+ await user.click(screen.getByLabelText('Open c.pdf'))
582
627
  expectViewerOpen('pdf-viewer')
583
628
 
584
629
  expect(() =>
@@ -599,7 +644,8 @@ describe('MessageAttachment.Pdf', () => {
599
644
  )
600
645
  })
601
646
 
602
- it('does not open the viewer when onClick returns false', () => {
647
+ it('does not open the viewer when onClick returns false', async () => {
648
+ const user = userEvent.setup()
603
649
  const onClick = vi.fn(() => false as const)
604
650
  renderWithProviders(
605
651
  <MessageAttachment.Pdf.Received
@@ -608,7 +654,7 @@ describe('MessageAttachment.Pdf', () => {
608
654
  onClick={onClick}
609
655
  />
610
656
  )
611
- fireEvent.click(screen.getByLabelText('Open doc.pdf'))
657
+ await user.click(screen.getByLabelText('Open doc.pdf'))
612
658
  expect(onClick).toHaveBeenCalledTimes(1)
613
659
  expectViewerClosed('pdf-viewer')
614
660
  })
@@ -669,7 +715,8 @@ describe('MessageAttachment.File', () => {
669
715
  expect(screen.getByLabelText('Download c.pdf')).toBeInTheDocument()
670
716
  })
671
717
 
672
- it('downloads the file when the row is clicked and onClick allows it', () => {
718
+ it('downloads the file when the row is clicked and onClick allows it', async () => {
719
+ const user = userEvent.setup()
673
720
  const onClick = vi.fn()
674
721
  renderWithProviders(
675
722
  <MessageAttachment.File.Received
@@ -678,7 +725,7 @@ describe('MessageAttachment.File', () => {
678
725
  onClick={onClick}
679
726
  />
680
727
  )
681
- fireEvent.click(screen.getByLabelText('Download workout-plan.zip'))
728
+ await user.click(screen.getByLabelText('Download workout-plan.zip'))
682
729
  expect(onClick).toHaveBeenCalledWith(0)
683
730
  expect(mockTriggerDownload).toHaveBeenCalledTimes(1)
684
731
  expect(mockTriggerDownload).toHaveBeenCalledWith(
@@ -687,7 +734,8 @@ describe('MessageAttachment.File', () => {
687
734
  )
688
735
  })
689
736
 
690
- it('lets onClick cancel the automatic download by returning false (P2 regression)', () => {
737
+ it('lets onClick cancel the automatic download by returning false (P2 regression)', async () => {
738
+ const user = userEvent.setup()
691
739
  // Regression test for the PR #151 P2 reviewer finding:
692
740
  // the docs advertised `onClick` as a hook for intercepting the
693
741
  // download (e.g. confirmation modal), but the handler used to fire
@@ -701,12 +749,13 @@ describe('MessageAttachment.File', () => {
701
749
  onClick={onClick}
702
750
  />
703
751
  )
704
- fireEvent.click(screen.getByLabelText('Download workout-plan.zip'))
752
+ await user.click(screen.getByLabelText('Download workout-plan.zip'))
705
753
  expect(onClick).toHaveBeenCalledTimes(1)
706
754
  expect(mockTriggerDownload).not.toHaveBeenCalled()
707
755
  })
708
756
 
709
- it('still downloads when onClick returns void', () => {
757
+ it('still downloads when onClick returns void', async () => {
758
+ const user = userEvent.setup()
710
759
  // Belt-and-braces: void / undefined returns must not be confused
711
760
  // with `false` (the only cancellation sentinel).
712
761
  const onClick = vi.fn(() => undefined)
@@ -717,61 +766,190 @@ describe('MessageAttachment.File', () => {
717
766
  onClick={onClick}
718
767
  />
719
768
  )
720
- fireEvent.click(screen.getByLabelText('Download workout-plan.zip'))
769
+ await user.click(screen.getByLabelText('Download workout-plan.zip'))
721
770
  expect(mockTriggerDownload).toHaveBeenCalledTimes(1)
722
771
  })
723
772
  })
724
773
 
725
774
  describe('MessageAttachment.Audio', () => {
726
- it('renders the native audio player only (no title, no extra download)', () => {
727
- renderWithProviders(
775
+ // 12 samples so the resample path is exercised on the way to the
776
+ // design's 35 bars.
777
+ const WAVEFORM = [
778
+ 0.1, 0.8, 0.65, 0.2, 0.3, 0.45, 0.5, 0.7, 1, 0.85, 0.4, 0.15,
779
+ ]
780
+
781
+ it('renders the waveform card: bars, play toggle, duration and seek', () => {
782
+ renderAudio(
728
783
  <MessageAttachment.Audio.Sent
729
784
  src="https://cdn.example.com/song.mp3"
730
785
  mimeType="audio/mpeg"
731
786
  filename="song.mp3"
787
+ durationSeconds={12}
788
+ waveformData={WAVEFORM}
732
789
  />
733
790
  )
734
- const bubble = screen.getByTestId('audio-attachment')
735
- expect(bubble.querySelector('audio')).not.toBeNull()
736
- // No filename title — the native `<audio>` element doesn't expose
737
- // a title slot, so we render just the player.
738
- expect(screen.queryByText('song.mp3')).toBeNull()
739
- // Native player handles its own download in the kebab menu, so
740
- // no extra Download button should be rendered.
741
- expect(screen.queryByRole('button', { name: /download/i })).toBeNull()
791
+
792
+ expect(screen.getAllByTestId('amplitude-bar')).toHaveLength(35)
793
+ expect(
794
+ screen.getByRole('button', { name: 'Play audio song.mp3' })
795
+ ).toBeInTheDocument()
796
+ expect(screen.getByText('00:12')).toBeInTheDocument()
797
+
798
+ const seek = screen.getByRole('slider', { name: 'Seek audio — song.mp3' })
799
+ expect(seek).toHaveAttribute('aria-valuetext', '00:00 of 00:12')
800
+ expect(seek).toHaveValue('0')
801
+
802
+ // The bars duplicate what the slider already announces, so they
803
+ // must stay out of the accessibility tree — otherwise they read as
804
+ // 35 unnamed nodes and (in Stream's own version of this widget) as
805
+ // an unnamed `progressbar`.
806
+ const track = screen.getByTestId('audio-waveform-track')
807
+ expect(track).toHaveAttribute('aria-hidden', 'true')
808
+ })
809
+
810
+ it('renders the full bar count as a flat placeholder when no waveform exists', () => {
811
+ // The total fallback: a decode failure, an unsupported codec, a
812
+ // re-staged zero-byte placeholder, or anything sent before
813
+ // `waveform_data` was persisted. Passing `[]` straight through
814
+ // would render no bars at all, so the card must supply its own
815
+ // all-zero row — the card's silhouette has to be unchanged.
816
+ renderAudio(
817
+ <MessageAttachment.Audio.Received src="https://cdn.example.com/legacy.mp3" />
818
+ )
819
+
820
+ const bars = screen.getAllByTestId('amplitude-bar')
821
+ expect(bars).toHaveLength(35)
822
+ bars.forEach((bar) => {
823
+ expect(bar).not.toHaveClass('mes-audio-wave__bar--played')
824
+ })
825
+ })
826
+
827
+ it.each([3, 12, 35, 200])(
828
+ 'resamples a %i-sample waveform to the rendered bar count',
829
+ (sampleCount) => {
830
+ const data = Array.from({ length: sampleCount }, (_, i) =>
831
+ Math.abs(Math.sin(i))
832
+ )
833
+ renderAudio(
834
+ <MessageAttachment.Audio.Sent
835
+ src="https://cdn.example.com/song.mp3"
836
+ waveformData={data}
837
+ />
838
+ )
839
+ expect(screen.getAllByTestId('amplitude-bar')).toHaveLength(35)
840
+ }
841
+ )
842
+
843
+ it('shows an unknown duration without collapsing the slot', () => {
844
+ renderAudio(
845
+ <MessageAttachment.Audio.Sent src="https://cdn.example.com/legacy.mp3" />
846
+ )
847
+
848
+ expect(screen.getByText('--:--')).toBeInTheDocument()
849
+ expect(screen.getByRole('slider', { name: 'Seek audio' })).toHaveAttribute(
850
+ 'aria-valuetext',
851
+ '00:00 of --:--'
852
+ )
742
853
  })
743
854
 
744
- it('shows an inline dismiss button next to the player on Composer state', () => {
855
+ it('removes the draft from the composer trailing action', async () => {
856
+ const user = userEvent.setup()
857
+ // There is no product-owned overflow menu on audio attachments in
858
+ // production, so the design's trailing control carries forward the
859
+ // one action each surface already had: Remove in the composer…
745
860
  const onDismiss = vi.fn()
746
- renderWithProviders(
861
+ renderAudio(
747
862
  <MessageAttachment.Audio.Composer
748
863
  src="https://cdn.example.com/song.mp3"
864
+ filename="song.mp3"
749
865
  onDismiss={onDismiss}
750
866
  />
751
867
  )
752
- const dismiss = screen.getByLabelText('Dismiss attachment')
753
- expect(dismiss).toBeInTheDocument()
754
- fireEvent.click(dismiss)
868
+
869
+ expect(
870
+ screen.queryByRole('button', { name: /download/i })
871
+ ).not.toBeInTheDocument()
872
+ await user.click(screen.getByRole('button', { name: 'Remove attachment' }))
755
873
  expect(onDismiss).toHaveBeenCalledTimes(1)
756
874
  })
757
875
 
758
- it('renders one native player per item for stacked audio', () => {
759
- renderWithProviders(
876
+ it('downloads from the sent trailing action', async () => {
877
+ const user = userEvent.setup()
878
+ // …and Download once sent, preserving the capability the native
879
+ // `<audio controls>` kebab used to provide.
880
+ renderAudio(
881
+ <MessageAttachment.Audio.Sent
882
+ src="https://cdn.example.com/song.mp3"
883
+ filename="song.mp3"
884
+ />
885
+ )
886
+
887
+ await user.click(
888
+ screen.getByRole('button', { name: 'Download audio — song.mp3' })
889
+ )
890
+ expect(mockTriggerDownload).toHaveBeenCalledWith(
891
+ 'https://cdn.example.com/song.mp3',
892
+ 'song.mp3'
893
+ )
894
+ })
895
+
896
+ it('renders one waveform row per item for stacked audio', () => {
897
+ renderAudio(
760
898
  <MessageAttachment.Audio.Sent
761
899
  items={[
762
- { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
763
- { src: 'https://cdn.example.com/b.mp3', mimeType: 'audio/mpeg' },
764
- { src: 'https://cdn.example.com/c.mp3', mimeType: 'audio/mpeg' },
900
+ { src: 'https://cdn.example.com/a.mp3', durationSeconds: 12 },
901
+ { src: 'https://cdn.example.com/b.mp3', durationSeconds: 30 },
902
+ { src: 'https://cdn.example.com/c.mp3', durationSeconds: 65 },
765
903
  ]}
766
904
  />
767
905
  )
768
- const bubble = screen.getByTestId('audio-attachment')
769
- expect(bubble.querySelectorAll('audio')).toHaveLength(3)
906
+
907
+ expect(screen.getAllByTestId('audio-attachment-row')).toHaveLength(3)
908
+ expect(screen.getByText('01:05')).toBeInTheDocument()
909
+ })
910
+
911
+ it('disables playback for a MIME type the browser cannot play, but keeps the download', () => {
912
+ // `canPlayType` gates playback, and clicking play on an unplayable
913
+ // track is a no-op inside the player anyway — so the control says
914
+ // so. The file must still be retrievable.
915
+ renderAudio(
916
+ <MessageAttachment.Audio.Received
917
+ src="https://cdn.example.com/master.flac"
918
+ mimeType="audio/flac"
919
+ filename="master.flac"
920
+ />
921
+ )
922
+
923
+ expect(screen.getByRole('button', { name: /^Play audio/ })).toBeDisabled()
924
+ expect(screen.getByRole('slider', { name: /^Seek audio/ })).toBeDisabled()
925
+ expect(
926
+ screen.getByRole('button', { name: /^Download audio/ })
927
+ ).toBeEnabled()
928
+ })
929
+
930
+ it('still renders the card without the audio-playback provider', () => {
931
+ // The pooled player lives on `<Channel>`. Outside one the card must
932
+ // degrade to an inert card rather than rendering nothing — a blank
933
+ // bubble is undiagnosable, and it would silently break every
934
+ // consumer that mounts an attachment outside a channel.
935
+ renderWithProviders(
936
+ <MessageAttachment.Audio.Sent
937
+ src="https://cdn.example.com/song.mp3"
938
+ durationSeconds={12}
939
+ waveformData={WAVEFORM}
940
+ />
941
+ )
942
+
943
+ expect(screen.getByTestId('audio-attachment')).toBeInTheDocument()
944
+ expect(screen.getAllByTestId('amplitude-bar')).toHaveLength(35)
945
+ expect(screen.getByText('00:12')).toBeInTheDocument()
946
+ expect(screen.getByRole('button', { name: /^Play audio/ })).toBeDisabled()
770
947
  })
771
948
  })
772
949
 
773
950
  describe('MessageAttachment.Video', () => {
774
- it('opens the VideoViewer when clicked', () => {
951
+ it('opens the VideoViewer when clicked', async () => {
952
+ const user = userEvent.setup()
775
953
  renderWithProviders(
776
954
  <MessageAttachment.Video.Received
777
955
  src="https://cdn.example.com/clip.mp4"
@@ -781,13 +959,14 @@ describe('MessageAttachment.Video', () => {
781
959
  />
782
960
  )
783
961
  expectViewerClosed('video-viewer')
784
- fireEvent.click(screen.getByLabelText('Play video 1 of 1'))
962
+ await user.click(screen.getByLabelText('Play video 1 of 1'))
785
963
  expectViewerOpen('video-viewer')
786
964
  // Single-item viewer has no carousel chrome.
787
965
  expect(screen.queryByLabelText('Next video')).toBeNull()
788
966
  })
789
967
 
790
- it('navigates between stacked videos via the carousel controls', () => {
968
+ it('navigates between stacked videos via the carousel controls', async () => {
969
+ const user = userEvent.setup()
791
970
  renderWithProviders(
792
971
  <MessageAttachment.Video.Sent
793
972
  filename="clip"
@@ -805,13 +984,15 @@ describe('MessageAttachment.Video', () => {
805
984
  ]}
806
985
  />
807
986
  )
808
- fireEvent.click(screen.getByLabelText('Play video 1 of 2'))
987
+ await user.click(screen.getByLabelText('Play video 1 of 2'))
809
988
  const viewer = expectViewerOpen('video-viewer')
989
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
810
990
  expect(viewer.querySelector('video')?.getAttribute('src')).toBe(
811
991
  'https://cdn.example.com/a.mp4'
812
992
  )
813
993
 
814
- fireEvent.click(screen.getByLabelText('Next video'))
994
+ await user.click(screen.getByLabelText('Next video'))
995
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
815
996
  expect(viewer.querySelector('video')?.getAttribute('src')).toBe(
816
997
  'https://cdn.example.com/b.mp4'
817
998
  )
@@ -841,11 +1022,13 @@ describe('MessageAttachment.Video overflow', () => {
841
1022
  expect(screen.queryByLabelText('Play video 5 of 9')).toBeNull()
842
1023
  })
843
1024
 
844
- it('opens the viewer on the fourth video from the overflow tile and pages to the ninth', () => {
1025
+ it('opens the viewer on the fourth video from the overflow tile and pages to the ninth', async () => {
1026
+ const user = userEvent.setup()
845
1027
  renderWithProviders(<MessageAttachment.Video.Sent items={NINE_VIDEOS} />)
846
1028
 
847
- fireEvent.click(screen.getByLabelText('Play video 4 of 9'))
1029
+ await user.click(screen.getByLabelText('Play video 4 of 9'))
848
1030
  const viewer = expectViewerOpen('video-viewer')
1031
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
849
1032
  expect(viewer.querySelector('video')?.getAttribute('src')).toBe(
850
1033
  'https://cdn.example.com/3.mp4'
851
1034
  )
@@ -854,8 +1037,9 @@ describe('MessageAttachment.Video overflow', () => {
854
1037
 
855
1038
  // The carousel spans all nine, not just the four the grid drew.
856
1039
  for (let i = 0; i < 5; i += 1) {
857
- fireEvent.click(screen.getByLabelText('Next video'))
1040
+ await user.click(screen.getByLabelText('Next video'))
858
1041
  }
1042
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
859
1043
  expect(viewer.querySelector('video')?.getAttribute('src')).toBe(
860
1044
  'https://cdn.example.com/8.mp4'
861
1045
  )
@@ -867,7 +1051,9 @@ describe('MessageAttachment.Video overflow', () => {
867
1051
 
868
1052
  const tiles = screen.getAllByRole('button', { name: /^Play video/ })
869
1053
  // The badge is the only `svg` a poster tile renders.
1054
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
870
1055
  expect(tiles[0].querySelector('svg')).not.toBeNull()
1056
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
871
1057
  expect(tiles[3].querySelector('svg')).toBeNull()
872
1058
  })
873
1059
  })
@@ -878,6 +1064,7 @@ describe('MessageAttachment.Video single-card aspect', () => {
878
1064
  // jsdom never decodes an `<img>`, so `naturalWidth` stays 0 and `load`
879
1065
  // never fires on its own — stub the intrinsic size and fire it.
880
1066
  const loadPosterAt = (naturalWidth: number, naturalHeight: number) => {
1067
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
881
1068
  const poster = screen.getByTestId('video-attachment').querySelector('img')
882
1069
  if (!poster) throw new Error('no poster rendered')
883
1070
  Object.defineProperty(poster, 'naturalWidth', {
@@ -892,6 +1079,7 @@ describe('MessageAttachment.Video single-card aspect', () => {
892
1079
  }
893
1080
 
894
1081
  const mediaAspect = () =>
1082
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
895
1083
  screen.getByTestId('video-attachment').querySelector<HTMLElement>('[style]')
896
1084
  ?.style.aspectRatio
897
1085
 
@@ -1054,23 +1242,25 @@ describe('MessageAttachment.Video single-card aspect', () => {
1054
1242
  })
1055
1243
 
1056
1244
  describe('ViewerShell focus management', () => {
1057
- it('moves focus into the dialog when a viewer opens', () => {
1245
+ it('moves focus into the dialog when a viewer opens', async () => {
1246
+ const user = userEvent.setup()
1058
1247
  renderWithProviders(
1059
1248
  <MessageAttachment.Pdf.Received
1060
1249
  src="https://cdn.example.com/doc.pdf"
1061
1250
  filename="doc.pdf"
1062
1251
  />
1063
1252
  )
1064
- fireEvent.click(screen.getByLabelText('Open doc.pdf'))
1253
+ await user.click(screen.getByLabelText('Open doc.pdf'))
1065
1254
 
1066
1255
  // The close button is the primary focus target in the viewer
1067
1256
  // toolbar — keyboard users should land on it on open so Escape
1068
1257
  // / Tab work without an extra click.
1069
1258
  const close = screen.getByLabelText('Close viewer')
1070
- expect(document.activeElement).toBe(close)
1259
+ expect(close).toHaveFocus()
1071
1260
  })
1072
1261
 
1073
- it('restores focus to the opener when the viewer closes', () => {
1262
+ it('restores focus to the opener when the viewer closes', async () => {
1263
+ const user = userEvent.setup()
1074
1264
  renderWithProviders(
1075
1265
  <MessageAttachment.Pdf.Received
1076
1266
  src="https://cdn.example.com/doc.pdf"
@@ -1079,18 +1269,18 @@ describe('ViewerShell focus management', () => {
1079
1269
  )
1080
1270
  const opener = screen.getByLabelText('Open doc.pdf')
1081
1271
  opener.focus()
1082
- expect(document.activeElement).toBe(opener)
1272
+ expect(opener).toHaveFocus()
1083
1273
 
1084
- fireEvent.click(opener)
1274
+ await user.click(opener)
1085
1275
  expectViewerOpen('pdf-viewer')
1086
1276
 
1087
- fireEvent.click(screen.getByLabelText('Close viewer'))
1277
+ await user.click(screen.getByLabelText('Close viewer'))
1088
1278
 
1089
1279
  // After close, focus should hop back to whatever was focused
1090
1280
  // before the viewer mounted (the row's Open button), and the
1091
1281
  // dialog should no longer carry the `[open]` attribute.
1092
1282
  expectViewerClosed('pdf-viewer')
1093
- expect(document.activeElement).toBe(opener)
1283
+ expect(opener).toHaveFocus()
1094
1284
  })
1095
1285
  })
1096
1286
 
@@ -1103,6 +1293,7 @@ describe('MessageAttachment lazy-loading defaults', () => {
1103
1293
  alt="Photo"
1104
1294
  />
1105
1295
  )
1296
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
1106
1297
  const img = screen.getByTestId('image-attachment').querySelector('img')
1107
1298
  expect(img?.getAttribute('loading')).toBe('lazy')
1108
1299
  expect(img?.getAttribute('decoding')).toBe('async')
@@ -1116,6 +1307,7 @@ describe('MessageAttachment lazy-loading defaults', () => {
1116
1307
  loading="eager"
1117
1308
  />
1118
1309
  )
1310
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
1119
1311
  const img = screen.getByTestId('image-attachment').querySelector('img')
1120
1312
  expect(img?.getAttribute('loading')).toBe('eager')
1121
1313
  expect(img?.getAttribute('decoding')).toBe('async')
@@ -1135,13 +1327,15 @@ describe('MessageAttachment lazy-loading defaults', () => {
1135
1327
  />
1136
1328
  )
1137
1329
  const [first, second] = Array.from(
1330
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
1138
1331
  screen.getByTestId('image-attachment').querySelectorAll('img')
1139
1332
  )
1140
1333
  expect(first?.getAttribute('loading')).toBe('eager')
1141
1334
  expect(second?.getAttribute('loading')).toBe('lazy')
1142
1335
  })
1143
1336
 
1144
- it('does not mount the viewer `<img>` while the lightbox is closed', () => {
1337
+ it('does not mount the viewer `<img>` while the lightbox is closed', async () => {
1338
+ const user = userEvent.setup()
1145
1339
  renderWithProviders(
1146
1340
  <MessageAttachment.Image.Sent
1147
1341
  src="https://cdn.example.com/photo.jpg"
@@ -1159,7 +1353,7 @@ describe('MessageAttachment lazy-loading defaults', () => {
1159
1353
  })
1160
1354
  ).toHaveLength(0)
1161
1355
 
1162
- fireEvent.click(screen.getByLabelText('Open image 1 of 1'))
1356
+ await user.click(screen.getByLabelText('Open image 1 of 1'))
1163
1357
  expect(
1164
1358
  within(screen.getByTestId('image-viewer')).getByRole('img', {
1165
1359
  hidden: true,
@@ -1177,6 +1371,7 @@ describe('MessageAttachment lazy-loading defaults', () => {
1177
1371
  mimeType="video/mp4"
1178
1372
  />
1179
1373
  )
1374
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
1180
1375
  const poster = screen.getByTestId('video-attachment').querySelector('img')
1181
1376
  expect(poster?.getAttribute('src')).toBe(
1182
1377
  'https://cdn.example.com/poster.jpg'
@@ -1185,7 +1380,8 @@ describe('MessageAttachment lazy-loading defaults', () => {
1185
1380
  expect(poster?.getAttribute('decoding')).toBe('async')
1186
1381
  })
1187
1382
 
1188
- it('opens the viewer with `<video preload="metadata">` for the active item', () => {
1383
+ it('opens the viewer with `<video preload="metadata">` for the active item', async () => {
1384
+ const user = userEvent.setup()
1189
1385
  renderWithProviders(
1190
1386
  <MessageAttachment.Video.Sent
1191
1387
  src="https://cdn.example.com/clip.mp4"
@@ -1194,7 +1390,8 @@ describe('MessageAttachment lazy-loading defaults', () => {
1194
1390
  filename="clip.mp4"
1195
1391
  />
1196
1392
  )
1197
- fireEvent.click(screen.getByLabelText('Play video 1 of 1'))
1393
+ await user.click(screen.getByLabelText('Play video 1 of 1'))
1394
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
1198
1395
  const video = screen.getByTestId('video-viewer').querySelector('video')
1199
1396
  expect(video?.getAttribute('preload')).toBe('metadata')
1200
1397
  })
@@ -1213,64 +1410,12 @@ describe('MessageAttachment lazy-loading defaults', () => {
1213
1410
  />
1214
1411
  )
1215
1412
  const bubble = screen.getByTestId('video-attachment')
1413
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
1216
1414
  expect(bubble.querySelectorAll('video')).toHaveLength(0)
1415
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
1217
1416
  expect(bubble.querySelectorAll('img')).toHaveLength(4)
1218
1417
  })
1219
1418
  })
1220
-
1221
- describe('Audio', () => {
1222
- it('defaults a single-track bubble to preload="metadata"', () => {
1223
- renderWithProviders(
1224
- <MessageAttachment.Audio.Received
1225
- src="https://cdn.example.com/song.mp3"
1226
- mimeType="audio/mpeg"
1227
- />
1228
- )
1229
- const audio = screen
1230
- .getByTestId('audio-attachment')
1231
- .querySelector('audio')
1232
- expect(audio?.getAttribute('preload')).toBe('metadata')
1233
- })
1234
-
1235
- it('defaults a stacked (3-track) bubble to preload="none" on every player', () => {
1236
- renderWithProviders(
1237
- <MessageAttachment.Audio.Sent
1238
- items={[
1239
- { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
1240
- { src: 'https://cdn.example.com/b.mp3', mimeType: 'audio/mpeg' },
1241
- { src: 'https://cdn.example.com/c.mp3', mimeType: 'audio/mpeg' },
1242
- ]}
1243
- />
1244
- )
1245
- const audios = Array.from(
1246
- screen.getByTestId('audio-attachment').querySelectorAll('audio')
1247
- )
1248
- expect(audios).toHaveLength(3)
1249
- audios.forEach((audio) => {
1250
- expect(audio.getAttribute('preload')).toBe('none')
1251
- })
1252
- })
1253
-
1254
- it('honors a per-track AudioItem.preload override', () => {
1255
- renderWithProviders(
1256
- <MessageAttachment.Audio.Sent
1257
- items={[
1258
- { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
1259
- {
1260
- src: 'https://cdn.example.com/b.mp3',
1261
- mimeType: 'audio/mpeg',
1262
- preload: 'metadata',
1263
- },
1264
- ]}
1265
- />
1266
- )
1267
- const audios = Array.from(
1268
- screen.getByTestId('audio-attachment').querySelectorAll('audio')
1269
- )
1270
- expect(audios[0]?.getAttribute('preload')).toBe('none')
1271
- expect(audios[1]?.getAttribute('preload')).toBe('metadata')
1272
- })
1273
- })
1274
1419
  })
1275
1420
 
1276
1421
  describe('bubbleGroupPositionFromStream', () => {