@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
@@ -18,7 +18,9 @@ describe('Bubble', () => {
18
18
  </Bubble>
19
19
  )
20
20
  const bubble = screen.getByTestId('bubble')
21
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
21
22
  if (present) expect(bubble.querySelector('p')).not.toBeNull()
23
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
22
24
  else expect(bubble.querySelector('p')).toBeNull()
23
25
  expect(bubble).toHaveClass(present ? 'pb-0' : 'py-2')
24
26
  })
@@ -1,12 +1,13 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
 
3
- import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
3
+ import { userEvent, renderWithProviders, screen } from '../../../test/utils'
4
4
 
5
5
  import CarouselNav from './CarouselNav'
6
6
  import { VIEWER_NAV_CLASS } from './viewerChromeClasses'
7
7
 
8
8
  describe('CarouselNav', () => {
9
- it('uses default labels and independent handlers', () => {
9
+ it('uses default labels and independent handlers', async () => {
10
+ const user = userEvent.setup()
10
11
  const onPrev = vi.fn()
11
12
  const onNext = vi.fn()
12
13
  renderWithProviders(<CarouselNav onPrev={onPrev} onNext={onNext} />)
@@ -15,8 +16,8 @@ describe('CarouselNav', () => {
15
16
  const next = screen.getByRole('button', { name: 'Next' })
16
17
  expect(prev).toHaveClass(VIEWER_NAV_CLASS, 'left-4')
17
18
  expect(next).toHaveClass(VIEWER_NAV_CLASS, 'right-4')
18
- fireEvent.click(prev)
19
- fireEvent.click(next)
19
+ await user.click(prev)
20
+ await user.click(next)
20
21
  expect(onPrev).toHaveBeenCalledOnce()
21
22
  expect(onNext).toHaveBeenCalledOnce()
22
23
  })
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
 
3
- import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
3
+ import { userEvent, renderWithProviders, screen } from '../../../test/utils'
4
4
 
5
5
  import CompactDocumentRow from './CompactDocumentRow'
6
6
 
@@ -71,7 +71,8 @@ describe('CompactDocumentRow', () => {
71
71
  )
72
72
  })
73
73
 
74
- it('activates its body without nesting the trailing action', () => {
74
+ it('activates its body without nesting the trailing action', async () => {
75
+ const user = userEvent.setup()
75
76
  const onActivate = vi.fn()
76
77
  renderWithProviders(
77
78
  <CompactDocumentRow
@@ -87,7 +88,7 @@ describe('CompactDocumentRow', () => {
87
88
  expect(body).not.toContainElement(
88
89
  screen.getByRole('button', { name: 'Download' })
89
90
  )
90
- fireEvent.click(body)
91
+ await user.click(body)
91
92
  expect(onActivate).toHaveBeenCalledOnce()
92
93
  })
93
94
 
@@ -1,11 +1,12 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
 
3
- import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
3
+ import { userEvent, renderWithProviders, screen } from '../../../test/utils'
4
4
 
5
5
  import DismissButton from './DismissButton'
6
6
 
7
7
  describe('DismissButton', () => {
8
- it('uses the default label, invokes the handler, and stops propagation', () => {
8
+ it('uses the default label, invokes the handler, and stops propagation', async () => {
9
+ const user = userEvent.setup()
9
10
  const onClick = vi.fn()
10
11
  const outerClick = vi.fn()
11
12
  renderWithProviders(
@@ -15,7 +16,7 @@ describe('DismissButton', () => {
15
16
  </div>
16
17
  )
17
18
 
18
- fireEvent.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
19
+ await user.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
19
20
  expect(onClick).toHaveBeenCalledOnce()
20
21
  expect(outerClick).not.toHaveBeenCalled()
21
22
  })
@@ -1,10 +1,10 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
3
  import {
4
- fireEvent,
5
4
  renderWithProviders,
6
5
  screen,
7
6
  waitFor,
7
+ userEvent,
8
8
  } from '../../../test/utils'
9
9
 
10
10
  import DownloadAction from './DownloadAction'
@@ -83,6 +83,7 @@ describe('DownloadAction', () => {
83
83
  )
84
84
 
85
85
  it('stops propagation and calls the download once', async () => {
86
+ const user = userEvent.setup()
86
87
  const outerClick = vi.fn()
87
88
  renderWithProviders(
88
89
  // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
@@ -94,7 +95,7 @@ describe('DownloadAction', () => {
94
95
  />
95
96
  </div>
96
97
  )
97
- fireEvent.click(screen.getByRole('button', { name: 'Download' }))
98
+ await user.click(screen.getByRole('button', { name: 'Download' }))
98
99
  await waitFor(() =>
99
100
  expect(mockedTriggerDownload).toHaveBeenCalledWith(
100
101
  '/file.pdf',
@@ -106,6 +107,7 @@ describe('DownloadAction', () => {
106
107
  })
107
108
 
108
109
  it('disables repeated clicks while downloading and settles on success', async () => {
110
+ const user = userEvent.setup()
109
111
  let resolve: () => void = () => undefined
110
112
  mockedTriggerDownload.mockReturnValueOnce(
111
113
  new Promise<void>((done) => {
@@ -117,8 +119,8 @@ describe('DownloadAction', () => {
117
119
  <DownloadAction url="/file.pdf" onTriggered={onTriggered} />
118
120
  )
119
121
  const button = screen.getByRole('button', { name: 'Download' })
120
- fireEvent.click(button)
121
- fireEvent.click(button)
122
+ await user.click(button)
123
+ await user.click(button)
122
124
  expect(button).toBeDisabled()
123
125
  expect(mockedTriggerDownload).toHaveBeenCalledOnce()
124
126
  resolve()
@@ -127,13 +129,14 @@ describe('DownloadAction', () => {
127
129
  })
128
130
 
129
131
  it('settles and notifies even when downloading rejects', async () => {
132
+ const user = userEvent.setup()
130
133
  mockedTriggerDownload.mockRejectedValueOnce(new Error('offline'))
131
134
  const onTriggered = vi.fn()
132
135
  renderWithProviders(
133
136
  <DownloadAction url="/file.pdf" onTriggered={onTriggered} />
134
137
  )
135
138
  const button = screen.getByRole('button', { name: 'Download' })
136
- fireEvent.click(button)
139
+ await user.click(button)
137
140
  await waitFor(() => expect(button).toBeEnabled())
138
141
  expect(onTriggered).toHaveBeenCalledOnce()
139
142
  })
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
 
3
- import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
3
+ import { userEvent, renderWithProviders, screen } from '../../../test/utils'
4
4
 
5
5
  import ImageViewer from './ImageViewer'
6
6
 
@@ -14,6 +14,7 @@ describe('ImageViewer', () => {
14
14
  const { container } = renderWithProviders(
15
15
  <ImageViewer open onClose={vi.fn()} items={[]} />
16
16
  )
17
+ // eslint-disable-next-line testing-library/no-node-access -- the null-rendering DOM contract has no semantic query.
17
18
  expect(container.firstChild).toBeNull()
18
19
  })
19
20
 
@@ -29,6 +30,7 @@ describe('ImageViewer', () => {
29
30
  expect(
30
31
  screen
31
32
  .getByTestId('image-viewer')
33
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
32
34
  .querySelector('button[aria-label="Download one.jpg"]')
33
35
  ).toBeInTheDocument()
34
36
  rerender(<ImageViewer open onClose={vi.fn()} items={[items[0]]} />)
@@ -36,7 +38,8 @@ describe('ImageViewer', () => {
36
38
  expect(screen.getByRole('img')).toHaveAttribute('draggable', 'false')
37
39
  })
38
40
 
39
- it('renders counter and navigation only for multiple items', () => {
41
+ it('renders counter and navigation only for multiple items', async () => {
42
+ const user = userEvent.setup()
40
43
  renderWithProviders(
41
44
  <ImageViewer open onClose={vi.fn()} items={items} initialIndex={99} />
42
45
  )
@@ -45,7 +48,7 @@ describe('ImageViewer', () => {
45
48
  screen.getByRole('button', { name: 'Previous image' })
46
49
  ).toBeInTheDocument()
47
50
  expect(screen.getByRole('img')).toHaveAttribute('src', items[1].src)
48
- fireEvent.click(screen.getByRole('button', { name: 'Previous image' }))
51
+ await user.click(screen.getByRole('button', { name: 'Previous image' }))
49
52
  expect(screen.getByText('1 / 2')).toBeInTheDocument()
50
53
  expect(screen.getByRole('img')).toHaveAttribute('src', items[0].src)
51
54
  })
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
 
3
- import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
3
+ import { userEvent, renderWithProviders, screen } from '../../../test/utils'
4
4
 
5
5
  import MediaStackGrid from './MediaStackGrid'
6
6
 
@@ -13,6 +13,7 @@ const tiles = (count: number) =>
13
13
  describe('MediaStackGrid', () => {
14
14
  it('renders nothing for empty tiles', () => {
15
15
  const { container } = renderWithProviders(<MediaStackGrid tiles={[]} />)
16
+ // eslint-disable-next-line testing-library/no-node-access -- the null-rendering DOM contract has no semantic query.
16
17
  expect(container.firstChild).toBeNull()
17
18
  })
18
19
 
@@ -25,6 +26,7 @@ describe('MediaStackGrid', () => {
25
26
  const { container } = renderWithProviders(
26
27
  <MediaStackGrid tiles={tiles(count)} className="custom-grid" />
27
28
  )
29
+ // eslint-disable-next-line testing-library/no-node-access -- the component shell wrapper has no semantic query.
28
30
  expect(container.firstElementChild).toHaveClass(layoutClass, 'custom-grid')
29
31
  expect(screen.getAllByText(/tile/)).toHaveLength(count)
30
32
  })
@@ -35,12 +37,14 @@ describe('MediaStackGrid', () => {
35
37
  )
36
38
  expect(screen.getAllByText(/tile/)).toHaveLength(4)
37
39
  expect(screen.getByText('+2')).toBeInTheDocument()
40
+ // eslint-disable-next-line testing-library/no-node-access -- the wrapper class is a deliberate DOM contract with no semantic query.
38
41
  expect(screen.getByText('+2').parentElement).toHaveTextContent('tile 4')
39
42
  rerender(<MediaStackGrid tiles={tiles(2)} maxVisible={4} />)
40
43
  expect(screen.getAllByText(/tile/)).toHaveLength(2)
41
44
  expect(screen.queryByText(/^\+/)).toBeNull()
42
45
  rerender(<MediaStackGrid tiles={tiles(1)} singleAspectRatio={0.5} />)
43
46
  expect(
47
+ // eslint-disable-next-line testing-library/no-node-access -- the component wrapper class has no semantic query.
44
48
  screen.getByText('tile 1').closest('div')?.parentElement
45
49
  ).toHaveStyle({
46
50
  aspectRatio: '0.5',
@@ -58,6 +62,7 @@ describe('MediaStackGrid', () => {
58
62
  )
59
63
  expect(screen.getAllByText(/tile/)).toHaveLength(maxVisible)
60
64
  expect(screen.getByText(badge)).toBeInTheDocument()
65
+ // eslint-disable-next-line testing-library/no-node-access -- the wrapper class is a deliberate DOM contract with no semantic query.
61
66
  expect(screen.getByText(badge).parentElement).toHaveTextContent(lastTile)
62
67
  }
63
68
  )
@@ -65,6 +70,7 @@ describe('MediaStackGrid', () => {
65
70
  it('shows overflow on the single visible tile', () => {
66
71
  renderWithProviders(<MediaStackGrid tiles={tiles(3)} maxVisible={1} />)
67
72
  expect(screen.getAllByText(/tile/)).toHaveLength(1)
73
+ // eslint-disable-next-line testing-library/no-node-access -- the wrapper class is a deliberate DOM contract with no semantic query.
68
74
  expect(screen.getByText('+2').parentElement).toHaveTextContent('tile 1')
69
75
  })
70
76
 
@@ -73,7 +79,8 @@ describe('MediaStackGrid', () => {
73
79
  expect(screen.queryByText(/^\+/)).toBeNull()
74
80
  })
75
81
 
76
- it('renders activatable buttons with labels and indexes', () => {
82
+ it('renders activatable buttons with labels and indexes', async () => {
83
+ const user = userEvent.setup()
77
84
  const onActivate = vi.fn()
78
85
  renderWithProviders(
79
86
  <MediaStackGrid tiles={tiles(3)} onTileActivate={onActivate} />
@@ -82,8 +89,8 @@ describe('MediaStackGrid', () => {
82
89
  expect(buttons).toHaveLength(3)
83
90
  expect(buttons[0]).toHaveAttribute('aria-label', 'Open media 1')
84
91
  expect(buttons[1]).toHaveAttribute('aria-label', 'Second tile')
85
- fireEvent.click(buttons[0])
86
- fireEvent.click(buttons[2])
92
+ await user.click(buttons[0])
93
+ await user.click(buttons[2])
87
94
  expect(onActivate).toHaveBeenNthCalledWith(1, 0)
88
95
  expect(onActivate).toHaveBeenNthCalledWith(2, 2)
89
96
  })
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
 
3
- import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
3
+ import { userEvent, renderWithProviders, screen } from '../../../test/utils'
4
4
 
5
5
  import PdfViewer from './PdfViewer'
6
6
 
@@ -14,6 +14,7 @@ describe('PdfViewer', () => {
14
14
  const { container } = renderWithProviders(
15
15
  <PdfViewer open onClose={vi.fn()} items={[]} />
16
16
  )
17
+ // eslint-disable-next-line testing-library/no-node-access -- the null-rendering DOM contract has no semantic query.
17
18
  expect(container.firstChild).toBeNull()
18
19
  })
19
20
 
@@ -30,7 +31,8 @@ describe('PdfViewer', () => {
30
31
  )
31
32
  })
32
33
 
33
- it('uses filename precedence and navigates stacked documents', () => {
34
+ it('uses filename precedence and navigates stacked documents', async () => {
35
+ const user = userEvent.setup()
34
36
  renderWithProviders(
35
37
  <PdfViewer open onClose={vi.fn()} items={items} initialIndex={1} />
36
38
  )
@@ -39,7 +41,7 @@ describe('PdfViewer', () => {
39
41
  'second.pdf'
40
42
  )
41
43
  expect(screen.getByText('2 / 2')).toBeInTheDocument()
42
- fireEvent.click(screen.getByRole('button', { name: 'Previous document' }))
44
+ await user.click(screen.getByRole('button', { name: 'Previous document' }))
43
45
  expect(screen.getByTitle('one.pdf')).toBeInTheDocument()
44
46
  })
45
47
  })
@@ -2,10 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
2
2
 
3
3
  import {
4
4
  act,
5
- fireEvent,
6
5
  renderWithProviders,
7
6
  screen,
8
7
  within,
8
+ userEvent,
9
9
  } from '../../../test/utils'
10
10
 
11
11
  import VideoViewer from './VideoViewer'
@@ -26,6 +26,7 @@ describe('VideoViewer', () => {
26
26
  const { container } = renderWithProviders(
27
27
  <VideoViewer open onClose={vi.fn()} items={[]} />
28
28
  )
29
+ // eslint-disable-next-line testing-library/no-node-access -- the null-rendering DOM contract has no semantic query.
29
30
  expect(container.firstChild).toBeNull()
30
31
  })
31
32
 
@@ -33,6 +34,7 @@ describe('VideoViewer', () => {
33
34
  renderWithProviders(
34
35
  <VideoViewer open onClose={vi.fn()} items={[items[0]]} />
35
36
  )
37
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
36
38
  const video = screen.getByTestId('video-viewer').querySelector('video')
37
39
  expect(video).not.toBeNull()
38
40
  if (!video) throw new Error('Expected video element')
@@ -41,20 +43,25 @@ describe('VideoViewer', () => {
41
43
  expect(video).toHaveAttribute('preload', 'auto')
42
44
  expect(video).toHaveAttribute('autoplay')
43
45
  expect(video?.muted).toBe(true)
46
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
44
47
  expect(video.querySelector('source')).toHaveAttribute('type', 'video/mp4')
45
48
  })
46
49
 
47
- it('defaults preload and navigates stacked items', () => {
50
+ it('defaults preload and navigates stacked items', async () => {
51
+ const user = userEvent.setup()
48
52
  renderWithProviders(<VideoViewer open onClose={vi.fn()} items={items} />)
49
53
  expect(screen.getByText('1 / 2')).toBeInTheDocument()
50
54
  expect(
55
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
51
56
  screen.getByTestId('video-viewer').querySelector('video')
52
57
  ).toHaveAttribute('preload', 'auto')
53
- fireEvent.click(screen.getByRole('button', { name: 'Next video' }))
58
+ await user.click(screen.getByRole('button', { name: 'Next video' }))
54
59
  expect(
60
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
55
61
  screen.getByTestId('video-viewer').querySelector('video')
56
62
  ).toHaveAttribute('src', items[1].src)
57
63
  expect(
64
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
58
65
  screen.getByTestId('video-viewer').querySelector('video')
59
66
  ).toHaveAttribute('preload', 'metadata')
60
67
  })
@@ -85,6 +92,7 @@ describe('VideoViewer', () => {
85
92
  const { rerender } = renderWithProviders(
86
93
  <VideoViewer open onClose={vi.fn()} items={[items[0]]} />
87
94
  )
95
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
88
96
  const video = screen.getByTestId('video-viewer').querySelector('video')
89
97
  if (!video) throw new Error('Expected video element')
90
98
  const pause = vi.spyOn(video, 'pause').mockImplementation(() => {})
@@ -96,6 +104,7 @@ describe('VideoViewer', () => {
96
104
  // Still there, so the last frame doesn't blink out from under the
97
105
  // 150ms backdrop fade — but paused, so nothing plays under it.
98
106
  expect(
107
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
99
108
  screen.getByTestId('video-viewer').querySelector('video')
100
109
  ).not.toBeNull()
101
110
  expect(pause).toHaveBeenCalled()
@@ -1,7 +1,12 @@
1
1
  import { useLayoutEffect } from 'react'
2
2
  import { afterEach, describe, expect, it, vi } from 'vitest'
3
3
 
4
- import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
4
+ import {
5
+ userEvent,
6
+ fireEvent,
7
+ renderWithProviders,
8
+ screen,
9
+ } from '../../../test/utils'
5
10
 
6
11
  import { VIEWER_ACTION_CLASS } from './viewerChromeClasses'
7
12
  import ViewerShell from './ViewerShell'
@@ -33,6 +38,7 @@ const restoreDialogMethod = (
33
38
 
34
39
  const PreOpenDialog = () => {
35
40
  useLayoutEffect(() => {
41
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
36
42
  document.querySelector('[data-testid="viewer"]')?.setAttribute('open', '')
37
43
  }, [])
38
44
  return null
@@ -201,7 +207,7 @@ describe('ViewerShell', () => {
201
207
  </ViewerShell>
202
208
  )
203
209
  ).not.toThrow()
204
- expect(document.activeElement).not.toBe(previouslyFocused)
210
+ expect(previouslyFocused).not.toHaveFocus()
205
211
  })
206
212
 
207
213
  it('uses the default accessible label', () => {
@@ -227,7 +233,8 @@ describe('ViewerShell', () => {
227
233
  expect(close).not.toHaveBeenCalled()
228
234
  })
229
235
 
230
- it('closes from the close, native close, and backdrop events', () => {
236
+ it('closes from the close, native close, and backdrop events', async () => {
237
+ const user = userEvent.setup()
231
238
  const onClose = vi.fn()
232
239
  renderWithProviders(
233
240
  <ViewerShell open onClose={onClose} data-testid="viewer">
@@ -235,12 +242,12 @@ describe('ViewerShell', () => {
235
242
  </ViewerShell>
236
243
  )
237
244
  const dialog = screen.getByTestId('viewer')
238
- fireEvent.click(screen.getByRole('button', { name: 'Close viewer' }))
245
+ await user.click(screen.getByRole('button', { name: 'Close viewer' }))
239
246
  fireEvent(dialog, new Event('close'))
240
- fireEvent.click(dialog)
247
+ await user.click(dialog)
241
248
  expect(onClose).toHaveBeenCalledTimes(3)
242
249
  onClose.mockClear()
243
- fireEvent.click(screen.getByText('content'))
250
+ await user.click(screen.getByText('content'))
244
251
  expect(onClose).not.toHaveBeenCalled()
245
252
  })
246
253
  })
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
3
+ import {
4
+ userEvent,
5
+ fireEvent,
6
+ renderWithProviders,
7
+ screen,
8
+ } from '../../../test/utils'
4
9
 
5
10
  import { useCarousel } from './useCarousel'
6
11
 
@@ -28,14 +33,15 @@ const Harness = ({
28
33
  }
29
34
 
30
35
  describe('useCarousel', () => {
31
- it('clamps initial index, wraps, and resets on reopen', () => {
36
+ it('clamps initial index, wraps, and resets on reopen', async () => {
37
+ const user = userEvent.setup()
32
38
  const { rerender } = renderWithProviders(
33
39
  <Harness open length={3} initialIndex={99} />
34
40
  )
35
41
  expect(screen.getByTestId('index')).toHaveTextContent('2')
36
- fireEvent.click(screen.getByRole('button', { name: 'Next' }))
42
+ await user.click(screen.getByRole('button', { name: 'Next' }))
37
43
  expect(screen.getByTestId('index')).toHaveTextContent('0')
38
- fireEvent.click(screen.getByRole('button', { name: 'Prev' }))
44
+ await user.click(screen.getByRole('button', { name: 'Prev' }))
39
45
  expect(screen.getByTestId('index')).toHaveTextContent('2')
40
46
  rerender(<Harness open={false} length={3} initialIndex={1} />)
41
47
  rerender(<Harness open length={3} initialIndex={1} />)
@@ -0,0 +1,23 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ /** No store to subscribe to — the value flips once, at hydration. */
4
+ const subscribe = () => () => {}
5
+ const getSnapshot = () => true
6
+ const getServerSnapshot = () => false
7
+
8
+ /**
9
+ * `false` while server-rendering and for the hydration render, `true`
10
+ * from the first client render onwards.
11
+ *
12
+ * `useSyncExternalStore` is what makes that safe: React uses
13
+ * `getServerSnapshot` for both the server pass and hydration, so the
14
+ * two trees match and only then does the component re-render as
15
+ * interactive. The `useState` + `useEffect` spelling of this produces
16
+ * the same result but trips `react-hooks/set-state-in-effect`.
17
+ *
18
+ * Used to keep browser-only work out of a server render — see
19
+ * `WaveformAudioRow`, where constructing Stream's pooled `AudioPlayer`
20
+ * reaches for `new Audio()` (MES-1360).
21
+ */
22
+ export const useIsClient = (): boolean =>
23
+ useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
@@ -56,7 +56,7 @@ import VideoAttachment, {
56
56
  * - Pdf / Audio / File render their compact rows / players in a
57
57
  * vertical stack with an 8px gap between rows. Each row keeps
58
58
  * its own activation target — Pdf opens the carousel-aware
59
- * `PdfViewer` at that index, Audio gets its own native player,
59
+ * `PdfViewer` at that index, Audio gets its own waveform player,
60
60
  * and File downloads the asset for that row.
61
61
  *
62
62
  * The Composer surface accepts only a single attachment at a time,
@@ -74,12 +74,29 @@ import VideoAttachment, {
74
74
  * download button to the chrome; the Video viewer relies on the
75
75
  * native `<video controls>` download menu, and the Pdf viewer
76
76
  * inherits the browser's built-in PDF download. Sibling-row Download
77
- * buttons on Pdf / File and the native `<audio>` controls expose
77
+ * buttons on Pdf / File and the Audio card's trailing action expose
78
78
  * downloads outside the viewer too. The lightbox chrome is styled
79
79
  * with plain CSS (`.mes-media-viewer*`) so the package's shipped
80
80
  * stylesheet is enough to render the viewer correctly without a
81
81
  * Tailwind dependency in the consumer.
82
82
  *
83
+ * # Audio requires a `<Channel>` ancestor
84
+ *
85
+ * `MessageAttachment.Audio.*` renders the waveform player card —
86
+ * a 32px play toggle, a 35-bar waveform with a progress fill and a
87
+ * native seek control, the duration, and a 32px trailing action
88
+ * (Remove in the composer, Download once sent). Playback is
89
+ * `stream-chat-react`'s pooled `AudioPlayer`, which is what supplies
90
+ * single-player-at-a-time across a thread, pause-on-navigation and
91
+ * saved-position restore across virtualized remounts.
92
+ *
93
+ * That pool is provided by `<Channel>`. Rendered outside one the card
94
+ * still draws in full, but its play and seek controls are disabled —
95
+ * so a story or a new consumer surface must mount `<Channel>` (or
96
+ * `stream-chat-react`'s `WithAudioPlayback`) for the card to be
97
+ * playable. The waveform bars themselves are styled with plain CSS
98
+ * (`.mes-audio-wave*`), so the shipped stylesheet is enough.
99
+ *
83
100
  * # Lazy-loading defaults
84
101
  *
85
102
  * Media attachments bake in network-friendly defaults for long chat
@@ -100,13 +117,12 @@ import VideoAttachment, {
100
117
  * `VideoItem.preload` per item). Once the viewer is open the
101
118
  * active item bumps to `preload="metadata"` so duration / the
102
119
  * first frame appear immediately.
103
- * - **Audio** — a single audio bubble defaults to
104
- * `preload="metadata"` so the native player surfaces duration
105
- * immediately. A stacked audio bubble (`items.length > 1`)
106
- * defaults to `preload="none"` for every player so a thread of
107
- * voice memos doesn't fan out N parallel metadata requests on
108
- * first paint. Override via the attachment's `preload` prop
109
- * (or `AudioItem.preload` per track).
120
+ * - **Audio** — no audio bytes are fetched until the user presses
121
+ * play: the pooled `AudioPlayer` acquires the shared `<audio>`
122
+ * element lazily. A card whose `durationSeconds` is unknown makes
123
+ * one `preload="metadata"` header request to fill the duration
124
+ * slot; supplying `durationSeconds` (which `StreamAttachmentMessage`
125
+ * maps from the attachment's persisted `duration`) avoids even that.
110
126
  * - **Pdf** / **File** — no inline media element to throttle; the
111
127
  * bubble renders compact rows that download / open lazily on
112
128
  * activation. No `loading` / `preload` props on these surfaces.
@@ -133,21 +133,31 @@ export interface PdfItem {
133
133
  /** A single audio file inside an Audio attachment (single or stacked). */
134
134
  export interface AudioItem {
135
135
  src: string
136
- /** MIME type hint — typed onto the inline `<source>` element. */
136
+ /** MIME type hint — gates playability via the player's `canPlayType` probe. */
137
137
  mimeType?: string
138
138
  /**
139
- * Filename — used as the download default name (consumed by the
140
- * native player's kebab menu).
139
+ * Filename — the download default name for the card's trailing
140
+ * action, and part of the card's accessible name.
141
141
  */
142
142
  filename?: string
143
143
  /**
144
- * Per-track override for the `<audio preload>` hint. Defaults to
145
- * the parent attachment's `preload` value, which itself defaults
146
- * to `'metadata'` for a single audio (so the native player can
147
- * surface duration immediately) and `'none'` for a stacked thread
148
- * (so a fan-out of metadata requests doesn't happen on first paint).
144
+ * Track length in seconds. Rendered as `MM:SS` before playback
145
+ * starts, so the duration is correct on first paint with no network
146
+ * request. Sourced from the Stream attachment's `duration` field,
147
+ * which the composer writes at send time. When omitted the card
148
+ * shows `--:--` until a one-shot `loadedmetadata` probe resolves.
149
149
  */
150
- preload?: MediaPreloadMode
150
+ durationSeconds?: number
151
+ /**
152
+ * Waveform amplitudes, each `0..1`, in track order. Resampled to the
153
+ * rendered bar count, so any length is accepted. Sourced from the
154
+ * Stream attachment's `waveform_data`, which the composer computes
155
+ * from the local file and writes at send time.
156
+ *
157
+ * When omitted, the card renders a flat placeholder row: every bar at
158
+ * the minimum height, with the progress fill still tracking playback.
159
+ */
160
+ waveformData?: number[]
151
161
  }
152
162
 
153
163
  /** A single generic file inside a File attachment (single or stacked). */
@@ -23,11 +23,15 @@ describe('MessageBubble', () => {
23
23
  <MessageBubble.Sent text="tailed" />
24
24
  )
25
25
  // The bubble box wraps the text; its last child is the tail <span>.
26
+ // eslint-disable-next-line testing-library/no-node-access -- the wrapper class is a deliberate DOM contract with no semantic query.
26
27
  const bubble = screen.getByText('tailed').parentElement as HTMLElement
28
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
27
29
  expect(bubble.querySelector('span[aria-hidden="true"]')).not.toBeNull()
28
30
 
29
31
  rerender(<MessageBubble.Sent text="tailed" tail={false} />)
32
+ // eslint-disable-next-line testing-library/no-node-access -- the wrapper class is a deliberate DOM contract with no semantic query.
30
33
  const bubbleNoTail = screen.getByText('tailed').parentElement as HTMLElement
34
+ // eslint-disable-next-line testing-library/no-node-access -- media or rendered-markup internals have no user-facing query.
31
35
  expect(bubbleNoTail.querySelector('span[aria-hidden="true"]')).toBeNull()
32
36
  })
33
37
 
@@ -47,12 +51,14 @@ describe('MessageBubble', () => {
47
51
 
48
52
  it('applies the sent and received surface treatments from theme vars', () => {
49
53
  const { rerender } = renderWithProviders(<MessageBubble.Sent text="mine" />)
54
+ // eslint-disable-next-line testing-library/no-node-access -- the wrapper class is a deliberate DOM contract with no semantic query.
50
55
  const sent = screen.getByText('mine').parentElement as HTMLElement
51
56
  expect(sent.style.backgroundColor).toBe(
52
57
  'var(--str-chat__own-message-bubble-background-color, #1e2330)'
53
58
  )
54
59
 
55
60
  rerender(<MessageBubble.Received text="theirs" />)
61
+ // eslint-disable-next-line testing-library/no-node-access -- the wrapper class is a deliberate DOM contract with no semantic query.
56
62
  const received = screen.getByText('theirs').parentElement as HTMLElement
57
63
  expect(received.style.backgroundColor).toBe(
58
64
  'var(--str-chat__message-bubble-background-color, #f1f0ee)'
@@ -61,6 +67,7 @@ describe('MessageBubble', () => {
61
67
 
62
68
  it('masks message content from session replay by default', () => {
63
69
  renderWithProviders(<MessageBubble.Received header="Brie" text="secret" />)
70
+ // eslint-disable-next-line testing-library/no-node-access -- the wrapper class is a deliberate DOM contract with no semantic query.
64
71
  const bubble = screen.getByText('secret').parentElement as HTMLElement
65
72
  expect(bubble.getAttribute('data-dd-privacy')).toBe('mask')
66
73
  })
@@ -69,6 +76,7 @@ describe('MessageBubble', () => {
69
76
  renderWithProviders(
70
77
  <MessageBubble.Sent text="wide" className="w-fit max-w-[520px]" />
71
78
  )
79
+ // eslint-disable-next-line testing-library/no-node-access -- the wrapper class is a deliberate DOM contract with no semantic query.
72
80
  const bubble = screen.getByText('wide').parentElement as HTMLElement
73
81
  expect(bubble.classList.contains('w-fit')).toBe(true)
74
82
  })