@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/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-9elRVMIF.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageBubble=e.MessageBubble;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.RichCard=e.RichCard;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.optimizeMessagingAttachmentUrl=e.optimizeMessagingAttachmentUrl;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessaging=e.useMessaging;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-B-9LkWep.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageBubble=e.MessageBubble;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.RichCard=e.RichCard;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.optimizeMessagingAttachmentUrl=e.optimizeMessagingAttachmentUrl;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessaging=e.useMessaging;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.d.ts CHANGED
@@ -36,37 +36,24 @@ export declare type AttachmentSourceType = 'image' | 'audio' | 'video' | 'docume
36
36
  declare interface AudioAttachmentSharedProps extends MessageAttachmentBaseProps {
37
37
  /** Audio source URL (`mp3`, `aac`, `wav`, …). */
38
38
  src?: string;
39
- /** MIME type hint — typed onto the inline `<source>` element. */
39
+ /** MIME type hint — gates playability via the player's `canPlayType` probe. */
40
40
  mimeType?: string;
41
41
  /**
42
- * Filename — used as the download default name (consumed by the
43
- * native player's kebab menu). The HTML `<audio>` element doesn't
44
- * expose a built-in title slot, so we don't render the filename
45
- * inside the bubble itself.
42
+ * Filename — the download default name for the trailing action, and
43
+ * part of each control's accessible name.
46
44
  */
47
45
  filename?: string;
48
46
  /**
49
47
  * Stacked audio. Takes precedence over `src` when set. Each item
50
- * renders its own native `<audio controls>` player, vertically
51
- * stacked inside the same bubble with an 8px gap between players.
52
- * Sent + Received only — the composer surface accepts a single
53
- * attachment at a time.
48
+ * renders its own waveform row, vertically stacked inside the same
49
+ * bubble with an 8px gap. Sent + Received only — the composer surface
50
+ * accepts a single attachment at a time.
54
51
  */
55
52
  items?: MessageAttachmentAudioItem[];
56
- /**
57
- * `<audio preload>` hint applied to every player on the bubble.
58
- * When omitted, the default depends on the rendered shape:
59
- *
60
- * - Single audio (no `items`, or `items.length === 1`) →
61
- * `'metadata'` so the native player surfaces duration
62
- * immediately.
63
- * - Stacked audio (`items.length > 1`) → `'none'` so a thread
64
- * of voice memos doesn't fan out N parallel metadata requests
65
- * on first paint.
66
- *
67
- * Per-track overrides live on `AudioItem.preload`.
68
- */
69
- preload?: MediaPreloadMode;
53
+ /** See `AudioItem.durationSeconds`. Applies to the single-`src` shape. */
54
+ durationSeconds?: number;
55
+ /** See `AudioItem.waveformData`. Applies to the single-`src` shape. */
56
+ waveformData?: number[];
70
57
  }
71
58
 
