@linktr.ee/messaging-react 2.1.0 → 2.2.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 (58) hide show
  1. package/dist/{Card-CsJvUF_b.js → Card-BdTueeyk.js} +2 -2
  2. package/dist/{Card-CsJvUF_b.js.map → Card-BdTueeyk.js.map} +1 -1
  3. package/dist/{Card-DlMSDSdm.js → Card-ChR37pLZ.js} +2 -2
  4. package/dist/{Card-DlMSDSdm.js.map → Card-ChR37pLZ.js.map} +1 -1
  5. package/dist/{Card-CFFNq49v.js → Card-EKxCn56j.js} +3 -3
  6. package/dist/{Card-CFFNq49v.js.map → Card-EKxCn56j.js.map} +1 -1
  7. package/dist/{LockedThumbnail-DpJx169C.js → LockedThumbnail-B16qP3eH.js} +2 -2
  8. package/dist/{LockedThumbnail-DpJx169C.js.map → LockedThumbnail-B16qP3eH.js.map} +1 -1
  9. package/dist/index-Dn7BC9xK.js +4748 -0
  10. package/dist/index-Dn7BC9xK.js.map +1 -0
  11. package/dist/index.d.ts +591 -25
  12. package/dist/index.js +24 -19
  13. package/package.json +1 -1
  14. package/src/components/CustomMessage/MessageAttachmentConversations.stories.tsx +841 -0
  15. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +7 -92
  16. package/src/components/LinkAttachment/LinkAttachment.test.tsx +69 -0
  17. package/src/components/LinkAttachment/components/Received/Card.tsx +10 -30
  18. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -1
  19. package/src/components/LinkAttachment/index.tsx +24 -50
  20. package/src/components/LinkAttachment/types.ts +12 -5
  21. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +203 -0
  22. package/src/components/MessageAttachment/Audio/index.tsx +189 -0
  23. package/src/components/MessageAttachment/File/FileAttachment.stories.tsx +352 -0
  24. package/src/components/MessageAttachment/File/index.tsx +240 -0
  25. package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +288 -0
  26. package/src/components/MessageAttachment/Image/index.tsx +257 -0
  27. package/src/components/MessageAttachment/MessageAttachment.test.tsx +783 -0
  28. package/src/components/MessageAttachment/Pdf/PdfAttachment.stories.tsx +292 -0
  29. package/src/components/MessageAttachment/Pdf/index.tsx +228 -0
  30. package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +272 -0
  31. package/src/components/MessageAttachment/Video/index.tsx +281 -0
  32. package/src/components/MessageAttachment/_shared/Bubble.tsx +173 -0
  33. package/src/components/MessageAttachment/_shared/CompactDocumentRow.tsx +152 -0
  34. package/src/components/MessageAttachment/_shared/DismissButton.tsx +39 -0
  35. package/src/components/MessageAttachment/_shared/DownloadAction.tsx +175 -0
  36. package/src/components/MessageAttachment/_shared/ImageViewer.tsx +314 -0
  37. package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +139 -0
  38. package/src/components/MessageAttachment/_shared/PdfViewer.tsx +100 -0
  39. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +171 -0
  40. package/src/components/MessageAttachment/_shared/ViewerShell.tsx +159 -0
  41. package/src/components/MessageAttachment/_shared/fileMeta.test.ts +82 -0
  42. package/src/components/MessageAttachment/_shared/fileMeta.ts +95 -0
  43. package/src/components/MessageAttachment/_shared/triggerDownload.ts +54 -0
  44. package/src/components/MessageAttachment/_shared/useViewer.ts +53 -0
  45. package/src/components/MessageAttachment/index.tsx +149 -0
  46. package/src/components/MessageAttachment/stories/StoryTable.tsx +72 -0
  47. package/src/components/MessageAttachment/types.ts +178 -0
  48. package/src/index.ts +32 -0
  49. package/dist/Card-D32U6KfZ.js +0 -85
  50. package/dist/Card-D32U6KfZ.js.map +0 -1
  51. package/dist/Card-DlSSJPip.js +0 -60
  52. package/dist/Card-DlSSJPip.js.map +0 -1
  53. package/dist/Card-zGbhRBwv.js +0 -48
  54. package/dist/Card-zGbhRBwv.js.map +0 -1
  55. package/dist/CardThumbnail-DTBuRQHF.js +0 -239
  56. package/dist/CardThumbnail-DTBuRQHF.js.map +0 -1
  57. package/dist/index-DfcRe-Hj.js +0 -3103
  58. package/dist/index-DfcRe-Hj.js.map +0 -1
