@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
|
@@ -0,0 +1,272 @@
|
|
|
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 { VideoItem } from '../types'
|
|
11
|
+
|
|
12
|
+
import VideoAttachment from '.'
|
|
13
|
+
|
|
14
|
+
// Sourced from `.storybook/public/` so we share the same assets used by
|
|
15
|
+
// the LockedAttachment stories.
|
|
16
|
+
const VIDEO_SRC = '/video-source.mp4'
|
|
17
|
+
const VIDEO_POSTER = '/video-thumbnail.jpg'
|
|
18
|
+
|
|
19
|
+
const STACK_VIDEOS: VideoItem[] = [
|
|
20
|
+
{
|
|
21
|
+
src: VIDEO_SRC,
|
|
22
|
+
poster: VIDEO_POSTER,
|
|
23
|
+
mimeType: 'video/mp4',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
src: VIDEO_SRC,
|
|
27
|
+
poster: VIDEO_POSTER,
|
|
28
|
+
mimeType: 'video/mp4',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
src: VIDEO_SRC,
|
|
32
|
+
poster: VIDEO_POSTER,
|
|
33
|
+
mimeType: 'video/mp4',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
src: VIDEO_SRC,
|
|
37
|
+
poster: VIDEO_POSTER,
|
|
38
|
+
mimeType: 'video/mp4',
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const meta: Meta = {
|
|
43
|
+
title: 'MessageAttachment/Video',
|
|
44
|
+
parameters: { layout: 'fullscreen' },
|
|
45
|
+
}
|
|
46
|
+
export default meta
|
|
47
|
+
|
|
48
|
+
const handleDismiss = () => {
|
|
49
|
+
// eslint-disable-next-line no-console
|
|
50
|
+
console.log('Dismissed video attachment')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Single video — the bubble shows the poster with a play overlay.
|
|
55
|
+
* Clicking opens the full-viewport `VideoViewer` with native controls
|
|
56
|
+
* (play / pause / seek / fullscreen / volume) and a download action.
|
|
57
|
+
*/
|
|
58
|
+
export const Single: StoryFn = () => (
|
|
59
|
+
<StoryPage>
|
|
60
|
+
<StoryHeading
|
|
61
|
+
title="Single video"
|
|
62
|
+
description="Click any bubble to open the full-screen video viewer with native controls and a download action."
|
|
63
|
+
/>
|
|
64
|
+
<StoryGrid>
|
|
65
|
+
<StoryRow
|
|
66
|
+
label="Default"
|
|
67
|
+
composer={
|
|
68
|
+
<VideoAttachment.Composer
|
|
69
|
+
src={VIDEO_SRC}
|
|
70
|
+
poster={VIDEO_POSTER}
|
|
71
|
+
mimeType="video/mp4"
|
|
72
|
+
filename="video-source.mp4"
|
|
73
|
+
onDismiss={handleDismiss}
|
|
74
|
+
/>
|
|
75
|
+
}
|
|
76
|
+
sent={
|
|
77
|
+
<VideoAttachment.Sent
|
|
78
|
+
src={VIDEO_SRC}
|
|
79
|
+
poster={VIDEO_POSTER}
|
|
80
|
+
mimeType="video/mp4"
|
|
81
|
+
filename="video-source.mp4"
|
|
82
|
+
/>
|
|
83
|
+
}
|
|
84
|
+
received={
|
|
85
|
+
<VideoAttachment.Received
|
|
86
|
+
src={VIDEO_SRC}
|
|
87
|
+
poster={VIDEO_POSTER}
|
|
88
|
+
mimeType="video/mp4"
|
|
89
|
+
filename="video-source.mp4"
|
|
90
|
+
/>
|
|
91
|
+
}
|
|
92
|
+
/>
|
|
93
|
+
<StoryRow
|
|
94
|
+
label="No poster"
|
|
95
|
+
composer={
|
|
96
|
+
<VideoAttachment.Composer
|
|
97
|
+
src={VIDEO_SRC}
|
|
98
|
+
mimeType="video/mp4"
|
|
99
|
+
filename="video-source.mp4"
|
|
100
|
+
onDismiss={handleDismiss}
|
|
101
|
+
/>
|
|
102
|
+
}
|
|
103
|
+
sent={
|
|
104
|
+
<VideoAttachment.Sent
|
|
105
|
+
src={VIDEO_SRC}
|
|
106
|
+
mimeType="video/mp4"
|
|
107
|
+
filename="video-source.mp4"
|
|
108
|
+
/>
|
|
109
|
+
}
|
|
110
|
+
received={
|
|
111
|
+
<VideoAttachment.Received
|
|
112
|
+
src={VIDEO_SRC}
|
|
113
|
+
mimeType="video/mp4"
|
|
114
|
+
filename="video-source.mp4"
|
|
115
|
+
/>
|
|
116
|
+
}
|
|
117
|
+
/>
|
|
118
|
+
</StoryGrid>
|
|
119
|
+
</StoryPage>
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
/** Single video with an accompanying caption. Sent + Received only —
|
|
123
|
+
* composer captions live in the message input, not the attachment
|
|
124
|
+
* preview. */
|
|
125
|
+
export const SingleWithText: StoryFn = () => (
|
|
126
|
+
<StoryPage>
|
|
127
|
+
<StoryHeading
|
|
128
|
+
title="Single video with caption"
|
|
129
|
+
description="Caption renders inside the same bubble, below the video poster. Composer column is intentionally blank — composer captions live in the message input."
|
|
130
|
+
/>
|
|
131
|
+
<StoryGrid>
|
|
132
|
+
<StoryRow
|
|
133
|
+
label="Short caption"
|
|
134
|
+
composer={
|
|
135
|
+
<VideoAttachment.Composer
|
|
136
|
+
src={VIDEO_SRC}
|
|
137
|
+
poster={VIDEO_POSTER}
|
|
138
|
+
mimeType="video/mp4"
|
|
139
|
+
filename="video-source.mp4"
|
|
140
|
+
onDismiss={handleDismiss}
|
|
141
|
+
/>
|
|
142
|
+
}
|
|
143
|
+
sent={
|
|
144
|
+
<VideoAttachment.Sent
|
|
145
|
+
src={VIDEO_SRC}
|
|
146
|
+
poster={VIDEO_POSTER}
|
|
147
|
+
mimeType="video/mp4"
|
|
148
|
+
filename="video-source.mp4"
|
|
149
|
+
text="Here is the video"
|
|
150
|
+
/>
|
|
151
|
+
}
|
|
152
|
+
received={
|
|
153
|
+
<VideoAttachment.Received
|
|
154
|
+
src={VIDEO_SRC}
|
|
155
|
+
poster={VIDEO_POSTER}
|
|
156
|
+
mimeType="video/mp4"
|
|
157
|
+
filename="video-source.mp4"
|
|
158
|
+
text="Here is the video"
|
|
159
|
+
/>
|
|
160
|
+
}
|
|
161
|
+
/>
|
|
162
|
+
</StoryGrid>
|
|
163
|
+
</StoryPage>
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
/** Stacked video attachments. Sent + Received only — the composer
|
|
167
|
+
* surface accepts a single video at a time. */
|
|
168
|
+
export const Stacked: StoryFn = () => (
|
|
169
|
+
<StoryPage>
|
|
170
|
+
<StoryHeading
|
|
171
|
+
title="Stacked videos"
|
|
172
|
+
description="Each tile shows its own poster + play overlay. Click any tile to open the viewer at that index. Composer column is intentionally blank — the composer accepts a single video at a time."
|
|
173
|
+
/>
|
|
174
|
+
<StoryGrid>
|
|
175
|
+
<StoryRow
|
|
176
|
+
label="2 clips"
|
|
177
|
+
composer={
|
|
178
|
+
<VideoAttachment.Composer
|
|
179
|
+
src={VIDEO_SRC}
|
|
180
|
+
poster={VIDEO_POSTER}
|
|
181
|
+
mimeType="video/mp4"
|
|
182
|
+
filename="video-source.mp4"
|
|
183
|
+
onDismiss={handleDismiss}
|
|
184
|
+
/>
|
|
185
|
+
}
|
|
186
|
+
sent={
|
|
187
|
+
<VideoAttachment.Sent items={STACK_VIDEOS.slice(0, 2)} filename="clips" />
|
|
188
|
+
}
|
|
189
|
+
received={
|
|
190
|
+
<VideoAttachment.Received
|
|
191
|
+
items={STACK_VIDEOS.slice(0, 2)}
|
|
192
|
+
filename="clips"
|
|
193
|
+
/>
|
|
194
|
+
}
|
|
195
|
+
/>
|
|
196
|
+
<StoryRow
|
|
197
|
+
label="3 clips"
|
|
198
|
+
composer={
|
|
199
|
+
<VideoAttachment.Composer
|
|
200
|
+
src={VIDEO_SRC}
|
|
201
|
+
poster={VIDEO_POSTER}
|
|
202
|
+
mimeType="video/mp4"
|
|
203
|
+
filename="video-source.mp4"
|
|
204
|
+
onDismiss={handleDismiss}
|
|
205
|
+
/>
|
|
206
|
+
}
|
|
207
|
+
sent={
|
|
208
|
+
<VideoAttachment.Sent items={STACK_VIDEOS.slice(0, 3)} filename="clips" />
|
|
209
|
+
}
|
|
210
|
+
received={
|
|
211
|
+
<VideoAttachment.Received
|
|
212
|
+
items={STACK_VIDEOS.slice(0, 3)}
|
|
213
|
+
filename="clips"
|
|
214
|
+
/>
|
|
215
|
+
}
|
|
216
|
+
/>
|
|
217
|
+
<StoryRow
|
|
218
|
+
label="4 clips"
|
|
219
|
+
composer={
|
|
220
|
+
<VideoAttachment.Composer
|
|
221
|
+
src={VIDEO_SRC}
|
|
222
|
+
poster={VIDEO_POSTER}
|
|
223
|
+
mimeType="video/mp4"
|
|
224
|
+
filename="video-source.mp4"
|
|
225
|
+
onDismiss={handleDismiss}
|
|
226
|
+
/>
|
|
227
|
+
}
|
|
228
|
+
sent={<VideoAttachment.Sent items={STACK_VIDEOS} filename="clips" />}
|
|
229
|
+
received={<VideoAttachment.Received items={STACK_VIDEOS} filename="clips" />}
|
|
230
|
+
/>
|
|
231
|
+
</StoryGrid>
|
|
232
|
+
</StoryPage>
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
/** Stacked videos with an accompanying caption.
|
|
236
|
+
* Sent + Received only — see `Stacked` for the rationale. */
|
|
237
|
+
export const StackedWithText: StoryFn = () => (
|
|
238
|
+
<StoryPage>
|
|
239
|
+
<StoryHeading
|
|
240
|
+
title="Stacked videos with caption"
|
|
241
|
+
description="Caption sits inside the same bubble, below the stack. Composer column is intentionally blank — the composer accepts a single video at a time."
|
|
242
|
+
/>
|
|
243
|
+
<StoryGrid>
|
|
244
|
+
<StoryRow
|
|
245
|
+
label="3 clips + caption"
|
|
246
|
+
composer={
|
|
247
|
+
<VideoAttachment.Composer
|
|
248
|
+
src={VIDEO_SRC}
|
|
249
|
+
poster={VIDEO_POSTER}
|
|
250
|
+
mimeType="video/mp4"
|
|
251
|
+
filename="video-source.mp4"
|
|
252
|
+
onDismiss={handleDismiss}
|
|
253
|
+
/>
|
|
254
|
+
}
|
|
255
|
+
sent={
|
|
256
|
+
<VideoAttachment.Sent
|
|
257
|
+
items={STACK_VIDEOS.slice(0, 3)}
|
|
258
|
+
filename="clips"
|
|
259
|
+
text="Three takes — pick your favorite"
|
|
260
|
+
/>
|
|
261
|
+
}
|
|
262
|
+
received={
|
|
263
|
+
<VideoAttachment.Received
|
|
264
|
+
items={STACK_VIDEOS.slice(0, 3)}
|
|
265
|
+
filename="clips"
|
|
266
|
+
text="Three takes — pick your favorite"
|
|
267
|
+
/>
|
|
268
|
+
}
|
|
269
|
+
/>
|
|
270
|
+
</StoryGrid>
|
|
271
|
+
</StoryPage>
|
|
272
|
+
)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { PlayIcon, VideoCameraIcon } from '@phosphor-icons/react'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import Bubble from '../_shared/Bubble'
|
|
5
|
+
import DismissButton from '../_shared/DismissButton'
|
|
6
|
+
import MediaStackGrid, {
|
|
7
|
+
type MediaStackTile,
|
|
8
|
+
} from '../_shared/MediaStackGrid'
|
|
9
|
+
import { useViewer } from '../_shared/useViewer'
|
|
10
|
+
import VideoViewer, { type VideoViewerItem } from '../_shared/VideoViewer'
|
|
11
|
+
import {
|
|
12
|
+
bubbleVariantForState,
|
|
13
|
+
type MediaPreloadMode,
|
|
14
|
+
type MessageAttachmentBaseProps,
|
|
15
|
+
type MessageAttachmentState,
|
|
16
|
+
type VideoItem,
|
|
17
|
+
} from '../types'
|
|
18
|
+
|
|
19
|
+
export interface VideoAttachmentSharedProps extends MessageAttachmentBaseProps {
|
|
20
|
+
/** Single video — convenience for the most common case. */
|
|
21
|
+
src?: string
|
|
22
|
+
/** Poster image — preview shown before playback starts. */
|
|
23
|
+
poster?: string
|
|
24
|
+
/** MIME type hint — typed onto the inline `<source>` element. */
|
|
25
|
+
mimeType?: string
|
|
26
|
+
/** Filename surfaced in the viewer toolbar + download default name. */
|
|
27
|
+
filename?: string
|
|
28
|
+
/**
|
|
29
|
+
* Stacked videos. Takes precedence over `src` when set. Renders a
|
|
30
|
+
* 1 / 2 / 3 / 4-tile grid (5+ collapse into a `+N` overflow tile).
|
|
31
|
+
* Sent + Received only — the composer surface intentionally accepts
|
|
32
|
+
* a single attachment at a time.
|
|
33
|
+
*/
|
|
34
|
+
items?: VideoItem[]
|
|
35
|
+
/**
|
|
36
|
+
* `<video preload>` hint forwarded into the viewer. Defaults to
|
|
37
|
+
* `'none'` — the poster `<img>` carries the visual weight on the
|
|
38
|
+
* bubble surface, and we shouldn't fetch any video bytes until the
|
|
39
|
+
* user actually opens the viewer. Per-item overrides live on
|
|
40
|
+
* `VideoItem.preload`. The opened `VideoViewer` always preloads
|
|
41
|
+
* metadata for the active item (so duration / first-frame appear
|
|
42
|
+
* immediately) regardless of this value.
|
|
43
|
+
*/
|
|
44
|
+
preload?: MediaPreloadMode
|
|
45
|
+
/**
|
|
46
|
+
* Forwarded to the viewer trigger. When omitted the click still
|
|
47
|
+
* opens the built-in `VideoViewer` — supply this for analytics, or
|
|
48
|
+
* return `false` to intercept the open (e.g. switch to a route-
|
|
49
|
+
* based player / confirmation modal). Any other return value
|
|
50
|
+
* (including `void`/`undefined`) lets the built-in viewer open.
|
|
51
|
+
* For stacked attachments the `index` argument identifies the tile.
|
|
52
|
+
*/
|
|
53
|
+
onClick?: (index: number) => boolean | void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PlayBadge: React.FC = () => (
|
|
57
|
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
58
|
+
<span className="flex size-12 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur">
|
|
59
|
+
<PlayIcon className="size-6" weight="fill" aria-hidden />
|
|
60
|
+
</span>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const PosterTile: React.FC<{ item: VideoItem; index: number }> = ({
|
|
65
|
+
item,
|
|
66
|
+
index,
|
|
67
|
+
}) => (
|
|
68
|
+
<div className="absolute inset-0 size-full bg-[#0d0d0d]">
|
|
69
|
+
{item.poster ? (
|
|
70
|
+
<img
|
|
71
|
+
src={item.poster}
|
|
72
|
+
alt={`Video ${index + 1} thumbnail`}
|
|
73
|
+
draggable={false}
|
|
74
|
+
loading="lazy"
|
|
75
|
+
decoding="async"
|
|
76
|
+
className="absolute inset-0 size-full rounded-md object-cover"
|
|
77
|
+
/>
|
|
78
|
+
) : (
|
|
79
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
80
|
+
<VideoCameraIcon
|
|
81
|
+
className="size-16 rounded-md text-white/30"
|
|
82
|
+
weight="regular"
|
|
83
|
+
aria-hidden
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
<PlayBadge />
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const tileFromItem = (
|
|
92
|
+
item: VideoItem,
|
|
93
|
+
index: number,
|
|
94
|
+
totalCount: number
|
|
95
|
+
): MediaStackTile => ({
|
|
96
|
+
ariaLabel: `Play video ${index + 1} of ${totalCount}`,
|
|
97
|
+
content: <PosterTile item={item} index={index} />,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const resolveItems = ({
|
|
101
|
+
src,
|
|
102
|
+
poster,
|
|
103
|
+
mimeType,
|
|
104
|
+
preload,
|
|
105
|
+
items,
|
|
106
|
+
}: {
|
|
107
|
+
src?: string
|
|
108
|
+
poster?: string
|
|
109
|
+
mimeType?: string
|
|
110
|
+
preload?: MediaPreloadMode
|
|
111
|
+
items?: VideoItem[]
|
|
112
|
+
}): VideoItem[] => {
|
|
113
|
+
if (items && items.length > 0) {
|
|
114
|
+
return preload ? items.map((it) => ({ ...it, preload: it.preload ?? preload })) : items
|
|
115
|
+
}
|
|
116
|
+
if (src) return [{ src, poster, mimeType, preload }]
|
|
117
|
+
return []
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface InternalVideoRowProps extends VideoAttachmentSharedProps {
|
|
121
|
+
state: MessageAttachmentState
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const buildViewerItems = (
|
|
125
|
+
resolvedItems: VideoItem[],
|
|
126
|
+
filename?: string
|
|
127
|
+
): VideoViewerItem[] =>
|
|
128
|
+
resolvedItems.map((item, index) => ({
|
|
129
|
+
src: item.src,
|
|
130
|
+
poster: item.poster,
|
|
131
|
+
mimeType: item.mimeType,
|
|
132
|
+
preload: item.preload,
|
|
133
|
+
filename:
|
|
134
|
+
filename && resolvedItems.length === 1
|
|
135
|
+
? filename
|
|
136
|
+
: filename
|
|
137
|
+
? `${filename} (${index + 1})`
|
|
138
|
+
: undefined,
|
|
139
|
+
}))
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Composer rendering — bare 280px-square poster with a play badge,
|
|
143
|
+
* a dismiss `×` overlay, and `rounded-md` corners. Renders without the
|
|
144
|
+
* shared `Bubble` chrome (no border / no background / no padding) so
|
|
145
|
+
* the in-progress attachment looks like a draft preview rather than a
|
|
146
|
+
* sent message. Single attachment only — `items` and `text` are not
|
|
147
|
+
* supported on the composer surface.
|
|
148
|
+
*/
|
|
149
|
+
const VideoComposerInner: React.FC<{
|
|
150
|
+
src: string
|
|
151
|
+
poster?: string
|
|
152
|
+
mimeType?: string
|
|
153
|
+
filename?: string
|
|
154
|
+
preload?: MediaPreloadMode
|
|
155
|
+
onClick?: (index: number) => boolean | void
|
|
156
|
+
onDismiss?: () => void
|
|
157
|
+
}> = ({ src, poster, mimeType, filename, preload, onClick, onDismiss }) => {
|
|
158
|
+
const { viewerOpen, viewerIndex, handleActivate, closeViewer } = useViewer(
|
|
159
|
+
onClick
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className="relative w-fit">
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
onClick={() => handleActivate(0)}
|
|
167
|
+
aria-label="Play video"
|
|
168
|
+
className="relative block size-[280px] cursor-pointer overflow-hidden rounded-md outline-none focus-visible:ring-2 focus-visible:ring-black/40"
|
|
169
|
+
>
|
|
170
|
+
<PosterTile item={{ src, poster, mimeType }} index={0} />
|
|
171
|
+
</button>
|
|
172
|
+
{onDismiss ? (
|
|
173
|
+
<div className="absolute right-2 top-2 z-10">
|
|
174
|
+
<DismissButton onClick={onDismiss} />
|
|
175
|
+
</div>
|
|
176
|
+
) : null}
|
|
177
|
+
|
|
178
|
+
<VideoViewer
|
|
179
|
+
open={viewerOpen}
|
|
180
|
+
items={buildViewerItems([{ src, poster, mimeType, preload }], filename)}
|
|
181
|
+
initialIndex={viewerIndex}
|
|
182
|
+
onClose={closeViewer}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Sent / Received rendering — wrapped in the shared `Bubble` chrome,
|
|
190
|
+
* supports single or stacked items, and renders an optional caption
|
|
191
|
+
* below the media.
|
|
192
|
+
*/
|
|
193
|
+
const VideoBubbleRow: React.FC<InternalVideoRowProps> = ({
|
|
194
|
+
state,
|
|
195
|
+
src,
|
|
196
|
+
poster,
|
|
197
|
+
mimeType,
|
|
198
|
+
filename,
|
|
199
|
+
items,
|
|
200
|
+
text,
|
|
201
|
+
groupPosition,
|
|
202
|
+
preload,
|
|
203
|
+
onClick,
|
|
204
|
+
}) => {
|
|
205
|
+
const resolvedItems = resolveItems({ src, poster, mimeType, preload, items })
|
|
206
|
+
const variant = bubbleVariantForState(state)
|
|
207
|
+
const { viewerOpen, viewerIndex, handleActivate, closeViewer } = useViewer(
|
|
208
|
+
onClick
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if (resolvedItems.length === 0) {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const tiles: MediaStackTile[] = resolvedItems.map((item, index) =>
|
|
216
|
+
tileFromItem(item, index, resolvedItems.length)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<Bubble
|
|
221
|
+
variant={variant}
|
|
222
|
+
text={text}
|
|
223
|
+
groupPosition={groupPosition}
|
|
224
|
+
data-testid="video-attachment"
|
|
225
|
+
>
|
|
226
|
+
<div className="relative">
|
|
227
|
+
<MediaStackGrid
|
|
228
|
+
tiles={tiles}
|
|
229
|
+
onTileActivate={handleActivate}
|
|
230
|
+
className="overflow-hidden rounded-md"
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<VideoViewer
|
|
235
|
+
open={viewerOpen}
|
|
236
|
+
items={buildViewerItems(resolvedItems, filename)}
|
|
237
|
+
initialIndex={viewerIndex}
|
|
238
|
+
onClose={closeViewer}
|
|
239
|
+
/>
|
|
240
|
+
</Bubble>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Composer-only props. Single video (`src`) is required; stacked
|
|
246
|
+
* `items` and `text` captions are not supported in the composer state.
|
|
247
|
+
*/
|
|
248
|
+
export interface VideoComposerProps {
|
|
249
|
+
src: string
|
|
250
|
+
poster?: string
|
|
251
|
+
mimeType?: string
|
|
252
|
+
filename?: string
|
|
253
|
+
/**
|
|
254
|
+
* `<video preload>` hint forwarded into the viewer. Defaults to
|
|
255
|
+
* `'none'`. See `VideoAttachmentSharedProps.preload`.
|
|
256
|
+
*/
|
|
257
|
+
preload?: MediaPreloadMode
|
|
258
|
+
onClick?: (index: number) => boolean | void
|
|
259
|
+
onDismiss?: () => void
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export type VideoSentProps = VideoAttachmentSharedProps
|
|
263
|
+
export type VideoReceivedProps = VideoAttachmentSharedProps
|
|
264
|
+
|
|
265
|
+
const VideoComposer: React.FC<VideoComposerProps> = (props) => (
|
|
266
|
+
<VideoComposerInner {...props} />
|
|
267
|
+
)
|
|
268
|
+
const VideoSent: React.FC<VideoSentProps> = (props) => (
|
|
269
|
+
<VideoBubbleRow {...props} state="sent" />
|
|
270
|
+
)
|
|
271
|
+
const VideoReceived: React.FC<VideoReceivedProps> = (props) => (
|
|
272
|
+
<VideoBubbleRow {...props} state="received" />
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
const VideoAttachment = {
|
|
276
|
+
Composer: VideoComposer,
|
|
277
|
+
Sent: VideoSent,
|
|
278
|
+
Received: VideoReceived,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default VideoAttachment
|