72
59
  /**
@@ -994,7 +981,7 @@ declare type MediaPreloadMode = 'none' | 'metadata' | 'auto';
994
981
  * - Pdf / Audio / File render their compact rows / players in a
995
982
  * vertical stack with an 8px gap between rows. Each row keeps
996
983
  * its own activation target — Pdf opens the carousel-aware
997
- * `PdfViewer` at that index, Audio gets its own native player,
984
+ * `PdfViewer` at that index, Audio gets its own waveform player,
998
985
  * and File downloads the asset for that row.
999
986
  *
1000
987
  * The Composer surface accepts only a single attachment at a time,
@@ -1012,12 +999,29 @@ declare type MediaPreloadMode = 'none' | 'metadata' | 'auto';
1012
999
  * download button to the chrome; the Video viewer relies on the
1013
1000
  * native `<video controls>` download menu, and the Pdf viewer
1014
1001
  * inherits the browser's built-in PDF download. Sibling-row Download
1015
- * buttons on Pdf / File and the native `<audio>` controls expose
1002
+ * buttons on Pdf / File and the Audio card's trailing action expose
1016
1003
  * downloads outside the viewer too. The lightbox chrome is styled
1017
1004
  * with plain CSS (`.mes-media-viewer*`) so the package's shipped
1018
1005
  * stylesheet is enough to render the viewer correctly without a
1019
1006
  * Tailwind dependency in the consumer.
1020
1007
  *
1008
+ * # Audio requires a `<Channel>` ancestor
1009
+ *
1010
+ * `MessageAttachment.Audio.*` renders the waveform player card —
1011
+ * a 32px play toggle, a 35-bar waveform with a progress fill and a
1012
+ * native seek control, the duration, and a 32px trailing action
1013
+ * (Remove in the composer, Download once sent). Playback is
1014
+ * `stream-chat-react`'s pooled `AudioPlayer`, which is what supplies
1015
+ * single-player-at-a-time across a thread, pause-on-navigation and
1016
+ * saved-position restore across virtualized remounts.
1017
+ *
1018
+ * That pool is provided by `<Channel>`. Rendered outside one the card
1019
+ * still draws in full, but its play and seek controls are disabled —
1020
+ * so a story or a new consumer surface must mount `<Channel>` (or
1021
+ * `stream-chat-react`'s `WithAudioPlayback`) for the card to be
1022
+ * playable. The waveform bars themselves are styled with plain CSS
1023
+ * (`.mes-audio-wave*`), so the shipped stylesheet is enough.
1024
+ *
1021
1025
  * # Lazy-loading defaults
1022
1026
  *
1023
1027
  * Media attachments bake in network-friendly defaults for long chat
@@ -1038,13 +1042,12 @@ declare type MediaPreloadMode = 'none' | 'metadata' | 'auto';
1038
1042
  * `VideoItem.preload` per item). Once the viewer is open the
1039
1043
  * active item bumps to `preload="metadata"` so duration / the
1040
1044
  * first frame appear immediately.
1041
- * - **Audio** — a single audio bubble defaults to
1042
- * `preload="metadata"` so the native player surfaces duration
1043
- * immediately. A stacked audio bubble (`items.length > 1`)
1044
- * defaults to `preload="none"` for every player so a thread of
1045
- * voice memos doesn't fan out N parallel metadata requests on
1046
- * first paint. Override via the attachment's `preload` prop
1047
- * (or `AudioItem.preload` per track).
1045
+ * - **Audio** — no audio bytes are fetched until the user presses
1046
+ * play: the pooled `AudioPlayer` acquires the shared `<audio>`
1047
+ * element lazily. A card whose `durationSeconds` is unknown makes
1048
+ * one `preload="metadata"` header request to fill the duration
1049
+ * slot; supplying `durationSeconds` (which `StreamAttachmentMessage`
1050
+ * maps from the attachment's persisted `duration`) avoids even that.
1048
1051
  * - **Pdf** / **File** — no inline media element to throttle; the
1049
1052
  * bubble renders compact rows that download / open lazily on
1050
1053
  * activation. No `loading` / `preload` props on these surfaces.
@@ -1089,28 +1092,40 @@ export declare interface MessageAttachmentAudioComposerProps extends ComposerExt
1089
1092
  src?: string;
1090
1093
  mimeType?: string;
1091
1094
  filename?: string;
1092
- /** See `AudioAttachmentSharedProps.preload`. */
1093
- preload?: MediaPreloadMode;
1095
+ /** See `AudioItem.durationSeconds`. */
1096
+ durationSeconds?: number;
1097
+ /** See `AudioItem.waveformData`. */
1098
+ waveformData?: number[];
1094
1099
  }
1095
1100
 
1096
1101
  /** A single audio file inside an Audio attachment (single or stacked). */
