@linktr.ee/messaging-react 3.31.0-rc-1785485102 → 3.31.0
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/{Card-2l_y20FM.js → Card-CmlaSw7i.js} +2 -2
- package/dist/{Card-2l_y20FM.js.map → Card-CmlaSw7i.js.map} +1 -1
- package/dist/{Card-Gr4HDVbn.cjs → Card-EWN07XIl.cjs} +2 -2
- package/dist/{Card-Gr4HDVbn.cjs.map → Card-EWN07XIl.cjs.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/{index-B96WLmx6.js → index-CN3errpB.js} +3055 -3109
- package/dist/index-CN3errpB.js.map +1 -0
- package/dist/index-CcitzVoW.cjs +5 -0
- package/dist/index-CcitzVoW.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +56 -60
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/components/ChannelView.broadcast-name.integration.test.tsx +116 -0
- package/src/components/ChannelView.tsx +17 -3
- package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +15 -0
- package/src/components/CustomLinkPreviewList/index.tsx +24 -26
- package/src/components/CustomMessage/BroadcastNameContext.test.ts +43 -0
- package/src/components/CustomMessage/BroadcastNameContext.ts +95 -0
- package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -22
- package/src/components/CustomMessage/CustomMessage.test.tsx +48 -0
- package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +62 -0
- package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +8 -2
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -8
- package/src/components/CustomMessage/index.tsx +23 -3
- package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +50 -2
- package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +180 -2
- package/src/components/CustomMessageInput/index.tsx +143 -16
- package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +57 -166
- package/src/components/MessageAttachment/Audio/index.tsx +77 -71
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +75 -175
- package/src/components/MessageAttachment/index.tsx +9 -25
- package/src/components/MessageAttachment/types.ts +9 -19
- package/src/components/MessagingShell/index.tsx +2 -0
- package/src/index.ts +1 -0
- package/src/styles.css +5 -120
- package/src/types.ts +11 -0
- package/src/utils/formatRelativeTime.test.ts +63 -57
- package/src/utils/formatRelativeTime.ts +21 -20
- package/dist/index-B96WLmx6.js.map +0 -1
- package/dist/index-CCtHMrmO.cjs +0 -5
- package/dist/index-CCtHMrmO.cjs.map +0 -1
- package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +0 -335
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Meta, StoryFn } from '@storybook/react'
|
|
2
2
|
import React from 'react'
|
|
3
|
-
import { Chat, WithAudioPlayback } from 'stream-chat-react'
|
|
4
3
|
|
|
5
|
-
import { createMockStreamChatClient } from '../../../testing/createMockStreamChatClient'
|
|
6
4
|
import {
|
|
7
5
|
StoryGrid,
|
|
8
6
|
StoryHeading,
|
|
@@ -16,63 +14,27 @@ import AudioAttachment from '.'
|
|
|
16
14
|
const AUDIO_SRC =
|
|
17
15
|
'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
|
|
18
16
|
|
|
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
|
-
|
|
33
17
|
const STACK_AUDIO: AudioItem[] = [
|
|
34
18
|
{
|
|
35
19
|
src: AUDIO_SRC,
|
|
36
20
|
mimeType: 'audio/mpeg',
|
|
37
21
|
filename: 'Take 1 — full mix.mp3',
|
|
38
|
-
durationSeconds: 12,
|
|
39
|
-
waveformData: FIGMA_WAVEFORM,
|
|
40
22
|
},
|
|
41
23
|
{
|
|
42
24
|
src: AUDIO_SRC,
|
|
43
25
|
mimeType: 'audio/mpeg',
|
|
44
26
|
filename: 'Take 2 — vocals isolated.mp3',
|
|
45
|
-
durationSeconds: 95,
|
|
46
|
-
waveformData: [...FIGMA_WAVEFORM].reverse(),
|
|
47
27
|
},
|
|
48
28
|
{
|
|
49
29
|
src: AUDIO_SRC,
|
|
50
30
|
mimeType: 'audio/mpeg',
|
|
51
31
|
filename: 'Take 3 — instrumental only.mp3',
|
|
52
|
-
durationSeconds: 3812,
|
|
53
32
|
},
|
|
54
33
|
]
|
|
55
34
|
|
|
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
|
-
|
|
72
35
|
const meta: Meta = {
|
|
73
36
|
title: 'MessageAttachment/Audio',
|
|
74
37
|
parameters: { layout: 'fullscreen' },
|
|
75
|
-
decorators: [withAudioPlayback],
|
|
76
38
|
}
|
|
77
39
|
export default meta
|
|
78
40
|
|
|
@@ -81,16 +43,16 @@ const handleDismiss = () => {
|
|
|
81
43
|
}
|
|
82
44
|
|
|
83
45
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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.
|
|
88
50
|
*/
|
|
89
51
|
export const Single: StoryFn = () => (
|
|
90
52
|
<StoryPage>
|
|
91
53
|
<StoryHeading
|
|
92
54
|
title="Audio attachment"
|
|
93
|
-
description="
|
|
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."
|
|
94
56
|
/>
|
|
95
57
|
<StoryGrid>
|
|
96
58
|
<StoryRow
|
|
@@ -100,8 +62,6 @@ export const Single: StoryFn = () => (
|
|
|
100
62
|
src={AUDIO_SRC}
|
|
101
63
|
mimeType="audio/mpeg"
|
|
102
64
|
filename="Morning meditation.mp3"
|
|
103
|
-
durationSeconds={12}
|
|
104
|
-
waveformData={FIGMA_WAVEFORM}
|
|
105
65
|
onDismiss={handleDismiss}
|
|
106
66
|
/>
|
|
107
67
|
}
|
|
@@ -110,8 +70,6 @@ export const Single: StoryFn = () => (
|
|
|
110
70
|
src={AUDIO_SRC}
|
|
111
71
|
mimeType="audio/mpeg"
|
|
112
72
|
filename="Morning meditation.mp3"
|
|
113
|
-
durationSeconds={12}
|
|
114
|
-
waveformData={FIGMA_WAVEFORM}
|
|
115
73
|
/>
|
|
116
74
|
}
|
|
117
75
|
received={
|
|
@@ -119,8 +77,6 @@ export const Single: StoryFn = () => (
|
|
|
119
77
|
src={AUDIO_SRC}
|
|
120
78
|
mimeType="audio/mpeg"
|
|
121
79
|
filename="Morning meditation.mp3"
|
|
122
|
-
durationSeconds={12}
|
|
123
|
-
waveformData={FIGMA_WAVEFORM}
|
|
124
80
|
/>
|
|
125
81
|
}
|
|
126
82
|
/>
|
|
@@ -128,28 +84,20 @@ export const Single: StoryFn = () => (
|
|
|
128
84
|
</StoryPage>
|
|
129
85
|
)
|
|
130
86
|
|
|
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 = () => (
|
|
87
|
+
export const SingleWithText: StoryFn = () => (
|
|
139
88
|
<StoryPage>
|
|
140
89
|
<StoryHeading
|
|
141
|
-
title="
|
|
142
|
-
description="
|
|
90
|
+
title="Audio with caption"
|
|
91
|
+
description="Caption sits below the player, inside the same bubble."
|
|
143
92
|
/>
|
|
144
93
|
<StoryGrid>
|
|
145
94
|
<StoryRow
|
|
146
|
-
label="
|
|
95
|
+
label="Caption"
|
|
147
96
|
composer={
|
|
148
97
|
<AudioAttachment.Composer
|
|
149
98
|
src={AUDIO_SRC}
|
|
150
99
|
mimeType="audio/mpeg"
|
|
151
|
-
filename="
|
|
152
|
-
durationSeconds={12}
|
|
100
|
+
filename="Morning meditation.mp3"
|
|
153
101
|
onDismiss={handleDismiss}
|
|
154
102
|
/>
|
|
155
103
|
}
|
|
@@ -157,69 +105,16 @@ export const NoWaveform: StoryFn = () => (
|
|
|
157
105
|
<AudioAttachment.Sent
|
|
158
106
|
src={AUDIO_SRC}
|
|
159
107
|
mimeType="audio/mpeg"
|
|
160
|
-
filename="
|
|
161
|
-
|
|
108
|
+
filename="Morning meditation.mp3"
|
|
109
|
+
text="Recorded this morning before training"
|
|
162
110
|
/>
|
|
163
111
|
}
|
|
164
112
|
received={
|
|
165
113
|
<AudioAttachment.Received
|
|
166
114
|
src={AUDIO_SRC}
|
|
167
115
|
mimeType="audio/mpeg"
|
|
168
|
-
filename="
|
|
169
|
-
|
|
170
|
-
/>
|
|
171
|
-
}
|
|
172
|
-
/>
|
|
173
|
-
<StoryRow
|
|
174
|
-
label="No waveform, no duration"
|
|
175
|
-
composer={
|
|
176
|
-
<AudioAttachment.Composer
|
|
177
|
-
src={UNREACHABLE_SRC}
|
|
178
|
-
filename="Unreachable.mp3"
|
|
179
|
-
onDismiss={handleDismiss}
|
|
180
|
-
/>
|
|
181
|
-
}
|
|
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
|
-
}
|
|
194
|
-
/>
|
|
195
|
-
<StoryRow
|
|
196
|
-
label="Unplayable codec"
|
|
197
|
-
composer={
|
|
198
|
-
<AudioAttachment.Composer
|
|
199
|
-
src={AUDIO_SRC}
|
|
200
|
-
mimeType="audio/x-made-up"
|
|
201
|
-
filename="Master.aiff"
|
|
202
|
-
durationSeconds={12}
|
|
203
|
-
waveformData={FIGMA_WAVEFORM}
|
|
204
|
-
onDismiss={handleDismiss}
|
|
205
|
-
/>
|
|
206
|
-
}
|
|
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}
|
|
116
|
+
filename="Morning meditation.mp3"
|
|
117
|
+
text="Recorded this morning before training"
|
|
223
118
|
/>
|
|
224
119
|
}
|
|
225
120
|
/>
|
|
@@ -228,29 +123,67 @@ export const NoWaveform: StoryFn = () => (
|
|
|
228
123
|
)
|
|
229
124
|
|
|
230
125
|
/**
|
|
231
|
-
* Stacked audio — multiple clips
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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.
|
|
234
132
|
*/
|
|
235
133
|
export const Stacked: StoryFn = () => (
|
|
236
134
|
<StoryPage>
|
|
237
135
|
<StoryHeading
|
|
238
136
|
title="Stacked audio"
|
|
239
|
-
description="Each clip
|
|
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."
|
|
240
138
|
/>
|
|
241
139
|
<StoryGrid>
|
|
242
140
|
<StoryRow
|
|
243
141
|
label="2 clips"
|
|
142
|
+
composer={
|
|
143
|
+
<AudioAttachment.Composer
|
|
144
|
+
src={AUDIO_SRC}
|
|
145
|
+
mimeType="audio/mpeg"
|
|
146
|
+
filename="Morning meditation.mp3"
|
|
147
|
+
onDismiss={handleDismiss}
|
|
148
|
+
/>}
|
|
244
149
|
sent={<AudioAttachment.Sent items={STACK_AUDIO.slice(0, 2)} />}
|
|
245
150
|
received={<AudioAttachment.Received items={STACK_AUDIO.slice(0, 2)} />}
|
|
246
151
|
/>
|
|
247
152
|
<StoryRow
|
|
248
153
|
label="3 clips"
|
|
154
|
+
composer={
|
|
155
|
+
<AudioAttachment.Composer
|
|
156
|
+
src={AUDIO_SRC}
|
|
157
|
+
mimeType="audio/mpeg"
|
|
158
|
+
filename="Morning meditation.mp3"
|
|
159
|
+
onDismiss={handleDismiss}
|
|
160
|
+
/>}
|
|
249
161
|
sent={<AudioAttachment.Sent items={STACK_AUDIO} />}
|
|
250
162
|
received={<AudioAttachment.Received items={STACK_AUDIO} />}
|
|
251
163
|
/>
|
|
164
|
+
</StoryGrid>
|
|
165
|
+
</StoryPage>
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
/** Stacked audio with an accompanying caption.
|
|
169
|
+
* Sent + Received only — see `Stacked` for the rationale. */
|
|
170
|
+
export const StackedWithText: StoryFn = () => (
|
|
171
|
+
<StoryPage>
|
|
172
|
+
<StoryHeading
|
|
173
|
+
title="Stacked audio with caption"
|
|
174
|
+
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."
|
|
175
|
+
/>
|
|
176
|
+
<StoryGrid>
|
|
252
177
|
<StoryRow
|
|
253
178
|
label="3 clips + caption"
|
|
179
|
+
composer={
|
|
180
|
+
<AudioAttachment.Composer
|
|
181
|
+
src={AUDIO_SRC}
|
|
182
|
+
mimeType="audio/mpeg"
|
|
183
|
+
filename="Morning meditation.mp3"
|
|
184
|
+
onDismiss={handleDismiss}
|
|
185
|
+
/>
|
|
186
|
+
}
|
|
254
187
|
sent={
|
|
255
188
|
<AudioAttachment.Sent
|
|
256
189
|
items={STACK_AUDIO}
|
|
@@ -267,45 +200,3 @@ export const Stacked: StoryFn = () => (
|
|
|
267
200
|
</StoryGrid>
|
|
268
201
|
</StoryPage>
|
|
269
202
|
)
|
|
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
|
-
)
|
|
@@ -1,74 +1,93 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { useMessageContext } from 'stream-chat-react'
|
|
3
2
|
|
|
4
3
|
import Bubble from '../_shared/Bubble'
|
|
4
|
+
import DismissButton from '../_shared/DismissButton'
|
|
5
5
|
import {
|
|
6
6
|
bubbleVariantForState,
|
|
7
7
|
type AudioItem,
|
|
8
8
|
type ComposerExtras,
|
|
9
|
+
type MediaPreloadMode,
|
|
9
10
|
type MessageAttachmentBaseProps,
|
|
10
11
|
type MessageAttachmentState,
|
|
11
12
|
} from '../types'
|
|
12
13
|
|
|
13
|
-
import WaveformAudioRow, {
|
|
14
|
-
type AudioRowTrailingAction,
|
|
15
|
-
} from './WaveformAudioRow'
|
|
16
|
-
|
|
17
|
-
// Figma `2754:17710` → `attachment-audio` (`…;2754:8062`): a 299×72
|
|
18
|
-
// card on `#1E2330` with a 20/12/20/20 inset, a uniform 20px radius and
|
|
19
|
-
// no stroke. `@linktr.ee/component-library@11.8.6`'s radius scale is
|
|
20
|
-
// 8 / 16 / 24 / 64 / full, so there is no 20px token to reach for.
|
|
21
|
-
//
|
|
22
|
-
// The radius is uniform in every state, so the card opts out of the
|
|
23
|
-
// same-author corner flattening the text bubbles apply — a 32px control
|
|
24
|
-
// tucked into a 4px corner would collide with it.
|
|
25
|
-
const CARD_WIDTH_CLASS = 'w-[299px]'
|
|
26
|
-
const CARD_PADDING_CLASS = 'py-5 pl-5 pr-3'
|
|
27
|
-
const CARD_RADIUS_CLASS = 'rounded-[20px]'
|
|
28
|
-
|
|
29
14
|
export interface AudioAttachmentSharedProps extends MessageAttachmentBaseProps {
|
|
30
15
|
/** Audio source URL (`mp3`, `aac`, `wav`, …). */
|
|
31
16
|
src?: string
|
|
32
|
-
/** MIME type hint —
|
|
17
|
+
/** MIME type hint — typed onto the inline `<source>` element. */
|
|
33
18
|
mimeType?: string
|
|
34
19
|
/**
|
|
35
|
-
* Filename — the download default name
|
|
36
|
-
*
|
|
20
|
+
* Filename — used as the download default name (consumed by the
|
|
21
|
+
* native player's kebab menu). The HTML `<audio>` element doesn't
|
|
22
|
+
* expose a built-in title slot, so we don't render the filename
|
|
23
|
+
* inside the bubble itself.
|
|
37
24
|
*/
|
|
38
25
|
filename?: string
|
|
39
26
|
/**
|
|
40
27
|
* Stacked audio. Takes precedence over `src` when set. Each item
|
|
41
|
-
* renders its own
|
|
42
|
-
* bubble with an 8px gap
|
|
43
|
-
*
|
|
28
|
+
* renders its own native `<audio controls>` player, vertically
|
|
29
|
+
* stacked inside the same bubble with an 8px gap between players.
|
|
30
|
+
* Sent + Received only — the composer surface accepts a single
|
|
31
|
+
* attachment at a time.
|
|
44
32
|
*/
|
|
45
33
|
items?: AudioItem[]
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
34
|
+
/**
|
|
35
|
+
* `<audio preload>` hint applied to every player on the bubble.
|
|
36
|
+
* When omitted, the default depends on the rendered shape:
|
|
37
|
+
*
|
|
38
|
+
* - Single audio (no `items`, or `items.length === 1`) →
|
|
39
|
+
* `'metadata'` so the native player surfaces duration
|
|
40
|
+
* immediately.
|
|
41
|
+
* - Stacked audio (`items.length > 1`) → `'none'` so a thread
|
|
42
|
+
* of voice memos doesn't fan out N parallel metadata requests
|
|
43
|
+
* on first paint.
|
|
44
|
+
*
|
|
45
|
+
* Per-track overrides live on `AudioItem.preload`.
|
|
46
|
+
*/
|
|
47
|
+
preload?: MediaPreloadMode
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
const resolveItems = ({
|
|
53
51
|
src,
|
|
54
52
|
mimeType,
|
|
55
53
|
filename,
|
|
56
|
-
durationSeconds,
|
|
57
|
-
waveformData,
|
|
58
54
|
items,
|
|
59
55
|
}: {
|
|
60
56
|
src?: string
|
|
61
57
|
mimeType?: string
|
|
62
58
|
filename?: string
|
|
63
|
-
durationSeconds?: number
|
|
64
|
-
waveformData?: number[]
|
|
65
59
|
items?: AudioItem[]
|
|
66
60
|
}): AudioItem[] => {
|
|
67
61
|
if (items && items.length > 0) return items
|
|
68
|
-
if (src) return [{ src, mimeType, filename
|
|
62
|
+
if (src) return [{ src, mimeType, filename }]
|
|
69
63
|
return []
|
|
70
64
|
}
|
|
71
65
|
|
|
66
|
+
const NativeAudioPlayer: React.FC<{
|
|
67
|
+
item: AudioItem
|
|
68
|
+
preload: MediaPreloadMode
|
|
69
|
+
trailingAction?: React.ReactNode
|
|
70
|
+
}> = ({ item, preload, trailingAction }) => (
|
|
71
|
+
<div className="flex items-center gap-2">
|
|
72
|
+
{/* No `<track>` is rendered — we don't author caption sidecars
|
|
73
|
+
for chat attachments, and an empty `<track kind="captions" />`
|
|
74
|
+
emits a runtime warning. Re-add a real track (with `src`,
|
|
75
|
+
`srcLang`, and `default`) once caption support actually ships.
|
|
76
|
+
The `jsx-a11y/media-has-caption` rule is suppressed for the
|
|
77
|
+
same reason. */}
|
|
78
|
+
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
|
79
|
+
<audio
|
|
80
|
+
src={item.src}
|
|
81
|
+
controls
|
|
82
|
+
preload={item.preload ?? preload}
|
|
83
|
+
className="block min-w-0 flex-1"
|
|
84
|
+
>
|
|
85
|
+
{item.mimeType ? <source src={item.src} type={item.mimeType} /> : null}
|
|
86
|
+
</audio>
|
|
87
|
+
{trailingAction ?? null}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
|
|
72
91
|
interface InternalAudioRowProps extends AudioAttachmentSharedProps {
|
|
73
92
|
state: MessageAttachmentState
|
|
74
93
|
onDismiss?: () => void
|
|
@@ -80,63 +99,52 @@ const AudioAttachmentRow: React.FC<InternalAudioRowProps> = ({
|
|
|
80
99
|
mimeType,
|
|
81
100
|
filename,
|
|
82
101
|
items,
|
|
83
|
-
durationSeconds,
|
|
84
|
-
waveformData,
|
|
85
102
|
text,
|
|
86
103
|
groupPosition,
|
|
104
|
+
preload,
|
|
87
105
|
onDismiss,
|
|
88
106
|
}) => {
|
|
89
|
-
// Namespaces the pooled players per message, so scrolling a
|
|
90
|
-
// virtualized thread away and back restores the playback position
|
|
91
|
-
// instead of starting a fresh player. Returns `{}` (silently) outside
|
|
92
|
-
// a message, which is the composer case.
|
|
93
|
-
const { message, threadList } = useMessageContext()
|
|
94
|
-
|
|
95
107
|
const variant = bubbleVariantForState(state)
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
mimeType,
|
|
99
|
-
filename,
|
|
100
|
-
durationSeconds,
|
|
101
|
-
waveformData,
|
|
102
|
-
items,
|
|
103
|
-
})
|
|
108
|
+
const showDismiss = state === 'composer' && !!onDismiss
|
|
109
|
+
const resolvedItems = resolveItems({ src, mimeType, filename, items })
|
|
104
110
|
|
|
105
111
|
if (resolvedItems.length === 0) {
|
|
106
112
|
return null
|
|
107
113
|
}
|
|
108
114
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// used to provide. See `AudioRowTrailingAction`.
|
|
116
|
-
const trailingAction: AudioRowTrailingAction =
|
|
117
|
-
state === 'composer' && onDismiss
|
|
118
|
-
? { kind: 'dismiss', onDismiss }
|
|
119
|
-
: { kind: 'download' }
|
|
115
|
+
// Default rule: surface duration immediately for a single player,
|
|
116
|
+
// but avoid fanning out N metadata requests when the bubble is a
|
|
117
|
+
// thread of voice memos. An explicit `preload` prop overrides;
|
|
118
|
+
// per-track overrides live on `AudioItem.preload`.
|
|
119
|
+
const resolvedPreload: MediaPreloadMode =
|
|
120
|
+
preload ?? (resolvedItems.length > 1 ? 'none' : 'metadata')
|
|
120
121
|
|
|
121
122
|
return (
|
|
122
123
|
<Bubble
|
|
123
124
|
variant={variant}
|
|
124
125
|
text={text}
|
|
125
126
|
groupPosition={groupPosition}
|
|
126
|
-
bordered={false}
|
|
127
|
-
widthClassName={CARD_WIDTH_CLASS}
|
|
128
|
-
paddingClassName={CARD_PADDING_CLASS}
|
|
129
|
-
radiusClassName={CARD_RADIUS_CLASS}
|
|
130
127
|
data-testid="audio-attachment"
|
|
131
128
|
>
|
|
129
|
+
{/* Native `<audio controls>` already exposes a download in its
|
|
130
|
+
kebab menu, so we don't render a separate Download button.
|
|
131
|
+
The element also has no built-in title slot — the dismiss
|
|
132
|
+
button (Composer only) sits inline next to the player.
|
|
133
|
+
Stacked players get an 8px vertical gap so each track reads
|
|
134
|
+
as a discrete attachment. */}
|
|
132
135
|
<div className="flex flex-col gap-2">
|
|
133
136
|
{resolvedItems.map((item, index) => (
|
|
134
|
-
<
|
|
137
|
+
<NativeAudioPlayer
|
|
135
138
|
key={`${item.src}-${index}`}
|
|
136
139
|
item={item}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
+
preload={resolvedPreload}
|
|
141
|
+
trailingAction={
|
|
142
|
+
// Composer only supports a single attachment, so the
|
|
143
|
+
// dismiss control sits on the only player.
|
|
144
|
+
showDismiss && index === 0 ? (
|
|
145
|
+
<DismissButton onClick={onDismiss!} variant="inline" />
|
|
146
|
+
) : undefined
|
|
147
|
+
}
|
|
140
148
|
/>
|
|
141
149
|
))}
|
|
142
150
|
</div>
|
|
@@ -156,10 +164,8 @@ export interface AudioComposerProps extends ComposerExtras {
|
|
|
156
164
|
src?: string
|
|
157
165
|
mimeType?: string
|
|
158
166
|
filename?: string
|
|
159
|
-
/** See `
|
|
160
|
-
|
|
161
|
-
/** See `AudioItem.waveformData`. */
|
|
162
|
-
waveformData?: number[]
|
|
167
|
+
/** See `AudioAttachmentSharedProps.preload`. */
|
|
168
|
+
preload?: MediaPreloadMode
|
|
163
169
|
}
|
|
164
170
|
export type AudioSentProps = AudioAttachmentSharedProps
|
|
165
171
|
export type AudioReceivedProps = AudioAttachmentSharedProps
|