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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/assets/index.css +1 -1
  2. package/dist/index.cjs +2 -2
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +58 -43
  5. package/dist/index.js +1167 -1033
  6. package/dist/index.js.map +1 -1
  7. package/package.json +3 -3
  8. package/src/components/AttachmentCard/MediaPlayer.test.tsx +9 -5
  9. package/src/components/AttachmentCard/Thumbnail.test.tsx +4 -0
  10. package/src/components/ChannelActionsMenu/ChannelActionsMenu.test.tsx +18 -10
  11. package/src/components/ChannelList/ChannelListContext.test.tsx +7 -5
  12. package/src/components/ChannelList/CustomChannelPreview.test.tsx +7 -5
  13. package/src/components/ChannelList/index.test.tsx +1 -0
  14. package/src/components/ChannelView.test.tsx +20 -13
  15. package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +4 -3
  16. package/src/components/CustomMessage/CustomMessage.test.tsx +6 -5
  17. package/src/components/CustomMessage/MessageTag.test.tsx +6 -0
  18. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +19 -9
  19. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +45 -5
  20. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +6 -0
  21. package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +3 -0
  22. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +16 -7
  23. package/src/components/FaqList/FaqList.test.tsx +20 -12
  24. package/src/components/LinkAttachment/LinkAttachment.test.tsx +27 -14
  25. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +1 -0
  26. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +6 -6
  27. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +8 -5
  28. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +7 -5
  29. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +3 -8
  30. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +165 -58
  31. package/src/components/MessageAttachment/Audio/WaveformAudioRow.test.tsx +155 -0
  32. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +455 -0
  33. package/src/components/MessageAttachment/Audio/audioRowRequester.test.ts +24 -0
  34. package/src/components/MessageAttachment/Audio/audioRowRequester.ts +16 -0
  35. package/src/components/MessageAttachment/Audio/index.tsx +83 -79
  36. package/src/components/MessageAttachment/MessageAttachment.behavior.test.tsx +22 -15
  37. package/src/components/MessageAttachment/MessageAttachment.test.tsx +279 -134
  38. package/src/components/MessageAttachment/_shared/Bubble.test.tsx +2 -0
  39. package/src/components/MessageAttachment/_shared/CarouselNav.test.tsx +5 -4
  40. package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +4 -3
  41. package/src/components/MessageAttachment/_shared/DismissButton.test.tsx +4 -3
  42. package/src/components/MessageAttachment/_shared/DownloadAction.test.tsx +8 -5
  43. package/src/components/MessageAttachment/_shared/ImageViewer.test.tsx +6 -3
  44. package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +11 -4
  45. package/src/components/MessageAttachment/_shared/PdfViewer.test.tsx +5 -3
  46. package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +12 -3
  47. package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +13 -6
  48. package/src/components/MessageAttachment/_shared/useCarousel.test.tsx +10 -4
  49. package/src/components/MessageAttachment/_shared/useIsClient.ts +23 -0
  50. package/src/components/MessageAttachment/index.tsx +25 -9
  51. package/src/components/MessageAttachment/types.ts +19 -9
  52. package/src/components/MessageBubble/MessageBubble.test.tsx +8 -0
  53. package/src/components/RichCard/RichCard.test.tsx +10 -2
  54. package/src/styles.css +120 -0
@@ -1,6 +1,8 @@
1
- import type { Meta, StoryFn } from '@storybook/react'
1
+ import type { Decorator, Meta, StoryFn } from '@storybook/react'
2
2
  import React from 'react'
3
+ import { Chat, WithAudioPlayback } from 'stream-chat-react'
3
4
 