1097
1102
  export declare interface MessageAttachmentAudioItem {
1098
1103
  src: string;
1099
- /** MIME type hint — typed onto the inline `<source>` element. */
1104
+ /** MIME type hint — gates playability via the player's `canPlayType` probe. */
1100
1105
  mimeType?: string;
1101
1106
  /**
1102
- * Filename — used as the download default name (consumed by the
1103
- * native player's kebab menu).
1107
+ * Filename — the download default name for the card's trailing
1108
+ * action, and part of the card's accessible name.
1104
1109
  */
1105
1110
  filename?: string;
1106
1111
  /**
1107
- * Per-track override for the `<audio preload>` hint. Defaults to
1108
- * the parent attachment's `preload` value, which itself defaults
1109
- * to `'metadata'` for a single audio (so the native player can
1110
- * surface duration immediately) and `'none'` for a stacked thread
1111
- * (so a fan-out of metadata requests doesn't happen on first paint).
1112
+ * Track length in seconds. Rendered as `MM:SS` before playback
1113
+ * starts, so the duration is correct on first paint with no network
1114
+ * request. Sourced from the Stream attachment's `duration` field,
1115
+ * which the composer writes at send time. When omitted the card
1116
+ * shows `--:--` until a one-shot `loadedmetadata` probe resolves.
1112
1117
  */
1113
- preload?: MediaPreloadMode;
1118
+ durationSeconds?: number;
1119
+ /**
1120
+ * Waveform amplitudes, each `0..1`, in track order. Resampled to the
1121
+ * rendered bar count, so any length is accepted. Sourced from the
1122
+ * Stream attachment's `waveform_data`, which the composer computes
1123
+ * from the local file and writes at send time.
1124
+ *
1125
+ * When omitted, the card renders a flat placeholder row: every bar at
1126
+ * the minimum height, with the progress fill still tracking playback.
1127
+ */
1128
+ waveformData?: number[];
1114
1129
  }
1115
1130
 
1116
1131
  export declare type MessageAttachmentAudioReceivedProps = AudioAttachmentSharedProps;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, b as t, C as i, c as n, d as o, e as m, F as r, f as l, L as g, h as c, M, i as h, j as L, k as u, l as d, R as p, m as C, n as v, p as A, q as k, s as F, t as b, u as P, v as f, w as x, o as y, x as z, y as S, z as q, B, D, E, G as R } from "./index-qKUd4Fz2.js";
1
+ import { a as e, b as t, C as i, c as n, d as o, e as m, F as r, f as l, L as g, h as c, M, i as h, j as L, k as u, l as d, R as p, m as C, n as v, p as A, q as k, s as F, t as b, u as P, v as f, w as x, o as y, x as z, y as S, z as q, B, D, E, G as R } from "./index-C93LTGLc.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "4.1.5",
3
+ "version": "4.2.0-rc-1787083791",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -50,7 +50,7 @@
50
50
  "chromatic:local": "yarn storybook:build && chromatic"
51
51
  },
52
52
  "dependencies": {
53
- "@linktr.ee/messaging-core": "^2.4.0",
53
+ "@linktr.ee/messaging-core": "2.4.0-rc-1787083791",
54
54
  "@linktr.ee/messaging-taxonomy": "^0.10.0",
55
55
  "@phosphor-icons/react": "^2.1.10"
56
56
  },
@@ -257,4 +257,37 @@ describe('StreamAttachmentMessage', () => {
257
257
  )
258
258
  ).toHaveTextContent('from the trip')
259
259
  })
260
+
261
+ it('forwards a persisted duration and waveform to the audio card', () => {
262
+ // Audio is sent as `type: "file"` with an `audio/*` mime type (for
263
+ // mobile compatibility), and the composer persists `duration` +
264
+ // `waveform_data` on the outgoing attachment. Without this
265
+ // passthrough every sent and received card would fall back to
266
+ // `--:--` and a flat placeholder even though the data is there.
267
+ renderWithProviders(
268
+ <StreamAttachmentMessage
269
+ groupPosition="single"
270
+ isMyMessage
271
+ message={message([
272
+ {
273
+ type: 'file',
274
+ asset_url: 'https://cdn.example.com/take-1.mp3',
275
+ mime_type: 'audio/mpeg',
276
+ title: 'take-1.mp3',
277
+ duration: 12,
278
+ waveform_data: [0.2, 0.9, 0.5, 0.7],
279
+ },
280
+ ])}
281
+ />
282
+ )
283
+
284
+ expect(screen.getByText('00:12')).toBeInTheDocument()
285
+ // The flat placeholder renders every bar at 0%; a real waveform is
286
+ // observable as bars drawn above that floor.
287
+ const barHeights = screen
288
+ .getAllByTestId('amplitude-bar')
289
+ .map((bar) => bar.style.getPropertyValue('--mes-audio-wave-bar'))
290
+ expect(barHeights).toHaveLength(35)
291
+ expect(barHeights.some((height) => height !== '0%')).toBe(true)
292
+ })
260
293
  })
