@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.
Files changed (33) hide show
  1. package/dist/{AttachmentCard-Pc93Wcd0.js → AttachmentCard-BqwWgqvi.js} +83 -78
  2. package/dist/AttachmentCard-BqwWgqvi.js.map +1 -0
  3. package/dist/AttachmentCard-DUrgGRsq.cjs +2 -0
  4. package/dist/AttachmentCard-DUrgGRsq.cjs.map +1 -0
  5. package/dist/{Card-ByU9P9GA.cjs → Card-BIbqWagz.cjs} +2 -2
  6. package/dist/{Card-ByU9P9GA.cjs.map → Card-BIbqWagz.cjs.map} +1 -1
  7. package/dist/{Card-D__kW2E_.js → Card-f51K6V7Y.js} +20 -20
  8. package/dist/{Card-D__kW2E_.js.map → Card-f51K6V7Y.js.map} +1 -1
  9. package/dist/index.cjs +2 -2
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.ts +15 -0
  12. package/dist/index.js +1017 -969
  13. package/dist/index.js.map +1 -1
  14. package/package.json +3 -3
  15. package/src/components/AttachmentCard/MediaPlayer.tsx +2 -0
  16. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +26 -0
  17. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +31 -3
  18. package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +2 -0
  19. package/src/components/MessageAttachment/Audio/index.tsx +2 -0
  20. package/src/components/MessageAttachment/Image/index.tsx +11 -56
  21. package/src/components/MessageAttachment/MessageAttachment.test.tsx +249 -0
  22. package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +163 -0
  23. package/src/components/MessageAttachment/Video/index.tsx +195 -36
  24. package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +27 -3
  25. package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +68 -1
  26. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +86 -25
  27. package/src/components/MessageAttachment/_shared/useSingleMediaRatio.ts +75 -0
  28. package/src/components/MessageAttachment/types.ts +9 -0
  29. package/src/utils/nativeMediaPlayers.test.ts +55 -0
  30. package/src/utils/nativeMediaPlayers.ts +41 -0
  31. package/dist/AttachmentCard-DI2chsB_.cjs +0 -2
  32. package/dist/AttachmentCard-DI2chsB_.cjs.map +0 -1
  33. 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.2",
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": "^2.5.0",
54
- "@linktr.ee/messaging-taxonomy": "^0.11.0",
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: trimToUndefined(
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 `useSingleImageRatio`. */
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 single = useSingleImageRatio(isSingle ? resolvedItems[0] : undefined)
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
+ )