@linktr.ee/messaging-react 2.1.0 → 2.2.0-rc-1778753733

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 (58) hide show
  1. package/dist/{Card-CsJvUF_b.js → Card-BdTueeyk.js} +2 -2
  2. package/dist/{Card-CsJvUF_b.js.map → Card-BdTueeyk.js.map} +1 -1
  3. package/dist/{Card-DlMSDSdm.js → Card-ChR37pLZ.js} +2 -2
  4. package/dist/{Card-DlMSDSdm.js.map → Card-ChR37pLZ.js.map} +1 -1
  5. package/dist/{Card-CFFNq49v.js → Card-EKxCn56j.js} +3 -3
  6. package/dist/{Card-CFFNq49v.js.map → Card-EKxCn56j.js.map} +1 -1
  7. package/dist/{LockedThumbnail-DpJx169C.js → LockedThumbnail-B16qP3eH.js} +2 -2
  8. package/dist/{LockedThumbnail-DpJx169C.js.map → LockedThumbnail-B16qP3eH.js.map} +1 -1
  9. package/dist/index-Dn7BC9xK.js +4748 -0
  10. package/dist/index-Dn7BC9xK.js.map +1 -0
  11. package/dist/index.d.ts +591 -25
  12. package/dist/index.js +24 -19
  13. package/package.json +1 -1
  14. package/src/components/CustomMessage/MessageAttachmentConversations.stories.tsx +841 -0
  15. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +7 -92
  16. package/src/components/LinkAttachment/LinkAttachment.test.tsx +69 -0
  17. package/src/components/LinkAttachment/components/Received/Card.tsx +10 -30
  18. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -1
  19. package/src/components/LinkAttachment/index.tsx +24 -50
  20. package/src/components/LinkAttachment/types.ts +12 -5
  21. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +203 -0
  22. package/src/components/MessageAttachment/Audio/index.tsx +189 -0
  23. package/src/components/MessageAttachment/File/FileAttachment.stories.tsx +352 -0
  24. package/src/components/MessageAttachment/File/index.tsx +240 -0
  25. package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +288 -0
  26. package/src/components/MessageAttachment/Image/index.tsx +257 -0
  27. package/src/components/MessageAttachment/MessageAttachment.test.tsx +783 -0
  28. package/src/components/MessageAttachment/Pdf/PdfAttachment.stories.tsx +292 -0
  29. package/src/components/MessageAttachment/Pdf/index.tsx +228 -0
  30. package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +272 -0
  31. package/src/components/MessageAttachment/Video/index.tsx +281 -0
  32. package/src/components/MessageAttachment/_shared/Bubble.tsx +173 -0
  33. package/src/components/MessageAttachment/_shared/CompactDocumentRow.tsx +152 -0
  34. package/src/components/MessageAttachment/_shared/DismissButton.tsx +39 -0
  35. package/src/components/MessageAttachment/_shared/DownloadAction.tsx +175 -0
  36. package/src/components/MessageAttachment/_shared/ImageViewer.tsx +314 -0
  37. package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +139 -0
  38. package/src/components/MessageAttachment/_shared/PdfViewer.tsx +100 -0
  39. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +171 -0
  40. package/src/components/MessageAttachment/_shared/ViewerShell.tsx +159 -0
  41. package/src/components/MessageAttachment/_shared/fileMeta.test.ts +82 -0
  42. package/src/components/MessageAttachment/_shared/fileMeta.ts +95 -0
  43. package/src/components/MessageAttachment/_shared/triggerDownload.ts +54 -0
  44. package/src/components/MessageAttachment/_shared/useViewer.ts +53 -0
  45. package/src/components/MessageAttachment/index.tsx +149 -0
  46. package/src/components/MessageAttachment/stories/StoryTable.tsx +72 -0
  47. package/src/components/MessageAttachment/types.ts +178 -0
  48. package/src/index.ts +32 -0
  49. package/dist/Card-D32U6KfZ.js +0 -85
  50. package/dist/Card-D32U6KfZ.js.map +0 -1
  51. package/dist/Card-DlSSJPip.js +0 -60
  52. package/dist/Card-DlSSJPip.js.map +0 -1
  53. package/dist/Card-zGbhRBwv.js +0 -48
  54. package/dist/Card-zGbhRBwv.js.map +0 -1
  55. package/dist/CardThumbnail-DTBuRQHF.js +0 -239
  56. package/dist/CardThumbnail-DTBuRQHF.js.map +0 -1
  57. package/dist/index-DfcRe-Hj.js +0 -3103
  58. package/dist/index-DfcRe-Hj.js.map +0 -1
