@linktr.ee/messaging-react 2.1.0 → 2.2.0-rc-1778753733
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-CsJvUF_b.js → Card-BdTueeyk.js} +2 -2
- package/dist/{Card-CsJvUF_b.js.map → Card-BdTueeyk.js.map} +1 -1
- package/dist/{Card-DlMSDSdm.js → Card-ChR37pLZ.js} +2 -2
- package/dist/{Card-DlMSDSdm.js.map → Card-ChR37pLZ.js.map} +1 -1
- package/dist/{Card-CFFNq49v.js → Card-EKxCn56j.js} +3 -3
- package/dist/{Card-CFFNq49v.js.map → Card-EKxCn56j.js.map} +1 -1
- package/dist/{LockedThumbnail-DpJx169C.js → LockedThumbnail-B16qP3eH.js} +2 -2
- package/dist/{LockedThumbnail-DpJx169C.js.map → LockedThumbnail-B16qP3eH.js.map} +1 -1
- package/dist/index-Dn7BC9xK.js +4748 -0
- package/dist/index-Dn7BC9xK.js.map +1 -0
- package/dist/index.d.ts +591 -25
- package/dist/index.js +24 -19
- package/package.json +1 -1
- package/src/components/CustomMessage/MessageAttachmentConversations.stories.tsx +841 -0
- package/src/components/LinkAttachment/LinkAttachment.stories.tsx +7 -92
- package/src/components/LinkAttachment/LinkAttachment.test.tsx +69 -0
- package/src/components/LinkAttachment/components/Received/Card.tsx +10 -30
- package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -1
- package/src/components/LinkAttachment/index.tsx +24 -50
- package/src/components/LinkAttachment/types.ts +12 -5
- package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +203 -0
- package/src/components/MessageAttachment/Audio/index.tsx +189 -0
- package/src/components/MessageAttachment/File/FileAttachment.stories.tsx +352 -0
- package/src/components/MessageAttachment/File/index.tsx +240 -0
- package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +288 -0
- package/src/components/MessageAttachment/Image/index.tsx +257 -0
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +783 -0
- package/src/components/MessageAttachment/Pdf/PdfAttachment.stories.tsx +292 -0
- package/src/components/MessageAttachment/Pdf/index.tsx +228 -0
- package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +272 -0
- package/src/components/MessageAttachment/Video/index.tsx +281 -0
- package/src/components/MessageAttachment/_shared/Bubble.tsx +173 -0
- package/src/components/MessageAttachment/_shared/CompactDocumentRow.tsx +152 -0
- package/src/components/MessageAttachment/_shared/DismissButton.tsx +39 -0
- package/src/components/MessageAttachment/_shared/DownloadAction.tsx +175 -0
- package/src/components/MessageAttachment/_shared/ImageViewer.tsx +314 -0
- package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +139 -0
- package/src/components/MessageAttachment/_shared/PdfViewer.tsx +100 -0
- package/src/components/MessageAttachment/_shared/VideoViewer.tsx +171 -0
- package/src/components/MessageAttachment/_shared/ViewerShell.tsx +159 -0
- package/src/components/MessageAttachment/_shared/fileMeta.test.ts +82 -0
- package/src/components/MessageAttachment/_shared/fileMeta.ts +95 -0
- package/src/components/MessageAttachment/_shared/triggerDownload.ts +54 -0
- package/src/components/MessageAttachment/_shared/useViewer.ts +53 -0
- package/src/components/MessageAttachment/index.tsx +149 -0
- package/src/components/MessageAttachment/stories/StoryTable.tsx +72 -0
- package/src/components/MessageAttachment/types.ts +178 -0
- package/src/index.ts +32 -0
- package/dist/Card-D32U6KfZ.js +0 -85
- package/dist/Card-D32U6KfZ.js.map +0 -1
- package/dist/Card-DlSSJPip.js +0 -60
- package/dist/Card-DlSSJPip.js.map +0 -1
- package/dist/Card-zGbhRBwv.js +0 -48
- package/dist/Card-zGbhRBwv.js.map +0 -1
- package/dist/CardThumbnail-DTBuRQHF.js +0 -239
- package/dist/CardThumbnail-DTBuRQHF.js.map +0 -1
- package/dist/index-DfcRe-Hj.js +0 -3103
- 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)
|
|
284
|
-
* a CTA button (FAQ, Form). Each app is shown in both the
|
|
285
|
-
* (hero image) and **Classic** (compact, no hero) layouts
|
|
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
|
-
* - **
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
* "
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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 = {
|
|
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 "
|
|
8
|
-
*
|
|
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
|
|
40
|
-
* video / audio `mimeType`, the hero
|
|
41
|
-
* native
|
|
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
|
+
)
|