@linktr.ee/messaging-react 4.3.2 → 4.4.0-rc-1787598778
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/{AttachmentCard-Pc93Wcd0.js → AttachmentCard-BqwWgqvi.js} +83 -78
- package/dist/AttachmentCard-BqwWgqvi.js.map +1 -0
- package/dist/AttachmentCard-DUrgGRsq.cjs +2 -0
- package/dist/AttachmentCard-DUrgGRsq.cjs.map +1 -0
- package/dist/{Card-ByU9P9GA.cjs → Card-BIbqWagz.cjs} +2 -2
- package/dist/{Card-ByU9P9GA.cjs.map → Card-BIbqWagz.cjs.map} +1 -1
- package/dist/{Card-D__kW2E_.js → Card-f51K6V7Y.js} +20 -20
- package/dist/{Card-D__kW2E_.js.map → Card-f51K6V7Y.js.map} +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.js +1017 -969
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/AttachmentCard/MediaPlayer.tsx +2 -0
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +26 -0
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +31 -3
- package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +2 -0
- package/src/components/MessageAttachment/Audio/index.tsx +2 -0
- package/src/components/MessageAttachment/Image/index.tsx +11 -56
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +249 -0
- package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +163 -0
- package/src/components/MessageAttachment/Video/index.tsx +195 -36
- package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +27 -3
- package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +68 -1
- package/src/components/MessageAttachment/_shared/VideoViewer.tsx +86 -25
- package/src/components/MessageAttachment/_shared/useSingleMediaRatio.ts +75 -0
- package/src/components/MessageAttachment/types.ts +9 -0
- package/src/utils/nativeMediaPlayers.test.ts +55 -0
- package/src/utils/nativeMediaPlayers.ts +41 -0
- package/dist/AttachmentCard-DI2chsB_.cjs +0 -2
- package/dist/AttachmentCard-DI2chsB_.cjs.map +0 -1
- package/dist/AttachmentCard-Pc93Wcd0.js.map +0 -1
|
@@ -3,7 +3,11 @@ import React from 'react'
|
|
|
3
3
|
|
|
4
4
|
import Bubble from '../_shared/Bubble'
|
|
5
5
|
import DismissButton from '../_shared/DismissButton'
|
|
6
|
-
import MediaStackGrid, {
|
|
6
|
+
import MediaStackGrid, {
|
|
7
|
+
MEDIA_STACK_MAX_VISIBLE,
|
|
8
|
+
type MediaStackTile,
|
|
9
|
+
} from '../_shared/MediaStackGrid'
|
|
10
|
+
import { useSingleMediaRatio } from '../_shared/useSingleMediaRatio'
|
|
7
11
|
import { useViewer } from '../_shared/useViewer'
|
|
8
12
|
import VideoViewer, { type VideoViewerItem } from '../_shared/VideoViewer'
|
|
9
13
|
import {
|
|
@@ -19,6 +23,12 @@ export interface VideoAttachmentSharedProps extends MessageAttachmentBaseProps {
|
|
|
19
23
|
src?: string
|
|
20
24
|
/** Poster image — preview shown before playback starts. */
|
|
21
25
|
poster?: string
|
|
26
|
+
/**
|
|
27
|
+
* Width ÷ height of the source clip. See `VideoItem.naturalAspectRatio` —
|
|
28
|
+
* this is the single-`src` convenience form of the same thing, and is what
|
|
29
|
+
* lets a posterless portrait clip size itself correctly (MES-1353).
|
|
30
|
+
*/
|
|
31
|
+
naturalAspectRatio?: number
|
|
22
32
|
/** MIME type hint — typed onto the inline `<source>` element. */
|
|
23
33
|
mimeType?: string
|
|
24
34
|
/** Filename used as the viewer dialog's accessible name. */
|
|
@@ -51,9 +61,41 @@ export interface VideoAttachmentSharedProps extends MessageAttachmentBaseProps {
|
|
|
51
61
|
onClick?: (index: number) => boolean | void
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A single-video card is sized by its own clip rather than force-cropped
|
|
66
|
+
* square: Figma draws the same component at 16:9 (`2032:16189`) and 4:3
|
|
67
|
+
* (`2032:22660`).
|
|
68
|
+
*
|
|
69
|
+
* The ratio is taken from `VideoItem.naturalAspectRatio` when the sender
|
|
70
|
+
* recorded it, and only otherwise measured off the decoded poster. Measuring
|
|
71
|
+
* the poster alone was not enough: an uploaded clip may have no poster, and a
|
|
72
|
+
* portrait video then sat in whatever `FALLBACK_ASPECT` guessed. Reading the
|
|
73
|
+
* dimensions Stream already stores costs nothing at render time and needs no
|
|
74
|
+
* `loadedmetadata` probe, which would fetch video bytes for every card in the
|
|
75
|
+
* thread against the deliberate `preload: 'none'`.
|
|
76
|
+
*
|
|
77
|
+
* `FALLBACK_ASPECT` is **square** on purpose. It covers only the case where we
|
|
78
|
+
* know nothing — no recorded dimensions, no decoded poster — and a square box
|
|
79
|
+
* is what this card rendered before any of this existed, so an unknown clip
|
|
80
|
+
* can never look worse than it used to. Guessing 16:9 here put portrait clips
|
|
81
|
+
* in a letterbox, which is more wrong than a square (MES-1353).
|
|
82
|
+
*/
|
|
83
|
+
const FALLBACK_ASPECT = 1
|
|
84
|
+
/**
|
|
85
|
+
* Tallest permitted card — Instagram's portrait limit.
|
|
86
|
+
*
|
|
87
|
+
* A 9:16 clip (0.5625) clamps to this, so it renders 4:5 and the poster is
|
|
88
|
+
* cropped rather than shown whole. Figma specifies only 16:9 and 4:3, so true
|
|
89
|
+
* portrait was never drawn; relaxing this to `9 / 16` is a one-line change if
|
|
90
|
+
* design wants a full-height portrait card.
|
|
91
|
+
*/
|
|
92
|
+
const MIN_ASPECT = 4 / 5
|
|
93
|
+
/** Widest permitted card. */
|
|
94
|
+
const MAX_ASPECT = 16 / 9
|
|
95
|
+
|
|
54
96
|
const PlayBadge: React.FC = () => (
|
|
55
97
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
56
|
-
<span className="flex size-12 items-center justify-center rounded-full bg-black/
|
|
98
|
+
<span className="flex size-12 items-center justify-center rounded-full bg-black/25 text-white backdrop-blur-[20px]">
|
|
57
99
|
<PlayIcon className="size-6" weight="fill" aria-hidden />
|
|
58
100
|
</span>
|
|
59
101
|
</div>
|
|
@@ -65,40 +107,78 @@ const PlayBadge: React.FC = () => (
|
|
|
65
107
|
// its own. A `rounded-md` here would round all four corners of every
|
|
66
108
|
// tile at 6px, cutting dark wedges out of the `bg-[#0d0d0d]` backing
|
|
67
109
|
// at each interior grid junction.
|
|
68
|
-
const PosterTile: React.FC<{
|
|
69
|
-
item
|
|
70
|
-
index
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
110
|
+
const PosterTile: React.FC<{
|
|
111
|
+
item: VideoItem
|
|
112
|
+
index: number
|
|
113
|
+
/**
|
|
114
|
+
* Suppressed on the grid's `+N` overflow tile — Figma gives that tile
|
|
115
|
+
* the count scrim as its only child (`2032:16414`), and a play badge
|
|
116
|
+
* stacked underneath just muddies the number.
|
|
117
|
+
*/
|
|
118
|
+
showPlayBadge?: boolean
|
|
119
|
+
/**
|
|
120
|
+
* Set on the single-video tile only — see `useSingleMediaRatio`. Both
|
|
121
|
+
* are needed: the ref catches a poster the browser already had in
|
|
122
|
+
* cache, which never fires `load`.
|
|
123
|
+
*/
|
|
124
|
+
imgRef?: React.Ref<HTMLImageElement>
|
|
125
|
+
onLoad?: React.ReactEventHandler<HTMLImageElement>
|
|
126
|
+
}> = ({ item, index, showPlayBadge = true, imgRef, onLoad }) => {
|
|
127
|
+
return (
|
|
128
|
+
<div className="absolute inset-0 size-full bg-[#0d0d0d]">
|
|
129
|
+
{item.poster ? (
|
|
130
|
+
<img
|
|
131
|
+
ref={imgRef}
|
|
132
|
+
src={item.poster}
|
|
133
|
+
alt={`Video ${index + 1} thumbnail`}
|
|
134
|
+
draggable={false}
|
|
135
|
+
loading="lazy"
|
|
136
|
+
decoding="async"
|
|
137
|
+
onLoad={onLoad}
|
|
138
|
+
className="absolute inset-0 size-full object-cover"
|
|
88
139
|
/>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
140
|
+
) : (
|
|
141
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
142
|
+
<VideoCameraIcon
|
|
143
|
+
className="size-16 text-white/30"
|
|
144
|
+
weight="regular"
|
|
145
|
+
aria-hidden
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
{showPlayBadge ? <PlayBadge /> : null}
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
94
153
|
|
|
95
|
-
const tileFromItem = (
|
|
96
|
-
item
|
|
97
|
-
index
|
|
154
|
+
const tileFromItem = ({
|
|
155
|
+
item,
|
|
156
|
+
index,
|
|
157
|
+
totalCount,
|
|
158
|
+
isOverflowTile,
|
|
159
|
+
imgRef,
|
|
160
|
+
onLoad,
|
|
161
|
+
}: {
|
|
162
|
+
item: VideoItem
|
|
163
|
+
index: number
|
|
98
164
|
totalCount: number
|
|
99
|
-
|
|
165
|
+
isOverflowTile: boolean
|
|
166
|
+
imgRef?: React.Ref<HTMLImageElement>
|
|
167
|
+
onLoad?: React.ReactEventHandler<HTMLImageElement>
|
|
168
|
+
}): MediaStackTile => ({
|
|
100
169
|
ariaLabel: `Play video ${index + 1} of ${totalCount}`,
|
|
101
|
-
|
|
170
|
+
// Activating a video tile starts playback rather than magnifying a
|
|
171
|
+
// still, so the grid's default zoom cursor would be a lie.
|
|
172
|
+
cursorClassName: 'cursor-pointer',
|
|
173
|
+
content: (
|
|
174
|
+
<PosterTile
|
|
175
|
+
item={item}
|
|
176
|
+
index={index}
|
|
177
|
+
showPlayBadge={!isOverflowTile}
|
|
178
|
+
imgRef={imgRef}
|
|
179
|
+
onLoad={onLoad}
|
|
180
|
+
/>
|
|
181
|
+
),
|
|
102
182
|
})
|
|
103
183
|
|
|
104
184
|
const resolveItems = ({
|
|
@@ -106,12 +186,14 @@ const resolveItems = ({
|
|
|
106
186
|
poster,
|
|
107
187
|
mimeType,
|
|
108
188
|
preload,
|
|
189
|
+
naturalAspectRatio,
|
|
109
190
|
items,
|
|
110
191
|
}: {
|
|
111
192
|
src?: string
|
|
112
193
|
poster?: string
|
|
113
194
|
mimeType?: string
|
|
114
195
|
preload?: MediaPreloadMode
|
|
196
|
+
naturalAspectRatio?: number
|
|
115
197
|
items?: VideoItem[]
|
|
116
198
|
}): VideoItem[] => {
|
|
117
199
|
if (items && items.length > 0) {
|
|
@@ -119,7 +201,11 @@ const resolveItems = ({
|
|
|
119
201
|
? items.map((it) => ({ ...it, preload: it.preload ?? preload }))
|
|
120
202
|
: items
|
|
121
203
|
}
|
|
122
|
-
|
|
204
|
+
// `naturalAspectRatio` has to be carried onto the item here, not read off
|
|
205
|
+
// the props later: everything downstream sizes itself from `VideoItem`, so a
|
|
206
|
+
// ratio left behind on the single-`src` shape would be silently dropped and
|
|
207
|
+
// the card would fall back to measuring a poster it may not have.
|
|
208
|
+
if (src) return [{ src, poster, mimeType, preload, naturalAspectRatio }]
|
|
123
209
|
return []
|
|
124
210
|
}
|
|
125
211
|
|
|
@@ -211,19 +297,61 @@ const VideoBubbleRow: React.FC<InternalVideoRowProps> = ({
|
|
|
211
297
|
text,
|
|
212
298
|
groupPosition,
|
|
213
299
|
preload,
|
|
300
|
+
naturalAspectRatio,
|
|
214
301
|
onClick,
|
|
215
302
|
}) => {
|
|
216
|
-
const resolvedItems = resolveItems({
|
|
303
|
+
const resolvedItems = resolveItems({
|
|
304
|
+
src,
|
|
305
|
+
poster,
|
|
306
|
+
mimeType,
|
|
307
|
+
preload,
|
|
308
|
+
naturalAspectRatio,
|
|
309
|
+
items,
|
|
310
|
+
})
|
|
217
311
|
const variant = bubbleVariantForState(state)
|
|
218
312
|
const { viewerOpen, viewerIndex, handleActivate, closeViewer } =
|
|
219
313
|
useViewer(onClick)
|
|
314
|
+
const isSingle = resolvedItems.length === 1
|
|
315
|
+
const singleItem = isSingle ? resolvedItems[0] : undefined
|
|
316
|
+
// Recorded dimensions first — known on first paint and available even
|
|
317
|
+
// with no poster — then the decoded poster, then square. The hook
|
|
318
|
+
// measures from a ref as well as `onLoad`, so a poster the browser
|
|
319
|
+
// already had in cache still resolves; `onLoad` alone left the card
|
|
320
|
+
// stuck on the fallback.
|
|
321
|
+
const single = useSingleMediaRatio({
|
|
322
|
+
src: singleItem?.poster,
|
|
323
|
+
suppliedRatio: singleItem?.naturalAspectRatio,
|
|
324
|
+
fallbackRatio: FALLBACK_ASPECT,
|
|
325
|
+
})
|
|
220
326
|
|
|
221
327
|
if (resolvedItems.length === 0) {
|
|
222
328
|
return null
|
|
223
329
|
}
|
|
224
330
|
|
|
331
|
+
const overflowTileIndex =
|
|
332
|
+
resolvedItems.length > MEDIA_STACK_MAX_VISIBLE
|
|
333
|
+
? MEDIA_STACK_MAX_VISIBLE - 1
|
|
334
|
+
: -1
|
|
335
|
+
|
|
225
336
|
const tiles: MediaStackTile[] = resolvedItems.map((item, index) =>
|
|
226
|
-
tileFromItem(
|
|
337
|
+
tileFromItem({
|
|
338
|
+
item,
|
|
339
|
+
index,
|
|
340
|
+
totalCount: resolvedItems.length,
|
|
341
|
+
isOverflowTile: index === overflowTileIndex,
|
|
342
|
+
// Only the single card is aspect-driven — grid tiles are cropped
|
|
343
|
+
// to a fixed 2×2 geometry, so measuring them is noise.
|
|
344
|
+
imgRef: isSingle ? single.imgRef : undefined,
|
|
345
|
+
onLoad: isSingle ? single.onLoad : undefined,
|
|
346
|
+
})
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
// Clamped so a 9:16 clip can't render a 587px-tall card and a panorama
|
|
350
|
+
// can't collapse into a strip. Only consumed by the grid's 1-tile
|
|
351
|
+
// layout; ignored for 2+.
|
|
352
|
+
const singleAspectRatio = Math.min(
|
|
353
|
+
Math.max(single.ratio, MIN_ASPECT),
|
|
354
|
+
MAX_ASPECT
|
|
227
355
|
)
|
|
228
356
|
|
|
229
357
|
return (
|
|
@@ -231,10 +359,41 @@ const VideoBubbleRow: React.FC<InternalVideoRowProps> = ({
|
|
|
231
359
|
variant={variant}
|
|
232
360
|
text={text}
|
|
233
361
|
groupPosition={groupPosition}
|
|
362
|
+
// The card carries no stroke in Figma, and the default hairline
|
|
363
|
+
// would eat 2px of the inset: 330 − 2×1 − 2×2 = 324, not the 326
|
|
364
|
+
// the inner media measures (`2032:16189`).
|
|
365
|
+
bordered={false}
|
|
366
|
+
// The card hugs its media plus the 2px the design leaves around
|
|
367
|
+
// it, so the 20px outer radius resolves to 18px inside. Figma's
|
|
368
|
+
// single card is 330px (`1972:13149`) and its grid card 383px
|
|
369
|
+
// (`1973:13668`), but `MediaStackGrid` caps a single tile at
|
|
370
|
+
// 326px web / 309px mobile, so the single card tracks that fitted
|
|
371
|
+
// box via `w-fit` + a per-breakpoint maximum instead of pinning
|
|
372
|
+
// 330px and leaving a gutter below `sm`. Same treatment the image
|
|
373
|
+
// media card lands on.
|
|
374
|
+
widthClassName={
|
|
375
|
+
isSingle
|
|
376
|
+
? 'w-fit max-w-[313px] sm:max-w-[330px]'
|
|
377
|
+
: 'w-[383px] max-w-full'
|
|
378
|
+
}
|
|
379
|
+
paddingClassName="p-0.5"
|
|
380
|
+
// Figma draws the media card with a uniform 20px radius. Only
|
|
381
|
+
// standalone bubbles get it: inside a same-author run the 18/4px
|
|
382
|
+
// corner table still has to flatten the facing corners so
|
|
383
|
+
// adjacent media clusters read as one surface.
|
|
384
|
+
radiusClassName={
|
|
385
|
+
groupPosition && groupPosition !== 'single'
|
|
386
|
+
? undefined
|
|
387
|
+
: 'rounded-[20px]'
|
|
388
|
+
}
|
|
234
389
|
data-testid="video-attachment"
|
|
235
390
|
>
|
|
236
391
|
<div className="relative">
|
|
237
|
-
<MediaStackGrid
|
|
392
|
+
<MediaStackGrid
|
|
393
|
+
tiles={tiles}
|
|
394
|
+
onTileActivate={handleActivate}
|
|
395
|
+
singleAspectRatio={isSingle ? singleAspectRatio : undefined}
|
|
396
|
+
/>
|
|
238
397
|
</div>
|
|
239
398
|
|
|
240
399
|
<VideoViewer
|
|
@@ -6,8 +6,23 @@ export interface MediaStackTile {
|
|
|
6
6
|
content: React.ReactNode
|
|
7
7
|
/** Pure-tile aria label (e.g. `'Photo 1'`). Used by `Pressable` mode. */
|
|
8
8
|
ariaLabel?: string
|
|
9
|
+
/**
|
|
10
|
+
* Cursor utility for the pressable tile shell. Defaults to
|
|
11
|
+
* `'cursor-zoom-in'` (correct for images); Video passes
|
|
12
|
+
* `'cursor-pointer'` because activating a video tile starts playback
|
|
13
|
+
* rather than magnifying a still.
|
|
14
|
+
*/
|
|
15
|
+
cursorClassName?: string
|
|
9
16
|
}
|
|
10
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Tiles rendered before the last one collapses into a `+N` overflow
|
|
20
|
+
* indicator. Exported so callers that need to style the overflow tile
|
|
21
|
+
* differently can work out which index it lands on without restating
|
|
22
|
+
* the number.
|
|
23
|
+
*/
|
|
24
|
+
export const MEDIA_STACK_MAX_VISIBLE = 4
|
|
25
|
+
|
|
11
26
|
export interface MediaStackGridProps {
|
|
12
27
|
tiles: MediaStackTile[]
|
|
13
28
|
/**
|
|
@@ -109,7 +124,7 @@ const SINGLE_HEIGHT_CAP_PX = 436
|
|
|
109
124
|
const MediaStackGrid: React.FC<MediaStackGridProps> = ({
|
|
110
125
|
tiles,
|
|
111
126
|
onTileActivate,
|
|
112
|
-
maxVisible =
|
|
127
|
+
maxVisible = MEDIA_STACK_MAX_VISIBLE,
|
|
113
128
|
singleAspectRatio,
|
|
114
129
|
className,
|
|
115
130
|
}) => {
|
|
@@ -120,7 +135,13 @@ const MediaStackGrid: React.FC<MediaStackGridProps> = ({
|
|
|
120
135
|
const overflow = total - visible.length
|
|
121
136
|
const overflowBadge =
|
|
122
137
|
overflow > 0 ? (
|
|
123
|
-
|
|
138
|
+
// `aria-hidden` because the tile's own label already reads
|
|
139
|
+
// "… 4 of 9" — announcing "+5" on top of that just makes the
|
|
140
|
+
// user do the arithmetic twice.
|
|
141
|
+
<div
|
|
142
|
+
aria-hidden
|
|
143
|
+
className="absolute inset-0 flex items-center justify-center bg-[#040403]/50 text-2xl font-bold leading-[28px] tracking-[-0.24px] text-white"
|
|
144
|
+
>
|
|
124
145
|
+{overflow}
|
|
125
146
|
</div>
|
|
126
147
|
) : null
|
|
@@ -142,7 +163,10 @@ const MediaStackGrid: React.FC<MediaStackGridProps> = ({
|
|
|
142
163
|
key={index}
|
|
143
164
|
onClick={() => onTileActivate(index)}
|
|
144
165
|
aria-label={tile.ariaLabel ?? `Open media ${index + 1}`}
|
|
145
|
-
className={classNames(
|
|
166
|
+
className={classNames(
|
|
167
|
+
sharedClass,
|
|
168
|
+
tile.cursorClassName ?? 'cursor-zoom-in'
|
|
169
|
+
)}
|
|
146
170
|
>
|
|
147
171
|
{tile.content}
|
|
148
172
|
{extra}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
act,
|
|
5
|
+
fireEvent,
|
|
6
|
+
renderWithProviders,
|
|
7
|
+
screen,
|
|
8
|
+
} from '../../../test/utils'
|
|
4
9
|
|
|
5
10
|
import VideoViewer from './VideoViewer'
|
|
6
11
|
|
|
@@ -52,4 +57,66 @@ describe('VideoViewer', () => {
|
|
|
52
57
|
screen.getByTestId('video-viewer').querySelector('video')
|
|
53
58
|
).toHaveAttribute('preload', 'metadata')
|
|
54
59
|
})
|
|
60
|
+
|
|
61
|
+
it('pauses the toolkit\u2019s own native audio when it opens', () => {
|
|
62
|
+
// `useActiveAudioPlayer` only covers the players stream-chat-react
|
|
63
|
+
// mounts. `MessageAttachment.Audio` and the playable `LinkAttachment`
|
|
64
|
+
// card render plain `<audio>` elements that never reach that pool, so
|
|
65
|
+
// without the marker sweep thread audio kept sounding under the video.
|
|
66
|
+
const audio = document.createElement('audio')
|
|
67
|
+
audio.setAttribute('data-mes-native-player', '')
|
|
68
|
+
document.body.appendChild(audio)
|
|
69
|
+
const pause = vi.spyOn(audio, 'pause').mockImplementation(() => {})
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
renderWithProviders(
|
|
73
|
+
<VideoViewer open onClose={vi.fn()} items={[items[0]]} />
|
|
74
|
+
)
|
|
75
|
+
expect(pause).toHaveBeenCalledTimes(1)
|
|
76
|
+
} finally {
|
|
77
|
+
audio.remove()
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('keeps the frame mounted, paused, until the dialog finishes closing', () => {
|
|
82
|
+
vi.useFakeTimers()
|
|
83
|
+
try {
|
|
84
|
+
const { rerender } = renderWithProviders(
|
|
85
|
+
<VideoViewer open onClose={vi.fn()} items={[items[0]]} />
|
|
86
|
+
)
|
|
87
|
+
const video = screen.getByTestId('video-viewer').querySelector('video')
|
|
88
|
+
if (!video) throw new Error('Expected video element')
|
|
89
|
+
const pause = vi.spyOn(video, 'pause').mockImplementation(() => {})
|
|
90
|
+
|
|
91
|
+
rerender(
|
|
92
|
+
<VideoViewer open={false} onClose={vi.fn()} items={[items[0]]} />
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// Still there, so the last frame doesn't blink out from under the
|
|
96
|
+
// 150ms backdrop fade — but paused, so nothing plays under it.
|
|
97
|
+
expect(
|
|
98
|
+
screen.getByTestId('video-viewer').querySelector('video')
|
|
99
|
+
).not.toBeNull()
|
|
100
|
+
expect(pause).toHaveBeenCalled()
|
|
101
|
+
|
|
102
|
+
act(() => {
|
|
103
|
+
vi.advanceTimersByTime(150)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Gone once the dialog is: a `<video preload="metadata">` left in a
|
|
107
|
+
// closed viewer fetches a header per bubble in the thread.
|
|
108
|
+
expect(
|
|
109
|
+
screen.getByTestId('video-viewer').querySelector('video')
|
|
110
|
+
).toBeNull()
|
|
111
|
+
} finally {
|
|
112
|
+
vi.useRealTimers()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('mounts no video before the viewer has ever opened', () => {
|
|
117
|
+
renderWithProviders(
|
|
118
|
+
<VideoViewer open={false} onClose={vi.fn()} items={[items[0]]} />
|
|
119
|
+
)
|
|
120
|
+
expect(screen.getByTestId('video-viewer').querySelector('video')).toBeNull()
|
|
121
|
+
})
|
|
55
122
|
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import React, { useMemo } from 'react'
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { useActiveAudioPlayer } from 'stream-chat-react'
|
|
2
3
|
|
|
4
|
+
import { pauseNativeMediaPlayers } from '../../../utils/nativeMediaPlayers'
|
|
3
5
|
import type { MediaPreloadMode } from '../types'
|
|
4
6
|
|
|
5
7
|
import CarouselNav from './CarouselNav'
|
|
@@ -30,6 +32,14 @@ export interface VideoViewerProps {
|
|
|
30
32
|
onClose: () => void
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* How long the viewer's `<video>` survives a close, in ms. Matches
|
|
37
|
+
* `--transition-duration` on `.mes-media-viewer` in `styles.css` — the
|
|
38
|
+
* dialog and its backdrop fade over the same window, and the frame has
|
|
39
|
+
* to stay put until they are gone.
|
|
40
|
+
*/
|
|
41
|
+
const VIEWER_EXIT_TRANSITION_MS = 150
|
|
42
|
+
|
|
33
43
|
/**
|
|
34
44
|
* Full-viewport video viewer used by every `MessageAttachment.Video.*`
|
|
35
45
|
* variant. Renders a single `<video controls>` element with the
|
|
@@ -52,6 +62,47 @@ const VideoViewer: React.FC<VideoViewerProps> = ({
|
|
|
52
62
|
open,
|
|
53
63
|
})
|
|
54
64
|
|
|
65
|
+
const videoRef = useRef<HTMLVideoElement | null>(null)
|
|
66
|
+
|
|
67
|
+
// Opening the lightbox stops any audio still playing in the thread —
|
|
68
|
+
// two media surfaces should never sound at once. That takes both
|
|
69
|
+
// halves: stream-chat-react's `AudioPlayerPool` for the players it
|
|
70
|
+
// mounts (voice recordings), and `pauseNativeMediaPlayers` for the
|
|
71
|
+
// plain `<audio>` elements the toolkit renders itself, which never
|
|
72
|
+
// register with the pool. Outside a `<Channel>` (Storybook, profiles'
|
|
73
|
+
// mock chat) the context default makes the pool call a no-op, which
|
|
74
|
+
// is why the optional call is load-bearing.
|
|
75
|
+
const activeAudioPlayer = useActiveAudioPlayer()
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!open) return
|
|
78
|
+
activeAudioPlayer?.pause()
|
|
79
|
+
pauseNativeMediaPlayers(videoRef.current)
|
|
80
|
+
}, [open, activeAudioPlayer])
|
|
81
|
+
|
|
82
|
+
// The `<video>` outlives `open` by the length of the dialog's exit
|
|
83
|
+
// transition. `ViewerShell` keeps its `<dialog>` mounted so the
|
|
84
|
+
// platform close animation can play (`--transition-duration` in
|
|
85
|
+
// `styles.css`), and unmounting the element the moment `open` flips
|
|
86
|
+
// made the frame vanish while an empty overlay faded out. It is
|
|
87
|
+
// paused immediately instead, so nothing plays under the fade, and
|
|
88
|
+
// the element is gone once the dialog is — which is what keeps a
|
|
89
|
+
// closed viewer from preloading every clip in the thread.
|
|
90
|
+
const [mounted, setMounted] = useState(open)
|
|
91
|
+
// Adjusted during render rather than from an effect, so the element is
|
|
92
|
+
// in the very first committed tree of an open viewer — an effect would
|
|
93
|
+
// paint one empty frame first, and `react-hooks/set-state-in-effect`
|
|
94
|
+
// rejects it anyway.
|
|
95
|
+
if (open && !mounted) setMounted(true)
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (open || !mounted) return undefined
|
|
98
|
+
videoRef.current?.pause()
|
|
99
|
+
const timer = window.setTimeout(
|
|
100
|
+
() => setMounted(false),
|
|
101
|
+
VIEWER_EXIT_TRANSITION_MS
|
|
102
|
+
)
|
|
103
|
+
return () => window.clearTimeout(timer)
|
|
104
|
+
}, [open, mounted])
|
|
105
|
+
|
|
55
106
|
const item = items[index]
|
|
56
107
|
const filename = useMemo(
|
|
57
108
|
() => item?.filename ?? (item ? filenameFromUrl(item.src) : 'video'),
|
|
@@ -72,30 +123,40 @@ const VideoViewer: React.FC<VideoViewerProps> = ({
|
|
|
72
123
|
for chat attachments, and an empty `<track kind="captions" />`
|
|
73
124
|
emits a runtime warning. Re-add a real track (with `src`,
|
|
74
125
|
`srcLang`, and `default`) once caption support ships. */}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
126
|
+
{/* Mounted from the first open until the close transition ends —
|
|
127
|
+
see the effect above. A `<video preload="metadata">` sitting in
|
|
128
|
+
a never-opened (or already-closed) dialog fetches the clip's
|
|
129
|
+
header for every video bubble in the thread, which is exactly
|
|
130
|
+
what the poster-only bubble surface exists to avoid. */}
|
|
131
|
+
{mounted ? (
|
|
132
|
+
<video
|
|
133
|
+
ref={videoRef}
|
|
134
|
+
// Forcing a key swap on item change ensures the new source
|
|
135
|
+
// mounts a fresh element instead of reusing the previous
|
|
136
|
+
// playback state — keeps the video paused-at-start when
|
|
137
|
+
// navigating between stacked items.
|
|
138
|
+
key={`${index}:${item.src}`}
|
|
139
|
+
src={item.src}
|
|
140
|
+
poster={item.poster}
|
|
141
|
+
controls
|
|
142
|
+
// `autoPlay` without `muted` is blocked by Chrome / Safari /
|
|
143
|
+
// Firefox autoplay policies — the click that opens the viewer
|
|
144
|
+
// doesn't count as a user-gesture for the freshly-mounted
|
|
145
|
+
// `<video>` element. Defaulting to muted means playback
|
|
146
|
+
// actually starts (matches how IG / Slack handle their video
|
|
147
|
+
// lightboxes); the user can unmute via the native controls if
|
|
148
|
+
// they want sound.
|
|
149
|
+
autoPlay
|
|
150
|
+
muted
|
|
151
|
+
playsInline
|
|
152
|
+
preload={item.preload ?? 'metadata'}
|
|
153
|
+
className="block h-auto max-h-[calc(100dvh-128px)] w-auto max-w-[min(96vw,1400px)] bg-black"
|
|
154
|
+
>
|
|
155
|
+
{item.mimeType ? (
|
|
156
|
+
<source src={item.src} type={item.mimeType} />
|
|
157
|
+
) : null}
|
|
158
|
+
</video>
|
|
159
|
+
) : null}
|
|
99
160
|
|
|
100
161
|
{items.length > 1 ? (
|
|
101
162
|
<CarouselNav
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the aspect ratio (width ÷ height) of a single-media bubble,
|
|
5
|
+
* in order:
|
|
6
|
+
*
|
|
7
|
+
* 1. `suppliedRatio` — known at first paint, so the card never
|
|
8
|
+
* resizes. `ImageItem.width` / `height` for images, and for video
|
|
9
|
+
* `VideoItem.naturalAspectRatio`, taken from the dimensions Stream
|
|
10
|
+
* stores on the attachment.
|
|
11
|
+
* 2. the decoded `<img>`'s `naturalWidth` / `naturalHeight`;
|
|
12
|
+
* 3. `fallbackRatio`.
|
|
13
|
+
*
|
|
14
|
+
* Measurement reads both from `onLoad` **and** from the ref, because an
|
|
15
|
+
* image already in the browser cache can be `complete` before React
|
|
16
|
+
* attaches a `load` listener — a second card showing the same URL, a
|
|
17
|
+
* remount while scrolling a thread, or a Storybook HMR update all hit
|
|
18
|
+
* that path, and an `onLoad`-only hook silently keeps the fallback
|
|
19
|
+
* forever.
|
|
20
|
+
*
|
|
21
|
+
* Shared by the image and video cards: a video's poster is an `<img>`
|
|
22
|
+
* on the bubble surface, so it measures identically (MES-1353).
|
|
23
|
+
*/
|
|
24
|
+
export const useSingleMediaRatio = ({
|
|
25
|
+
src,
|
|
26
|
+
suppliedRatio,
|
|
27
|
+
fallbackRatio,
|
|
28
|
+
}: {
|
|
29
|
+
/** Media source, used to discard a measurement from a previous item. */
|
|
30
|
+
src: string | undefined
|
|
31
|
+
/** Ratio the caller already knows, if any. Wins over measuring. */
|
|
32
|
+
suppliedRatio?: number
|
|
33
|
+
/** Used until something better is known. */
|
|
34
|
+
fallbackRatio: number
|
|
35
|
+
}): {
|
|
36
|
+
ratio: number
|
|
37
|
+
imgRef: (img: HTMLImageElement | null) => void
|
|
38
|
+
onLoad: React.ReactEventHandler<HTMLImageElement>
|
|
39
|
+
} => {
|
|
40
|
+
const [measured, setMeasured] = React.useState<{
|
|
41
|
+
src: string
|
|
42
|
+
ratio: number
|
|
43
|
+
}>()
|
|
44
|
+
|
|
45
|
+
const measure = React.useCallback(
|
|
46
|
+
(img: HTMLImageElement | null) => {
|
|
47
|
+
if (!img || img.naturalWidth <= 0 || img.naturalHeight <= 0) return
|
|
48
|
+
if (src === undefined) return
|
|
49
|
+
const ratio = img.naturalWidth / img.naturalHeight
|
|
50
|
+
// Returning `prev` unchanged matters: the ref callback runs on
|
|
51
|
+
// every commit, and a fresh object each time would schedule an
|
|
52
|
+
// endless render loop.
|
|
53
|
+
setMeasured((prev) =>
|
|
54
|
+
prev && prev.src === src && prev.ratio === ratio ? prev : { src, ratio }
|
|
55
|
+
)
|
|
56
|
+
},
|
|
57
|
+
[src]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const handleLoad = React.useCallback(
|
|
61
|
+
(event: React.SyntheticEvent<HTMLImageElement>) =>
|
|
62
|
+
measure(event.currentTarget),
|
|
63
|
+
[measure]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// Discard a measurement left over from a previous `src`.
|
|
67
|
+
const measuredRatio =
|
|
68
|
+
measured && measured.src === src ? measured.ratio : undefined
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
ratio: suppliedRatio ?? measuredRatio ?? fallbackRatio,
|
|
72
|
+
imgRef: measure,
|
|
73
|
+
onLoad: handleLoad,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -99,6 +99,15 @@ export interface VideoItem {
|
|
|
99
99
|
src: string
|
|
100
100
|
/** Poster / thumbnail rendered before playback starts. */
|
|
101
101
|
poster?: string
|
|
102
|
+
/**
|
|
103
|
+
* Width ÷ height of the source clip, when the sender recorded it on the
|
|
104
|
+
* attachment (Stream's `original_width` / `original_height`). Preferred over
|
|
105
|
+
* measuring the poster, because it is known before any image loads and works
|
|
106
|
+
* for a clip that has no poster at all — the case that otherwise leaves a
|
|
107
|
+
* portrait video in a square or landscape box. Absent on anything sent
|
|
108
|
+
* before the composer began writing it (MES-1353).
|
|
109
|
+
*/
|
|
110
|
+
naturalAspectRatio?: number
|
|
102
111
|
/** Optional MIME type (e.g. `video/mp4`). */
|
|
103
112
|
mimeType?: string
|
|
104
113
|
/**
|