@linktr.ee/messaging-react 3.17.0-rc-1785345258 → 3.17.0

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 (34) hide show
  1. package/dist/{Card-Cyp6T66U.cjs → Card-D7OY-zlB.cjs} +2 -2
  2. package/dist/{Card-Cyp6T66U.cjs.map → Card-D7OY-zlB.cjs.map} +1 -1
  3. package/dist/{Card-DDZI9ZIj.js → Card-DkW8wedR.js} +2 -2
  4. package/dist/{Card-DDZI9ZIj.js.map → Card-DkW8wedR.js.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/index-BFZ-G_jg.cjs +2 -0
  7. package/dist/index-BFZ-G_jg.cjs.map +1 -0
  8. package/dist/{index-DskHxSVN.js → index-CxGxAIhL.js} +1568 -1527
  9. package/dist/index-CxGxAIhL.js.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +2 -14
  12. package/dist/index.js +1 -1
  13. package/dist/testing.d.ts +1 -3
  14. package/package.json +2 -2
  15. package/src/components/ChannelView.tsx +1 -6
  16. package/src/components/CustomMessage/CustomMessage.stories.tsx +0 -40
  17. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +4 -76
  18. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +7 -75
  19. package/src/components/CustomMessage/index.tsx +1 -4
  20. package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +77 -0
  21. package/src/components/MessageAttachment/Image/index.tsx +117 -8
  22. package/src/components/MessageAttachment/MessageAttachment.test.tsx +177 -0
  23. package/src/components/MessageAttachment/Video/index.tsx +1 -5
  24. package/src/components/MessageAttachment/_shared/Bubble.tsx +40 -7
  25. package/src/components/MessageAttachment/_shared/ImageViewer.tsx +25 -16
  26. package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +105 -7
  27. package/src/components/MessagingShell/index.tsx +0 -2
  28. package/src/index.ts +0 -1
  29. package/src/stream-custom-data.ts +0 -3
  30. package/src/styles.css +5 -48
  31. package/src/types.ts +0 -12
  32. package/dist/index-DBjALsli.cjs +0 -2
  33. package/dist/index-DBjALsli.cjs.map +0 -1
  34. package/dist/index-DskHxSVN.js.map +0 -1
@@ -53,15 +53,28 @@ export interface ImageAttachmentSharedProps extends MessageAttachmentBaseProps {
53
53
  onClick?: (index: number) => boolean | void
54
54
  }
55
55
 
56
- const tileFromItem = (
57
- item: ImageItem,
58
- index: number,
59
- totalCount: number,
56
+ const tileFromItem = ({
57
+ item,
58
+ index,
59
+ totalCount,
60
+ fallbackLoading,
61
+ imgRef,
62
+ onLoad,
63
+ }: {
64
+ item: ImageItem
65
+ index: number
66
+ totalCount: number
60
67
  fallbackLoading: ImageLoadingMode
61
- ): MediaStackTile => ({
68
+ /** Set on the single-image tile only — see `useSingleImageRatio`. */
69
+ imgRef?: React.Ref<HTMLImageElement>
70
+ onLoad?: React.ReactEventHandler<HTMLImageElement>
71
+ }): MediaStackTile => ({
62
72
  ariaLabel: `Open image ${index + 1} of ${totalCount}`,
63
73
  content: (
74
+ // No corner radius here: `MediaStackGrid` clips the four outer
75
+ // corners of the whole cluster and keeps every inner edge square.
64
76
  <img
77
+ ref={imgRef}
65
78
  src={optimizeMessagingAttachmentUrl(item.src) ?? item.src}
66
79
  alt={item.alt ?? ''}
67
80
  width={item.width}
@@ -69,7 +82,8 @@ const tileFromItem = (
69
82
  draggable={false}
70
83
  loading={item.loading ?? fallbackLoading}
71
84
  decoding="async"
72
- className="absolute inset-0 size-full rounded-md object-cover"
85
+ onLoad={onLoad}
86
+ className="absolute inset-0 size-full object-cover"
73
87
  />
74
88
  ),
75
89
  })
@@ -173,6 +187,69 @@ const ImageComposerInner: React.FC<{
173
187
  )
174
188
  }
175
189
 
190
+ /**
191
+ * Box reserved for a single image whose ratio is not known yet — the
192
+ * 4:3 landscape card Figma draws for the most common case (330×250
193
+ * card, 326×246 content, `attachment-single-media` 1972:13149). A
194
+ * bounded reserve keeps a scrolling thread from jumping by an
195
+ * arbitrary amount between mount and decode.
196
+ */
197
+ const RESERVED_SINGLE_ASPECT_RATIO = 326 / 246
198
+
199
+ /**
200
+ * Resolves the aspect ratio of a single-image bubble, in order:
201
+ *
202
+ * 1. caller-supplied `ImageItem.width` / `height` — ratio known at
203
+ * first paint, so the card never resizes;
204
+ * 2. the decoded `<img>`'s `naturalWidth` / `naturalHeight`;
205
+ * 3. `RESERVED_SINGLE_ASPECT_RATIO`.
206
+ *
207
+ * Nothing propagates dimensions through the Stream path today, so (2)
208
+ * is the branch that runs in production. It reads both on `load` and
209
+ * from the ref, because an image already in the browser cache can be
210
+ * `complete` before React attaches a `load` listener.
211
+ */
212
+ const useSingleImageRatio = (item: ImageItem | undefined) => {
213
+ const src = item?.src
214
+ const [measured, setMeasured] = React.useState<{
215
+ src: string
216
+ ratio: number
217
+ }>()
218
+
219
+ const measure = React.useCallback(
220
+ (img: HTMLImageElement | null) => {
221
+ if (!img || img.naturalWidth <= 0 || img.naturalHeight <= 0) return
222
+ if (src === undefined) return
223
+ const ratio = img.naturalWidth / img.naturalHeight
224
+ // Returning `prev` unchanged matters: the ref callback runs on
225
+ // every commit, and a fresh object each time would schedule an
226
+ // endless render loop.
227
+ setMeasured((prev) =>
228
+ prev && prev.src === src && prev.ratio === ratio ? prev : { src, ratio }
229
+ )
230
+ },
231
+ [src]
232
+ )
233
+
234
+ const handleLoad = React.useCallback(
235
+ (event: React.SyntheticEvent<HTMLImageElement>) =>
236
+ measure(event.currentTarget),
237
+ [measure]
238
+ )
239
+
240
+ const supplied =
241
+ item?.width && item?.height ? item.width / item.height : undefined
242
+ // Discard a measurement left over from a previous `src`.
243
+ const measuredRatio =
244
+ measured && measured.src === src ? measured.ratio : undefined
245
+
246
+ return {
247
+ ratio: supplied ?? measuredRatio ?? RESERVED_SINGLE_ASPECT_RATIO,
248
+ imgRef: measure,
249
+ onLoad: handleLoad,
250
+ }
251
+ }
252
+
176
253
  /**
177
254
  * Sent / Received rendering — wrapped in the shared `Bubble` chrome,
178
255
  * supports single or stacked items, and renders an optional caption
@@ -194,13 +271,22 @@ const ImageBubbleRow: React.FC<InternalImageRowProps> = ({
194
271
  const { viewerOpen, viewerIndex, handleActivate, closeViewer } = useViewer(
195
272
  onClick
196
273
  )
274
+ const isSingle = resolvedItems.length === 1
275
+ const single = useSingleImageRatio(isSingle ? resolvedItems[0] : undefined)
197
276
 
198
277
  if (resolvedItems.length === 0) {
199
278
  return null
200
279
  }
201
280
 
202
281
  const tiles: MediaStackTile[] = resolvedItems.map((item, index) =>
203
- tileFromItem(item, index, resolvedItems.length, loading)
282
+ tileFromItem({
283
+ item,
284
+ index,
285
+ totalCount: resolvedItems.length,
286
+ fallbackLoading: loading,
287
+ imgRef: isSingle ? single.imgRef : undefined,
288
+ onLoad: isSingle ? single.onLoad : undefined,
289
+ })
204
290
  )
205
291
 
206
292
  return (
@@ -208,10 +294,33 @@ const ImageBubbleRow: React.FC<InternalImageRowProps> = ({
208
294
  variant={variant}
209
295
  text={text}
210
296
  groupPosition={groupPosition}
297
+ // Media cards drop the 280px document width and the 8px inset
298
+ // for a 2px frame around the media, at a uniform r20 that never
299
+ // flattens against a caption bubble — `attachment-single-media`
300
+ // 330×250 and `attachment-single-image` 383×290, both `pad2`
301
+ // `cornerRadius: 20` with no stroke. The single card sizes to
302
+ // the fitted media box (capped so a long in-bubble caption can
303
+ // not stretch it past the 330px design maximum); the stacked
304
+ // card is a fixed 383px, capped so it cannot overflow a narrow
305
+ // viewport. `bordered={false}` because a hairline on top of the
306
+ // 2px frame would read as a third edge and push the card 2px
307
+ // over its measured width.
308
+ bordered={false}
309
+ widthClassName={
310
+ isSingle
311
+ ? 'w-fit max-w-[313px] sm:max-w-[330px]'
312
+ : 'w-[383px] max-w-full'
313
+ }
314
+ paddingClassName="p-0.5"
315
+ radiusClassName="rounded-[20px]"
211
316
  data-testid="image-attachment"
212
317
  >
213
318
  <div className="relative">
214
- <MediaStackGrid tiles={tiles} onTileActivate={handleActivate} />
319
+ <MediaStackGrid
320
+ tiles={tiles}
321
+ onTileActivate={handleActivate}
322
+ singleAspectRatio={isSingle ? single.ratio : undefined}
323
+ />
215
324
  </div>
216
325
 
217
326
  <ImageViewer
@@ -226,6 +226,163 @@ describe('MessageAttachment.Image', () => {
226
226
  })
227
227
  })
228
228
 
229
+ describe('MessageAttachment.Image single-image aspect ratio', () => {
230
+ // A single image used to be force-cropped to 1:1. It now fits inside
231
+ // a max 326x436 content box on web and 309x436 on mobile, preserving
232
+ // its own ratio, and the card adopts the fitted box so `object-cover`
233
+ // performs no crop. The width caps differ per breakpoint; the 436px
234
+ // height cap deliberately does not, which is why Figma's 9:16
235
+ // portrait renders 246x436 on web *and* mobile.
236
+ const WIDTH_CAP_MOBILE_PX = 309
237
+ const WIDTH_CAP_WEB_PX = 326
238
+
239
+ const mediaBox = (label: string) =>
240
+ screen.getByLabelText(label).parentElement as HTMLElement
241
+
242
+ /** The `min(var(--…), Npx)` term the height cap contributes. */
243
+ const heightBoundWidthPx = (box: HTMLElement) =>
244
+ Number(/([\d.]+)px\)\s*$/.exec(box.style.width)?.[1])
245
+
246
+ it('adopts the source ratio instead of forcing a square', () => {
247
+ renderWithProviders(
248
+ <MessageAttachment.Image.Sent
249
+ items={[
250
+ {
251
+ src: 'https://cdn.example.com/tall.jpg',
252
+ alt: 'Tall',
253
+ width: 1080,
254
+ height: 1920,
255
+ },
256
+ ]}
257
+ />
258
+ )
259
+ const box = mediaBox('Open image 1 of 1')
260
+ expect(box).not.toHaveClass('aspect-square')
261
+ expect(Number(box.style.aspectRatio)).toBeCloseTo(0.5625, 4)
262
+ expect(box).toHaveClass(
263
+ '[--mes-single-width-cap:309px]',
264
+ 'sm:[--mes-single-width-cap:326px]'
265
+ )
266
+ })
267
+
268
+ it('resolves a portrait source against the height cap on both breakpoints', () => {
269
+ renderWithProviders(
270
+ <MessageAttachment.Image.Sent
271
+ items={[
272
+ {
273
+ src: 'https://cdn.example.com/tall.jpg',
274
+ width: 1080,
275
+ height: 1920,
276
+ },
277
+ ]}
278
+ />
279
+ )
280
+ // 436 x 0.5625 = 245.25, under both width caps — so the height cap
281
+ // governs everywhere and the card resolves to 249 x 440.
282
+ const bound = heightBoundWidthPx(mediaBox('Open image 1 of 1'))
283
+ expect(bound).toBeCloseTo(245.25, 2)
284
+ expect(bound).toBeLessThan(WIDTH_CAP_MOBILE_PX)
285
+ })
286
+
287
+ it('resolves a landscape source against the breakpoint width cap', () => {
288
+ renderWithProviders(
289
+ <MessageAttachment.Image.Sent
290
+ items={[
291
+ {
292
+ src: 'https://cdn.example.com/wide.jpg',
293
+ width: 1920,
294
+ height: 1080,
295
+ },
296
+ ]}
297
+ />
298
+ )
299
+ // 436 x 1.7778 = 775.11, over both width caps — so 326px (309px on
300
+ // mobile) governs and the card resolves to 330 x 187.
301
+ expect(heightBoundWidthPx(mediaBox('Open image 1 of 1'))).toBeGreaterThan(
302
+ WIDTH_CAP_WEB_PX
303
+ )
304
+ })
305
+
306
+ it('reserves the 4:3 landscape box until the image decodes, then adopts its real ratio', () => {
307
+ renderWithProviders(
308
+ <MessageAttachment.Image.Sent src="https://cdn.example.com/unsized.jpg" />
309
+ )
310
+ expect(
311
+ Number(mediaBox('Open image 1 of 1').style.aspectRatio)
312
+ ).toBeCloseTo(326 / 246, 4)
313
+
314
+ // Scoped to the tile: the always-mounted `ImageViewer` renders a
315
+ // second `<img>` for the same source.
316
+ const img = mediaBox('Open image 1 of 1').querySelector(
317
+ 'img'
318
+ ) as HTMLImageElement
319
+ Object.defineProperty(img, 'naturalWidth', {
320
+ value: 1080,
321
+ configurable: true,
322
+ })
323
+ Object.defineProperty(img, 'naturalHeight', {
324
+ value: 1920,
325
+ configurable: true,
326
+ })
327
+ fireEvent.load(img)
328
+
329
+ expect(
330
+ Number(mediaBox('Open image 1 of 1').style.aspectRatio)
331
+ ).toBeCloseTo(0.5625, 4)
332
+ })
333
+
334
+ it('leaves a stacked grid on its fixed aspect', () => {
335
+ renderWithProviders(
336
+ <MessageAttachment.Image.Sent
337
+ items={Array.from({ length: 4 }, (_, i) => ({
338
+ src: `https://cdn.example.com/${i}.jpg`,
339
+ width: 1080,
340
+ height: 1920,
341
+ }))}
342
+ />
343
+ )
344
+ // 379x286 is the content box of Figma's 383x290 stacked card.
345
+ const grid = mediaBox('Open image 1 of 4')
346
+ expect(grid).toHaveClass('aspect-[379/286]')
347
+ expect(grid.style.width).toBe('')
348
+ expect(grid.style.aspectRatio).toBe('')
349
+ })
350
+ })
351
+
352
+ describe('MessageAttachment.Bubble geometry overrides', () => {
353
+ // `widthClassName` / `paddingClassName` / `radiusClassName` REPLACE
354
+ // their defaults outright rather than merging: an appended radius and
355
+ // the corner table have identical specificity, so Tailwind's emitted
356
+ // source order — not argument order — would pick the winner. Document
357
+ // and audio rows keep the 280px / 8px defaults; media cards override
358
+ // all three.
359
+ it('keeps the default width, padding and corner utilities when nothing is overridden', () => {
360
+ renderWithProviders(
361
+ <MessageAttachment.File.Sent
362
+ src="https://cdn.example.com/file.zip"
363
+ filename="file.zip"
364
+ />
365
+ )
366
+ expect(screen.getByTestId('file-attachment')).toHaveClass(
367
+ 'w-[280px]',
368
+ 'px-2',
369
+ 'py-2',
370
+ 'rounded-tl-[18px]'
371
+ )
372
+ })
373
+
374
+ it('replaces every default on a media card instead of merging', () => {
375
+ renderWithProviders(
376
+ <MessageAttachment.Image.Sent src="https://cdn.example.com/photo.jpg" />
377
+ )
378
+ const bubble = screen.getByTestId('image-attachment')
379
+ expect(bubble).toHaveClass('w-fit', 'p-0.5', 'rounded-[20px]')
380
+ expect(bubble.className).not.toMatch(
381
+ /w-\[280px\]|px-2|py-2|rounded-tl-\[18px\]/
382
+ )
383
+ })
384
+ })
385
+
229
386
  describe('MessageAttachment.Pdf', () => {
230
387
  it('renders the compact row with filename + meta', () => {
231
388
  renderWithProviders(
@@ -739,6 +896,26 @@ describe('MessageAttachment lazy-loading defaults', () => {
739
896
  expect(first?.getAttribute('loading')).toBe('eager')
740
897
  expect(second?.getAttribute('loading')).toBe('lazy')
741
898
  })
899
+
900
+ it('does not mount the viewer `<img>` while the lightbox is closed', () => {
901
+ renderWithProviders(
902
+ <MessageAttachment.Image.Sent
903
+ src="https://cdn.example.com/photo.jpg"
904
+ alt="Photo"
905
+ />
906
+ )
907
+ // `ViewerShell` keeps its `<dialog>` mounted so the open / close
908
+ // transition can play, and the viewer image is `loading="eager"`
909
+ // on the raw (un-optimised) source. The element being absent
910
+ // while closed is therefore the only thing stopping a scrolled
911
+ // thread from downloading every full-resolution original.
912
+ expect(screen.getByTestId('image-viewer').querySelector('img')).toBeNull()
913
+
914
+ fireEvent.click(screen.getByLabelText('Open image 1 of 1'))
915
+ expect(
916
+ screen.getByTestId('image-viewer').querySelector('img')
917
+ ).not.toBeNull()
918
+ })
742
919
  })
743
920
 
744
921
  describe('Video', () => {
@@ -233,11 +233,7 @@ const VideoBubbleRow: React.FC<InternalVideoRowProps> = ({
233
233
  data-testid="video-attachment"
234
234
  >
235
235
  <div className="relative">
236
- <MediaStackGrid
237
- tiles={tiles}
238
- onTileActivate={handleActivate}
239
- className="overflow-hidden rounded-md"
240
- />
236
+ <MediaStackGrid tiles={tiles} onTileActivate={handleActivate} />
241
237
  </div>
242
238
 
243
239
  <VideoViewer
@@ -21,6 +21,31 @@ export interface BubbleProps {
21
21
  * fully rounded.
22
22
  */
23
23
  groupPosition?: BubbleGroupPosition
24
+ /**
25
+ * Width utility that REPLACES the default `'w-[280px]'`. Supply the
26
+ * whole declaration (`'w-fit'`, `'w-[383px] max-w-full'`) — nothing
27
+ * is merged with the default.
28
+ */
29
+ widthClassName?: string
30
+ /**
31
+ * Padding utility that REPLACES the default `'px-2 py-2'`. Supply
32
+ * the full inset (`'p-0.5'`, `'py-5 pl-5 pr-3'`).
33
+ */
34
+ paddingClassName?: string
35
+ /**
36
+ * Corner utility that REPLACES the `groupPosition`-derived corner
37
+ * table entirely. Opting in means opting OUT of same-author corner
38
+ * flattening — the bubble renders the supplied radius on all four
39
+ * corners. That is what the attachment redesign wants: Figma draws
40
+ * media cards at a uniform r20 even when a caption bubble sits 4px
41
+ * below (nodes `1973:12776`, `1973:20239`).
42
+ *
43
+ * It replaces rather than appends because appending cannot be made
44
+ * reliable — an appended `rounded-[20px]` and the corner table have
45
+ * identical CSS specificity, so Tailwind's emitted source order,
46
+ * not argument order, would pick the winner.
47
+ */
48
+ radiusClassName?: string
24
49
  className?: string
25
50
  children: React.ReactNode
26
51
  'data-testid'?: string
@@ -119,12 +144,16 @@ const Bubble: React.FC<BubbleProps> = ({
119
144
  text,
120
145
  bordered = true,
121
146
  groupPosition = 'single',
147
+ widthClassName = 'w-[280px]',
148
+ paddingClassName = 'px-2 py-2',
149
+ radiusClassName,
122
150
  className,
123
151
  children,
124
152
  'data-testid': dataTestId,
125
153
  }) => {
126
154
  const hasText = text != null && text !== ''
127
155
  const cornerClasses =
156
+ radiusClassName ??
128
157
  CORNER_CLASSES_BY_SIDE_AND_POSITION[sideForVariant(variant)][groupPosition]
129
158
 
130
159
  return (
@@ -132,13 +161,17 @@ const Bubble: React.FC<BubbleProps> = ({
132
161
  data-testid={dataTestId}
133
162
  data-group-position={groupPosition}
134
163
  className={classNames(
135
- // 280px-wide bubble matches the mobile chat attachment width
136
- // and keeps the document / image / audio bubbles visually
137
- // consistent inside the conversation timeline. The 8px / 16px
138
- // inset matches `--str-chat__spacing-2 --str-chat__spacing-4`
139
- // so attachments share the same hit / negative-space rhythm
140
- // as the surrounding `CustomMessage` text bubbles.
141
- 'relative w-[280px] overflow-hidden px-2 py-2',
164
+ // Defaults describe a 280px-wide document / audio / link
165
+ // bubble: that width matches the mobile chat attachment and
166
+ // the 8px / 16px inset matches `--str-chat__spacing-2
167
+ // --str-chat__spacing-4`, so attachments share the same hit /
168
+ // negative-space rhythm as the surrounding `CustomMessage`
169
+ // text bubbles. Media cards override both — Figma sizes them
170
+ // to the media (330 / 383px) with a 2px frame instead.
171
+ 'relative',
172
+ widthClassName,
173
+ 'overflow-hidden',
174
+ paddingClassName,
142
175
  cornerClasses,
143
176
  BUBBLE_BG_BY_VARIANT[variant],
144
177
  BUBBLE_TEXT_BY_VARIANT[variant],
@@ -80,22 +80,31 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
80
80
  }
81
81
  data-testid="image-viewer"
82
82
  >
83
- <img
84
- // Forcing a key swap on item change ensures React replaces the
85
- // `<img>` cleanly between siblings otherwise the previous
86
- // image stays painted for a frame while the new `src`
87
- // decodes, which reads as a stutter at carousel pace.
88
- key={`${index}:${item.src}`}
89
- src={item.src}
90
- alt={item.alt ?? filename}
91
- draggable={false}
92
- // The user has explicitly opened the viewer, so we want the
93
- // active image to appear immediately rather than fall to the
94
- // browser's lazy-load heuristics.
95
- loading="eager"
96
- decoding="async"
97
- className="mes-media-viewer__image"
98
- />
83
+ {open ? (
84
+ <img
85
+ // Forcing a key swap on item change ensures React replaces
86
+ // the `<img>` cleanly between siblings otherwise the
87
+ // previous image stays painted for a frame while the new
88
+ // `src` decodes, which reads as a stutter at carousel pace.
89
+ key={`${index}:${item.src}`}
90
+ src={item.src}
91
+ alt={item.alt ?? filename}
92
+ draggable={false}
93
+ // The user has explicitly opened the viewer, so we want the
94
+ // active image to appear immediately rather than fall to the
95
+ // browser's lazy-load heuristics. Which is exactly why the
96
+ // element is gated on `open`: `ViewerShell` keeps its
97
+ // `<dialog>` mounted across open/close so the platform
98
+ // transition can play, and an eager `<img>` inside a closed
99
+ // dialog made every image bubble in a thread download its
100
+ // full-resolution original — note the viewer intentionally
101
+ // uses the raw `item.src`, not the CDN-optimised URL the
102
+ // bubble tile renders.
103
+ loading="eager"
104
+ decoding="async"
105
+ className="mes-media-viewer__image"
106
+ />
107
+ ) : null}
99
108
 
100
109
  {items.length > 1 ? (
101
110
  <CarouselNav
@@ -21,11 +21,76 @@ export interface MediaStackGridProps {
21
21
  * "+N more" overflow indicator. Defaults to `4`.
22
22
  */
23
23
  maxVisible?: number
24
+ /**
25
+ * Aspect ratio (width / height) for the SINGLE-tile layout. Applies
26
+ * to every media kind, not just images. When omitted the 1-tile
27
+ * branch keeps today's `aspect-square`. Ignored for 2+ tiles, whose
28
+ * grid aspect is fixed.
29
+ */
30
+ singleAspectRatio?: number
24
31
  className?: string
25
32
  }
26
33
 
34
+ // The tile is the focusable element, and its focus ring has to be drawn
35
+ // INSIDE it. Figma leaves only a 2px frame around the media and `Bubble`
36
+ // clips its own overflow, so an outset ring is swallowed by the frame;
37
+ // an inset ring or a negative-offset `outline` on the tile is covered by
38
+ // the media, which is `absolute inset-0`. A positioned `::after` is the
39
+ // last child in tree order, so it paints above the media, inherits the
40
+ // tile's outer-corner radius, and ignores every ancestor clip. Same
41
+ // two-tone treatment as before — a dark band over a white ring — so it
42
+ // stays legible on light and dark photos alike. Verified in Chromium.
27
43
  const TILE_SHELL =
28
- 'relative block size-full overflow-hidden bg-black/5 outline-none focus-visible:ring-2 focus-visible:ring-white/80 focus-visible:ring-offset-2 focus-visible:ring-offset-black'
44
+ 'relative block size-full overflow-hidden bg-black/5 outline-none after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-white/80 focus-visible:after:ring-offset-2 focus-visible:after:ring-offset-black'
45
+
46
+ // Figma rounds only the four OUTER corners of a media cluster and leaves
47
+ // every inner edge square: on `attachment-single-image` 1973:13863 the
48
+ // `left` column carries `rectangleCornerRadii [18,0,0,18]` and `right`
49
+ // `[0,18,18,0]`, while the tiles themselves carry none.
50
+ //
51
+ // So the radius lives on the tiles, keyed by layout and DOM position.
52
+ // A single `overflow-hidden rounded-[18px]` container would be shorter,
53
+ // but it also clips the tiles' `focus-visible` ring — an outset
54
+ // `box-shadow` — and in the 1-tile layout the tile fills the container
55
+ // exactly, so keyboard users would get no focus indicator at all.
56
+ // Neither `ring-inset` nor a negative `outline-offset` rescues that:
57
+ // the tile's `<img>` is `absolute inset-0` and paints over both.
58
+ // Measured in Chromium, not assumed.
59
+ const OUTER_CORNER_CLASSES: Record<number, readonly string[]> = {
60
+ 1: ['rounded-[18px]'],
61
+ 2: ['rounded-l-[18px]', 'rounded-r-[18px]'],
62
+ 3: ['rounded-l-[18px]', 'rounded-tr-[18px]', 'rounded-br-[18px]'],
63
+ 4: [
64
+ 'rounded-tl-[18px]',
65
+ 'rounded-tr-[18px]',
66
+ 'rounded-bl-[18px]',
67
+ 'rounded-br-[18px]',
68
+ ],
69
+ }
70
+
71
+ // Figma fits a single media item inside a max 326x436 content box on
72
+ // web and 309x436 on mobile, preserving its aspect ratio, and the card
73
+ // then adopts the resulting box — so `object-fit: cover` performs no
74
+ // crop and nothing is letterboxed.
75
+ //
76
+ // The fit is `width = min(widthCap, heightCap * ratio)`, with
77
+ // `aspect-ratio` supplying the other axis. Only the width cap is
78
+ // breakpoint-dependent, so it travels as a custom property while the
79
+ // ratio-derived term resolves to a concrete pixel value. Together they
80
+ // reproduce all eight measured frames (`2032:22651` / `23000` /
81
+ // `23353` / `23707` plus their mobile counterparts) to within Figma's
82
+ // float rounding — including the 9:16 portrait, which renders 246x436
83
+ // on web *and* mobile precisely because the height cap is NOT
84
+ // breakpoint-dependent: a width-only rule would give mobile 309x547.6.
85
+ //
86
+ // The property name is spelled out again inside `SINGLE_WIDTH_CAPS` on
87
+ // purpose. Tailwind extracts classes by scanning source text, so an
88
+ // interpolated `` `[${SINGLE_WIDTH_CAP_PROPERTY}:309px]` `` would emit
89
+ // no CSS at all and silently drop the width cap. Keep it literal.
90
+ const SINGLE_WIDTH_CAP_PROPERTY = '--mes-single-width-cap'
91
+ const SINGLE_WIDTH_CAPS =
92
+ '[--mes-single-width-cap:309px] sm:[--mes-single-width-cap:326px]'
93
+ const SINGLE_HEIGHT_CAP_PX = 436
29
94
 
30
95
  /**
31
96
  * Adaptive grid used by stacked image / video attachments. Layouts:
@@ -36,14 +101,16 @@ const TILE_SHELL =
36
101
  * - 4 tiles — 2×2 grid
37
102
  * - 5+ — 2×2 grid with the bottom-right tile showing "+N more"
38
103
  *
39
- * The grid is square-ish overall (1:1 for 1, 16:9 for 2, 4:3 for 3+) so
40
- * stacks fit comfortably inside the bubble width without dominating the
41
- * conversation.
104
+ * A single tile adopts `singleAspectRatio` when supplied and otherwise
105
+ * stays square; 2 tiles sit in a 16:9 row, 3 in a 4:3 box, and 4 in
106
+ * the 379x286 content box Figma draws for a stacked media card. Only
107
+ * the cluster's four outer corners round — see `OUTER_CORNER_CLASSES`.
42
108
  */
43
109
  const MediaStackGrid: React.FC<MediaStackGridProps> = ({
44
110
  tiles,
45
111
  onTileActivate,
46
112
  maxVisible = 4,
113
+ singleAspectRatio,
47
114
  className,
48
115
  }) => {
49
116
  const total = tiles.length
@@ -53,7 +120,11 @@ const MediaStackGrid: React.FC<MediaStackGridProps> = ({
53
120
  const overflow = total - visible.length
54
121
 
55
122
  const renderTile = (tile: MediaStackTile, index: number, extra?: React.ReactNode) => {
56
- const sharedClass = classNames(TILE_SHELL, 'h-full w-full')
123
+ const sharedClass = classNames(
124
+ TILE_SHELL,
125
+ 'h-full w-full',
126
+ OUTER_CORNER_CLASSES[visible.length]?.[index]
127
+ )
57
128
  if (onTileActivate) {
58
129
  return (
59
130
  <button
@@ -76,9 +147,29 @@ const MediaStackGrid: React.FC<MediaStackGridProps> = ({
76
147
  )
77
148
  }
78
149
 
150
+ let singleTileStyle: React.CSSProperties | undefined
151
+ if (singleAspectRatio) {
152
+ singleTileStyle = {
153
+ aspectRatio: singleAspectRatio,
154
+ // Hundredths keep the emitted declaration readable in devtools;
155
+ // the rounding error is well under a device pixel.
156
+ width: `min(var(${SINGLE_WIDTH_CAP_PROPERTY}), ${
157
+ Math.round(SINGLE_HEIGHT_CAP_PX * singleAspectRatio * 100) / 100
158
+ }px)`,
159
+ // Only bites in a container narrower than the width cap.
160
+ maxWidth: '100%',
161
+ }
162
+ }
163
+
79
164
  if (visible.length === 1) {
80
165
  return (
81
- <div className={classNames('aspect-square w-full', className)}>
166
+ <div
167
+ className={classNames(
168
+ singleAspectRatio ? SINGLE_WIDTH_CAPS : 'aspect-square w-full',
169
+ className
170
+ )}
171
+ style={singleTileStyle}
172
+ >
82
173
  {renderTile(visible[0], 0)}
83
174
  </div>
84
175
  )
@@ -116,7 +207,14 @@ const MediaStackGrid: React.FC<MediaStackGridProps> = ({
116
207
  return (
117
208
  <div
118
209
  className={classNames(
119
- 'grid aspect-[4/3] w-full grid-cols-2 grid-rows-2 gap-0.5',
210
+ // 379x286 is the content box of Figma's 383x290 stacked-media
211
+ // card (`attachment-single-image` 1973:13863) once its 2px
212
+ // frame is removed. Pinning the aspect on the container — as
213
+ // this branch has always done — makes the card resolve to
214
+ // exactly 383x290, and the 2px gutters then split it into
215
+ // 188.5x142 tiles (Figma draws 188x142; its own 383 does not
216
+ // divide evenly, so half a pixel goes to the columns).
217
+ 'grid aspect-[379/286] w-full grid-cols-2 grid-rows-2 gap-0.5',
120
218
  className
121
219
  )}
122
220
  >
@@ -35,7 +35,6 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
35
35
  customChannelActions,
36
36
  renderChannelActions,
37
37
  onParticipantNameClick,
38
- onBroadcastClick,
39
38
  renderMessage,
40
39
  onMessageLinkClick,
41
40
  showChannelInfo,
@@ -261,7 +260,6 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
261
260
  customChannelActions={customChannelActions}
262
261
  renderChannelActions={renderChannelActions}
263
262
  onParticipantNameClick={onParticipantNameClick}
264
- onBroadcastClick={onBroadcastClick}
265
263
  renderMessage={renderMessage}
266
264
  onMessageLinkClick={onMessageLinkClick}
267
265
  showChannelInfo={showChannelInfo}