@@ -10,10 +10,6 @@ import React from 'react'
10
10
  import LinkAttachment from '.'
11
11
 
12
12
  const IMAGE_THUMBNAIL = '/image-thumbnail.jpg'
13
- const VIDEO_SOURCE = '/video-source.mp4'
14
- const VIDEO_POSTER = '/video-thumbnail.jpg'
15
- const AUDIO_SOURCE = '/audio-source.mp3'
16
- const PDF_SOURCE = '/document-source.pdf'
17
13
 
18
14
  const meta: Meta = {
19
15
  title: 'LinkAttachment',
@@ -130,90 +126,6 @@ const LINK_APPS: Array<{
130
126
  },
131
127
  ]
132
128
 
133
- /**
134
- * "Attachments" section of the Figma board — image / video / audio /
135
- * file attachments shown across the three messaging states. These are
136
- * pure media cards: the hero fills the card and there's no title /
137
- * description / link metadata. Playable types use inline native controls:
138
- * - **Image** — full-bleed thumbnail; Received opens an image preview.
139
- * - **Video** — inline `<video controls>` with a poster.
140
- * - **Audio** — inline `<audio controls>` over the audio type-icon.
141
- * - **PDF** — type-icon placeholder; Received opens the PDF in the
142
- * browser's native viewer.
143
- * - **File** — generic file type-icon; Received opens a preview.
144
- * - **Placeholder** — empty draft state with the image type-icon.
145
- */
146
- const ATTACHMENT_ROWS: Array<{
147
- label: string
148
- /** Drives the placeholder type-icon and the inline player switch. */
149
- mimeType?: string
150
- /** Thumbnail (image source or video poster). */
151
- thumbnailUrl?: string
152
- /** Playable media URL — when set with a video/audio mime, renders inline. */
153
- sourceUrl?: string
154
- /**
155
- * Override the Received `onClick` handler. Used by the PDF row to open
156
- * the PDF in the browser's native viewer.
157
- */
158
- onReceivedClick?: () => void
159
- }> = [
160
- { label: 'Image', mimeType: 'image/jpeg', thumbnailUrl: IMAGE_THUMBNAIL },
161
- {
162
- label: 'Video',
163
- mimeType: 'video/mp4',
164
- thumbnailUrl: VIDEO_POSTER,
165
- sourceUrl: VIDEO_SOURCE,
166
- },
167
- { label: 'Audio', mimeType: 'audio/mpeg', sourceUrl: AUDIO_SOURCE },
168
- {
169
- label: 'PDF',
170
- mimeType: 'application/pdf',
171
- onReceivedClick: () => window.open(PDF_SOURCE, '_blank', 'noopener'),
172
- },
173
- { label: 'File', mimeType: 'application/octet-stream' },
174
- { label: 'Placeholder' },
175
- ]
176
-
177
- export const Attachments: StoryFn = () => (
178
- <Table>
179
- <TableHead columns={['Composer', 'Sent', 'Received']} />
180
- <tbody>
181
- {ATTACHMENT_ROWS.map(
182
- ({ label, mimeType, thumbnailUrl, sourceUrl, onReceivedClick }) => (
183
- <tr key={label}>
184
- <RowLabel>{label}</RowLabel>
185
- <td className="align-top">
186
- <LinkAttachment.Composer
187
- mimeType={mimeType}
188
- thumbnailUrl={thumbnailUrl}
189
- sourceUrl={sourceUrl}
190
- onDismiss={() => alert(`Dismissed ${label}`)}
191
- />
192
- </td>
193
- <td className="align-top">
194
- <LinkAttachment.Sent
195
- mimeType={mimeType}
196
- thumbnailUrl={thumbnailUrl}
197
- sourceUrl={sourceUrl}
198
- />
199
- </td>
200
- <td className="align-top">
201
- <LinkAttachment.Received
202
- mimeType={mimeType}
203
- thumbnailUrl={thumbnailUrl}
204
- sourceUrl={sourceUrl}
205
- onClick={
206
- onReceivedClick ?? (() => alert(`Open ${label} preview`))
207
- }
208
- />
209
- </td>
210
- </tr>
211
- )
212
- )}
213
- </tbody>
214
- </Table>
215
- )
216
-
217
129
  type LinkAppLayout = 'featured' | 'classic'