@@ -0,0 +1,783 @@
1
+ import React from 'react'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ import { renderWithProviders, screen, fireEvent } from '../../test/utils'
5
+
6
+ import { triggerDownload } from './_shared/triggerDownload'
7
+
8
+ import MessageAttachment, { bubbleGroupPositionFromStream } from '.'
9
+
10
+ vi.mock('./_shared/triggerDownload', () => ({
11
+ triggerDownload: vi.fn(() => Promise.resolve()),
12
+ }))
13
+
14
+ const mockTriggerDownload = vi.mocked(triggerDownload)
15
+
16
+ beforeEach(() => {
17
+ mockTriggerDownload.mockClear()
18
+ })
19
+
20
+ describe('MessageAttachment.Image', () => {
21
+ it('renders an image bubble with caption', () => {
22
+ renderWithProviders(
23
+ <MessageAttachment.Image.Sent
24
+ src="https://cdn.example.com/photo.jpg"
25
+ alt="Photo"
26
+ text="Here is the image"
27
+ />
28
+ )
29
+ const bubble = screen.getByTestId('image-attachment')
30
+ expect(bubble).toBeInTheDocument()
31
+ expect(screen.getByText('Here is the image')).toBeInTheDocument()
32
+ expect(bubble.querySelector('img')?.getAttribute('src')).toBe(
33
+ 'https://cdn.example.com/photo.jpg'
34
+ )
35
+ })
36
+
37
+ it('opens the ImageViewer lightbox when clicked', () => {
38
+ renderWithProviders(
39
+ <MessageAttachment.Image.Received
40
+ src="https://cdn.example.com/photo.jpg"
41
+ alt="Photo"
42
+ filename="photo.jpg"
43
+ />
44
+ )
45
+
46
+ expect(screen.queryByTestId('image-viewer')).toBeNull()
47
+
48
+ fireEvent.click(screen.getByLabelText('Open image 1 of 1'))
49
+
50
+ expect(screen.getByTestId('image-viewer')).toBeInTheDocument()
51
+ })
52
+
53
+ it('renders the dismiss button only on Composer state', () => {
54
+ const onDismiss = vi.fn()
55
+ const { rerender } = renderWithProviders(
56
+ <MessageAttachment.Image.Composer
57
+ src="https://cdn.example.com/photo.jpg"
58
+ onDismiss={onDismiss}
59
+ />
60
+ )
61
+ expect(screen.getByLabelText('Dismiss attachment')).toBeInTheDocument()
62
+
63
+ rerender(
64
+ <MessageAttachment.Image.Sent src="https://cdn.example.com/photo.jpg" />
65
+ )
66
+ expect(screen.queryByLabelText('Dismiss attachment')).toBeNull()
67
+ })
68
+
69
+ it('renders a stacked grid for multiple items', () => {
70
+ renderWithProviders(
71
+ <MessageAttachment.Image.Sent
72
+ items={[
73
+ { src: 'https://cdn.example.com/a.jpg', alt: 'A' },
74
+ { src: 'https://cdn.example.com/b.jpg', alt: 'B' },
75
+ { src: 'https://cdn.example.com/c.jpg', alt: 'C' },
76
+ ]}
77
+ />
78
+ )
79
+ expect(screen.getByLabelText('Open image 1 of 3')).toBeInTheDocument()
80
+ expect(screen.getByLabelText('Open image 2 of 3')).toBeInTheDocument()
81
+ expect(screen.getByLabelText('Open image 3 of 3')).toBeInTheDocument()
82
+ })
83
+
84
+ it('shows a +N overflow tile for 5+ items', () => {
85
+ renderWithProviders(
86
+ <MessageAttachment.Image.Sent
87
+ items={Array.from({ length: 6 }, (_, i) => ({
88
+ src: `https://cdn.example.com/${i}.jpg`,
89
+ }))}
90
+ />
91
+ )
92
+ expect(screen.getByText('+2')).toBeInTheDocument()
93
+ })
94
+ })
95
+
96
+ describe('MessageAttachment.Pdf', () => {
97
+ it('renders the compact row with filename + meta', () => {
98
+ renderWithProviders(
99
+ <MessageAttachment.Pdf.Received
100
+ src="https://cdn.example.com/doc.pdf"
101
+ filename="ESOP-summary.pdf"
102
+ fileSize={388_658}
103
+ />
104
+ )
105
+ expect(screen.getByText('ESOP-summary.pdf')).toBeInTheDocument()
106
+ expect(screen.getByText('PDF · 379.5 KB')).toBeInTheDocument()
107
+ })
108
+
109
+ it('opens the PdfViewer when clicked', () => {
110
+ renderWithProviders(
111
+ <MessageAttachment.Pdf.Sent
112
+ src="https://cdn.example.com/doc.pdf"
113
+ filename="doc.pdf"
114
+ />
115
+ )
116
+ expect(screen.queryByTestId('pdf-viewer')).toBeNull()
117
+ fireEvent.click(screen.getByLabelText('Open doc.pdf'))
118
+ expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument()
119
+ })
120
+
121
+ it('forwards the onClick handler before opening the viewer', () => {
122
+ const onClick = vi.fn()
123
+ renderWithProviders(
124
+ <MessageAttachment.Pdf.Received
125
+ src="https://cdn.example.com/doc.pdf"
126
+ filename="doc.pdf"
127
+ onClick={onClick}
128
+ />
129
+ )
130
+ fireEvent.click(screen.getByLabelText('Open doc.pdf'))
131
+ expect(onClick).toHaveBeenCalledTimes(1)
132
+ })
133
+
134
+ it('renders caption inside the same bubble', () => {
135
+ renderWithProviders(
136
+ <MessageAttachment.Pdf.Sent
137
+ src="https://cdn.example.com/doc.pdf"
138
+ filename="doc.pdf"
139
+ text="Here is the file"
140
+ />
141
+ )
142
+ const bubble = screen.getByTestId('pdf-attachment')
143
+ expect(bubble.textContent).toContain('Here is the file')
144
+ })
145
+
146
+ it('renders a row per item for stacked PDFs', () => {
147
+ renderWithProviders(
148
+ <MessageAttachment.Pdf.Sent
149
+ items={[
150
+ { src: 'https://cdn.example.com/a.pdf', filename: 'a.pdf' },
151
+ { src: 'https://cdn.example.com/b.pdf', filename: 'b.pdf' },
152
+ { src: 'https://cdn.example.com/c.pdf', filename: 'c.pdf' },
153
+ ]}
154
+ />
155
+ )
156
+ expect(screen.getByLabelText('Open a.pdf')).toBeInTheDocument()
157
+ expect(screen.getByLabelText('Open b.pdf')).toBeInTheDocument()
158
+ expect(screen.getByLabelText('Open c.pdf')).toBeInTheDocument()
159
+ })
160
+
161
+ it('opens the viewer on the activated row in a stacked PDF bubble', () => {
162
+ renderWithProviders(
163
+ <MessageAttachment.Pdf.Received
164
+ items={[
165
+ { src: 'https://cdn.example.com/a.pdf', filename: 'a.pdf' },
166
+ { src: 'https://cdn.example.com/b.pdf', filename: 'b.pdf' },
167
+ ]}
168
+ />
169
+ )
170
+ expect(screen.queryByTestId('pdf-viewer')).toBeNull()
171
+ fireEvent.click(screen.getByLabelText('Open b.pdf'))
172
+ const viewer = screen.getByTestId('pdf-viewer')
173
+ expect(viewer).toBeInTheDocument()
174
+ expect(viewer.textContent).toContain('b.pdf')
175
+ })
176
+
177
+ it('renders a sibling Download button on Sent / Received rows', () => {
178
+ renderWithProviders(
179
+ <MessageAttachment.Pdf.Received
180
+ src="https://cdn.example.com/notes.pdf"
181
+ filename="notes.pdf"
182
+ fileSize={1_024}
183
+ />
184
+ )
185
+ const open = screen.getByLabelText('Open notes.pdf')
186
+ const download = screen.getByLabelText('Download notes.pdf')
187
+ expect(open).toBeInTheDocument()
188
+ expect(download).toBeInTheDocument()
189
+ // The download button must be a real sibling button — not nested
190
+ // inside the open button — so the two activations are independent.
191
+ expect(open.contains(download)).toBe(false)
192
+ expect(download.contains(open)).toBe(false)
193
+ })
194
+
195
+ it('renders one Download button per row for stacked PDFs', () => {
196
+ renderWithProviders(
197
+ <MessageAttachment.Pdf.Sent
198
+ items={[
199
+ { src: 'https://cdn.example.com/a.pdf', filename: 'a.pdf' },
200
+ { src: 'https://cdn.example.com/b.pdf', filename: 'b.pdf' },
201
+ { src: 'https://cdn.example.com/c.pdf', filename: 'c.pdf' },
202
+ ]}
203
+ />
204
+ )
205
+ expect(screen.getByLabelText('Download a.pdf')).toBeInTheDocument()
206
+ expect(screen.getByLabelText('Download b.pdf')).toBeInTheDocument()
207
+ expect(screen.getByLabelText('Download c.pdf')).toBeInTheDocument()
208
+ })
209
+
210
+ it('does not open the viewer when the Download button is clicked', () => {
211
+ renderWithProviders(
212
+ <MessageAttachment.Pdf.Sent
213
+ src="https://cdn.example.com/notes.pdf"
214
+ filename="notes.pdf"
215
+ />
216
+ )
217
+ expect(screen.queryByTestId('pdf-viewer')).toBeNull()
218
+ fireEvent.click(screen.getByLabelText('Download notes.pdf'))
219
+ // Download should fire on its own without bubbling up to the row
220
+ // activation that opens the viewer.
221
+ expect(screen.queryByTestId('pdf-viewer')).toBeNull()
222
+ })
223
+
224
+ it('hides the Download button on the Composer state (dismiss takes its place)', () => {
225
+ renderWithProviders(
226
+ <MessageAttachment.Pdf.Composer
227
+ src="https://cdn.example.com/notes.pdf"
228
+ filename="notes.pdf"
229
+ onDismiss={() => undefined}
230
+ />
231
+ )
232
+ expect(screen.queryByLabelText('Download notes.pdf')).toBeNull()
233
+ expect(screen.getByLabelText('Dismiss attachment')).toBeInTheDocument()
234
+ })
235
+
236
+ it('does not crash when items shrinks after the viewer opens (P1 regression)', () => {
237
+ // Regression test for the PR #151 P1 reviewer finding:
238
+ // `viewerIndex` was dereferenced directly against `resolvedItems`,
239
+ // so a re-render with fewer items than the open index would crash
240
+ // with "Cannot read properties of undefined". The component now
241
+ // clamps the index defensively.
242
+ const { rerender } = renderWithProviders(
243
+ <MessageAttachment.Pdf.Received
244
+ items={[
245
+ { src: 'https://cdn.example.com/a.pdf', filename: 'a.pdf' },
246
+ { src: 'https://cdn.example.com/b.pdf', filename: 'b.pdf' },
247
+ { src: 'https://cdn.example.com/c.pdf', filename: 'c.pdf' },
248
+ ]}
249
+ />
250
+ )
251
+ fireEvent.click(screen.getByLabelText('Open c.pdf'))
252
+ expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument()
253
+
254
+ expect(() =>
255
+ rerender(
256
+ <MessageAttachment.Pdf.Received
257
+ items={[
258
+ { src: 'https://cdn.example.com/a.pdf', filename: 'a.pdf' },
259
+ ]}
260
+ />
261
+ )
262
+ ).not.toThrow()
263
+
264
+ // Bubble still renders the surviving row instead of blowing up.
265
+ expect(screen.getByLabelText('Open a.pdf')).toBeInTheDocument()
266
+ // Viewer falls back to the last available item (the surviving one).
267
+ expect(screen.getByTestId('pdf-viewer').textContent).toContain('a.pdf')
268
+ })
269
+
270
+ it('does not open the viewer when onClick returns false', () => {
271
+ const onClick = vi.fn(() => false as const)
272
+ renderWithProviders(
273
+ <MessageAttachment.Pdf.Received
274
+ src="https://cdn.example.com/doc.pdf"
275
+ filename="doc.pdf"
276
+ onClick={onClick}
277
+ />
278
+ )
279
+ fireEvent.click(screen.getByLabelText('Open doc.pdf'))
280
+ expect(onClick).toHaveBeenCalledTimes(1)
281
+ expect(screen.queryByTestId('pdf-viewer')).toBeNull()
282
+ })
283
+ })
284
+
285
+ describe('MessageAttachment.File', () => {
286
+ it('renders the row with the trailing download icon', () => {
287
+ renderWithProviders(
288
+ <MessageAttachment.File.Received
289
+ src="https://cdn.example.com/file.zip"
290
+ filename="workout-plan.zip"
291
+ mimeType="application/zip"
292
+ fileSize={2_457_600}
293
+ />
294
+ )
295
+ expect(screen.getByText('workout-plan.zip')).toBeInTheDocument()
296
+ expect(screen.getByText('ZIP · 2.34 MB')).toBeInTheDocument()
297
+ expect(
298
+ screen.getByLabelText('Download workout-plan.zip')
299
+ ).toBeInTheDocument()
300
+ })
301
+
302
+ it('falls back to the URL filename when none is provided', () => {
303
+ renderWithProviders(
304
+ <MessageAttachment.File.Sent src="https://cdn.example.com/notes.pdf" />
305
+ )
306
+ expect(screen.getByText('notes.pdf')).toBeInTheDocument()
307
+ })
308
+
309
+ it('renders one row per item for stacked files', () => {
310
+ renderWithProviders(
311
+ <MessageAttachment.File.Sent
312
+ items={[
313
+ {
314
+ src: 'https://cdn.example.com/a.zip',
315
+ filename: 'a.zip',
316
+ mimeType: 'application/zip',
317
+ fileSize: 1_024,
318
+ },
319
+ {
320
+ src: 'https://cdn.example.com/b.docx',
321
+ filename: 'b.docx',
322
+ mimeType:
323
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
324
+ fileSize: 2_048,
325
+ },
326
+ {
327
+ src: 'https://cdn.example.com/c.pdf',
328
+ filename: 'c.pdf',
329
+ mimeType: 'application/pdf',
330
+ fileSize: 4_096,
331
+ },
332
+ ]}
333
+ />
334
+ )
335
+ expect(screen.getByLabelText('Download a.zip')).toBeInTheDocument()
336
+ expect(screen.getByLabelText('Download b.docx')).toBeInTheDocument()
337
+ expect(screen.getByLabelText('Download c.pdf')).toBeInTheDocument()
338
+ })
339
+
340
+ it('downloads the file when the row is clicked and onClick allows it', () => {
341
+ const onClick = vi.fn()
342
+ renderWithProviders(
343
+ <MessageAttachment.File.Received
344
+ src="https://cdn.example.com/file.zip"
345
+ filename="workout-plan.zip"
346
+ onClick={onClick}
347
+ />
348
+ )
349
+ fireEvent.click(screen.getByLabelText('Download workout-plan.zip'))
350
+ expect(onClick).toHaveBeenCalledWith(0)
351
+ expect(mockTriggerDownload).toHaveBeenCalledTimes(1)
352
+ expect(mockTriggerDownload).toHaveBeenCalledWith(
353
+ 'https://cdn.example.com/file.zip',
354
+ 'workout-plan.zip'
355
+ )
356
+ })
357
+
358
+ it('lets onClick cancel the automatic download by returning false (P2 regression)', () => {
359
+ // Regression test for the PR #151 P2 reviewer finding:
360
+ // the docs advertised `onClick` as a hook for intercepting the
361
+ // download (e.g. confirmation modal), but the handler used to fire
362
+ // the default download unconditionally. Returning `false` from
363
+ // `onClick` now suppresses the built-in trigger.
364
+ const onClick = vi.fn(() => false as const)
365
+ renderWithProviders(
366
+ <MessageAttachment.File.Received
367
+ src="https://cdn.example.com/file.zip"
368
+ filename="workout-plan.zip"
369
+ onClick={onClick}
370
+ />
371
+ )
372
+ fireEvent.click(screen.getByLabelText('Download workout-plan.zip'))
373
+ expect(onClick).toHaveBeenCalledTimes(1)
374
+ expect(mockTriggerDownload).not.toHaveBeenCalled()
375
+ })
376
+
377
+ it('still downloads when onClick returns void', () => {
378
+ // Belt-and-braces: void / undefined returns must not be confused
379
+ // with `false` (the only cancellation sentinel).
380
+ const onClick = vi.fn(() => undefined)
381
+ renderWithProviders(
382
+ <MessageAttachment.File.Received
383
+ src="https://cdn.example.com/file.zip"
384
+ filename="workout-plan.zip"
385
+ onClick={onClick}
386
+ />
387
+ )
388
+ fireEvent.click(screen.getByLabelText('Download workout-plan.zip'))
389
+ expect(mockTriggerDownload).toHaveBeenCalledTimes(1)
390
+ })
391
+ })
392
+
393
+ describe('MessageAttachment.Audio', () => {
394
+ it('renders the native audio player only (no title, no extra download)', () => {
395
+ renderWithProviders(
396
+ <MessageAttachment.Audio.Sent
397
+ src="https://cdn.example.com/song.mp3"
398
+ mimeType="audio/mpeg"
399
+ filename="song.mp3"
400
+ />
401
+ )
402
+ const bubble = screen.getByTestId('audio-attachment')
403
+ expect(bubble.querySelector('audio')).not.toBeNull()
404
+ // No filename title — the native `<audio>` element doesn't expose
405
+ // a title slot, so we render just the player.
406
+ expect(screen.queryByText('song.mp3')).toBeNull()
407
+ // Native player handles its own download in the kebab menu, so
408
+ // no extra Download button should be rendered.
409
+ expect(
410
+ screen.queryByRole('button', { name: /download/i })
411
+ ).toBeNull()
412
+ })
413
+
414
+ it('shows an inline dismiss button next to the player on Composer state', () => {
415
+ const onDismiss = vi.fn()
416
+ renderWithProviders(
417
+ <MessageAttachment.Audio.Composer
418
+ src="https://cdn.example.com/song.mp3"
419
+ onDismiss={onDismiss}
420
+ />
421
+ )
422
+ const dismiss = screen.getByLabelText('Dismiss attachment')
423
+ expect(dismiss).toBeInTheDocument()
424
+ fireEvent.click(dismiss)
425
+ expect(onDismiss).toHaveBeenCalledTimes(1)
426
+ })
427
+
428
+ it('renders one native player per item for stacked audio', () => {
429
+ renderWithProviders(
430
+ <MessageAttachment.Audio.Sent
431
+ items={[
432
+ { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
433
+ { src: 'https://cdn.example.com/b.mp3', mimeType: 'audio/mpeg' },
434
+ { src: 'https://cdn.example.com/c.mp3', mimeType: 'audio/mpeg' },
435
+ ]}
436
+ />
437
+ )
438
+ const bubble = screen.getByTestId('audio-attachment')
439
+ expect(bubble.querySelectorAll('audio')).toHaveLength(3)
440
+ })
441
+ })
442
+
443
+ describe('MessageAttachment.Video', () => {
444
+ it('opens the VideoViewer when clicked', () => {
445
+ renderWithProviders(
446
+ <MessageAttachment.Video.Received
447
+ src="https://cdn.example.com/clip.mp4"
448
+ poster="https://cdn.example.com/poster.jpg"
449
+ mimeType="video/mp4"
450
+ filename="clip.mp4"
451
+ />
452
+ )
453
+ expect(screen.queryByTestId('video-viewer')).toBeNull()
454
+ fireEvent.click(screen.getByLabelText('Play video 1 of 1'))
455
+ expect(screen.getByTestId('video-viewer')).toBeInTheDocument()
456
+ })
457
+ })
458
+
459
+ describe('ViewerShell focus management', () => {
460
+ it('moves focus into the dialog when a viewer opens', () => {
461
+ renderWithProviders(
462
+ <MessageAttachment.Pdf.Received
463
+ src="https://cdn.example.com/doc.pdf"
464
+ filename="doc.pdf"
465
+ />
466
+ )
467
+ fireEvent.click(screen.getByLabelText('Open doc.pdf'))
468
+
469
+ // The close button is the primary focus target in the viewer
470
+ // toolbar — keyboard users should land on it on open so Escape
471
+ // / Tab work without an extra click.
472
+ const close = screen.getByLabelText('Close viewer')
473
+ expect(document.activeElement).toBe(close)
474
+ })
475
+
476
+ it('restores focus to the opener when the viewer closes', () => {
477
+ renderWithProviders(
478
+ <MessageAttachment.Pdf.Received
479
+ src="https://cdn.example.com/doc.pdf"
480
+ filename="doc.pdf"
481
+ />
482
+ )
483
+ const opener = screen.getByLabelText('Open doc.pdf')
484
+ opener.focus()
485
+ expect(document.activeElement).toBe(opener)
486
+
487
+ fireEvent.click(opener)
488
+ expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument()
489
+
490
+ fireEvent.click(screen.getByLabelText('Close viewer'))
491
+
492
+ // After close, focus should hop back to whatever was focused
493
+ // before the viewer mounted (the row's Open button).
494
+ expect(screen.queryByTestId('pdf-viewer')).toBeNull()
495
+ expect(document.activeElement).toBe(opener)
496
+ })
497
+ })
498
+
499
+ describe('MessageAttachment lazy-loading defaults', () => {
500
+ describe('Image', () => {
501
+ it('renders every `<img>` with loading="lazy" decoding="async" by default', () => {
502
+ renderWithProviders(
503
+ <MessageAttachment.Image.Sent
504
+ src="https://cdn.example.com/photo.jpg"
505
+ alt="Photo"
506
+ />
507
+ )
508
+ const img = screen.getByTestId('image-attachment').querySelector('img')
509
+ expect(img?.getAttribute('loading')).toBe('lazy')
510
+ expect(img?.getAttribute('decoding')).toBe('async')
511
+ })
512
+
513
+ it('honors loading="eager" when passed at the attachment level', () => {
514
+ renderWithProviders(
515
+ <MessageAttachment.Image.Received
516
+ src="https://cdn.example.com/photo.jpg"
517
+ alt="Photo"
518
+ loading="eager"
519
+ />
520
+ )
521
+ const img = screen.getByTestId('image-attachment').querySelector('img')
522
+ expect(img?.getAttribute('loading')).toBe('eager')
523
+ expect(img?.getAttribute('decoding')).toBe('async')
524
+ })
525
+
526
+ it('honors per-tile ImageItem.loading overrides in a stack', () => {
527
+ renderWithProviders(
528
+ <MessageAttachment.Image.Sent
529
+ items={[
530
+ { src: 'https://cdn.example.com/a.jpg', alt: 'A', loading: 'eager' },
531
+ { src: 'https://cdn.example.com/b.jpg', alt: 'B' },
532
+ ]}
533
+ />
534
+ )
535
+ const [first, second] = Array.from(
536
+ screen.getByTestId('image-attachment').querySelectorAll('img')
537
+ )
538
+ expect(first?.getAttribute('loading')).toBe('eager')
539
+ expect(second?.getAttribute('loading')).toBe('lazy')
540
+ })
541
+ })
542
+
543
+ describe('Video', () => {
544
+ it('renders the poster `<img>` with loading="lazy" decoding="async"', () => {
545
+ renderWithProviders(
546
+ <MessageAttachment.Video.Received
547
+ src="https://cdn.example.com/clip.mp4"
548
+ poster="https://cdn.example.com/poster.jpg"
549
+ mimeType="video/mp4"
550
+ />
551
+ )
552
+ const poster = screen
553
+ .getByTestId('video-attachment')
554
+ .querySelector('img')
555
+ expect(poster?.getAttribute('src')).toBe(
556
+ 'https://cdn.example.com/poster.jpg'
557
+ )
558
+ expect(poster?.getAttribute('loading')).toBe('lazy')
559
+ expect(poster?.getAttribute('decoding')).toBe('async')
560
+ })
561
+
562
+ it('opens the viewer with `<video preload="metadata">` for the active item', () => {
563
+ renderWithProviders(
564
+ <MessageAttachment.Video.Sent
565
+ src="https://cdn.example.com/clip.mp4"
566
+ poster="https://cdn.example.com/poster.jpg"
567
+ mimeType="video/mp4"
568
+ filename="clip.mp4"
569
+ />
570
+ )
571
+ fireEvent.click(screen.getByLabelText('Play video 1 of 1'))
572
+ const video = screen
573
+ .getByTestId('video-viewer')
574
+ .querySelector('video')
575
+ expect(video?.getAttribute('preload')).toBe('metadata')
576
+ })
577
+ })
578
+
579
+ describe('Audio', () => {
580
+ it('defaults a single-track bubble to preload="metadata"', () => {
581
+ renderWithProviders(
582
+ <MessageAttachment.Audio.Received
583
+ src="https://cdn.example.com/song.mp3"
584
+ mimeType="audio/mpeg"
585
+ />
586
+ )
587
+ const audio = screen
588
+ .getByTestId('audio-attachment')
589
+ .querySelector('audio')
590
+ expect(audio?.getAttribute('preload')).toBe('metadata')
591
+ })
592
+
593
+ it('defaults a stacked (3-track) bubble to preload="none" on every player', () => {
594
+ renderWithProviders(
595
+ <MessageAttachment.Audio.Sent
596
+ items={[
597
+ { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
598
+ { src: 'https://cdn.example.com/b.mp3', mimeType: 'audio/mpeg' },
599
+ { src: 'https://cdn.example.com/c.mp3', mimeType: 'audio/mpeg' },
600
+ ]}
601
+ />
602
+ )
603
+ const audios = Array.from(
604
+ screen.getByTestId('audio-attachment').querySelectorAll('audio')
605
+ )
606
+ expect(audios).toHaveLength(3)
607
+ audios.forEach((audio) => {
608
+ expect(audio.getAttribute('preload')).toBe('none')
609
+ })
610
+ })
611
+
612
+ it('honors a per-track AudioItem.preload override', () => {
613
+ renderWithProviders(
614
+ <MessageAttachment.Audio.Sent
615
+ items={[
616
+ { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
617
+ {
618
+ src: 'https://cdn.example.com/b.mp3',
619
+ mimeType: 'audio/mpeg',
620
+ preload: 'metadata',
621
+ },
622
+ ]}
623
+ />
624
+ )
625
+ const audios = Array.from(
626
+ screen.getByTestId('audio-attachment').querySelectorAll('audio')
627
+ )
628
+ expect(audios[0]?.getAttribute('preload')).toBe('none')
629
+ expect(audios[1]?.getAttribute('preload')).toBe('metadata')
630
+ })
631
+ })
632
+ })
633
+
634
+ describe('bubbleGroupPositionFromStream', () => {
635
+ it('returns "single" when not grouped', () => {
636
+ expect(
637
+ bubbleGroupPositionFromStream({
638
+ groupedByUser: false,
639
+ firstOfGroup: true,
640
+ endOfGroup: true,
641
+ })
642
+ ).toBe('single')
643
+ })
644
+
645
+ it('returns "single" when both first and end are true (run of one)', () => {
646
+ expect(
647
+ bubbleGroupPositionFromStream({
648
+ groupedByUser: true,
649
+ firstOfGroup: true,
650
+ endOfGroup: true,
651
+ })
652
+ ).toBe('single')
653
+ })
654
+
655
+ it('returns "first" when firstOfGroup is the only flag set', () => {
656
+ expect(
657
+ bubbleGroupPositionFromStream({
658
+ groupedByUser: true,
659
+ firstOfGroup: true,
660
+ endOfGroup: false,
661
+ })
662
+ ).toBe('first')
663
+ })
664
+
665
+ it('returns "end" when endOfGroup is the only flag set', () => {
666
+ expect(
667
+ bubbleGroupPositionFromStream({
668
+ groupedByUser: true,
669
+ firstOfGroup: false,
670
+ endOfGroup: true,
671
+ })
672
+ ).toBe('end')
673
+ })
674
+
675
+ it('returns "middle" when grouped but neither first nor end', () => {
676
+ expect(
677
+ bubbleGroupPositionFromStream({
678
+ groupedByUser: true,
679
+ firstOfGroup: false,
680
+ endOfGroup: false,
681
+ })
682
+ ).toBe('middle')
683
+ })
684
+ })
685
+
686
+ describe('MessageAttachment.Bubble grouping', () => {
687
+ // Sender bubbles (Sent) cluster against the right edge — the corner
688
+ // facing the previous bubble in a run is `top-right`, the corner
689
+ // facing the next bubble is `bottom-right`. Receiver bubbles
690
+ // (Received) mirror those onto the left edge.
691
+ //
692
+ // We assert on the `data-group-position` attribute the Bubble
693
+ // serializes, not on the Tailwind class strings. The class strings
694
+ // encode the same intent, but asserting on them ties the test to
695
+ // the current `rounded-tr-[Xpx]` token format — if the bubble ever
696
+ // migrates to a CSS variable / theme token (see `Bubble.tsx` TODO),
697
+ // every one of those assertions would break for an implementation
698
+ // detail. `data-group-position` is the documented contract.
699
+ const FILE_PROPS = {
700
+ src: 'https://cdn.example.com/notes.pdf',
701
+ filename: 'notes.pdf',
702
+ fileSize: 1_024,
703
+ mimeType: 'application/pdf',
704
+ }
705
+
706
+ it('serializes groupPosition="single" for a standalone bubble', () => {
707
+ renderWithProviders(
708
+ <MessageAttachment.File.Sent {...FILE_PROPS} groupPosition="single" />
709
+ )
710
+ expect(
711
+ screen.getByTestId('file-attachment').getAttribute('data-group-position')
712
+ ).toBe('single')
713
+ })
714
+
715
+ it('serializes groupPosition="first" for the first bubble in a run', () => {
716
+ renderWithProviders(
717
+ <MessageAttachment.File.Sent {...FILE_PROPS} groupPosition="first" />
718
+ )
719
+ expect(
720
+ screen.getByTestId('file-attachment').getAttribute('data-group-position')
721
+ ).toBe('first')
722
+ })
723
+
724
+ it('serializes groupPosition="middle" for an interior bubble in a run', () => {
725
+ renderWithProviders(
726
+ <MessageAttachment.File.Sent {...FILE_PROPS} groupPosition="middle" />
727
+ )
728
+ expect(
729
+ screen.getByTestId('file-attachment').getAttribute('data-group-position')
730
+ ).toBe('middle')
731
+ })
732
+
733
+ it('serializes groupPosition="end" for the last bubble in a run', () => {
734
+ renderWithProviders(
735
+ <MessageAttachment.File.Sent {...FILE_PROPS} groupPosition="end" />
736
+ )
737
+ expect(
738
+ screen.getByTestId('file-attachment').getAttribute('data-group-position')
739
+ ).toBe('end')
740
+ })
741
+
742
+ it('serializes groupPosition on receiver-side bubbles too', () => {
743
+ renderWithProviders(
744
+ <MessageAttachment.File.Received {...FILE_PROPS} groupPosition="middle" />
745
+ )
746
+ expect(
747
+ screen.getByTestId('file-attachment').getAttribute('data-group-position')
748
+ ).toBe('middle')
749
+ })
750
+
751
+ it('forwards groupPosition through every attachment type', () => {
752
+ const { rerender } = renderWithProviders(
753
+ <MessageAttachment.Audio.Sent
754
+ src="https://cdn.example.com/song.mp3"
755
+ groupPosition="middle"
756
+ />
757
+ )
758
+ expect(
759
+ screen.getByTestId('audio-attachment').getAttribute('data-group-position')
760
+ ).toBe('middle')
761
+
762
+ rerender(
763
+ <MessageAttachment.Pdf.Sent
764
+ src="https://cdn.example.com/notes.pdf"
765
+ filename="notes.pdf"
766
+ groupPosition="end"
767
+ />
768
+ )
769
+ expect(
770
+ screen.getByTestId('pdf-attachment').getAttribute('data-group-position')
771
+ ).toBe('end')
772
+
773
+ rerender(
774
+ <MessageAttachment.Image.Sent
775
+ src="https://cdn.example.com/photo.jpg"
776
+ groupPosition="first"
777
+ />
778
+ )
779
+ expect(
780
+ screen.getByTestId('image-attachment').getAttribute('data-group-position')
781
+ ).toBe('first')
782
+ })
783
+ })