@linktr.ee/messaging-react 4.2.0-rc-1787325521 → 4.2.0-rc-1787599724

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 (96) hide show
  1. package/dist/{Card-BYJyA83X.js → Card-D7UPq5-g.js} +2 -2
  2. package/dist/{Card-BYJyA83X.js.map → Card-D7UPq5-g.js.map} +1 -1
  3. package/dist/{Card-BplJtZao.cjs → Card-DcBZqMBt.cjs} +2 -2
  4. package/dist/{Card-BplJtZao.cjs.map → Card-DcBZqMBt.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-BUC9vqhJ.js → index-CSkr_CO5.js} +2089 -1952
  7. package/dist/index-CSkr_CO5.js.map +1 -0
  8. package/dist/index-CwkY7QwT.cjs +3 -0
  9. package/dist/index-CwkY7QwT.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +59 -78
  12. package/dist/index.js +26 -30
  13. package/dist/testing.cjs +1 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.js +2 -2
  16. package/dist/testing.js.map +1 -1
  17. package/package.json +13 -11
  18. package/src/components/AttachmentCard/utils/icons.ts +7 -6
  19. package/src/components/Avatar/Avatar.test.tsx +0 -126
  20. package/src/components/Avatar/getAvatarEmoji.test.ts +0 -39
  21. package/src/components/ChannelList/CustomChannelPreview.test.tsx +3 -410
  22. package/src/components/ChannelList/index.test.tsx +1 -132
  23. package/src/components/ChannelView.stories.tsx +0 -4
  24. package/src/components/ChannelView.test.tsx +3 -9
  25. package/src/components/CustomMessage/CustomMessage.stories.tsx +314 -6
  26. package/src/components/CustomMessage/CustomMessage.test.tsx +2 -56
  27. package/src/components/CustomMessage/MessageTag.stories.tsx +53 -17
  28. package/src/components/CustomMessage/MessageTag.test.tsx +40 -7
  29. package/src/components/CustomMessage/MessageTag.tsx +39 -7
  30. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +7 -8
  31. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +1 -2
  32. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +33 -0
  33. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +12 -5
  34. package/src/components/CustomMessage/index.tsx +19 -22
  35. package/src/components/CustomMessageInput/index.tsx +1 -1
  36. package/src/components/CustomSystemMessage/index.tsx +2 -3
  37. package/src/components/LinkAttachment/LinkAttachment.test.tsx +0 -327
  38. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +165 -58
  39. package/src/components/MessageAttachment/Audio/WaveformAudioRow.test.tsx +153 -0
  40. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +450 -0
  41. package/src/components/MessageAttachment/Audio/audioRowRequester.test.ts +24 -0
  42. package/src/components/MessageAttachment/Audio/audioRowRequester.ts +16 -0
  43. package/src/components/MessageAttachment/Audio/index.tsx +83 -77
  44. package/src/components/MessageAttachment/MessageAttachment.test.tsx +176 -74
  45. package/src/components/MessageAttachment/_shared/fileMeta.test.ts +0 -44
  46. package/src/components/MessageAttachment/_shared/useIsClient.ts +23 -0
  47. package/src/components/MessageAttachment/index.tsx +25 -9
  48. package/src/components/MessageAttachment/types.ts +19 -9
  49. package/src/components/MessagingShell/MessagingShell.test.tsx +0 -20
  50. package/src/components/MessagingShell/index.tsx +0 -2
  51. package/src/components/RichCard/RichCard.test.tsx +1 -3
  52. package/src/index.ts +0 -4
  53. package/src/providers/MessagingProvider.test.tsx +2 -130
  54. package/src/providers/MessagingProvider.tsx +15 -26
  55. package/src/styles.css +210 -57
  56. package/src/testing/createMockMessagingClient.ts +2 -2
  57. package/src/types.ts +0 -9
  58. package/dist/index-BUC9vqhJ.js.map +0 -1
  59. package/dist/index-BkqyKPJP.cjs +0 -3
  60. package/dist/index-BkqyKPJP.cjs.map +0 -1
  61. package/src/components/AttachmentCard/MediaPlayer.test.tsx +0 -346
  62. package/src/components/AttachmentCard/Thumbnail.test.tsx +0 -215
  63. package/src/components/AttachmentCard/index.test.tsx +0 -160
  64. package/src/components/ChannelList/ChannelListContext.test.tsx +0 -87
  65. package/src/components/CustomMessage/Locked.stories.tsx +0 -108
  66. package/src/components/CustomMessage/PaidMessage.stories.tsx +0 -42
  67. package/src/components/CustomMessage/Tip.stories.tsx +0 -48
  68. package/src/components/CustomMessage/TipMessage/TipMessage.stories.tsx +0 -51
  69. package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +0 -65
  70. package/src/components/CustomMessage/TipMessage/index.tsx +0 -27
  71. package/src/components/FaqList/FaqList.test.tsx +0 -204
  72. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +0 -76
  73. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +0 -248
  74. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +0 -123
  75. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +0 -193
  76. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +0 -242
  77. package/src/components/LinkAttachment/components/_shared/hex.test.ts +0 -37
  78. package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.test.ts +0 -72
  79. package/src/components/LinkAttachment/components/_shared/useChinPalette.test.ts +0 -162
  80. package/src/components/MessageAttachment/MessageAttachment.behavior.test.tsx +0 -182
  81. package/src/components/MessageAttachment/_shared/Bubble.test.tsx +0 -111
  82. package/src/components/MessageAttachment/_shared/CarouselNav.test.tsx +0 -36
  83. package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +0 -114
  84. package/src/components/MessageAttachment/_shared/DismissButton.test.tsx +0 -42
  85. package/src/components/MessageAttachment/_shared/DownloadAction.test.tsx +0 -168
  86. package/src/components/MessageAttachment/_shared/ImageViewer.test.tsx +0 -52
  87. package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +0 -69
  88. package/src/components/MessageAttachment/_shared/PdfViewer.test.tsx +0 -45
  89. package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +0 -55
  90. package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +0 -246
  91. package/src/components/MessageAttachment/_shared/triggerDownload.test.ts +0 -113
  92. package/src/components/MessageAttachment/_shared/useCarousel.test.tsx +0 -68
  93. package/src/components/MessageAttachment/_shared/viewerChromeClasses.test.ts +0 -16
  94. package/src/logging/index.test.tsx +0 -136
  95. package/src/logging/index.tsx +0 -48
  96. package/src/stories/customMessageStoryTemplate.tsx +0 -202