5
+ import { createMockStreamChatClient } from '../../../testing/createMockStreamChatClient'
4
6
  import {
5
7
  StoryGrid,
6
8
  StoryHeading,
@@ -14,27 +16,63 @@ import AudioAttachment from '.'
14
16
  const AUDIO_SRC =
15
17
  'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
16
18
 
19
+ /** Resolves nothing, so the metadata probe never fills the duration slot. */
20
+ const UNREACHABLE_SRC = 'https://cdn.example.invalid/unreachable.mp3'
21
+
22
+ /**
23
+ * The exact amplitudes Figma draws (`2754:17710` → `sound-wave`
24
+ * `…;2754:8065`), as bar heights on the 30px track normalised to `0..1`.
25
+ * Rendering the design's own values is what makes these stories a usable
26
+ * pixel reference.
27
+ */
28
+ const FIGMA_WAVEFORM = [
29
+ 4, 4, 4, 4, 24, 20, 16, 21, 8, 9, 13, 15, 16, 21, 24, 18, 14, 11, 11, 11, 11,
30
+ 11, 18, 24, 24, 30, 25, 21, 19, 16, 11, 12, 8, 4, 4,
31
+ ].map((height) => height / 30)
32
+
17
33
  const STACK_AUDIO: AudioItem[] = [
18
34
  {
19
35
  src: AUDIO_SRC,
20
36
  mimeType: 'audio/mpeg',
21
37
  filename: 'Take 1 — full mix.mp3',
38
+ durationSeconds: 12,
39
+ waveformData: FIGMA_WAVEFORM,
22
40
  },
23
41
  {
24
42
  src: AUDIO_SRC,
25
43
  mimeType: 'audio/mpeg',
26
44
  filename: 'Take 2 — vocals isolated.mp3',
45
+ durationSeconds: 95,
46
+ waveformData: [...FIGMA_WAVEFORM].reverse(),
27
47
  },
28
48
  {
29
49
  src: AUDIO_SRC,
30
50
  mimeType: 'audio/mpeg',
31
51
  filename: 'Take 3 — instrumental only.mp3',
52
+ durationSeconds: 3812,
32
53
  },
33
54
  ]
34
55
 
56
+ /**
57
+ * The card's playback comes from `stream-chat-react`'s pooled
58
+ * `AudioPlayer`, which `<Channel>` mounts in production. Mounting the
59
+ * pool directly gives the stories the same contract — real playback,
60
+ * one player at a time across the page — without needing a channel that
61
+ * can `watch()` against a backend. Without it every play / seek control
62
+ * renders disabled.
63
+ */
64
+ const withAudioPlayback: Decorator = (Story) => (
65
+ <Chat client={createMockStreamChatClient({ id: 'story-user' })}>
66
+ <WithAudioPlayback>
67
+ <Story />
68
+ </WithAudioPlayback>
69
+ </Chat>
70
+ )
71
+
35
72
  const meta: Meta = {
36
73
  title: 'MessageAttachment/Audio',
37
74
  parameters: { layout: 'fullscreen' },
75
+ decorators: [withAudioPlayback],
38
76
  }
39
77
  export default meta
40
78
 
@@ -43,16 +81,16 @@ const handleDismiss = () => {
43
81
  }
44
82
 
45
83
  /**
46
- * Native `<audio controls>` player inside a chat bubble. The browser
47
- * chrome provides play / scrub / volume / kebab-menu (which already
48
- * includes a download), so we don't render any extra controls.
49
- * Composer adds an inline dismiss `×` to the right of the player.
84
+ * The design's waveform player card: a 299×72 bubble holding a 32px
85
+ * play toggle, a 35-bar waveform with a progress fill and an invisible
86
+ * native seek control over it, the duration, and a 32px trailing action
87
+ * Remove in the composer, Download once sent.
50
88
  */
51
89
  export const Single: StoryFn = () => (
52
90
  <StoryPage>
53
91
  <StoryHeading
54
92
  title="Audio attachment"
55
- description="Native browser audio chrome play, scrub, volume, and the built-in download in the kebab menu. Composer renders an inline dismiss button next to the player."
93
+ description="Play to watch the fill advance bar by bar and the duration count up; click or drag the waveform to seek, or Tab to it and use the arrow keys. Starting a second card pauses the first one player at a time across the whole thread."
56
94
  />
57
95
  <StoryGrid>
58
96
  <StoryRow
@@ -62,6 +100,8 @@ export const Single: StoryFn = () => (
62
100
  src={AUDIO_SRC}
63
101
  mimeType="audio/mpeg"
64
102
  filename="Morning meditation.mp3"
103
+ durationSeconds={12}
104
+ waveformData={FIGMA_WAVEFORM}
65
105
  onDismiss={handleDismiss}
66
106
  />
67
107
  }
@@ -70,6 +110,8 @@ export const Single: StoryFn = () => (
70
110
  src={AUDIO_SRC}
71
111
  mimeType="audio/mpeg"
72
112
  filename="Morning meditation.mp3"
113
+ durationSeconds={12}
114
+ waveformData={FIGMA_WAVEFORM}
73
115
  />
74
116
  }
75
117
  received={
@@ -77,6 +119,8 @@ export const Single: StoryFn = () => (
77
119
  src={AUDIO_SRC}
78
120
  mimeType="audio/mpeg"
79
121
  filename="Morning meditation.mp3"
122
+ durationSeconds={12}
123
+ waveformData={FIGMA_WAVEFORM}
80
124
  />
81
125
  }
82
126
  />
@@ -84,20 +128,28 @@ export const Single: StoryFn = () => (
84
128
  </StoryPage>
85
129
  )
86
130
 
87
- export const SingleWithText: StoryFn = () => (
131
+ /**
132
+ * Every case where no amplitudes can be produced — a decode failure, an
133
+ * unsupported codec, a file over the composer's decode ceiling, a clip
134
+ * re-staged from *Recently shared* (whose `File` is a zero-byte
135
+ * placeholder), or anything sent before `waveform_data` was persisted
136
+ * (legacy web, the mobile app, a visitor).
137
+ */
138
+ export const NoWaveform: StoryFn = () => (
88
139
  <StoryPage>
89
140
  <StoryHeading
90
- title="Audio with caption"
91
- description="Caption sits below the player, inside the same bubble."
141
+ title="Degraded states"
142
+ description="The waveform falls back to every bar at the design's 4px minimum, so the card keeps exactly the same silhouette and the progress fill still tracks playback. An unknown duration holds its slot as `--:--` until a metadata probe resolves it."
92
143
  />
93
144
  <StoryGrid>
94
145
  <StoryRow
95
- label="Caption"
146
+ label="No waveform"
96
147
  composer={
97
148
  <AudioAttachment.Composer
98
149
  src={AUDIO_SRC}
99
150
  mimeType="audio/mpeg"
100
- filename="Morning meditation.mp3"
151
+ filename="Voice note.m4a"
152
+ durationSeconds={12}
101
153
  onDismiss={handleDismiss}
102
154
  />
103
155
  }
@@ -105,87 +157,100 @@ export const SingleWithText: StoryFn = () => (
105
157
  <AudioAttachment.Sent
106
158
  src={AUDIO_SRC}
107
159
  mimeType="audio/mpeg"
108
- filename="Morning meditation.mp3"
109
- text="Recorded this morning before training"
160
+ filename="Voice note.m4a"
161
+ durationSeconds={12}
110
162
  />
111
163
  }
112
164
  received={
113
165
  <AudioAttachment.Received
114
166
  src={AUDIO_SRC}
115
167
  mimeType="audio/mpeg"
116
- filename="Morning meditation.mp3"
117
- text="Recorded this morning before training"
168
+ filename="Voice note.m4a"
169
+ durationSeconds={12}
118
170
  />
119
171
  }
120
172
  />
121
- </StoryGrid>
122
- </StoryPage>
123
- )
124
-
125
- /**
126
- * Stacked audio attachments — multiple clips inside one bubble. Each
127
- * clip renders its own native `<audio controls>` player, vertically
128
- * stacked with a hairline separator.
129
- *
130
- * Sent + Received only — the composer surface accepts a single
131
- * attachment at a time.
132
- */
133
- export const Stacked: StoryFn = () => (
134
- <StoryPage>
135
- <StoryHeading
136
- title="Stacked audio"
137
- description="Each clip plays independently with its own native controls. Composer column is intentionally blank — the composer accepts a single attachment at a time."
138
- />
139
- <StoryGrid>
140
173
  <StoryRow
141
- label="2 clips"
174
+ label="No waveform, no duration"
142
175
  composer={
143
176
  <AudioAttachment.Composer
144
- src={AUDIO_SRC}
145
- mimeType="audio/mpeg"
146
- filename="Morning meditation.mp3"
177
+ src={UNREACHABLE_SRC}
178
+ filename="Unreachable.mp3"
147
179
  onDismiss={handleDismiss}
148
180
  />
149
181
  }
150
- sent={<AudioAttachment.Sent items={STACK_AUDIO.slice(0, 2)} />}
151
- received={<AudioAttachment.Received items={STACK_AUDIO.slice(0, 2)} />}
182
+ sent={
183
+ <AudioAttachment.Sent
184
+ src={UNREACHABLE_SRC}
185
+ filename="Unreachable.mp3"
186
+ />
187
+ }
188
+ received={
189
+ <AudioAttachment.Received
190
+ src={UNREACHABLE_SRC}
191
+ filename="Unreachable.mp3"
192
+ />
193
+ }
152
194
  />
153
195
  <StoryRow
154
- label="3 clips"
196
+ label="Unplayable codec"
155
197
  composer={
156
198
  <AudioAttachment.Composer
157
199
  src={AUDIO_SRC}
158
- mimeType="audio/mpeg"
159
- filename="Morning meditation.mp3"
200
+ mimeType="audio/x-made-up"
201
+ filename="Master.aiff"
202
+ durationSeconds={12}
203
+ waveformData={FIGMA_WAVEFORM}
160
204
  onDismiss={handleDismiss}
161
205
  />
162
206
  }
163
- sent={<AudioAttachment.Sent items={STACK_AUDIO} />}
164
- received={<AudioAttachment.Received items={STACK_AUDIO} />}
207
+ sent={
208
+ <AudioAttachment.Sent
209
+ src={AUDIO_SRC}
210
+ mimeType="audio/x-made-up"
211
+ filename="Master.aiff"
212
+ durationSeconds={12}
213
+ waveformData={FIGMA_WAVEFORM}
214
+ />
215
+ }
216
+ received={
217
+ <AudioAttachment.Received
218
+ src={AUDIO_SRC}
219
+ mimeType="audio/x-made-up"
220
+ filename="Master.aiff"
221
+ durationSeconds={12}
222
+ waveformData={FIGMA_WAVEFORM}
223
+ />
224
+ }
165
225
  />
166
226
  </StoryGrid>
167
227
  </StoryPage>
168
228
  )
169
229
 
170
- /** Stacked audio with an accompanying caption.
171
- * Sent + Received only see `Stacked` for the rationale. */
172
- export const StackedWithText: StoryFn = () => (
230
+ /**
231
+ * Stacked audiomultiple clips clustered into one bubble, as
232
+ * `StreamAttachmentMessage` does for consecutive same-kind attachments.
233
+ * Sent + Received only: the composer accepts one attachment at a time.
234
+ */
235
+ export const Stacked: StoryFn = () => (
173
236
  <StoryPage>
174
237
  <StoryHeading
175
- title="Stacked audio with caption"
176
- description="Caption sits inside the same bubble, below the player stack. Composer column is intentionally blank the composer accepts a single attachment at a time."
238
+ title="Stacked audio"
239
+ description="Each clip is its own row and its own player, stacked with an 8px gap. Only one can play at a time. The third row has no waveform, so it shows the flat fallback beside two real ones."
177
240
  />
178
241
  <StoryGrid>
242
+ <StoryRow
243
+ label="2 clips"
244
+ sent={<AudioAttachment.Sent items={STACK_AUDIO.slice(0, 2)} />}
245
+ received={<AudioAttachment.Received items={STACK_AUDIO.slice(0, 2)} />}
246
+ />
247
+ <StoryRow
248
+ label="3 clips"
249
+ sent={<AudioAttachment.Sent items={STACK_AUDIO} />}
250
+ received={<AudioAttachment.Received items={STACK_AUDIO} />}
251
+ />
179
252
  <StoryRow
180
253
  label="3 clips + caption"
181
- composer={
182
- <AudioAttachment.Composer
183
- src={AUDIO_SRC}
184
- mimeType="audio/mpeg"
185
- filename="Morning meditation.mp3"
186
- onDismiss={handleDismiss}
187
- />
188
- }
189
254
  sent={
190
255
  <AudioAttachment.Sent
191
256
  items={STACK_AUDIO}
@@ -202,3 +267,45 @@ export const StackedWithText: StoryFn = () => (
202
267
  </StoryGrid>
203
268
  </StoryPage>
204
269
  )
270
+
271
+ /**
272
+ * The card keeps a uniform 20px radius in every grouping position — a
273
+ * 32px control tucked into a 4px flattened corner would collide with it
274
+ * — so a run of audio bubbles reads as a stack of identical cards.
275
+ */
276
+ export const Grouped: StoryFn = () => (
277
+ <StoryPage>
278
+ <StoryHeading
279
+ title="Same-author run"
280
+ description="`groupPosition` still serialises onto the bubble, but the audio card opts out of the corner flattening the text bubbles apply."
281
+ />
282
+ <StoryGrid>
283
+ {(['first', 'middle', 'end'] as const).map((groupPosition) => (
284
+ <StoryRow
285
+ key={groupPosition}
286
+ label={groupPosition}
287
+ sent={
288
+ <AudioAttachment.Sent
289
+ src={AUDIO_SRC}
290
+ mimeType="audio/mpeg"
291
+ filename="Morning meditation.mp3"
292
+ durationSeconds={12}
293
+ waveformData={FIGMA_WAVEFORM}
294
+ groupPosition={groupPosition}
295
+ />
296
+ }
297
+ received={
298
+ <AudioAttachment.Received
299
+ src={AUDIO_SRC}
300
+ mimeType="audio/mpeg"
301
+ filename="Morning meditation.mp3"
302
+ durationSeconds={12}
303
+ waveformData={FIGMA_WAVEFORM}
304
+ groupPosition={groupPosition}
305
+ />
306
+ }
307
+ />
308
+ ))}
309
+ </StoryGrid>
310
+ </StoryPage>
311
+ )
@@ -0,0 +1,155 @@
1
+ import React from 'react'
2
+ import { renderToString } from 'react-dom/server'
3
+ import {
4
+ ChatProvider,
5
+ WithAudioPlayback,
6
+ type ChatContextValue,
7
+ } from 'stream-chat-react'
8
+ import { describe, expect, it, vi } from 'vitest'
9
+
10
+ import MessageAttachment from '..'
11
+ import { renderWithProviders, screen, userEvent } from '../../../test/utils'
12
+
13
+ // See `MessageAttachment.test.tsx` — the player reads only `client` off
14
+ // the chat context, so a stub is all the pool needs.
15
+ const CHAT_CONTEXT = {} as ChatContextValue
16
+
17
+ const withPlayback = (ui: React.ReactElement) => (
18
+ <ChatProvider value={CHAT_CONTEXT}>
19
+ <WithAudioPlayback>{ui}</WithAudioPlayback>
20
+ </ChatProvider>
21
+ )
22
+
23
+ describe('WaveformAudioRow duration formatting', () => {
24
+ it('carries a rounded-up second into the next minute', () => {
25
+ // `displayDuration` buckets first and `Math.ceil`s the leftover
26
+ // seconds, so 59.5 formatted straight through reads `00:60` — and a
27
+ // playing clip flashed it once a minute.
28
+ renderWithProviders(
29
+ withPlayback(
30
+ <MessageAttachment.Audio.Sent
31
+ src="https://cdn.example.com/song.mp3"
32
+ durationSeconds={59.5}
33
+ />
34
+ )
35
+ )
36
+
37
+ expect(screen.queryByText('00:60')).not.toBeInTheDocument()
38
+ expect(screen.getByText('00:59')).toBeInTheDocument()
39
+ })
40
+
41
+ it('floors a fractional duration rather than inventing a second', () => {
42
+ renderWithProviders(
43
+ withPlayback(
44
+ <MessageAttachment.Audio.Sent
45
+ src="https://cdn.example.com/song.mp3"
46
+ durationSeconds={125.9}
47
+ />
48
+ )
49
+ )
50
+
51
+ expect(screen.getByText('02:05')).toBeInTheDocument()
52
+ })
53
+ })
54
+
55
+ describe('WaveformAudioRow play control', () => {
56
+ it('does not advertise Pause while a start is still in flight', async () => {
57
+ const user = userEvent.setup()
58
+ // The control returns early while `togglePlay` is pending, so
59
+ // showing Pause made it look live while swallowing every press —
60
+ // and announced "Pause audio" to assistive tech.
61
+ renderWithProviders(
62
+ withPlayback(
63
+ <MessageAttachment.Audio.Sent
64
+ src="https://cdn.example.com/song.mp3"
65
+ mimeType="audio/mpeg"
66
+ filename="song.mp3"
67
+ durationSeconds={12}
68
+ />
69
+ )
70
+ )
71
+
72
+ const play = screen.getByRole('button', { name: 'Play audio — song.mp3' })
73
+ await user.click(play)
74
+
75
+ expect(
76
+ screen.getByRole('button', { name: 'Play audio — song.mp3' })
77
+ ).toBeInTheDocument()
78
+ expect(
79
+ screen.queryByRole('button', { name: /^Pause audio/ })
80
+ ).not.toBeInTheDocument()
81
+ })
82
+ })
83
+
84
+ describe('WaveformAudioRow composer trailing action', () => {
85
+ it('renders no trailing control for a draft with no dismiss handler', () => {
86
+ // Falling through to `download` handed a composer draft the
87
+ // sent-only action; the native player it replaces rendered nothing.
88
+ renderWithProviders(
89
+ withPlayback(
90
+ <MessageAttachment.Audio.Composer
91
+ src="https://cdn.example.com/song.mp3"
92
+ filename="song.mp3"
93
+ />
94
+ )
95
+ )
96
+
97
+ expect(
98
+ screen.queryByRole('button', { name: /^Download audio/ })
99
+ ).not.toBeInTheDocument()
100
+ expect(
101
+ screen.queryByRole('button', { name: 'Remove attachment' })
102
+ ).not.toBeInTheDocument()
103
+ // The card itself is unchanged.
104
+ expect(screen.getAllByTestId('amplitude-bar')).toHaveLength(35)
105
+ })
106
+
107
+ it('still renders Remove when the draft can be dismissed', async () => {
108
+ const user = userEvent.setup()
109
+ const onDismiss = vi.fn()
110
+ renderWithProviders(
111
+ withPlayback(
112
+ <MessageAttachment.Audio.Composer
113
+ src="https://cdn.example.com/song.mp3"
114
+ onDismiss={onDismiss}
115
+ />
116
+ )
117
+ )
118
+
119
+ await user.click(screen.getByRole('button', { name: 'Remove attachment' }))
120
+ expect(onDismiss).toHaveBeenCalledTimes(1)
121
+ })
122
+ })
123
+
124
+ describe('WaveformAudioRow server rendering', () => {
125
+ it('renders an inert card without reaching for `new Audio()`', () => {
126
+ // Stream constructs its pooled player during render, and the
127
+ // constructor evaluates `new Audio().canPlayType(mimeType)` — which
128
+ // is a ReferenceError in Node, so a host server-rendering a channel
129
+ // got a crash instead of markup. Deleting the global reproduces the
130
+ // server environment inside jsdom.
131
+ const OriginalAudio = globalThis.Audio
132
+ // @ts-expect-error — deliberately removing a DOM global
133
+ delete globalThis.Audio
134
+
135
+ try {
136
+ const html = renderToString(
137
+ withPlayback(
138
+ <MessageAttachment.Audio.Sent
139
+ src="https://cdn.example.com/song.mp3"
140
+ mimeType="audio/mpeg"
141
+ durationSeconds={12}
142
+ />
143
+ )
144
+ )
145
+
146
+ // The card is there in full, just not yet interactive.
147
+ expect(html).toContain('data-testid="audio-attachment"')
148
+ expect(html).toContain('mes-audio-wave__bar')
149
+ expect(html).toContain('00:12')
150
+ expect(html).toContain('disabled')
151
+ } finally {
152
+ globalThis.Audio = OriginalAudio
153
+ }
154
+ })
155
+ })