@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.
Files changed (44) hide show
  1. package/dist/{Card-2l_y20FM.js → Card-CmlaSw7i.js} +2 -2
  2. package/dist/{Card-2l_y20FM.js.map → Card-CmlaSw7i.js.map} +1 -1
  3. package/dist/{Card-Gr4HDVbn.cjs → Card-EWN07XIl.cjs} +2 -2
  4. package/dist/{Card-Gr4HDVbn.cjs.map → Card-EWN07XIl.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-B96WLmx6.js → index-CN3errpB.js} +3055 -3109
  7. package/dist/index-CN3errpB.js.map +1 -0
  8. package/dist/index-CcitzVoW.cjs +5 -0
  9. package/dist/index-CcitzVoW.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +56 -60
  12. package/dist/index.js +1 -1
  13. package/package.json +2 -2
  14. package/src/components/ChannelView.broadcast-name.integration.test.tsx +116 -0
  15. package/src/components/ChannelView.tsx +17 -3
  16. package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +15 -0
  17. package/src/components/CustomLinkPreviewList/index.tsx +24 -26
  18. package/src/components/CustomMessage/BroadcastNameContext.test.ts +43 -0
  19. package/src/components/CustomMessage/BroadcastNameContext.ts +95 -0
  20. package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -22
  21. package/src/components/CustomMessage/CustomMessage.test.tsx +48 -0
  22. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +62 -0
  23. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +8 -2
  24. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
  25. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -8
  26. package/src/components/CustomMessage/index.tsx +23 -3
  27. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +50 -2
  28. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +180 -2
  29. package/src/components/CustomMessageInput/index.tsx +143 -16
  30. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +57 -166
  31. package/src/components/MessageAttachment/Audio/index.tsx +77 -71
  32. package/src/components/MessageAttachment/MessageAttachment.test.tsx +75 -175
  33. package/src/components/MessageAttachment/index.tsx +9 -25
  34. package/src/components/MessageAttachment/types.ts +9 -19
  35. package/src/components/MessagingShell/index.tsx +2 -0
  36. package/src/index.ts +1 -0
  37. package/src/styles.css +5 -120
  38. package/src/types.ts +11 -0
  39. package/src/utils/formatRelativeTime.test.ts +63 -57
  40. package/src/utils/formatRelativeTime.ts +21 -20
  41. package/dist/index-B96WLmx6.js.map +0 -1
  42. package/dist/index-CCtHMrmO.cjs +0 -5
  43. package/dist/index-CCtHMrmO.cjs.map +0 -1
  44. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +0 -335
@@ -1,8 +1,6 @@
1
- import type { Decorator, Meta, StoryFn } from '@storybook/react'
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
- * 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.
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="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."
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="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."
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="No waveform"
95
+ label="Caption"
147
96
  composer={
148
97
  <AudioAttachment.Composer
149
98
  src={AUDIO_SRC}
150
99
  mimeType="audio/mpeg"
151
- filename="Voice note.m4a"
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="Voice note.m4a"
161
- durationSeconds={12}
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="Voice note.m4a"
169
- durationSeconds={12}
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 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.
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 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."
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 — gates playability via the player's `canPlayType` probe. */
17
+ /** MIME type hint — typed onto the inline `<source>` element. */
33
18
  mimeType?: string
34
19
  /**
35
- * Filename — the download default name for the trailing action, and
36
- * part of each control's accessible name.
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 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.
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
- /** 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[]
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, durationSeconds, waveformData }]
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 resolvedItems = resolveItems({
97
- src,
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
- 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' }
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
- <WaveformAudioRow
137
+ <NativeAudioPlayer
135
138
  key={`${item.src}-${index}`}
136
139
  item={item}
137
- variant={variant}
138
- requester={requester}
139
- trailingAction={trailingAction}
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 `AudioItem.durationSeconds`. */
160
- durationSeconds?: number
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