@@ -1,346 +0,0 @@
1
- import React from 'react'
2
- import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
3
-
4
- import {
5
- fireEvent,
6
- renderWithProviders,
7
- screen,
8
- waitFor,
9
- } from '../../test/utils'
10
-
11
- import MediaPlayer from './MediaPlayer'
12
-
13
- const SOURCE = 'https://cdn.example.com/preview.mp4'
14
- const POSTER = 'https://cdn.example.com/poster.jpg'
15
-
16
- describe('MediaPlayer', () => {
17
- beforeEach(() => {
18
- vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
19
- vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
20
- vi.stubGlobal(
21
- 'requestAnimationFrame',
22
- vi.fn(() => 1)
23
- )
24
- vi.stubGlobal('cancelAnimationFrame', vi.fn())
25
- })
26
-
27
- afterEach(() => {
28
- vi.restoreAllMocks()
29
- vi.unstubAllGlobals()
30
- })
31
-
32
- it('selects audio or video from the mime type and wires media props', () => {
33
- const { container, rerender } = renderWithProviders(
34
- <MediaPlayer
35
- source={SOURCE}
36
- mimeType="audio/mpeg"
37
- loop
38
- muted
39
- controls={false}
40
- />
41
- )
42
-
43
- const audio = container.querySelector('audio') as HTMLAudioElement
44
- expect(audio).toBeInTheDocument()
45
- expect(audio).toHaveAttribute('src', SOURCE)
46
- expect(audio.loop).toBe(true)
47
- expect(audio.muted).toBe(true)
48
- expect(container.querySelector('video')).toBeNull()
49
-
50
- rerender(
51
- <MediaPlayer
52
- source={SOURCE}
53
- mimeType="video/mp4"
54
- loop
55
- muted
56
- controls={false}
57
- />
58
- )
59
-
60
- const video = container.querySelector('video') as HTMLVideoElement
61
- expect(video).toBeInTheDocument()
62
- expect(video).toHaveAttribute('src', SOURCE)
63
- expect(video.loop).toBe(true)
64
- expect(video.muted).toBe(true)
65
- expect(video.playsInline).toBe(true)
66
- expect(container.querySelector('audio')).toBeNull()
67
- })
68
-
69
- it('keeps an audio poster after canPlay but hides a video poster', () => {
70
- const audio = renderWithProviders(
71
- <MediaPlayer source={SOURCE} mimeType="audio/mpeg" poster={POSTER} />
72
- )
73
- const audioElement = audio.container.querySelector(
74
- 'audio'
75
- ) as HTMLAudioElement
76
- expect(audio.container.querySelectorAll('img')).toHaveLength(1)
77
- fireEvent.canPlay(audioElement)
78
- expect(audio.container.querySelectorAll('img')).toHaveLength(1)
79
- audio.unmount()
80
-
81
- const video = renderWithProviders(
82
- <MediaPlayer source={SOURCE} mimeType="video/mp4" poster={POSTER} />
83
- )
84
- const videoElement = video.container.querySelector(
85
- 'video'
86
- ) as HTMLVideoElement
87
- expect(video.container.querySelectorAll('img')).toHaveLength(1)
88
- fireEvent.canPlay(videoElement)
89
- expect(video.container.querySelectorAll('img')).toHaveLength(0)
90
- })
91
-
92
- it('uses a type icon as the initial placeholder when no poster is provided', async () => {
93
- const { container } = renderWithProviders(
94
- <MediaPlayer source={SOURCE} mimeType="video/mp4" />
95
- )
96
- const video = container.querySelector('video') as HTMLVideoElement
97
- const placeholderIcon = () =>
98
- Array.from(container.querySelectorAll('svg')).find((svg) =>
99
- svg.classList.contains('size-12')
100
- )
101
-
102
- expect(placeholderIcon()).toBeInTheDocument()
103
- fireEvent.canPlay(video)
104
- await waitFor(() => expect(placeholderIcon()).toBeUndefined())
105
- })
106
-
107
- it('syncs controlled playback and calls play or pause imperatively', async () => {
108
- const play = vi.mocked(HTMLMediaElement.prototype.play)
109
- const pause = vi.mocked(HTMLMediaElement.prototype.pause)
110
- const { container, rerender } = renderWithProviders(
111
- <MediaPlayer source={SOURCE} mimeType="video/mp4" playing={false} />
112
- )
113
- const video = container.querySelector('video') as HTMLVideoElement
114
- expect(pause).toHaveBeenCalled()
115
-
116
- rerender(
117
- <MediaPlayer source={SOURCE} mimeType="video/mp4" playing={true} />
118
- )
119
- await waitFor(() => expect(play).toHaveBeenCalledWith())
120
- expect(video).toBeInTheDocument()
121
-
122
- rerender(
123
- <MediaPlayer source={SOURCE} mimeType="video/mp4" playing={false} />
124
- )
125
- await waitFor(() => expect(pause.mock.calls.length).toBeGreaterThan(1))
126
- })
127
-
128
- it('shows a manual play overlay after rejected playback and starts from a gesture', async () => {
129
- const play = vi.mocked(HTMLMediaElement.prototype.play)
130
- play.mockRejectedValueOnce(new Error('autoplay blocked'))
131
- const onContainerClick = vi.fn()
132
-
133
- renderWithProviders(
134
- <MediaPlayer
135
- source={SOURCE}
136
- mimeType="video/mp4"
137
- autoPlay
138
- controls={false}
139
- onContainerClick={onContainerClick}
140
- />
141
- )
142
-
143
- const overlay = await screen.findByRole('button', { name: 'Play preview' })
144
- expect(play).toHaveBeenCalledTimes(1)
145
- fireEvent.click(overlay)
146
- await waitFor(() => expect(play).toHaveBeenCalledTimes(2))
147
- expect(onContainerClick).not.toHaveBeenCalled()
148
- expect(screen.queryByRole('button', { name: 'Play preview' })).toBeNull()
149
- })
150
-
151
- it('toggles from the container only with controls, while an override owns the click', async () => {
152
- const play = vi.mocked(HTMLMediaElement.prototype.play)
153
- const { container, rerender } = renderWithProviders(
154
- <MediaPlayer source={SOURCE} mimeType="video/mp4" controls />
155
- )
156
- const root = container.firstElementChild as HTMLElement
157
-
158
- fireEvent.click(root)
159
- await waitFor(() =>
160
- expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
161
- )
162
- fireEvent.click(root)
163
- await waitFor(() =>
164
- expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument()
165
- )
166
- expect(play).toHaveBeenCalledTimes(1)
167
-
168
- const onContainerClick = vi.fn()
169
- rerender(
170
- <MediaPlayer
171
- source={SOURCE}
172
- mimeType="video/mp4"
173
- controls
174
- onContainerClick={onContainerClick}
175
- />
176
- )
177
- fireEvent.click(root)
178
- expect(onContainerClick).toHaveBeenCalledTimes(1)
179
- expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument()
180
- fireEvent.keyDown(root, { key: 'Enter' })
181
- expect(onContainerClick).toHaveBeenCalledTimes(2)
182
- })
183
-
184
- it('handles Enter and Space but ignores other container keys and prevents defaults', () => {
185
- const { container } = renderWithProviders(
186
- <MediaPlayer source={SOURCE} mimeType="video/mp4" controls />
187
- )
188
- const root = container.firstElementChild as HTMLElement
189
- const pause = () => screen.getByRole('button', { name: 'Pause' })
190
-
191
- const otherKey = new KeyboardEvent('keydown', {
192
- key: 'ArrowDown',
193
- bubbles: true,
194
- cancelable: true,
195
- })
196
- fireEvent(root, otherKey)
197
- expect(otherKey.defaultPrevented).toBe(false)
198
- expect(screen.queryByRole('button', { name: 'Pause' })).toBeNull()
199
-
200
- const enter = new KeyboardEvent('keydown', {
201
- key: 'Enter',
202
- bubbles: true,
203
- cancelable: true,
204
- })
205
- fireEvent(root, enter)
206
- expect(enter.defaultPrevented).toBe(true)
207
- expect(pause()).toBeInTheDocument()
208
-
209
- const space = new KeyboardEvent('keydown', {
210
- key: ' ',
211
- bubbles: true,
212
- cancelable: true,
213
- })
214
- fireEvent(root, space)
215
- expect(space.defaultPrevented).toBe(true)
216
- expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument()
217
- })
218
-
219
- it('does not let the controls button bubble into the container toggle', async () => {
220
- const { container } = renderWithProviders(
221
- <MediaPlayer source={SOURCE} mimeType="video/mp4" controls />
222
- )
223
- const button = screen.getByRole('button', { name: 'Play' })
224
-
225
- fireEvent.click(button)
226
- await waitFor(() =>
227
- expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
228
- )
229
- expect(container.firstElementChild).toBeInTheDocument()
230
- })
231
-
232
- it('shows buffering while loading and suppresses it after manual play is required', async () => {
233
- const play = vi.mocked(HTMLMediaElement.prototype.play)
234
- play.mockRejectedValueOnce(new Error('autoplay blocked'))
235
- const { container } = renderWithProviders(
236
- <MediaPlayer
237
- source={SOURCE}
238
- mimeType="video/mp4"
239
- autoPlay
240
- controls={false}
241
- />
242
- )
243
- const video = container.querySelector('video') as HTMLVideoElement
244
- await screen.findByRole('button', { name: 'Play preview' })
245
-
246
- fireEvent.loadStart(video)
247
- expect(container.querySelector('.animate-spin')).toBeNull()
248
-
249
- fireEvent.canPlay(video)
250
- expect(container.querySelector('.animate-spin')).toBeNull()
251
- })
252
-
253
- it('shows and hides the buffering spinner around media readiness', () => {
254
- const { container } = renderWithProviders(
255
- <MediaPlayer source={SOURCE} mimeType="video/mp4" />
256
- )
257
- const video = container.querySelector('video') as HTMLVideoElement
258
-
259
- fireEvent.loadStart(video)
260
- expect(container.querySelector('.animate-spin')).toBeInTheDocument()
261
- fireEvent.waiting(video)
262
- expect(container.querySelector('.animate-spin')).toBeInTheDocument()
263
- fireEvent.canPlay(video)
264
- expect(container.querySelector('.animate-spin')).toBeNull()
265
- })
266
-
267
- it('seeks with keyboard and pointer input and expands the scrubber on hover', () => {
268
- const { container } = renderWithProviders(
269
- <MediaPlayer source={SOURCE} mimeType="video/mp4" controls showProgress />
270
- )
271
- const video = container.querySelector('video') as HTMLVideoElement
272
- Object.defineProperty(video, 'duration', {
273
- configurable: true,
274
- value: 100,
275
- })
276
- const slider = screen.getByRole('slider', { name: 'Playback position' })
277
- const track = slider.firstElementChild as HTMLElement
278
- const thumb = slider.lastElementChild as HTMLElement
279
- vi.spyOn(slider, 'getBoundingClientRect').mockReturnValue({
280
- left: 100,
281
- right: 300,
282
- top: 0,
283
- bottom: 10,
284
- width: 200,
285
- height: 10,
286
- x: 100,
287
- y: 0,
288
- toJSON: () => ({}),
289
- })
290
-
291
- expect(track).toHaveClass('h-1')
292
- expect(thumb).toHaveClass('scale-0', 'opacity-0')
293
- fireEvent.mouseEnter(slider)
294
- expect(track).toHaveClass('h-1.5')
295
- expect(thumb).toHaveClass('scale-100', 'opacity-100')
296
- fireEvent.mouseLeave(slider)
297
- expect(track).toHaveClass('h-1')
298
-
299
- fireEvent.mouseDown(slider, { clientX: 150 })
300
- expect(slider).toHaveAttribute('aria-valuenow', '25')
301
- expect(video.currentTime).toBe(25)
302
- expect(track).toHaveClass('h-1.5')
303
- expect(thumb).toHaveClass('scale-100', 'opacity-100')
304
-
305
- fireEvent.mouseUp(window, { clientX: 150 })
306
- fireEvent.keyDown(slider, { key: 'ArrowRight' })
307
- expect(video.currentTime).toBe(30)
308
- fireEvent.keyDown(slider, { key: 'ArrowLeft' })
309
- expect(video.currentTime).toBe(20)
310
- })
311
-
312
- it('renders the progress-only track without controls when requested', () => {
313
- const { container } = renderWithProviders(
314
- <MediaPlayer
315
- source={SOURCE}
316
- mimeType="video/mp4"
317
- controls={false}
318
- showProgress
319
- />
320
- )
321
-
322
- expect(
323
- screen.getByRole('slider', { name: 'Playback position' })
324
- ).toBeInTheDocument()
325
- expect(screen.queryByRole('button', { name: 'Play' })).toBeNull()
326
- expect(container.querySelectorAll('[role="slider"]')).toHaveLength(1)
327
- })
328
-
329
- it('uses natural video dimensions after metadata and aspect-video before then', () => {
330
- const { container } = renderWithProviders(
331
- <MediaPlayer source={SOURCE} mimeType="video/mp4" />
332
- )
333
- const root = container.firstElementChild as HTMLElement
334
- const video = container.querySelector('video') as HTMLVideoElement
335
- Object.defineProperties(video, {
336
- videoWidth: { configurable: true, value: 1920 },
337
- videoHeight: { configurable: true, value: 1080 },
338
- })
339
-
340
- expect(root).toHaveClass('aspect-video')
341
- expect(root).not.toHaveStyle({ aspectRatio: '1.7777777777777777' })
342
- fireEvent.loadedMetadata(video)
343
- expect(root).not.toHaveClass('aspect-video')
344
- expect(root.style.aspectRatio).toBe(String(1920 / 1080))
345
- })
346
- })
@@ -1,215 +0,0 @@
1
- import React from 'react'
2
- import { describe, expect, it, vi } from 'vitest'
3
-
4
- import { fireEvent, renderWithProviders, screen } from '../../test/utils'
5
-
6
- import AttachmentThumbnail from './Thumbnail'
7
-
8
- vi.mock('./MediaPlayer', () => ({
9
- default: ({
10
- source,
11
- mimeType,
12
- poster,
13
- controls,
14
- loop,
15
- muted,
16
- }: {
17
- source: string
18
- mimeType: string
19
- poster?: string
20
- controls?: boolean
21
- loop?: boolean
22
- muted?: boolean
23
- }) => (
24
- <div
25
- data-testid="media-player"
26
- data-source={source}
27
- data-mime-type={mimeType}
28
- data-poster={poster}
29
- data-controls={String(controls)}
30
- data-loop={String(loop)}
31
- data-muted={String(muted)}
32
- />
33
- ),
34
- }))
35
-
36
- const LINKTREE_SOURCE = 'https://cdn.linktr.ee/attachment/photo.jpg'
37
- const LINKTREE_THUMBNAIL = 'https://cdn.linktr.ee/attachment/poster.jpg'
38
- const OPTIMIZED_SOURCE =
39
- 'https://cdn.linktr.ee/attachment/photo.jpg?io=true&size=messaging-attachment-v1_0'
40
- const OPTIMIZED_THUMBNAIL =
41
- 'https://cdn.linktr.ee/attachment/poster.jpg?io=true&size=messaging-attachment-v1_0'
42
-
43
- describe('AttachmentThumbnail', () => {
44
- it('delegates video and audio sources to MediaPlayer with an optimized poster', () => {
45
- const { rerender } = renderWithProviders(
46
- <AttachmentThumbnail
47
- mimeType="video/mp4"
48
- sourceUrl={LINKTREE_SOURCE}
49
- thumbnailUrl={LINKTREE_THUMBNAIL}
50
- variant="dark"
51
- mediaPlayerProps={{ controls: false, loop: true, muted: true }}
52
- />
53
- )
54
- const player = screen.getByTestId('media-player')
55
- expect(player).toHaveAttribute('data-source', LINKTREE_SOURCE)
56
- expect(player).toHaveAttribute('data-mime-type', 'video/mp4')
57
- expect(player).toHaveAttribute('data-poster', OPTIMIZED_THUMBNAIL)
58
- expect(player).toHaveAttribute('data-controls', 'false')
59
- expect(player).toHaveAttribute('data-loop', 'true')
60
- expect(player).toHaveAttribute('data-muted', 'true')
61
-
62
- rerender(
63
- <AttachmentThumbnail
64
- mimeType="audio/mpeg"
65
- sourceUrl="https://external.example/track.mp3"
66
- variant="light"
67
- />
68
- )
69
- expect(screen.getByTestId('media-player')).toHaveAttribute(
70
- 'data-source',
71
- 'https://external.example/track.mp3'
72
- )
73
- expect(screen.getByTestId('media-player')).toHaveAttribute(
74
- 'data-controls',
75
- 'true'
76
- )
77
- })
78
-
79
- it('renders an image with an optimized source and contained fade-in transition', () => {
80
- const { container } = renderWithProviders(
81
- <AttachmentThumbnail
82
- mimeType="image/jpeg"
83
- sourceUrl={LINKTREE_SOURCE}
84
- title="Photo"
85
- variant="light"
86
- containedImage
87
- />
88
- )
89
- const image = screen.getByRole('img', { name: 'Photo' })
90
- expect(image).toHaveAttribute('src', OPTIMIZED_SOURCE)
91
- expect(image).toHaveClass('opacity-0')
92
- fireEvent.load(image)
93
- expect(image).toHaveClass('opacity-100')
94
- expect(container.firstElementChild).toHaveClass('aspect-video')
95
- })
96
-
97
- it('renders a non-contained image and falls back to the raw URL', () => {
98
- renderWithProviders(
99
- <AttachmentThumbnail
100
- mimeType="image/jpeg"
101
- sourceUrl="https://external.example/photo.jpg"
102
- variant="light"
103
- />
104
- )
105
- expect(screen.getByRole('img')).toHaveAttribute(
106
- 'src',
107
- 'https://external.example/photo.jpg'
108
- )
109
- expect(screen.getByRole('img')).toHaveClass('block', 'w-full')
110
- expect(screen.getByRole('img')).toHaveAttribute('alt', '')
111
- })
112
-
113
- it('renders document thumbnails contained or full width, using optimized URLs', () => {
114
- const { rerender, container } = renderWithProviders(
115
- <AttachmentThumbnail
116
- mimeType="application/pdf"
117
- sourceUrl="https://external.example/document.pdf"
118
- thumbnailUrl={LINKTREE_THUMBNAIL}
119
- title="Document"
120
- variant="dark"
121
- containedImage
122
- />
123
- )
124
- const contained = screen.getByRole('img', { name: 'Document' })
125
- expect(contained).toHaveAttribute('src', OPTIMIZED_THUMBNAIL)
126
- expect(contained).toHaveClass('opacity-0')
127
- fireEvent.load(contained)
128
- expect(contained).toHaveClass('opacity-100')
129
- expect(container.firstElementChild).toHaveClass('aspect-video')
130
-
131
- rerender(
132
- <AttachmentThumbnail
133
- mimeType="application/pdf"
134
- sourceUrl="https://external.example/document.pdf"
135
- thumbnailUrl="https://external.example/thumb.png"
136
- title="Document"
137
- variant="light"
138
- />
139
- )
140
- const fullWidth = screen.getByRole('img')
141
- expect(fullWidth).toHaveAttribute(
142
- 'src',
143
- 'https://external.example/thumb.png'
144
- )
145
- expect(fullWidth).toHaveAttribute('alt', '')
146
- expect(fullWidth).toHaveClass('block', 'w-full')
147
- })
148
-
149
- it('renders a document icon placeholder without a thumbnail', () => {
150
- const dark = renderWithProviders(
151
- <AttachmentThumbnail
152
- mimeType="application/pdf"
153
- variant="dark"
154
- title="PDF"
155
- />
156
- )
157
- const darkIcon = dark.container.querySelector('svg') as SVGElement
158
- expect(darkIcon).toHaveClass('size-12', 'text-white/20')
159
- expect(dark.container.firstElementChild).toHaveClass('bg-white/10')
160
- dark.unmount()
161
-
162
- const light = renderWithProviders(
163
- <AttachmentThumbnail mimeType="application/pdf" variant="light" />
164
- )
165
- const lightIcon = light.container.querySelector('svg') as SVGElement
166
- expect(lightIcon).toHaveClass('size-12', 'text-black/20')
167
- expect(light.container.firstElementChild).toHaveClass('bg-black/5')
168
- })
169
-
170
- it('renders the poster-only branch with variant-specific shell and alt fallback', () => {
171
- const dark = renderWithProviders(
172
- <AttachmentThumbnail
173
- mimeType="image/jpeg"
174
- thumbnailUrl={LINKTREE_THUMBNAIL}
175
- title="Poster"
176
- variant="dark"
177
- />
178
- )
179
- const darkImage = screen.getByRole('img', { name: 'Poster' })
180
- expect(darkImage).toHaveAttribute('src', OPTIMIZED_THUMBNAIL)
181
- expect(dark.container.firstElementChild).toHaveClass(
182
- 'aspect-video',
183
- 'bg-white/10'
184
- )
185
- dark.unmount()
186
-
187
- const light = renderWithProviders(
188
- <AttachmentThumbnail
189
- mimeType="image/jpeg"
190
- thumbnailUrl="https://external.example/poster.jpg"
191
- variant="light"
192
- />
193
- )
194
- expect(screen.getByRole('img')).toHaveAttribute('alt', '')
195
- expect(light.container.firstElementChild).toHaveClass(
196
- 'aspect-video',
197
- 'bg-black/5'
198
- )
199
- })
200
-
201
- it('renders the empty branch with a light placeholder', () => {
202
- const { container } = renderWithProviders(
203
- <AttachmentThumbnail
204
- mimeType="application/octet-stream"
205
- variant="light"
206
- />
207
- )
208
- expect(container.querySelector('img')).toBeNull()
209
- expect(container.querySelector('svg')).toHaveClass(
210
- 'size-12',
211
- 'text-black/20'
212
- )
213
- expect(container.firstElementChild).toHaveClass('bg-black/5')
214
- })
215
- })