@@ -221,6 +221,12 @@ function mediaItemForKind(
221
221
  src: trimToUndefined(attachment.asset_url) ?? '',
222
222
  mimeType: trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
223
223
  filename: trimToUndefined((attachment as { title?: string }).title),
224
+ // Both are first-class `stream-chat` Attachment fields. The
225
+ // composer writes them at send time so the waveform card paints
226
+ // the real amplitudes and the correct duration with no remote
227
+ // decode and no CDN CORS exposure.
228
+ durationSeconds: attachment.duration,
229
+ waveformData: attachment.waveform_data,
224
230
  }
225
231
  case 'pdf':
226
232
  return {
@@ -1,6 +1,8 @@
1
- import type { Meta, StoryFn } from '@storybook/react'
1
+ import type { Decorator, Meta, StoryFn } from '@storybook/react'
2
2
  import React from 'react'
3
+ import { Chat, WithAudioPlayback } from 'stream-chat-react'
3
4
 
5
+ import { createMockStreamChatClient } from '../../../testing/createMockStreamChatClient'
4
6
  import {
5
7
  StoryGrid,
6
8
  StoryHeading,
@@ -14,27 +16,63 @@ import AudioAttachment from '.'
14
16
  const AUDIO_SRC =
15
17
  'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
16
18
 
19
+ /** Resolves nothing, so the metadata probe never fills the duration slot. */
20
+ const UNREACHABLE_SRC = 'https://cdn.example.invalid/unreachable.mp3'
21
+
22
+ /**
23
+ * The exact amplitudes Figma draws (`2754:17710` → `sound-wave`
24
+ * `…;2754:8065`), as bar heights on the 30px track normalised to `0..1`.
25
+ * Rendering the design's own values is what makes these stories a usable
26
+ * pixel reference.
27
+ */
28
+ const FIGMA_WAVEFORM = [
29
+ 4, 4, 4, 4, 24, 20, 16, 21, 8, 9, 13, 15, 16, 21, 24, 18, 14, 11, 11, 11, 11,
30
+ 11, 18, 24, 24, 30, 25, 21, 19, 16, 11, 12, 8, 4, 4,
31
+ ].map((height) => height / 30)
32
+
17
33
  const STACK_AUDIO: AudioItem[] = [
18
34
  {
19
35
  src: AUDIO_SRC,
20
36
  mimeType: 'audio/mpeg',
21
37
  filename: 'Take 1 — full mix.mp3',
38
+ durationSeconds: 12,
39
+ waveformData: FIGMA_WAVEFORM,
22
40
  },
23
41
  {
24
42
  src: AUDIO_SRC,
25
43
  mimeType: 'audio/mpeg',
26
44
  filename: 'Take 2 — vocals isolated.mp3',
45
+ durationSeconds: 95,
46
+ waveformData: [...FIGMA_WAVEFORM].reverse(),
27
47
  },
28
48
  {
29
49
  src: AUDIO_SRC,
30
50
  mimeType: 'audio/mpeg',
31
51
  filename: 'Take 3 — instrumental only.mp3',
52
+ durationSeconds: 3812,
32
53
  },
33
54
  ]
34
55
 
56
+ /**
57
+ * The card's playback comes from `stream-chat-react`'s pooled
58
+ * `AudioPlayer`, which `<Channel>` mounts in production. Mounting the
59
+ * pool directly gives the stories the same contract — real playback,
60
+ * one player at a time across the page — without needing a channel that
61
+ * can `watch()` against a backend. Without it every play / seek control
62
+ * renders disabled.
63
+ */
64
+ const withAudioPlayback: Decorator = (Story) => (
65
+ <Chat client={createMockStreamChatClient({ id: 'story-user' })}>
66
+ <WithAudioPlayback>
67
+ <Story />
68
+ </WithAudioPlayback>
69
+ </Chat>
70
+ )
71
+
35
72
  const meta: Meta = {
36
73
  title: 'MessageAttachment/Audio',
37
74
  parameters: { layout: 'fullscreen' },
75
+ decorators: [withAudioPlayback],
38
76
  }
39
77
  export default meta
40
78
 
@@ -43,16 +81,16 @@ const handleDismiss = () => {
43
81
  }
44
82
 
45
83
  /**
46
- * Native `<audio controls>` player inside a chat bubble. The browser
47
- * chrome provides play / scrub / volume / kebab-menu (which already
48
- * includes a download), so we don't render any extra controls.
49
- * Composer adds an inline dismiss `×` to the right of the player.
84
+ * The design's waveform player card: a 299×72 bubble holding a 32px
85
+ * play toggle, a 35-bar waveform with a progress fill and an invisible
86
+ * native seek control over it, the duration, and a 32px trailing action
87
+ * — Remove in the composer, Download once sent.
50
88
  */
51
89
  export const Single: StoryFn = () => (
52
90
  <StoryPage>
53
91
  <StoryHeading
54
92
  title="Audio attachment"
55
- description="Native browser audio chrome — play, scrub, volume, and the built-in download in the kebab menu. Composer renders an inline dismiss button next to the player."
93
+ description="Play to watch the fill advance bar by bar and the duration count up; click or drag the waveform to seek, or Tab to it and use the arrow keys. Starting a second card pauses the first — one player at a time across the whole thread."
56
94
  />
57
95
  <StoryGrid>
58
96
  <StoryRow
@@ -62,6 +100,8 @@ export const Single: StoryFn = () => (
62
100
  src={AUDIO_SRC}
63
101
  mimeType="audio/mpeg"
64
102
  filename="Morning meditation.mp3"
103
+ durationSeconds={12}
104
+ waveformData={FIGMA_WAVEFORM}
65
105
  onDismiss={handleDismiss}
66
106
  />
67
107
  }
@@ -70,6 +110,8 @@ export const Single: StoryFn = () => (
70
110
  src={AUDIO_SRC}
71
111
  mimeType="audio/mpeg"
72
112
  filename="Morning meditation.mp3"
113
+ durationSeconds={12}
114
+ waveformData={FIGMA_WAVEFORM}
73
115
  />
74
116
  }
75
117
  received={
@@ -77,6 +119,8 @@ export const Single: StoryFn = () => (
77
119
  src={AUDIO_SRC}
78
120
  mimeType="audio/mpeg"
79
121
  filename="Morning meditation.mp3"
122
+ durationSeconds={12}
123
+ waveformData={FIGMA_WAVEFORM}
80
124
  />
81
125
  }
82
126
  />
@@ -84,20 +128,28 @@ export const Single: StoryFn = () => (
84
128
  </StoryPage>
85
129
  )
86
130
 
87
- export const SingleWithText: StoryFn = () => (
131
+ /**
132
+ * Every case where no amplitudes can be produced — a decode failure, an
133
+ * unsupported codec, a file over the composer's decode ceiling, a clip
134
+ * re-staged from *Recently shared* (whose `File` is a zero-byte
135
+ * placeholder), or anything sent before `waveform_data` was persisted
136
+ * (legacy web, the mobile app, a visitor).
137
+ */
138
+ export const NoWaveform: StoryFn = () => (
88
139
  <StoryPage>
89
140
  <StoryHeading
90
- title="Audio with caption"
91
- description="Caption sits below the player, inside the same bubble."
141
+ title="Degraded states"
142
+ description="The waveform falls back to every bar at the design's 4px minimum, so the card keeps exactly the same silhouette and the progress fill still tracks playback. An unknown duration holds its slot as `--:--` until a metadata probe resolves it."
92
143
  />
93
144
  <StoryGrid>
94
145
  <StoryRow
95
- label="Caption"
146
+ label="No waveform"
96
147
  composer={
97
148
  <AudioAttachment.Composer
98
149
  src={AUDIO_SRC}
99
150
  mimeType="audio/mpeg"
100
- filename="Morning meditation.mp3"
151
+ filename="Voice note.m4a"
152
+ durationSeconds={12}
101
153
  onDismiss={handleDismiss}
102
154
  />
103
155
  }
@@ -105,87 +157,100 @@ export const SingleWithText: StoryFn = () => (
105
157
  <AudioAttachment.Sent
106
158
  src={AUDIO_SRC}
107
159
  mimeType="audio/mpeg"
108
- filename="Morning meditation.mp3"
109
- text="Recorded this morning before training"
160
+ filename="Voice note.m4a"
161
+ durationSeconds={12}
110
162
  />
111
163
  }
112
164
  received={
113
165
  <AudioAttachment.Received
114
166
  src={AUDIO_SRC}
115
167
  mimeType="audio/mpeg"
116
- filename="Morning meditation.mp3"
117
- text="Recorded this morning before training"
168
+ filename="Voice note.m4a"
169
+ durationSeconds={12}
118
170
  />
119
171
  }
120
172
  />
121
- </StoryGrid>
122
- </StoryPage>
123
- )
124
-
125
- /**
126
- * Stacked audio attachments — multiple clips inside one bubble. Each
127
- * clip renders its own native `<audio controls>` player, vertically
128
- * stacked with a hairline separator.
129
- *
130
- * Sent + Received only — the composer surface accepts a single
131
- * attachment at a time.
132
- */
133
- export const Stacked: StoryFn = () => (
134
- <StoryPage>
135
- <StoryHeading
136
- title="Stacked audio"
137
- description="Each clip plays independently with its own native controls. Composer column is intentionally blank — the composer accepts a single attachment at a time."
138
- />
139
- <StoryGrid>
140
173
  <StoryRow
141
- label="2 clips"
174
+ label="No waveform, no duration"
142
175
  composer={
143
176
  <AudioAttachment.Composer
144
- src={AUDIO_SRC}
145
- mimeType="audio/mpeg"
146
- filename="Morning meditation.mp3"
177
+ src={UNREACHABLE_SRC}
178
+ filename="Unreachable.mp3"
147
179
  onDismiss={handleDismiss}
148
180
  />
149
181
  }
150
- sent={<AudioAttachment.Sent items={STACK_AUDIO.slice(0, 2)} />}
151
- received={<AudioAttachment.Received items={STACK_AUDIO.slice(0, 2)} />}
182
+ sent={
183
+ <AudioAttachment.Sent
184
+ src={UNREACHABLE_SRC}
185
+ filename="Unreachable.mp3"
186
+ />
187
+ }
188
+ received={
189
+ <AudioAttachment.Received
190
+ src={UNREACHABLE_SRC}
191
+ filename="Unreachable.mp3"
192
+ />
193
+ }
152
194
  />
153
195
  <StoryRow
154
- label="3 clips"
196
+ label="Unplayable codec"
155
197
  composer={
156
198
  <AudioAttachment.Composer
157
199
  src={AUDIO_SRC}
158
- mimeType="audio/mpeg"
159
- filename="Morning meditation.mp3"
200
+ mimeType="audio/x-made-up"
201
+ filename="Master.aiff"
202
+ durationSeconds={12}
203
+ waveformData={FIGMA_WAVEFORM}
160
204
  onDismiss={handleDismiss}
161
205
  />
162
206
  }
163
- sent={<AudioAttachment.Sent items={STACK_AUDIO} />}
164
- received={<AudioAttachment.Received items={STACK_AUDIO} />}
207
+ sent={
208
+ <AudioAttachment.Sent
209
+ src={AUDIO_SRC}
210
+ mimeType="audio/x-made-up"
211
+ filename="Master.aiff"
212
+ durationSeconds={12}
213
+ waveformData={FIGMA_WAVEFORM}
214
+ />
215
+ }
216
+ received={
217
+ <AudioAttachment.Received
218
+ src={AUDIO_SRC}
219
+ mimeType="audio/x-made-up"
220
+ filename="Master.aiff"
221
+ durationSeconds={12}
222
+ waveformData={FIGMA_WAVEFORM}
223
+ />
224
+ }
165
225
  />
166
226
  </StoryGrid>
167
227
  </StoryPage>
168
228
  )
169
229
 
170
- /** Stacked audio with an accompanying caption.
171
- * Sent + Received only — see `Stacked` for the rationale. */
172
- export const StackedWithText: StoryFn = () => (
230
+ /**
231
+ * Stacked 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 with caption"
176
- description="Caption sits inside the same bubble, below the player stack. Composer column is intentionally blank — the composer accepts a single attachment at a time."
238
+ title="Stacked audio"
239
+ description="Each clip is its own row and its own player, stacked with an 8px gap. Only one can play at a time. The third row has no waveform, so it shows the flat fallback beside two real ones."
177
240
  />
178
241
  <StoryGrid>
242
+ <StoryRow
243
+ label="2 clips"
244
+ sent={<AudioAttachment.Sent items={STACK_AUDIO.slice(0, 2)} />}
245
+ received={<AudioAttachment.Received items={STACK_AUDIO.slice(0, 2)} />}
246
+ />
247
+ <StoryRow
248
+ label="3 clips"
249
+ sent={<AudioAttachment.Sent items={STACK_AUDIO} />}
250
+ received={<AudioAttachment.Received items={STACK_AUDIO} />}
251
+ />
179
252
  <StoryRow
180
253
  label="3 clips + caption"
181
- composer={
182
- <AudioAttachment.Composer
183
- src={AUDIO_SRC}
184
- mimeType="audio/mpeg"
185
- filename="Morning meditation.mp3"
186
- onDismiss={handleDismiss}
187
- />
188
- }
189
254
  sent={
190
255
  <AudioAttachment.Sent
191
256
  items={STACK_AUDIO}
@@ -202,3 +267,45 @@ export const StackedWithText: StoryFn = () => (
202
267
  </StoryGrid>
203
268
  </StoryPage>
204
269
  )
270
+
271
+ /**
272
+ * The card keeps a uniform 20px radius in every grouping position — a
273
+ * 32px control tucked into a 4px flattened corner would collide with it
274
+ * — so a run of audio bubbles reads as a stack of identical cards.
275
+ */
276
+ export const Grouped: StoryFn = () => (
277
+ <StoryPage>
278
+ <StoryHeading
279
+ title="Same-author run"
280
+ description="`groupPosition` still serialises onto the bubble, but the audio card opts out of the corner flattening the text bubbles apply."
281
+ />
282
+ <StoryGrid>
283
+ {(['first', 'middle', 'end'] as const).map((groupPosition) => (
284
+ <StoryRow
285
+ key={groupPosition}
286
+ label={groupPosition}
287
+ sent={
288
+ <AudioAttachment.Sent
289
+ src={AUDIO_SRC}
290
+ mimeType="audio/mpeg"
291
+ filename="Morning meditation.mp3"
292
+ durationSeconds={12}
293
+ waveformData={FIGMA_WAVEFORM}
294
+ groupPosition={groupPosition}
295
+ />
296
+ }
297
+ received={
298
+ <AudioAttachment.Received
299
+ src={AUDIO_SRC}
300
+ mimeType="audio/mpeg"
301
+ filename="Morning meditation.mp3"
302
+ durationSeconds={12}
303
+ waveformData={FIGMA_WAVEFORM}
304
+ groupPosition={groupPosition}
305
+ />
306
+ }
307
+ />
308
+ ))}
309
+ </StoryGrid>
310
+ </StoryPage>
311
+ )