@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
|
@@ -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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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="
|
|
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
|
-
|
|
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="
|
|
91
|
-
description="
|
|
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="
|
|
146
|
+
label="No waveform"
|
|
96
147
|
composer={
|
|
97
148
|
<AudioAttachment.Composer
|
|
98
149
|
src={AUDIO_SRC}
|
|
99
150
|
mimeType="audio/mpeg"
|
|
100
|
-
filename="
|
|
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="
|
|
109
|
-
|
|
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="
|
|
117
|
-
|
|
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="
|
|
174
|
+
label="No waveform, no duration"
|
|
142
175
|
composer={
|
|
143
176
|
<AudioAttachment.Composer
|
|
144
|
-
src={
|
|
145
|
-
|
|
146
|
-
filename="Morning meditation.mp3"
|
|
177
|
+
src={UNREACHABLE_SRC}
|
|
178
|
+
filename="Unreachable.mp3"
|
|
147
179
|
onDismiss={handleDismiss}
|
|
148
180
|
/>
|
|
149
181
|
}
|
|
150
|
-
sent={
|
|
151
|
-
|
|
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="
|
|
196
|
+
label="Unplayable codec"
|
|
155
197
|
composer={
|
|
156
198
|
<AudioAttachment.Composer
|
|
157
199
|
src={AUDIO_SRC}
|
|
158
|
-
mimeType="audio/
|
|
159
|
-
filename="
|
|
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={
|
|
164
|
-
|
|
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
|
-
/**
|
|
171
|
-
*
|
|
172
|
-
|
|
230
|
+
/**
|
|
231
|
+
* Stacked audio — multiple 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
|
|
176
|
-
description="
|
|
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
|
+
})
|