@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linktr.ee/messaging-react",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0-rc-1787598778",
|
|
4
4
|
"description": "React messaging components built on messaging-core for web applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"chromatic:local": "yarn storybook:build && chromatic"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@linktr.ee/messaging-core": "
|
|
54
|
-
"@linktr.ee/messaging-taxonomy": "
|
|
53
|
+
"@linktr.ee/messaging-core": "2.6.0-rc-1787598778",
|
|
54
|
+
"@linktr.ee/messaging-taxonomy": "0.11.0-rc-1787598778",
|
|
55
55
|
"@phosphor-icons/react": "^2.1.10"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
@@ -2,6 +2,7 @@ import { CircleNotchIcon, PauseIcon, PlayIcon } from '@phosphor-icons/react'
|
|
|
2
2
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
3
|
|
|
4
4
|
import { isDevBuild } from '../../utils/isDevBuild'
|
|
5
|
+
import { nativePlayerProps } from '../../utils/nativeMediaPlayers'
|
|
5
6
|
|
|
6
7
|
import { renderTypeIcon } from './utils/icons'
|
|
7
8
|
import { getSourceType } from './utils/mimeType'
|
|
@@ -235,6 +236,7 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
|
|
|
235
236
|
<div className="absolute inset-0">
|
|
236
237
|
{sourceType === 'audio' ? (
|
|
237
238
|
<audio
|
|
239
|
+
{...nativePlayerProps}
|
|
238
240
|
ref={playerRef as React.RefObject<HTMLAudioElement>}
|
|
239
241
|
src={source}
|
|
240
242
|
loop={loop}
|
|
@@ -184,6 +184,32 @@ describe('StreamAttachmentMessage', () => {
|
|
|
184
184
|
)
|
|
185
185
|
})
|
|
186
186
|
|
|
187
|
+
it('optimizes Linktree-hosted video posters for the channel bubble', () => {
|
|
188
|
+
renderWithProviders(
|
|
189
|
+
<StreamAttachmentMessage
|
|
190
|
+
groupPosition="single"
|
|
191
|
+
isMyMessage
|
|
192
|
+
message={message([
|
|
193
|
+
{
|
|
194
|
+
type: 'video',
|
|
195
|
+
asset_url: 'https://ugc.production.linktr.ee/clip.mp4',
|
|
196
|
+
thumb_url: 'https://ugc.production.linktr.ee/poster.jpg',
|
|
197
|
+
mime_type: 'video/mp4',
|
|
198
|
+
},
|
|
199
|
+
])}
|
|
200
|
+
/>
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
// Video posters were the last media URL leaving here at full size —
|
|
204
|
+
// four of them per 2×2 grid.
|
|
205
|
+
expect(
|
|
206
|
+
screen.getByTestId('video-attachment').querySelector('img')
|
|
207
|
+
).toHaveAttribute(
|
|
208
|
+
'src',
|
|
209
|
+
'https://ugc.production.linktr.ee/poster.jpg?io=true&size=messaging-attachment-v1_0'
|
|
210
|
+
)
|
|
211
|
+
})
|
|
212
|
+
|
|
187
213
|
it('keeps the original image source in the viewer for channel image attachments', () => {
|
|
188
214
|
renderWithProviders(
|
|
189
215
|
<StreamAttachmentMessage
|
|
@@ -27,6 +27,28 @@ function trimToUndefined(value?: string | null): string | undefined {
|
|
|
27
27
|
return trimmed ? trimmed : undefined
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Width ÷ height of the source media, from the dimensions Stream stores on the
|
|
32
|
+
* attachment, or `undefined` when they are absent or unusable.
|
|
33
|
+
*
|
|
34
|
+
* These are what let a single video card know its shape on first paint. The
|
|
35
|
+
* alternative — probing `loadedmetadata` — would fetch video bytes for every
|
|
36
|
+
* card in a thread, against the deliberate `preload: 'none'`, and a poster is
|
|
37
|
+
* not a substitute because an uploaded clip may not have one.
|
|
38
|
+
*
|
|
39
|
+
* Both fields are optional on Stream's `Attachment` and are absent on anything
|
|
40
|
+
* sent before the composer began writing them, so every caller must still cope
|
|
41
|
+
* with `undefined` rather than assuming a ratio (MES-1353).
|
|
42
|
+
*/
|
|
43
|
+
function aspectRatioFrom(attachment: Attachment): number | undefined {
|
|
44
|
+
const { original_width: width, original_height: height } = attachment
|
|
45
|
+
if (typeof width !== 'number' || typeof height !== 'number') return undefined
|
|
46
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) return undefined
|
|
47
|
+
if (width <= 0 || height <= 0) return undefined
|
|
48
|
+
|
|
49
|
+
return width / height
|
|
50
|
+
}
|
|
51
|
+
|
|
30
52
|
function normalizeLinkUrl(url: string): string {
|
|
31
53
|
return url
|
|
32
54
|
.trim()
|
|
@@ -209,10 +231,16 @@ function mediaItemForKind(
|
|
|
209
231
|
case 'video':
|
|
210
232
|
return {
|
|
211
233
|
src: trimToUndefined(attachment.asset_url) ?? '',
|
|
212
|
-
poster:
|
|
213
|
-
(attachment as { thumb_url?: string }).thumb_url
|
|
214
|
-
),
|
|
234
|
+
poster: optimizeMessagingAttachmentUrl(attachment.thumb_url),
|
|
215
235
|
mimeType: trimToUndefined(attachment.mime_type),
|
|
236
|
+
// Stream carries the source dimensions and the composer writes them
|
|
237
|
+
// at send time, so a card knows its shape on first paint — no poster
|
|
238
|
+
// needed and no `loadedmetadata` probe, which would mean fetching
|
|
239
|
+
// video bytes for every card in the thread against the deliberate
|
|
240
|
+
// `preload: 'none'`. Same trick the audio card uses for `duration` /
|
|
241
|
+
// `waveform_data`. This builder serves both the single and clustered
|
|
242
|
+
// paths, so the grid tiles get the ratio here too (MES-1353).
|
|
243
|
+
naturalAspectRatio: aspectRatioFrom(attachment),
|
|
216
244
|
}
|
|
217
245
|
case 'audio':
|
|
218
246
|
return {
|
|
@@ -2,6 +2,7 @@ import classNames from 'classnames'
|
|
|
2
2
|
import React from 'react'
|
|
3
3
|
|
|
4
4
|
import { optimizeMessagingAttachmentUrl } from '../../../../utils/cdnImageUrl'
|
|
5
|
+
import { nativePlayerProps } from '../../../../utils/nativeMediaPlayers'
|
|
5
6
|
import { renderTypeIcon } from '../../../AttachmentCard'
|
|
6
7
|
import { getSourceType } from '../../../AttachmentCard/utils/mimeType'
|
|
7
8
|
|
|
@@ -108,6 +109,7 @@ const CardThumbnail: React.FC<CardThumbnailProps> = ({
|
|
|
108
109
|
return (
|
|
109
110
|
<div className="p-3">
|
|
110
111
|
<audio
|
|
112
|
+
{...nativePlayerProps}
|
|
111
113
|
src={sourceUrl}
|
|
112
114
|
controls
|
|
113
115
|
preload="metadata"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
|
|
3
|
+
import { nativePlayerProps } from '../../../utils/nativeMediaPlayers'
|
|
3
4
|
import Bubble from '../_shared/Bubble'
|
|
4
5
|
import DismissButton from '../_shared/DismissButton'
|
|
5
6
|
import {
|
|
@@ -77,6 +78,7 @@ const NativeAudioPlayer: React.FC<{
|
|
|
77
78
|
same reason. */}
|
|
78
79
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
|
79
80
|
<audio
|
|
81
|
+
{...nativePlayerProps}
|
|
80
82
|
src={item.src}
|
|
81
83
|
controls
|
|
82
84
|
preload={item.preload ?? preload}
|
|
@@ -5,6 +5,7 @@ import Bubble from '../_shared/Bubble'
|
|
|
5
5
|
import DismissButton from '../_shared/DismissButton'
|
|
6
6
|
import ImageViewer, { type ImageViewerItem } from '../_shared/ImageViewer'
|
|
7
7
|
import MediaStackGrid, { type MediaStackTile } from '../_shared/MediaStackGrid'
|
|
8
|
+
import { useSingleMediaRatio } from '../_shared/useSingleMediaRatio'
|
|
8
9
|
import { useViewer } from '../_shared/useViewer'
|
|
9
10
|
import {
|
|
10
11
|
bubbleVariantForState,
|
|
@@ -61,7 +62,7 @@ const tileFromItem = ({
|
|
|
61
62
|
index: number
|
|
62
63
|
totalCount: number
|
|
63
64
|
fallbackLoading: ImageLoadingMode
|
|
64
|
-
/** Set on the single-image tile only — see `
|
|
65
|
+
/** Set on the single-image tile only — see `useSingleMediaRatio`. */
|
|
65
66
|
imgRef?: React.Ref<HTMLImageElement>
|
|
66
67
|
onLoad?: React.ReactEventHandler<HTMLImageElement>
|
|
67
68
|
}): MediaStackTile => ({
|
|
@@ -191,60 +192,6 @@ const ImageComposerInner: React.FC<{
|
|
|
191
192
|
*/
|
|
192
193
|
const RESERVED_SINGLE_ASPECT_RATIO = 326 / 246
|
|
193
194
|
|
|
194
|
-
/**
|
|
195
|
-
* Resolves the aspect ratio of a single-image bubble, in order:
|
|
196
|
-
*
|
|
197
|
-
* 1. caller-supplied `ImageItem.width` / `height` — ratio known at
|
|
198
|
-
* first paint, so the card never resizes;
|
|
199
|
-
* 2. the decoded `<img>`'s `naturalWidth` / `naturalHeight`;
|
|
200
|
-
* 3. `RESERVED_SINGLE_ASPECT_RATIO`.
|
|
201
|
-
*
|
|
202
|
-
* Nothing propagates dimensions through the Stream path today, so (2)
|
|
203
|
-
* is the branch that runs in production. It reads both on `load` and
|
|
204
|
-
* from the ref, because an image already in the browser cache can be
|
|
205
|
-
* `complete` before React attaches a `load` listener.
|
|
206
|
-
*/
|
|
207
|
-
const useSingleImageRatio = (item: ImageItem | undefined) => {
|
|
208
|
-
const src = item?.src
|
|
209
|
-
const [measured, setMeasured] = React.useState<{
|
|
210
|
-
src: string
|
|
211
|
-
ratio: number
|
|
212
|
-
}>()
|
|
213
|
-
|
|
214
|
-
const measure = React.useCallback(
|
|
215
|
-
(img: HTMLImageElement | null) => {
|
|
216
|
-
if (!img || img.naturalWidth <= 0 || img.naturalHeight <= 0) return
|
|
217
|
-
if (src === undefined) return
|
|
218
|
-
const ratio = img.naturalWidth / img.naturalHeight
|
|
219
|
-
// Returning `prev` unchanged matters: the ref callback runs on
|
|
220
|
-
// every commit, and a fresh object each time would schedule an
|
|
221
|
-
// endless render loop.
|
|
222
|
-
setMeasured((prev) =>
|
|
223
|
-
prev && prev.src === src && prev.ratio === ratio ? prev : { src, ratio }
|
|
224
|
-
)
|
|
225
|
-
},
|
|
226
|
-
[src]
|
|
227
|
-
)
|
|
228
|
-
|
|
229
|
-
const handleLoad = React.useCallback(
|
|
230
|
-
(event: React.SyntheticEvent<HTMLImageElement>) =>
|
|
231
|
-
measure(event.currentTarget),
|
|
232
|
-
[measure]
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
const supplied =
|
|
236
|
-
item?.width && item?.height ? item.width / item.height : undefined
|
|
237
|
-
// Discard a measurement left over from a previous `src`.
|
|
238
|
-
const measuredRatio =
|
|
239
|
-
measured && measured.src === src ? measured.ratio : undefined
|
|
240
|
-
|
|
241
|
-
return {
|
|
242
|
-
ratio: supplied ?? measuredRatio ?? RESERVED_SINGLE_ASPECT_RATIO,
|
|
243
|
-
imgRef: measure,
|
|
244
|
-
onLoad: handleLoad,
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
195
|
/**
|
|
249
196
|
* Sent / Received rendering — wrapped in the shared `Bubble` chrome,
|
|
250
197
|
* supports single or stacked items, and renders an optional caption
|
|
@@ -266,7 +213,15 @@ const ImageBubbleRow: React.FC<InternalImageRowProps> = ({
|
|
|
266
213
|
const { viewerOpen, viewerIndex, handleActivate, closeViewer } =
|
|
267
214
|
useViewer(onClick)
|
|
268
215
|
const isSingle = resolvedItems.length === 1
|
|
269
|
-
const
|
|
216
|
+
const singleItem = isSingle ? resolvedItems[0] : undefined
|
|
217
|
+
const single = useSingleMediaRatio({
|
|
218
|
+
src: singleItem?.src,
|
|
219
|
+
suppliedRatio:
|
|
220
|
+
singleItem?.width && singleItem?.height
|
|
221
|
+
? singleItem.width / singleItem.height
|
|
222
|
+
: undefined,
|
|
223
|
+
fallbackRatio: RESERVED_SINGLE_ASPECT_RATIO,
|
|
224
|
+
})
|
|
270
225
|
|
|
271
226
|
if (resolvedItems.length === 0) {
|
|
272
227
|
return null
|
|
@@ -811,6 +811,237 @@ describe('MessageAttachment.Video', () => {
|
|
|
811
811
|
})
|
|
812
812
|
})
|
|
813
813
|
|
|
814
|
+
// The grid only *displays* four tiles; the viewer is a carousel over the
|
|
815
|
+
// whole array. Every number here is an off-by-one waiting to happen —
|
|
816
|
+
// `total - visible` vs `total - 1`, index 3 vs index 4, `4 / 9` vs
|
|
817
|
+
// `4 / 4` — and none of it was covered.
|
|
818
|
+
describe('MessageAttachment.Video overflow', () => {
|
|
819
|
+
const NINE_VIDEOS = Array.from({ length: 9 }, (_, i) => ({
|
|
820
|
+
src: `https://cdn.example.com/${i}.mp4`,
|
|
821
|
+
poster: `https://cdn.example.com/${i}.jpg`,
|
|
822
|
+
mimeType: 'video/mp4',
|
|
823
|
+
}))
|
|
824
|
+
|
|
825
|
+
it('renders four tiles and a +5 badge for nine videos', () => {
|
|
826
|
+
renderWithProviders(<MessageAttachment.Video.Sent items={NINE_VIDEOS} />)
|
|
827
|
+
|
|
828
|
+
const tiles = screen.getAllByRole('button', { name: /^Play video/ })
|
|
829
|
+
expect(tiles).toHaveLength(4)
|
|
830
|
+
// `total - visible`, not `total - 1` and not a count that swallows
|
|
831
|
+
// the tile the scrim sits on.
|
|
832
|
+
expect(screen.getByText('+5')).toBeInTheDocument()
|
|
833
|
+
// Tiles 5..9 are never rendered, so they stay out of the tab order.
|
|
834
|
+
expect(screen.queryByLabelText('Play video 5 of 9')).toBeNull()
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
it('opens the viewer on the fourth video from the overflow tile and pages to the ninth', () => {
|
|
838
|
+
renderWithProviders(<MessageAttachment.Video.Sent items={NINE_VIDEOS} />)
|
|
839
|
+
|
|
840
|
+
fireEvent.click(screen.getByLabelText('Play video 4 of 9'))
|
|
841
|
+
const viewer = expectViewerOpen('video-viewer')
|
|
842
|
+
expect(viewer.querySelector('video')?.getAttribute('src')).toBe(
|
|
843
|
+
'https://cdn.example.com/3.mp4'
|
|
844
|
+
)
|
|
845
|
+
// How the user learns what "+5" resolved to.
|
|
846
|
+
expect(viewer.textContent).toContain('4 / 9')
|
|
847
|
+
|
|
848
|
+
// The carousel spans all nine, not just the four the grid drew.
|
|
849
|
+
for (let i = 0; i < 5; i += 1) {
|
|
850
|
+
fireEvent.click(screen.getByLabelText('Next video'))
|
|
851
|
+
}
|
|
852
|
+
expect(viewer.querySelector('video')?.getAttribute('src')).toBe(
|
|
853
|
+
'https://cdn.example.com/8.mp4'
|
|
854
|
+
)
|
|
855
|
+
expect(viewer.textContent).toContain('9 / 9')
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
it('drops the play badge on the overflow tile only', () => {
|
|
859
|
+
renderWithProviders(<MessageAttachment.Video.Sent items={NINE_VIDEOS} />)
|
|
860
|
+
|
|
861
|
+
const tiles = screen.getAllByRole('button', { name: /^Play video/ })
|
|
862
|
+
// The badge is the only `svg` a poster tile renders.
|
|
863
|
+
expect(tiles[0].querySelector('svg')).not.toBeNull()
|
|
864
|
+
expect(tiles[3].querySelector('svg')).toBeNull()
|
|
865
|
+
})
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
// A fixed-width card with a free height is one bad ratio away from a
|
|
869
|
+
// 587px-tall bubble, so the clamp is the whole point of the feature.
|
|
870
|
+
describe('MessageAttachment.Video single-card aspect', () => {
|
|
871
|
+
// jsdom never decodes an `<img>`, so `naturalWidth` stays 0 and `load`
|
|
872
|
+
// never fires on its own — stub the intrinsic size and fire it.
|
|
873
|
+
const loadPosterAt = (naturalWidth: number, naturalHeight: number) => {
|
|
874
|
+
const poster = screen.getByTestId('video-attachment').querySelector('img')
|
|
875
|
+
if (!poster) throw new Error('no poster rendered')
|
|
876
|
+
Object.defineProperty(poster, 'naturalWidth', {
|
|
877
|
+
configurable: true,
|
|
878
|
+
value: naturalWidth,
|
|
879
|
+
})
|
|
880
|
+
Object.defineProperty(poster, 'naturalHeight', {
|
|
881
|
+
configurable: true,
|
|
882
|
+
value: naturalHeight,
|
|
883
|
+
})
|
|
884
|
+
fireEvent.load(poster)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const mediaAspect = () =>
|
|
888
|
+
screen.getByTestId('video-attachment').querySelector<HTMLElement>('[style]')
|
|
889
|
+
?.style.aspectRatio
|
|
890
|
+
|
|
891
|
+
it('stays square while the poster is still undecoded', () => {
|
|
892
|
+
// Square, not 16:9. The fallback covers "we know nothing yet", and this
|
|
893
|
+
// card was square before any of this existed — guessing landscape put
|
|
894
|
+
// portrait clips in a letterbox, which is more wrong (MES-1353).
|
|
895
|
+
renderWithProviders(
|
|
896
|
+
<MessageAttachment.Video.Sent
|
|
897
|
+
src="https://cdn.example.com/clip.mp4"
|
|
898
|
+
poster="https://cdn.example.com/poster.jpg"
|
|
899
|
+
/>
|
|
900
|
+
)
|
|
901
|
+
expect(mediaAspect()).toBe(String(1))
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
it('sizes a posterless clip from the dimensions the sender recorded', () => {
|
|
905
|
+
// The case that made this necessary: an uploaded clip with no poster.
|
|
906
|
+
// Nothing can be measured, so without recorded dimensions the card sat at
|
|
907
|
+
// whatever the fallback guessed, regardless of the clip's real shape.
|
|
908
|
+
renderWithProviders(
|
|
909
|
+
<MessageAttachment.Video.Sent
|
|
910
|
+
src="https://cdn.example.com/clip.mp4"
|
|
911
|
+
naturalAspectRatio={9 / 16}
|
|
912
|
+
/>
|
|
913
|
+
)
|
|
914
|
+
expect(
|
|
915
|
+
screen.getByTestId('video-attachment').querySelector('img')
|
|
916
|
+
).toBeNull()
|
|
917
|
+
// Clamped to MIN_ASPECT: a true 9:16 would render a 587px-tall card, so
|
|
918
|
+
// portrait pins at 4:5 until design asks for a full-height card.
|
|
919
|
+
expect(mediaAspect()).toBe(String(4 / 5))
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
it('prefers the recorded dimensions over the decoded poster', () => {
|
|
923
|
+
// Recorded dimensions describe the clip; the poster is only a proxy for it
|
|
924
|
+
// and can be cropped or letterboxed by whatever produced it.
|
|
925
|
+
renderWithProviders(
|
|
926
|
+
<MessageAttachment.Video.Sent
|
|
927
|
+
src="https://cdn.example.com/clip.mp4"
|
|
928
|
+
poster="https://cdn.example.com/poster.jpg"
|
|
929
|
+
naturalAspectRatio={4 / 3}
|
|
930
|
+
/>
|
|
931
|
+
)
|
|
932
|
+
expect(mediaAspect()).toBe(String(4 / 3))
|
|
933
|
+
|
|
934
|
+
loadPosterAt(1920, 1080)
|
|
935
|
+
expect(mediaAspect()).toBe(String(4 / 3))
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
it('stays square when there is neither a poster nor recorded dimensions', () => {
|
|
939
|
+
renderWithProviders(
|
|
940
|
+
<MessageAttachment.Video.Sent src="https://cdn.example.com/clip.mp4" />
|
|
941
|
+
)
|
|
942
|
+
expect(mediaAspect()).toBe(String(1))
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
it('adopts the poster ratio inside the clamp and pins it outside', () => {
|
|
946
|
+
renderWithProviders(
|
|
947
|
+
<MessageAttachment.Video.Sent
|
|
948
|
+
src="https://cdn.example.com/clip.mp4"
|
|
949
|
+
poster="https://cdn.example.com/poster.jpg"
|
|
950
|
+
/>
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
// 4:3 sits inside [4/5, 16/9] and passes through untouched.
|
|
954
|
+
loadPosterAt(1440, 1080)
|
|
955
|
+
expect(mediaAspect()).toBe(String(4 / 3))
|
|
956
|
+
|
|
957
|
+
// 9:16 is taller than the 4/5 floor.
|
|
958
|
+
loadPosterAt(1080, 1920)
|
|
959
|
+
expect(mediaAspect()).toBe(String(4 / 5))
|
|
960
|
+
|
|
961
|
+
// 21:9 is wider than the 16/9 ceiling.
|
|
962
|
+
loadPosterAt(2560, 1080)
|
|
963
|
+
expect(mediaAspect()).toBe(String(16 / 9))
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
it('measures a poster the browser served from cache, which never fires `load`', () => {
|
|
967
|
+
// A cached `<img>` can be `complete` before React attaches its `load`
|
|
968
|
+
// listener — a second card on the same URL, a remount while scrolling a
|
|
969
|
+
// thread, a Storybook HMR update. Measuring on `load` alone left the card
|
|
970
|
+
// stuck on the fallback with no way out, which is why the hook also reads
|
|
971
|
+
// from the ref (MES-1353).
|
|
972
|
+
const saved = (['naturalWidth', 'naturalHeight'] as const).map(
|
|
973
|
+
(name) =>
|
|
974
|
+
[
|
|
975
|
+
name,
|
|
976
|
+
Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, name),
|
|
977
|
+
] as const
|
|
978
|
+
)
|
|
979
|
+
// Stubbed on the prototype so the poster already reports its intrinsic
|
|
980
|
+
// size when the ref callback first sees it, and no `load` is fired below.
|
|
981
|
+
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', {
|
|
982
|
+
configurable: true,
|
|
983
|
+
value: 1440,
|
|
984
|
+
})
|
|
985
|
+
Object.defineProperty(HTMLImageElement.prototype, 'naturalHeight', {
|
|
986
|
+
configurable: true,
|
|
987
|
+
value: 1080,
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
try {
|
|
991
|
+
renderWithProviders(
|
|
992
|
+
<MessageAttachment.Video.Sent
|
|
993
|
+
src="https://cdn.example.com/clip.mp4"
|
|
994
|
+
poster="https://cdn.example.com/poster.jpg"
|
|
995
|
+
/>
|
|
996
|
+
)
|
|
997
|
+
expect(mediaAspect()).toBe(String(4 / 3))
|
|
998
|
+
} finally {
|
|
999
|
+
for (const [name, descriptor] of saved) {
|
|
1000
|
+
if (descriptor) {
|
|
1001
|
+
Object.defineProperty(HTMLImageElement.prototype, name, descriptor)
|
|
1002
|
+
} else {
|
|
1003
|
+
delete (
|
|
1004
|
+
HTMLImageElement.prototype as unknown as Record<string, unknown>
|
|
1005
|
+
)[name]
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
it('stays square on a received clip with no poster', () => {
|
|
1012
|
+
// Was asserted as 16:9. That guess is what put a portrait clip in a
|
|
1013
|
+
// letterbox box, since nothing here knows the clip's shape — the sender
|
|
1014
|
+
// has to record it. Square is the honest answer and matches what this
|
|
1015
|
+
// card rendered before the feature existed (MES-1353).
|
|
1016
|
+
renderWithProviders(
|
|
1017
|
+
<MessageAttachment.Video.Received src="https://cdn.example.com/clip.mp4" />
|
|
1018
|
+
)
|
|
1019
|
+
expect(
|
|
1020
|
+
screen.getByTestId('video-attachment').querySelector('img')
|
|
1021
|
+
).toBeNull()
|
|
1022
|
+
expect(mediaAspect()).toBe(String(1))
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
it('leaves multi-video grids on their fixed geometry', () => {
|
|
1026
|
+
renderWithProviders(
|
|
1027
|
+
<MessageAttachment.Video.Sent
|
|
1028
|
+
items={[
|
|
1029
|
+
{
|
|
1030
|
+
src: 'https://cdn.example.com/a.mp4',
|
|
1031
|
+
poster: 'https://cdn.example.com/a.jpg',
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
src: 'https://cdn.example.com/b.mp4',
|
|
1035
|
+
poster: 'https://cdn.example.com/b.jpg',
|
|
1036
|
+
},
|
|
1037
|
+
]}
|
|
1038
|
+
/>
|
|
1039
|
+
)
|
|
1040
|
+
// No inline aspect-ratio: the 2-tile layout owns its own class.
|
|
1041
|
+
expect(mediaAspect()).toBeUndefined()
|
|
1042
|
+
})
|
|
1043
|
+
})
|
|
1044
|
+
|
|
814
1045
|
describe('ViewerShell focus management', () => {
|
|
815
1046
|
it('moves focus into the dialog when a viewer opens', () => {
|
|
816
1047
|
renderWithProviders(
|
|
@@ -950,6 +1181,24 @@ describe('MessageAttachment lazy-loading defaults', () => {
|
|
|
950
1181
|
const video = screen.getByTestId('video-viewer').querySelector('video')
|
|
951
1182
|
expect(video?.getAttribute('preload')).toBe('metadata')
|
|
952
1183
|
})
|
|
1184
|
+
|
|
1185
|
+
// The single most load-bearing clause of the preload policy: a nine
|
|
1186
|
+
// -video message must cost four lazy poster images, never nine (or
|
|
1187
|
+
// four) media pipelines. Any future hover-to-preview would break
|
|
1188
|
+
// this, which is the point.
|
|
1189
|
+
it('mounts no `<video>` on the bubble surface, however many items', () => {
|
|
1190
|
+
renderWithProviders(
|
|
1191
|
+
<MessageAttachment.Video.Sent
|
|
1192
|
+
items={Array.from({ length: 9 }, (_, i) => ({
|
|
1193
|
+
src: `https://cdn.example.com/${i}.mp4`,
|
|
1194
|
+
poster: `https://cdn.example.com/${i}.jpg`,
|
|
1195
|
+
}))}
|
|
1196
|
+
/>
|
|
1197
|
+
)
|
|
1198
|
+
const bubble = screen.getByTestId('video-attachment')
|
|
1199
|
+
expect(bubble.querySelectorAll('video')).toHaveLength(0)
|
|
1200
|
+
expect(bubble.querySelectorAll('img')).toHaveLength(4)
|
|
1201
|
+
})
|
|
953
1202
|
})
|
|
954
1203
|
|
|
955
1204
|
describe('Audio', () => {
|
|
@@ -39,6 +39,28 @@ const STACK_VIDEOS: VideoItem[] = [
|
|
|
39
39
|
},
|
|
40
40
|
]
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Nine clips — four tiles plus a `+5` scrim on the bottom-right. The cap
|
|
44
|
+
* on the creator surface is 10 attachments, so the reachable badge range
|
|
45
|
+
* is `+1` … `+6`; this is real production state, not defensive UI.
|
|
46
|
+
*/
|
|
47
|
+
const OVERFLOW_VIDEOS: VideoItem[] = Array.from({ length: 9 }, () => ({
|
|
48
|
+
src: VIDEO_SRC,
|
|
49
|
+
poster: VIDEO_POSTER,
|
|
50
|
+
mimeType: 'video/mp4',
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Solid-colour poster at an exact intrinsic size. The single-video card
|
|
55
|
+
* measures `naturalWidth / naturalHeight` off the decoded poster, so the
|
|
56
|
+
* aspect row needs posters whose ratio is declared rather than inherited
|
|
57
|
+
* from whatever photo happens to sit in `.storybook/public/`.
|
|
58
|
+
*/
|
|
59
|
+
const aspectPoster = (width: number, height: number): string =>
|
|
60
|
+
`data:image/svg+xml;utf8,${encodeURIComponent(
|
|
61
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" fill="#3f6b8f"/></svg>`
|
|
62
|
+
)}`
|
|
63
|
+
|
|
42
64
|
const meta: Meta = {
|
|
43
65
|
title: 'MessageAttachment/Video',
|
|
44
66
|
parameters: { layout: 'fullscreen' },
|
|
@@ -277,3 +299,144 @@ export const StackedWithText: StoryFn = () => (
|
|
|
277
299
|
</StoryGrid>
|
|
278
300
|
</StoryPage>
|
|
279
301
|
)
|
|
302
|
+
|
|
303
|
+
/** Stacked videos past the 4-tile grid — the last visible tile trades
|
|
304
|
+
* its play badge for the `+N` count scrim. */
|
|
305
|
+
export const Overflow: StoryFn = () => (
|
|
306
|
+
<StoryPage>
|
|
307
|
+
<StoryHeading
|
|
308
|
+
title="Stacked videos with overflow"
|
|
309
|
+
description="`+N` counts the clips beyond the four shown (total − visible), and the overflow tile drops its play badge — the scrim is the affordance. Activating it opens the viewer on the 4th clip; the carousel still spans every item, not just the visible four."
|
|
310
|
+
/>
|
|
311
|
+
<StoryGrid>
|
|
312
|
+
<StoryRow
|
|
313
|
+
label="5 clips (+1)"
|
|
314
|
+
sent={
|
|
315
|
+
<VideoAttachment.Sent
|
|
316
|
+
items={OVERFLOW_VIDEOS.slice(0, 5)}
|
|
317
|
+
filename="clips"
|
|
318
|
+
/>
|
|
319
|
+
}
|
|
320
|
+
received={
|
|
321
|
+
<VideoAttachment.Received
|
|
322
|
+
items={OVERFLOW_VIDEOS.slice(0, 5)}
|
|
323
|
+
filename="clips"
|
|
324
|
+
/>
|
|
325
|
+
}
|
|
326
|
+
/>
|
|
327
|
+
<StoryRow
|
|
328
|
+
label="9 clips (+5)"
|
|
329
|
+
sent={<VideoAttachment.Sent items={OVERFLOW_VIDEOS} filename="clips" />}
|
|
330
|
+
received={
|
|
331
|
+
<VideoAttachment.Received items={OVERFLOW_VIDEOS} filename="clips" />
|
|
332
|
+
}
|
|
333
|
+
/>
|
|
334
|
+
<StoryRow
|
|
335
|
+
label="9 clips + caption"
|
|
336
|
+
sent={
|
|
337
|
+
<VideoAttachment.Sent
|
|
338
|
+
items={OVERFLOW_VIDEOS}
|
|
339
|
+
filename="clips"
|
|
340
|
+
text="Nine takes — the last tile shows how many are hiding"
|
|
341
|
+
/>
|
|
342
|
+
}
|
|
343
|
+
received={
|
|
344
|
+
<VideoAttachment.Received
|
|
345
|
+
items={OVERFLOW_VIDEOS}
|
|
346
|
+
filename="clips"
|
|
347
|
+
text="Nine takes — the last tile shows how many are hiding"
|
|
348
|
+
/>
|
|
349
|
+
}
|
|
350
|
+
/>
|
|
351
|
+
</StoryGrid>
|
|
352
|
+
</StoryPage>
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
/** Single-video cards size themselves to the poster's own aspect, clamped
|
|
356
|
+
* to `[4/5, 16/9]` so one portrait clip can't dominate the thread. */
|
|
357
|
+
export const SingleAspect: StoryFn = () => (
|
|
358
|
+
<StoryPage>
|
|
359
|
+
<StoryHeading
|
|
360
|
+
title="Single video — source aspect"
|
|
361
|
+
description="Card width is fixed at 330px; height follows the poster's intrinsic ratio, clamped to [4/5, 16/9]. The 21:9 row proves the wide clamp and the 9:16 row the tall one — both land on the same box as the 16:9 and 4/5 limits respectively."
|
|
362
|
+
/>
|
|
363
|
+
<StoryGrid>
|
|
364
|
+
<StoryRow
|
|
365
|
+
label="16:9 (1.78)"
|
|
366
|
+
sent={
|
|
367
|
+
<VideoAttachment.Sent
|
|
368
|
+
src={VIDEO_SRC}
|
|
369
|
+
poster={aspectPoster(1920, 1080)}
|
|
370
|
+
mimeType="video/mp4"
|
|
371
|
+
filename="landscape.mp4"
|
|
372
|
+
/>
|
|
373
|
+
}
|
|
374
|
+
received={
|
|
375
|
+
<VideoAttachment.Received
|
|
376
|
+
src={VIDEO_SRC}
|
|
377
|
+
poster={aspectPoster(1920, 1080)}
|
|
378
|
+
mimeType="video/mp4"
|
|
379
|
+
filename="landscape.mp4"
|
|
380
|
+
/>
|
|
381
|
+
}
|
|
382
|
+
/>
|
|
383
|
+
<StoryRow
|
|
384
|
+
label="4:3 (1.33)"
|
|
385
|
+
sent={
|
|
386
|
+
<VideoAttachment.Sent
|
|
387
|
+
src={VIDEO_SRC}
|
|
388
|
+
poster={aspectPoster(1440, 1080)}
|
|
389
|
+
mimeType="video/mp4"
|
|
390
|
+
filename="classic.mp4"
|
|
391
|
+
/>
|
|
392
|
+
}
|
|
393
|
+
received={
|
|
394
|
+
<VideoAttachment.Received
|
|
395
|
+
src={VIDEO_SRC}
|
|
396
|
+
poster={aspectPoster(1440, 1080)}
|
|
397
|
+
mimeType="video/mp4"
|
|
398
|
+
filename="classic.mp4"
|
|
399
|
+
/>
|
|
400
|
+
}
|
|
401
|
+
/>
|
|
402
|
+
<StoryRow
|
|
403
|
+
label="9:16 → clamped to 4:5"
|
|
404
|
+
sent={
|
|
405
|
+
<VideoAttachment.Sent
|
|
406
|
+
src={VIDEO_SRC}
|
|
407
|
+
poster={aspectPoster(1080, 1920)}
|
|
408
|
+
mimeType="video/mp4"
|
|
409
|
+
filename="portrait.mp4"
|
|
410
|
+
/>
|
|
411
|
+
}
|
|
412
|
+
received={
|
|
413
|
+
<VideoAttachment.Received
|
|
414
|
+
src={VIDEO_SRC}
|
|
415
|
+
poster={aspectPoster(1080, 1920)}
|
|
416
|
+
mimeType="video/mp4"
|
|
417
|
+
filename="portrait.mp4"
|
|
418
|
+
/>
|
|
419
|
+
}
|
|
420
|
+
/>
|
|
421
|
+
<StoryRow
|
|
422
|
+
label="21:9 → clamped to 16:9"
|
|
423
|
+
sent={
|
|
424
|
+
<VideoAttachment.Sent
|
|
425
|
+
src={VIDEO_SRC}
|
|
426
|
+
poster={aspectPoster(2560, 1080)}
|
|
427
|
+
mimeType="video/mp4"
|
|
428
|
+
filename="ultrawide.mp4"
|
|
429
|
+
/>
|
|
430
|
+
}
|
|
431
|
+
received={
|
|
432
|
+
<VideoAttachment.Received
|
|
433
|
+
src={VIDEO_SRC}
|
|
434
|
+
poster={aspectPoster(2560, 1080)}
|
|
435
|
+
mimeType="video/mp4"
|
|
436
|
+
filename="ultrawide.mp4"
|
|
437
|
+
/>
|
|
438
|
+
}
|
|
439
|
+
/>
|
|
440
|
+
</StoryGrid>
|
|
441
|
+
</StoryPage>
|
|
442
|
+
)
|