218
130
  type LinkAppState = 'Received' | 'Sent' | 'Composer'
219
131
 
@@ -280,10 +192,13 @@ const renderLinkAppCard = (
280
192
 
281
193
  /**
282
194
  * "LinkApps" section of the Figma board — link previews with a brand
283
- * badge prefixing the title and either a URL footer (Spotify, TikTok) or
284
- * a CTA button (FAQ, Form). Each app is shown in both the **Featured**
285
- * (hero image) and **Classic** (compact, no hero) layouts across all
286
- * three messaging states.
195
+ * badge prefixing the title and either a URL footer (Spotify, TikTok)
196
+ * or a CTA button (FAQ, Form). Each app is shown in both the
197
+ * **Featured** (hero image) and **Classic** (compact, no hero) layouts
198
+ * across all three messaging states.
199
+ *
200
+ * For chat **document / image / video / audio attachments**, see the
201
+ * `MessageAttachment` stories instead.
287
202
  */
288
203
  export const LinkApps: StoryFn = () => (
289
204
  <Table>
@@ -0,0 +1,69 @@
1
+ import React from 'react'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { renderWithProviders, screen, fireEvent } from '../../test/utils'
5
+
6
+ import LinkAttachment from '.'
7
+
8
+ describe('LinkAttachment.Received (link previews)', () => {
9
+ it('renders link previews with the hairline border treatment', async () => {
10
+ renderWithProviders(
11
+ <LinkAttachment.Received
12
+ title="Brie’s FAQ"
13
+ description="Get answers"
14
+ url="tr.ee/briemix"
15
+ />
16
+ )
17
+ const root = await screen.findByTestId('link-attachment')
18
+ expect(root.className).toContain('border')
19
+ expect(root.tagName).toBe('A')
20
+ expect(root.getAttribute('href')).toBe('https://tr.ee/briemix')
21
+ expect(root.getAttribute('target')).toBe('_blank')
22
+ })
23
+
24
+ it('renders as a button when no URL is set and onClick is provided', async () => {
25
+ const handleClick = vi.fn()
26
+ renderWithProviders(
27
+ <LinkAttachment.Received
28
+ title="Featured image"
29
+ thumbnailUrl="https://cdn.example.com/picture.jpg"
30
+ onClick={handleClick}
31
+ />
32
+ )
33
+ const root = await screen.findByTestId('link-attachment')
34
+ expect(root.tagName).toBe('BUTTON')
35
+ fireEvent.click(root)
36
+ expect(handleClick).toHaveBeenCalledTimes(1)
37
+ })
38
+
39
+ it('routes the card-level onClick through the CTA when one is set', async () => {
40
+ const onClick = vi.fn()
41
+ const ctaOnClick = vi.fn()
42
+ renderWithProviders(
43
+ <LinkAttachment.Received
44
+ title="FAQ"
45
+ description="Get answers"
46
+ cta={{ label: 'View FAQs', onClick: ctaOnClick }}
47
+ onClick={onClick}
48
+ />
49
+ )
50
+ fireEvent.click(await screen.findByText('View FAQs'))
51
+ expect(onClick).toHaveBeenCalledTimes(1)
52
+ expect(ctaOnClick).toHaveBeenCalledTimes(1)
53
+ })
54
+ })
55
+
56
+ describe('LinkAttachment.Sent', () => {
57
+ it('renders the dark variant non-interactively', async () => {
58
+ renderWithProviders(
59
+ <LinkAttachment.Sent
60
+ title="My Playlist"
61
+ description="A handpicked workout mix"
62
+ url="tr.ee/briemix"
63
+ />
64
+ )
65
+ expect(await screen.findByText('My Playlist')).toBeInTheDocument()
66
+ expect(screen.queryByRole('button')).toBeNull()
67
+ expect(screen.queryByRole('link')).toBeNull()
68
+ })
69
+ })
@@ -20,12 +20,11 @@ export interface ReceivedCardProps extends LinkAttachmentBaseProps {
20
20
  * - **Link app with a URL** (Spotify / TikTok / generic link): the card
21
21
  * chrome is an `<a target="_blank">` opening `url` — `onClick` fires
22
22
  * alongside the navigation (use for analytics).
23
- * - **Image / file / placeholder attachment**: the card has no URL, so
24
- * it renders as a button. `onClick` is the consumer's hook for
25
- * opening an image / file preview (lightbox).
26
- * - **Video / audio attachment**: the shell stays non-interactive so
27
- * the native media controls remain operable — `onClick` is ignored
28
- * in this configuration.
23
+ * - **Hero-image only card**: the card has no URL, so it renders as
24
+ * a button. `onClick` is the consumer's hook for opening a preview.
25
+ * - **Video / audio link previews**: the shell stays non-interactive
26
+ * so the native media controls remain operable `onClick` is
27
+ * ignored in this configuration.
29
28
  */
30
29
  onClick?: () => void
31
30
  }
@@ -33,14 +32,6 @@ export interface ReceivedCardProps extends LinkAttachmentBaseProps {
33
32
  /**
34
33
  * The card the recipient sees in chat for a link attachment. Matches the
35
34
  * Received column of the messaging design system in Figma.
36
- *
37
- * The chrome adapts to its content:
38
- * - Link previews / link apps render the light card body and either
39
- * navigate via `url` or surface a CTA button when `cta` is set.
40
- * - Image / file attachments render as media-only cards — the 180px
41
- * thumbnail (or type-icon placeholder) fills the card with no title /
42
- * description body. `onClick` fires when the recipient taps the card
43
- * so consumers can open an image preview / lightbox.
44
35
  */
45
36
  const ReceivedCard: React.FC<ReceivedCardProps> = ({
46
37
  title,
@@ -54,32 +45,21 @@ const ReceivedCard: React.FC<ReceivedCardProps> = ({
54
45
  cta,
55
46
  onClick,
56
47
  }) => {
57
- // The Received card is opened by either the CTA (FAQ / Form), the URL
58
- // (link previews / Spotify / TikTok), or for media-only attachments —
59
- // by tapping the entire card chrome to open a preview. We hand the
60
- // anchor behavior off to CardShell only when there's no CTA so we don't
61
- // end up with nested anchors when a CTA is present.
62
- //
63
- // Video / audio attachments are an exception: wrapping the shell in
64
- // either a `<button>` or an `<a>` around `<video controls>` /
65
- // `<audio controls>` creates nested interactive elements, and clicks on
66
- // the native media controls can bubble up to fire the outer card
67
- // action (preview / link navigation). For those, we render a plain
68
- // non-interactive shell and let the media element own clicks — both
69
- // `shellHref` and `shellOnClick` are suppressed even when `url` is set.
48
+ // Video / audio link previews wrap the native media element render a
49
+ // plain non-interactive shell and let the media controls own clicks so
50
+ // taps on play/pause/scrubber don't fire the outer card action.
70
51
  const isPlayingMedia = isPlayableMedia(mimeType, sourceUrl)
71
52
  // Normalize the URL so a bare hostname like `tr.ee/briemix` (used in
72
53
  // our own docs / stories) is treated as an external link instead of a
73
54
  // relative path. Returns `undefined` for empty / whitespace-only
74
- // values, so those fall through to the media-preview path instead of
55
+ // values, so those fall through to the preview path instead of
75
56
  // producing an empty `href` on the shell anchor.
76
57
  const normalizedUrl = normalizeExternalHref(url)
77
58
  const shellHref =
78
59
  cta == null && normalizedUrl != null && !isPlayingMedia
79
60
  ? normalizedUrl
80
61
  : undefined
81
- const shellOnClick =
82
- cta == null && !isPlayingMedia ? onClick : undefined
62
+ const shellOnClick = cta == null && !isPlayingMedia ? onClick : undefined
83
63
  const audioBg = isPlayableAudio(mimeType, sourceUrl)
84
64
  ? AUDIO_BG_CLASS
85
65
  : undefined
@@ -38,7 +38,11 @@ export interface CardShellProps {
38
38
 
39
39
  const SHELL_CLASS = classNames(
40
40
  'relative block w-[280px] select-none overflow-hidden rounded-md',
41
- 'shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_1px_2px_rgba(0,0,0,0.04),0_8px_32px_rgba(0,0,0,0.1)]'
41
+ // 1px hairline border that sits flush with the card chrome — matches
42
+ // the messaging design system's "small border around link attachments"
43
+ // treatment from the mobile spec. The drop shadow remains for depth.
44
+ 'border border-black/[0.08]',
45
+ 'shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_32px_rgba(0,0,0,0.1)]'
42
46
  )
43
47
 
44
48
  /**
@@ -1,63 +1,37 @@
1
- import React, { Suspense } from 'react'
2
-
3
- import type { ComposerCardProps } from './components/Composer/Card'
4
- import type { ReceivedCardProps } from './components/Received/Card'
5
- import type { SentCardProps } from './components/Sent/Card'
6
-
7
- const ComposerCardLazy = React.lazy(
8
- () => import('./components/Composer/Card')
9
- )
10
- const SentCardLazy = React.lazy(() => import('./components/Sent/Card'))
11
- const ReceivedCardLazy = React.lazy(
12
- () => import('./components/Received/Card')
13
- )
14
-
15
- const LinkAttachmentFallback = () => (
16
- <div
17
- className="h-[280px] w-[280px] animate-pulse rounded-md bg-black/[0.06] shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_1px_2px_rgba(0,0,0,0.04),0_8px_32px_rgba(0,0,0,0.1)]"
18
- aria-hidden
19
- />
20
- )
21
-
22
- const Composer = (props: ComposerCardProps) => (
23
- <Suspense fallback={<LinkAttachmentFallback />}>
24
- <ComposerCardLazy {...props} />
25
- </Suspense>
26
- )
27
-
28
- const Sent = (props: SentCardProps) => (
29
- <Suspense fallback={<LinkAttachmentFallback />}>
30
- <SentCardLazy {...props} />
31
- </Suspense>
32
- )
33
-
34
- const Received = (props: ReceivedCardProps) => (
35
- <Suspense fallback={<LinkAttachmentFallback />}>
36
- <ReceivedCardLazy {...props} />
37
- </Suspense>
38
- )
1
+ import ComposerCard, {
2
+ type ComposerCardProps,
3
+ } from './components/Composer/Card'
4
+ import ReceivedCard, {
5
+ type ReceivedCardProps,
6
+ } from './components/Received/Card'
7
+ import SentCard, { type SentCardProps } from './components/Sent/Card'
39
8
 
40
9
  /**
41
- * Link attachments (image / file media + 1P/3P Link Apps) shown in the chat
42
- * thread. Mirrors the `LockedAttachment` API — render `LinkAttachment.Composer`
43
- * while drafting, `LinkAttachment.Sent` after posting, and
10
+ * Link previews (1P / 3P Link Apps) shown in the chat thread. Mirrors
11
+ * the `LockedAttachment` API — render `LinkAttachment.Composer` while
12
+ * drafting, `LinkAttachment.Sent` after posting, and
44
13
  * `LinkAttachment.Received` in the recipient's thread. Maps to the
45
- * "Attachments" and "LinkApps" sections of the messaging design system.
14
+ * "LinkApps" section of the messaging design system.
46
15
  *
47
16
  * Two visual layouts via the `layout` prop:
48
- * - **Featured** (default) — 180px hero thumbnail above the body. Used by
49
- * image / file Attachments and by hero-image LinkApps (Spotify with
50
- * cover art, TikTok with a frame, etc.).
17
+ * - **Featured** (default) — 180px hero thumbnail above the body. Used
18
+ * by hero-image LinkApps (Spotify with cover art, TikTok with a
19
+ * frame, etc.).
51
20
  * - **Classic** — compact card with no hero thumbnail; title /
52
21
  * description / URL / CTA only. Used for LinkApp embeds without
53
22
  * artwork (FAQ, Form) and any link preview that lacks OG imagery.
54
23
  *
55
- * Image / file Attachments use `layout="featured"` and skip the title /
56
- * description / URL body entirely (`CardBody` collapses to nothing when no
57
- * text content is provided). LinkApps always carry a title + description
58
- * and prefix the title with an `appIcon` brand badge.
24
+ * For chat **document / image / video / audio attachments**, reach for
25
+ * `MessageAttachment.{Image,Video,Audio,Pdf,File}` instead those
26
+ * render as bubbles with built-in viewers (zoom-capable image
27
+ * lightbox, native PDF viewer, video / audio with download) and a
28
+ * caption slot for accompanying text.
59
29
  */
60
- const LinkAttachment = { Composer, Sent, Received }
30
+ const LinkAttachment = {
31
+ Composer: ComposerCard,
32
+ Sent: SentCard,
33
+ Received: ReceivedCard,
34
+ }
61
35
 
62
36
  export default LinkAttachment
63
37
  export type { ComposerCardProps, SentCardProps, ReceivedCardProps }
@@ -4,11 +4,16 @@ import type React from 'react'
4
4
  * Visual layout for a `LinkAttachment.*` card.
5
5
  *
6
6
  * - `featured` — full card with a 180px hero thumbnail above the body.
7
- * The default; matches the "Attachments" frames and the hero-image
8
- * "LinkApps" frames in Figma.
7
+ * The default; matches the hero-image "LinkApps" frames in Figma
8
+ * (Spotify with cover art, TikTok with a frame, etc.).
9
9
  * - `classic` — compact card without a hero thumbnail. Title /
10
10
  * description / URL / CTA only. Used for Link App embeds that don't
11
11
  * carry artwork (and for plain link previews without OG imagery).
12
+ *
13
+ * For document / image / video / audio chat attachments, reach for
14
+ * `MessageAttachment.{Image,Video,Audio,Pdf,File}` instead — those
15
+ * render as bubbles with built-in viewers and download support and
16
+ * carry a `text` slot for accompanying captions.
12
17
  */
13
18
  export type LinkAttachmentLayout = 'featured' | 'classic'
14
19
 
@@ -36,9 +41,11 @@ export interface LinkAttachmentBaseProps {
36
41
  /** Hero thumbnail (180px tall) shown above the title block. */
37
42
  thumbnailUrl?: string
38
43
  /**
39
- * Source URL for playable media (video, audio). When provided alongside a
40
- * video / audio `mimeType`, the hero region renders an inline player with
41
- * native controls instead of the static thumbnail / type-icon.
44
+ * Source URL for playable media in the hero region. When provided
45
+ * alongside a video / audio `mimeType`, the hero renders an inline
46
+ * native player (used by media-rich link previews that embed a
47
+ * preview clip). For chat document / file attachments, reach for
48
+ * `MessageAttachment.{Pdf,File}` instead.
42
49
  */
43
50
  sourceUrl?: string
44
51
  /**
@@ -0,0 +1,203 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React from 'react'
3
+
4
+ import {
5
+ StoryGrid,
6
+ StoryHeading,
7
+ StoryPage,
8
+ StoryRow,
9
+ } from '../stories/StoryTable'
10
+ import type { AudioItem } from '../types'
11
+
12
+ import AudioAttachment from '.'
13
+
14
+ const AUDIO_SRC =
15
+ 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
16
+
17
+ const STACK_AUDIO: AudioItem[] = [
18
+ {
19
+ src: AUDIO_SRC,
20
+ mimeType: 'audio/mpeg',
21
+ filename: 'Take 1 — full mix.mp3',
22
+ },
23
+ {
24
+ src: AUDIO_SRC,
25
+ mimeType: 'audio/mpeg',
26
+ filename: 'Take 2 — vocals isolated.mp3',
27
+ },
28
+ {
29
+ src: AUDIO_SRC,
30
+ mimeType: 'audio/mpeg',
31
+ filename: 'Take 3 — instrumental only.mp3',
32
+ },
33
+ ]
34
+
35
+ const meta: Meta = {
36
+ title: 'MessageAttachment/Audio',
37
+ parameters: { layout: 'fullscreen' },
38
+ }
39
+ export default meta
40
+
41
+ const handleDismiss = () => {
42
+ // eslint-disable-next-line no-console
43
+ console.log('Dismissed audio attachment')
44
+ }
45
+
46
+ /**
47
+ * Native `<audio controls>` player inside a chat bubble. The browser
48
+ * chrome provides play / scrub / volume / kebab-menu (which already
49
+ * includes a download), so we don't render any extra controls.
50
+ * Composer adds an inline dismiss `×` to the right of the player.
51
+ */
52
+ export const Single: StoryFn = () => (
53
+ <StoryPage>
54
+ <StoryHeading
55
+ title="Audio attachment"
56
+ 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."
57
+ />
58
+ <StoryGrid>
59
+ <StoryRow
60
+ label="Default"
61
+ composer={
62
+ <AudioAttachment.Composer
63
+ src={AUDIO_SRC}
64
+ mimeType="audio/mpeg"
65
+ filename="Morning meditation.mp3"
66
+ onDismiss={handleDismiss}
67
+ />
68
+ }
69
+ sent={
70
+ <AudioAttachment.Sent
71
+ src={AUDIO_SRC}
72
+ mimeType="audio/mpeg"
73
+ filename="Morning meditation.mp3"
74
+ />
75
+ }
76
+ received={
77
+ <AudioAttachment.Received
78
+ src={AUDIO_SRC}
79
+ mimeType="audio/mpeg"
80
+ filename="Morning meditation.mp3"
81
+ />
82
+ }
83
+ />
84
+ </StoryGrid>
85
+ </StoryPage>
86
+ )
87
+
88
+ export const SingleWithText: StoryFn = () => (
89
+ <StoryPage>
90
+ <StoryHeading
91
+ title="Audio with caption"
92
+ description="Caption sits below the player, inside the same bubble."
93
+ />
94
+ <StoryGrid>
95
+ <StoryRow
96
+ label="Caption"
97
+ composer={
98
+ <AudioAttachment.Composer
99
+ src={AUDIO_SRC}
100
+ mimeType="audio/mpeg"
101
+ filename="Morning meditation.mp3"
102
+ onDismiss={handleDismiss}
103
+ />
104
+ }
105
+ sent={
106
+ <AudioAttachment.Sent
107
+ src={AUDIO_SRC}
108
+ mimeType="audio/mpeg"
109
+ filename="Morning meditation.mp3"
110
+ text="Recorded this morning before training"
111
+ />
112
+ }
113
+ received={
114
+ <AudioAttachment.Received
115
+ src={AUDIO_SRC}
116
+ mimeType="audio/mpeg"
117
+ filename="Morning meditation.mp3"
118
+ text="Recorded this morning before training"
119
+ />
120
+ }
121
+ />
122
+ </StoryGrid>
123
+ </StoryPage>
124
+ )
125
+
126
+ /**
127
+ * Stacked audio attachments — multiple clips inside one bubble. Each
128
+ * clip renders its own native `<audio controls>` player, vertically
129
+ * stacked with a hairline separator.
130
+ *
131
+ * Sent + Received only — the composer surface accepts a single
132
+ * attachment at a time.
133
+ */
134
+ export const Stacked: StoryFn = () => (
135
+ <StoryPage>
136
+ <StoryHeading
137
+ title="Stacked audio"
138
+ description="Each clip plays independently with its own native controls. Composer column is intentionally blank — the composer accepts a single attachment at a time."
139
+ />
140
+ <StoryGrid>
141
+ <StoryRow
142
+ label="2 clips"
143
+ composer={
144
+ <AudioAttachment.Composer
145
+ src={AUDIO_SRC}
146
+ mimeType="audio/mpeg"
147
+ filename="Morning meditation.mp3"
148
+ onDismiss={handleDismiss}
149
+ />}
150
+ sent={<AudioAttachment.Sent items={STACK_AUDIO.slice(0, 2)} />}
151
+ received={<AudioAttachment.Received items={STACK_AUDIO.slice(0, 2)} />}
152
+ />
153
+ <StoryRow
154
+ label="3 clips"
155
+ composer={
156
+ <AudioAttachment.Composer
157
+ src={AUDIO_SRC}
158
+ mimeType="audio/mpeg"
159
+ filename="Morning meditation.mp3"
160
+ onDismiss={handleDismiss}
161
+ />}
162
+ sent={<AudioAttachment.Sent items={STACK_AUDIO} />}
163
+ received={<AudioAttachment.Received items={STACK_AUDIO} />}
164
+ />
165
+ </StoryGrid>
166
+ </StoryPage>
167
+ )
168
+
169
+ /** Stacked audio with an accompanying caption.
170
+ * Sent + Received only — see `Stacked` for the rationale. */
171
+ export const StackedWithText: StoryFn = () => (
172
+ <StoryPage>
173
+ <StoryHeading
174
+ title="Stacked audio with caption"
175
+ 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."
176
+ />
177
+ <StoryGrid>
178
+ <StoryRow
179
+ label="3 clips + caption"
180
+ composer={
181
+ <AudioAttachment.Composer
182
+ src={AUDIO_SRC}
183
+ mimeType="audio/mpeg"
184
+ filename="Morning meditation.mp3"
185
+ onDismiss={handleDismiss}
186
+ />
187
+ }
188
+ sent={
189
+ <AudioAttachment.Sent
190
+ items={STACK_AUDIO}
191
+ text="Three takes from the studio — let me know which mix you prefer."
192
+ />
193
+ }
194
+ received={
195
+ <AudioAttachment.Received
196
+ items={STACK_AUDIO}
197
+ text="Three takes from the studio — let me know which mix you prefer."
198
+ />
199
+ }
200
+ />
201
+ </StoryGrid>
202
+ </StoryPage>
203
+ )