@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.
- package/dist/assets/index.css +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +58 -43
- package/dist/index.js +1167 -1033
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/AttachmentCard/MediaPlayer.test.tsx +9 -5
- package/src/components/AttachmentCard/Thumbnail.test.tsx +4 -0
- package/src/components/ChannelActionsMenu/ChannelActionsMenu.test.tsx +18 -10
- package/src/components/ChannelList/ChannelListContext.test.tsx +7 -5
- package/src/components/ChannelList/CustomChannelPreview.test.tsx +7 -5
- package/src/components/ChannelList/index.test.tsx +1 -0
- package/src/components/ChannelView.test.tsx +20 -13
- package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +4 -3
- package/src/components/CustomMessage/CustomMessage.test.tsx +6 -5
- package/src/components/CustomMessage/MessageTag.test.tsx +6 -0
- package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +19 -9
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +45 -5
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +6 -0
- package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +3 -0
- package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +16 -7
- package/src/components/FaqList/FaqList.test.tsx +20 -12
- package/src/components/LinkAttachment/LinkAttachment.test.tsx +27 -14
- package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +1 -0
- package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +6 -6
- package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +8 -5
- package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +7 -5
- package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +3 -8
- package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +165 -58
- package/src/components/MessageAttachment/Audio/WaveformAudioRow.test.tsx +155 -0
- package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +455 -0
- package/src/components/MessageAttachment/Audio/audioRowRequester.test.ts +24 -0
- package/src/components/MessageAttachment/Audio/audioRowRequester.ts +16 -0
- package/src/components/MessageAttachment/Audio/index.tsx +83 -79
- package/src/components/MessageAttachment/MessageAttachment.behavior.test.tsx +22 -15
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +279 -134
- package/src/components/MessageAttachment/_shared/Bubble.test.tsx +2 -0
- package/src/components/MessageAttachment/_shared/CarouselNav.test.tsx +5 -4
- package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +4 -3
- package/src/components/MessageAttachment/_shared/DismissButton.test.tsx +4 -3
- package/src/components/MessageAttachment/_shared/DownloadAction.test.tsx +8 -5
- package/src/components/MessageAttachment/_shared/ImageViewer.test.tsx +6 -3
- package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +11 -4
- package/src/components/MessageAttachment/_shared/PdfViewer.test.tsx +5 -3
- package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +12 -3
- package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +13 -6
- package/src/components/MessageAttachment/_shared/useCarousel.test.tsx +10 -4
- package/src/components/MessageAttachment/_shared/useIsClient.ts +23 -0
- package/src/components/MessageAttachment/index.tsx +25 -9
- package/src/components/MessageAttachment/types.ts +19 -9
- package/src/components/MessageBubble/MessageBubble.test.tsx +8 -0
- package/src/components/RichCard/RichCard.test.tsx +10 -2
- 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 {
|
|
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
|
-
|
|
19
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
86
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
245
|
+
await user.click(screen.getByRole('button', { name: 'Close viewer' }))
|
|
239
246
|
fireEvent(dialog, new Event('close'))
|
|
240
|
-
|
|
247
|
+
await user.click(dialog)
|
|
241
248
|
expect(onClose).toHaveBeenCalledTimes(3)
|
|
242
249
|
onClose.mockClear()
|
|
243
|
-
|
|
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 {
|
|
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
|
-
|
|
42
|
+
await user.click(screen.getByRole('button', { name: 'Next' }))
|
|
37
43
|
expect(screen.getByTestId('index')).toHaveTextContent('0')
|
|
38
|
-
|
|
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
|
|
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
|
|
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** —
|
|
104
|
-
* `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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 —
|
|
136
|
+
/** MIME type hint — gates playability via the player's `canPlayType` probe. */
|
|
137
137
|
mimeType?: string
|
|
138
138
|
/**
|
|
139
|
-
* Filename —
|
|
140
|
-
*
|
|
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
|
-
*
|
|
145
|
-
* the
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
-
|
|
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
|
})
|