@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
@@ -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, { type MediaStackTile } from '../_shared/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/55 text-white backdrop-blur">
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<{ item: VideoItem; index: number }> = ({
69
- item,
70
- index,
71
- }) => (
72
- <div className="absolute inset-0 size-full bg-[#0d0d0d]">
73
- {item.poster ? (
74
- <img
75
- src={item.poster}
76
- alt={`Video ${index + 1} thumbnail`}
77
- draggable={false}
78
- loading="lazy"
79
- decoding="async"
80
- className="absolute inset-0 size-full object-cover"
81
- />
82
- ) : (
83
- <div className="absolute inset-0 flex items-center justify-center">
84
- <VideoCameraIcon
85
- className="size-16 text-white/30"
86
- weight="regular"
87
- aria-hidden
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
- </div>
90
- )}
91
- <PlayBadge />
92
- </div>
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: VideoItem,
97
- index: number,
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
- ): MediaStackTile => ({
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
- content: <PosterTile item={item} index={index} />,
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
- if (src) return [{ src, poster, mimeType, preload }]
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({ src, poster, mimeType, preload, items })
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(item, index, resolvedItems.length)
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 tiles={tiles} onTileActivate={handleActivate} />
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 = 4,
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
- <div className="absolute inset-0 flex items-center justify-center bg-black/55 text-2xl font-semibold text-white">
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(sharedClass, 'cursor-zoom-in')}
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 { fireEvent, renderWithProviders, screen } from '../../../test/utils'
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
- <video
76
- // Forcing a key swap on item change ensures the new source
77
- // mounts a fresh element instead of reusing the previous
78
- // playback state keeps the video paused-at-start when
79
- // navigating between stacked items.
80
- key={`${index}:${item.src}`}
81
- src={item.src}
82
- poster={item.poster}
83
- controls
84
- // `autoPlay` without `muted` is blocked by Chrome / Safari /
85
- // Firefox autoplay policies — the click that opens the viewer
86
- // doesn't count as a user-gesture for the freshly-mounted
87
- // `<video>` element. Defaulting to muted means playback
88
- // actually starts (matches how IG / Slack handle their video
89
- // lightboxes); the user can unmute via the native controls if
90
- // they want sound.
91
- autoPlay
92
- muted
93
- playsInline
94
- preload={item.preload ?? 'metadata'}
95
- className="block h-auto max-h-[calc(100dvh-128px)] w-auto max-w-[min(96vw,1400px)] bg-black"
96
- >
97
- {item.mimeType ? <source src={item.src} type={item.mimeType} /> : null}
98
- </video>
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 statekeeps 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
  /**