@linktr.ee/messaging-react 4.1.5 → 4.2.0-rc-1787083791
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-DDv7af2K.js → Card-B4lMv-0o.js} +2 -2
- package/dist/{Card-DDv7af2K.js.map → Card-B4lMv-0o.js.map} +1 -1
- package/dist/{Card-BD16XAaT.cjs → Card-DWL5I62S.cjs} +2 -2
- package/dist/{Card-BD16XAaT.cjs.map → Card-DWL5I62S.cjs.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/index-B-9LkWep.cjs +3 -0
- package/dist/index-B-9LkWep.cjs.map +1 -0
- package/dist/{index-qKUd4Fz2.js → index-C93LTGLc.js} +1495 -1356
- package/dist/index-C93LTGLc.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +58 -43
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +33 -0
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +6 -0
- package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +165 -58
- package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +335 -0
- package/src/components/MessageAttachment/Audio/index.tsx +71 -77
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +176 -74
- package/src/components/MessageAttachment/index.tsx +25 -9
- package/src/components/MessageAttachment/types.ts +19 -9
- package/src/styles.css +120 -0
- package/dist/index-9elRVMIF.cjs +0 -3
- package/dist/index-9elRVMIF.cjs.map +0 -1
- package/dist/index-qKUd4Fz2.js.map +0 -1
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DownloadSimpleIcon,
|
|
3
|
+
PauseIcon,
|
|
4
|
+
PlayIcon,
|
|
5
|
+
XIcon,
|
|
6
|
+
} from '@phosphor-icons/react'
|
|
7
|
+
import classNames from 'classnames'
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import {
|
|
10
|
+
displayDuration,
|
|
11
|
+
resampleWaveformData,
|
|
12
|
+
useAudioPlayer,
|
|
13
|
+
useStateStore,
|
|
14
|
+
type AudioPlayerState,
|
|
15
|
+
} from 'stream-chat-react'
|
|
16
|
+
|
|
17
|
+
import { triggerDownload } from '../_shared/triggerDownload'
|
|
18
|
+
import type { AudioItem, BubbleVariant } from '../types'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Bar count in the design's waveform (Figma `2754:17710`, nodes
|
|
22
|
+
* `…;2754:8066` – `…;2754:8100`). The 138px track is exactly
|
|
23
|
+
* `35 × 2px bars + 34 × 2px gaps`, so the count is derived from the
|
|
24
|
+
* drawn geometry rather than picked.
|
|
25
|
+
*/
|
|
26
|
+
const WAVEFORM_BAR_COUNT = 35
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Total fallback for every case where no amplitudes exist: a decode
|
|
30
|
+
* failure, an unsupported codec, a re-staged zero-byte placeholder, a
|
|
31
|
+
* file over the composer's decode ceiling, or any attachment sent
|
|
32
|
+
* before `waveform_data` was persisted (legacy web, the mobile app, a
|
|
33
|
+
* visitor).
|
|
34
|
+
*
|
|
35
|
+
* `.mes-audio-wave__bar` floors every bar at the design's 4px minimum,
|
|
36
|
+
* so all 35 bars render as the shortest bar the design already draws:
|
|
37
|
+
* the card's silhouette, height and geometry are identical to a real
|
|
38
|
+
* waveform, and the progress fill still sweeps left to right during
|
|
39
|
+
* playback. A shape-changing degraded state (e.g. a plain progress
|
|
40
|
+
* bar) would read as a different feature.
|
|
41
|
+
*/
|
|
42
|
+
const FLAT_WAVEFORM: readonly number[] = Object.freeze(
|
|
43
|
+
new Array<number>(WAVEFORM_BAR_COUNT).fill(0)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
/** Shown in the fixed duration slot until a duration is known. */
|
|
47
|
+
const UNKNOWN_DURATION = '--:--'
|
|
48
|
+
|
|
49
|
+
const AUDIO_PLAYER_STATE_SELECTOR = (state: AudioPlayerState) => ({
|
|
50
|
+
canPlayRecord: state.canPlayRecord,
|
|
51
|
+
isPlaying: state.isPlaying,
|
|
52
|
+
progressPercent: state.progressPercent,
|
|
53
|
+
secondsElapsed: state.secondsElapsed,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Both of Figma's `content-control-buttons` instances (`…;2754:8063`
|
|
57
|
+
// play, `…;2754:8102` trailing) are the same 32×32 pill with a 6px inset
|
|
58
|
+
// around a 12px glyph and a 20px background blur; only the fill differs.
|
|
59
|
+
const CONTROL_CLASS =
|
|
60
|
+
'flex size-8 shrink-0 items-center justify-center rounded-full backdrop-blur-[20px]'
|
|
61
|
+
|
|
62
|
+
// Figma draws only the dark sender card (`fill:#1E2330`), with every
|
|
63
|
+
// control in white. The light (`Received`) variant — the one visitors see
|
|
64
|
+
// on linktr.ee-profiles — is undrawn, so it inverts the same treatment
|
|
65
|
+
// against the `#f2f1ef` bubble. Ratios below were measured off the
|
|
66
|
+
// composited render, not estimated:
|
|
67
|
+
//
|
|
68
|
+
// played unplayed played:unplayed play button
|
|
69
|
+
// dark on #1E2330 15.69 3.71 4.24 15.69
|
|
70
|
+
// light on #F2F1EF 18.17 3.78 4.81 15.16
|
|
71
|
+
//
|
|
72
|
+
// Both unplayed fills clear WCAG 1.4.11's 3:1 floor for non-text contrast,
|
|
73
|
+
// and the played/unplayed step is well past it so progress is legible on
|
|
74
|
+
// its own. The light variant needs `#040403` at 0.5 where dark needs white
|
|
75
|
+
// at 0.4, because it composites towards a lighter backdrop.
|
|
76
|
+
const PLAY_BUTTON_BY_VARIANT: Record<BubbleVariant, string> = {
|
|
77
|
+
dark: 'bg-white text-black/90 disabled:bg-white/40',
|
|
78
|
+
light: 'bg-[#040403]/90 text-white disabled:bg-[#040403]/30',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const TRAILING_BUTTON_BY_VARIANT: Record<BubbleVariant, string> = {
|
|
82
|
+
dark: 'bg-[#040403]/80 text-white hover:bg-[#040403]',
|
|
83
|
+
light: 'bg-black/10 text-black/90 hover:bg-black/20',
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What the design's trailing 32px control does. The ticket asks to
|
|
88
|
+
* "keep the same options as current production", and production has no
|
|
89
|
+
* product-owned menu on an audio attachment — the only menu was the
|
|
90
|
+
* native `<audio controls>` kebab, which disappears with the native
|
|
91
|
+
* player. So each surface carries forward the single action it already
|
|
92
|
+
* had: the composer removes the draft, and a sent / received card
|
|
93
|
+
* downloads the file (the capability the browser kebab provided).
|
|
94
|
+
*/
|
|
95
|
+
export type AudioRowTrailingAction =
|
|
96
|
+
| { kind: 'dismiss'; onDismiss: () => void }
|
|
97
|
+
| { kind: 'download' }
|
|
98
|
+
|
|
99
|
+
export interface WaveformAudioRowProps {
|
|
100
|
+
item: AudioItem
|
|
101
|
+
variant: BubbleVariant
|
|
102
|
+
trailingAction: AudioRowTrailingAction
|
|
103
|
+
/**
|
|
104
|
+
* Namespaces the pooled `AudioPlayer` so the same asset rendered in
|
|
105
|
+
* two different messages keeps two independent playback positions.
|
|
106
|
+
* Derived from the Stream message id where one is available.
|
|
107
|
+
*/
|
|
108
|
+
requester?: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolves the track length. `durationSeconds` (persisted on the Stream
|
|
113
|
+
* attachment at send time) is authoritative and correct on first paint.
|
|
114
|
+
* Without it — legacy sends, mobile-app sends, visitor sends — a single
|
|
115
|
+
* detached `preload="metadata"` element resolves it from the response
|
|
116
|
+
* headers, which is exactly what the native player it replaces did.
|
|
117
|
+
*/
|
|
118
|
+
const useResolvedDuration = (
|
|
119
|
+
src: string,
|
|
120
|
+
durationSeconds?: number
|
|
121
|
+
): number | undefined => {
|
|
122
|
+
const [probed, setProbed] = React.useState<number>()
|
|
123
|
+
|
|
124
|
+
React.useEffect(() => {
|
|
125
|
+
if (durationSeconds != null || !src) return
|
|
126
|
+
|
|
127
|
+
const probe = document.createElement('audio')
|
|
128
|
+
probe.preload = 'metadata'
|
|
129
|
+
const handleLoadedMetadata = () => {
|
|
130
|
+
if (Number.isFinite(probe.duration) && probe.duration > 0) {
|
|
131
|
+
setProbed(probe.duration)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
probe.addEventListener('loadedmetadata', handleLoadedMetadata)
|
|
135
|
+
probe.src = src
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
probe.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
|
139
|
+
probe.removeAttribute('src')
|
|
140
|
+
setProbed(undefined)
|
|
141
|
+
}
|
|
142
|
+
}, [src, durationSeconds])
|
|
143
|
+
|
|
144
|
+
return durationSeconds ?? probed
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One waveform player row: 32px play toggle, 35-bar waveform with a
|
|
149
|
+
* progress fill and a native seek control, the duration, and the 32px
|
|
150
|
+
* trailing action. A single-item bubble is the design's 299×72 card;
|
|
151
|
+
* a stacked bubble repeats the row.
|
|
152
|
+
*
|
|
153
|
+
* Playback is `stream-chat-react`'s pooled `AudioPlayer`, which gives
|
|
154
|
+
* single-player-at-a-time across the thread, pause-on-unmount and
|
|
155
|
+
* saved-position restore. That pool lives on `<Channel>`, so outside
|
|
156
|
+
* one the card still renders in full with its controls disabled rather
|
|
157
|
+
* than vanishing.
|
|
158
|
+
*/
|
|
159
|
+
const WaveformAudioRow: React.FC<WaveformAudioRowProps> = ({
|
|
160
|
+
item,
|
|
161
|
+
variant,
|
|
162
|
+
trailingAction,
|
|
163
|
+
requester,
|
|
164
|
+
}) => {
|
|
165
|
+
const trackRef = React.useRef<HTMLDivElement>(null)
|
|
166
|
+
const [isStarting, setIsStarting] = React.useState(false)
|
|
167
|
+
|
|
168
|
+
// An empty array must never reach `resampleWaveformData` — its
|
|
169
|
+
// `upSample` branch `console.warn`s and returns `[]`, which renders no
|
|
170
|
+
// bars at all (and trips consumers whose tests fail on console output).
|
|
171
|
+
const amplitudes = React.useMemo<readonly number[]>(() => {
|
|
172
|
+
const data = item.waveformData
|
|
173
|
+
if (!data || data.length === 0) return FLAT_WAVEFORM
|
|
174
|
+
if (data.length === WAVEFORM_BAR_COUNT) return data
|
|
175
|
+
return resampleWaveformData(data, WAVEFORM_BAR_COUNT)
|
|
176
|
+
}, [item.waveformData])
|
|
177
|
+
const durationSeconds = useResolvedDuration(item.src, item.durationSeconds)
|
|
178
|
+
|
|
179
|
+
const audioPlayer = useAudioPlayer({
|
|
180
|
+
durationSeconds,
|
|
181
|
+
mimeType: item.mimeType,
|
|
182
|
+
requester,
|
|
183
|
+
src: item.src,
|
|
184
|
+
title: item.filename,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const {
|
|
188
|
+
canPlayRecord = false,
|
|
189
|
+
isPlaying = false,
|
|
190
|
+
progressPercent = 0,
|
|
191
|
+
secondsElapsed = 0,
|
|
192
|
+
} = useStateStore(audioPlayer?.state, AUDIO_PLAYER_STATE_SELECTOR) ?? {}
|
|
193
|
+
|
|
194
|
+
const progress = Math.min(Math.max(progressPercent, 0), 100)
|
|
195
|
+
const playable = !!audioPlayer && canPlayRecord
|
|
196
|
+
const showPause = isPlaying || isStarting
|
|
197
|
+
|
|
198
|
+
const totalLabel =
|
|
199
|
+
durationSeconds == null ? UNKNOWN_DURATION : displayDuration(durationSeconds)
|
|
200
|
+
// Mirrors the native player: the slot counts up while playing and
|
|
201
|
+
// holds the elapsed position when paused, falling back to the total.
|
|
202
|
+
const displayedLabel =
|
|
203
|
+
secondsElapsed > 0 ? displayDuration(secondsElapsed) : totalLabel
|
|
204
|
+
|
|
205
|
+
const handleTogglePlay = () => {
|
|
206
|
+
if (!audioPlayer || isStarting) return
|
|
207
|
+
setIsStarting(true)
|
|
208
|
+
void audioPlayer.togglePlay().finally(() => setIsStarting(false))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// One seek code path for pointer, keyboard and drag: the native range
|
|
212
|
+
// input owns the interaction, and its value is translated into the
|
|
213
|
+
// `SeekFn` contract from the bar track's own rect. Going through
|
|
214
|
+
// `AudioPlayer.seek` inherits its lazy element acquisition (so
|
|
215
|
+
// seeking before the first play works), its seekable-range check and
|
|
216
|
+
// its 16ms throttle — `setSecondsElapsed` alone would not, because
|
|
217
|
+
// no `<audio>` element exists until the pool hands one over.
|
|
218
|
+
const handleSeek = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
219
|
+
const track = trackRef.current
|
|
220
|
+
if (!audioPlayer || !track) return
|
|
221
|
+
const ratio = Math.min(Math.max(Number(event.target.value) / 100, 0), 1)
|
|
222
|
+
const rect = track.getBoundingClientRect()
|
|
223
|
+
void audioPlayer.seek({
|
|
224
|
+
clientX: rect.x + ratio * rect.width,
|
|
225
|
+
currentTarget: track,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Suffixed onto each control's accessible name so a thread of stacked
|
|
230
|
+
// clips reads as "Play audio — take-2.mp3", not three identical labels.
|
|
231
|
+
const label = item.filename ? ` \u2014 ${item.filename}` : ''
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div
|
|
235
|
+
role="group"
|
|
236
|
+
aria-label={item.filename ?? 'Audio attachment'}
|
|
237
|
+
className="flex items-center"
|
|
238
|
+
data-testid="audio-attachment-row"
|
|
239
|
+
>
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
onClick={handleTogglePlay}
|
|
243
|
+
disabled={!playable}
|
|
244
|
+
aria-busy={isStarting || undefined}
|
|
245
|
+
aria-label={`${showPause ? 'Pause audio' : 'Play audio'}${label}`}
|
|
246
|
+
className={classNames(CONTROL_CLASS, PLAY_BUTTON_BY_VARIANT[variant])}
|
|
247
|
+
>
|
|
248
|
+
{showPause ? (
|
|
249
|
+
<PauseIcon className="size-3" weight="fill" aria-hidden />
|
|
250
|
+
) : (
|
|
251
|
+
<PlayIcon className="size-3" weight="fill" aria-hidden />
|
|
252
|
+
)}
|
|
253
|
+
</button>
|
|
254
|
+
|
|
255
|
+
{/* Figma `soundwave-container` (…;2754:8064): 203×30, 10px gap,
|
|
256
|
+
12px horizontal inset, waveform flexing against a fixed
|
|
257
|
+
duration slot. */}
|
|
258
|
+
<div className="flex min-w-0 flex-1 items-center gap-2.5 px-3">
|
|
259
|
+
<div
|
|
260
|
+
className={classNames(
|
|
261
|
+
'mes-audio-wave',
|
|
262
|
+
variant === 'light' && 'mes-audio-wave--light'
|
|
263
|
+
)}
|
|
264
|
+
>
|
|
265
|
+
{/* The bars carry no information a screen reader can use —
|
|
266
|
+
elapsed / total is announced by the range input below —
|
|
267
|
+
so they stay out of the accessibility tree entirely. */}
|
|
268
|
+
<div ref={trackRef} className="mes-audio-wave__track" aria-hidden>
|
|
269
|
+
{amplitudes.map((amplitude, index) => (
|
|
270
|
+
<span
|
|
271
|
+
// Bars are positional, and the array is a fixed-length
|
|
272
|
+
// resample of the same track — index is the identity.
|
|
273
|
+
key={index}
|
|
274
|
+
data-testid="amplitude-bar"
|
|
275
|
+
className={classNames(
|
|
276
|
+
'mes-audio-wave__bar',
|
|
277
|
+
progress > (index / amplitudes.length) * 100 &&
|
|
278
|
+
'mes-audio-wave__bar--played'
|
|
279
|
+
)}
|
|
280
|
+
style={
|
|
281
|
+
{
|
|
282
|
+
'--mes-audio-wave-bar': `${
|
|
283
|
+
Math.min(Math.max(amplitude, 0), 1) * 100
|
|
284
|
+
}%`,
|
|
285
|
+
} as React.CSSProperties
|
|
286
|
+
}
|
|
287
|
+
/>
|
|
288
|
+
))}
|
|
289
|
+
</div>
|
|
290
|
+
<input
|
|
291
|
+
type="range"
|
|
292
|
+
className="mes-audio-wave__seek"
|
|
293
|
+
min={0}
|
|
294
|
+
max={100}
|
|
295
|
+
step={0.1}
|
|
296
|
+
value={progress}
|
|
297
|
+
onChange={handleSeek}
|
|
298
|
+
disabled={!playable}
|
|
299
|
+
aria-label={`Seek audio${label}`}
|
|
300
|
+
aria-valuetext={`${displayDuration(secondsElapsed)} of ${totalLabel}`}
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<span className="shrink-0 text-xs leading-4 tracking-[0.24px] tabular-nums">
|
|
305
|
+
{displayedLabel}
|
|
306
|
+
</span>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
onClick={
|
|
312
|
+
trailingAction.kind === 'dismiss'
|
|
313
|
+
? trailingAction.onDismiss
|
|
314
|
+
: () => {
|
|
315
|
+
void triggerDownload(item.src, item.filename)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
aria-label={
|
|
319
|
+
trailingAction.kind === 'dismiss'
|
|
320
|
+
? 'Remove attachment'
|
|
321
|
+
: `Download audio${label}`
|
|
322
|
+
}
|
|
323
|
+
className={classNames(CONTROL_CLASS, TRAILING_BUTTON_BY_VARIANT[variant])}
|
|
324
|
+
>
|
|
325
|
+
{trailingAction.kind === 'dismiss' ? (
|
|
326
|
+
<XIcon className="size-3" weight="bold" aria-hidden />
|
|
327
|
+
) : (
|
|
328
|
+
<DownloadSimpleIcon className="size-3" weight="bold" aria-hidden />
|
|
329
|
+
)}
|
|
330
|
+
</button>
|
|
331
|
+
</div>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export default WaveformAudioRow
|
|
@@ -1,93 +1,74 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
+
import { useMessageContext } from 'stream-chat-react'
|
|
2
3
|
|
|
3
4
|
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,
|
|
10
9
|
type MessageAttachmentBaseProps,
|
|
11
10
|
type MessageAttachmentState,
|
|
12
11
|
} from '../types'
|
|
13
12
|
|
|
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
|
+
|
|
14
29
|
export interface AudioAttachmentSharedProps extends MessageAttachmentBaseProps {
|
|
15
30
|
/** Audio source URL (`mp3`, `aac`, `wav`, …). */
|
|
16
31
|
src?: string
|
|
17
|
-
/** MIME type hint —
|
|
32
|
+
/** MIME type hint — gates playability via the player's `canPlayType` probe. */
|
|
18
33
|
mimeType?: string
|
|
19
34
|
/**
|
|
20
|
-
* Filename —
|
|
21
|
-
*
|
|
22
|
-
* expose a built-in title slot, so we don't render the filename
|
|
23
|
-
* inside the bubble itself.
|
|
35
|
+
* Filename — the download default name for the trailing action, and
|
|
36
|
+
* part of each control's accessible name.
|
|
24
37
|
*/
|
|
25
38
|
filename?: string
|
|
26
39
|
/**
|
|
27
40
|
* Stacked audio. Takes precedence over `src` when set. Each item
|
|
28
|
-
* renders its own
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* attachment at a time.
|
|
41
|
+
* renders its own waveform row, vertically stacked inside the same
|
|
42
|
+
* bubble with an 8px gap. Sent + Received only — the composer surface
|
|
43
|
+
* accepts a single attachment at a time.
|
|
32
44
|
*/
|
|
33
45
|
items?: AudioItem[]
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
46
|
+
/** See `AudioItem.durationSeconds`. Applies to the single-`src` shape. */
|
|
47
|
+
durationSeconds?: number
|
|
48
|
+
/** See `AudioItem.waveformData`. Applies to the single-`src` shape. */
|
|
49
|
+
waveformData?: number[]
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
const resolveItems = ({
|
|
51
53
|
src,
|
|
52
54
|
mimeType,
|
|
53
55
|
filename,
|
|
56
|
+
durationSeconds,
|
|
57
|
+
waveformData,
|
|
54
58
|
items,
|
|
55
59
|
}: {
|
|
56
60
|
src?: string
|
|
57
61
|
mimeType?: string
|
|
58
62
|
filename?: string
|
|
63
|
+
durationSeconds?: number
|
|
64
|
+
waveformData?: number[]
|
|
59
65
|
items?: AudioItem[]
|
|
60
66
|
}): AudioItem[] => {
|
|
61
67
|
if (items && items.length > 0) return items
|
|
62
|
-
if (src) return [{ src, mimeType, filename }]
|
|
68
|
+
if (src) return [{ src, mimeType, filename, durationSeconds, waveformData }]
|
|
63
69
|
return []
|
|
64
70
|
}
|
|
65
71
|
|
|
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
|
-
|
|
91
72
|
interface InternalAudioRowProps extends AudioAttachmentSharedProps {
|
|
92
73
|
state: MessageAttachmentState
|
|
93
74
|
onDismiss?: () => void
|
|
@@ -99,52 +80,63 @@ const AudioAttachmentRow: React.FC<InternalAudioRowProps> = ({
|
|
|
99
80
|
mimeType,
|
|
100
81
|
filename,
|
|
101
82
|
items,
|
|
83
|
+
durationSeconds,
|
|
84
|
+
waveformData,
|
|
102
85
|
text,
|
|
103
86
|
groupPosition,
|
|
104
|
-
preload,
|
|
105
87
|
onDismiss,
|
|
106
88
|
}) => {
|
|
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
|
+
|
|
107
95
|
const variant = bubbleVariantForState(state)
|
|
108
|
-
const
|
|
109
|
-
|
|
96
|
+
const resolvedItems = resolveItems({
|
|
97
|
+
src,
|
|
98
|
+
mimeType,
|
|
99
|
+
filename,
|
|
100
|
+
durationSeconds,
|
|
101
|
+
waveformData,
|
|
102
|
+
items,
|
|
103
|
+
})
|
|
110
104
|
|
|
111
105
|
if (resolvedItems.length === 0) {
|
|
112
106
|
return null
|
|
113
107
|
}
|
|
114
108
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
109
|
+
const requester = message?.id
|
|
110
|
+
? `${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`
|
|
111
|
+
: undefined
|
|
112
|
+
|
|
113
|
+
// The composer's single action is removing the draft; every other
|
|
114
|
+
// surface carries forward the download the native player's own kebab
|
|
115
|
+
// used to provide. See `AudioRowTrailingAction`.
|
|
116
|
+
const trailingAction: AudioRowTrailingAction =
|
|
117
|
+
state === 'composer' && onDismiss
|
|
118
|
+
? { kind: 'dismiss', onDismiss }
|
|
119
|
+
: { kind: 'download' }
|
|
121
120
|
|
|
122
121
|
return (
|
|
123
122
|
<Bubble
|
|
124
123
|
variant={variant}
|
|
125
124
|
text={text}
|
|
126
125
|
groupPosition={groupPosition}
|
|
126
|
+
bordered={false}
|
|
127
|
+
widthClassName={CARD_WIDTH_CLASS}
|
|
128
|
+
paddingClassName={CARD_PADDING_CLASS}
|
|
129
|
+
radiusClassName={CARD_RADIUS_CLASS}
|
|
127
130
|
data-testid="audio-attachment"
|
|
128
131
|
>
|
|
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. */}
|
|
135
132
|
<div className="flex flex-col gap-2">
|
|
136
133
|
{resolvedItems.map((item, index) => (
|
|
137
|
-
<
|
|
134
|
+
<WaveformAudioRow
|
|
138
135
|
key={`${item.src}-${index}`}
|
|
139
136
|
item={item}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// dismiss control sits on the only player.
|
|
144
|
-
showDismiss && index === 0 ? (
|
|
145
|
-
<DismissButton onClick={onDismiss!} variant="inline" />
|
|
146
|
-
) : undefined
|
|
147
|
-
}
|
|
137
|
+
variant={variant}
|
|
138
|
+
requester={requester}
|
|
139
|
+
trailingAction={trailingAction}
|
|
148
140
|
/>
|
|
149
141
|
))}
|
|
150
142
|
</div>
|
|
@@ -164,8 +156,10 @@ export interface AudioComposerProps extends ComposerExtras {
|
|
|
164
156
|
src?: string
|
|
165
157
|
mimeType?: string
|
|
166
158
|
filename?: string
|
|
167
|
-
/** See `
|
|
168
|
-
|
|
159
|
+
/** See `AudioItem.durationSeconds`. */
|
|
160
|
+
durationSeconds?: number
|
|
161
|
+
/** See `AudioItem.waveformData`. */
|
|
162
|
+
waveformData?: number[]
|
|
169
163
|
}
|
|
170
164
|
export type AudioSentProps = AudioAttachmentSharedProps
|
|
171
165
|
export type AudioReceivedProps = AudioAttachmentSharedProps
|