@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.
- package/dist/{Card-Cyp6T66U.cjs → Card-D7OY-zlB.cjs} +2 -2
- package/dist/{Card-Cyp6T66U.cjs.map → Card-D7OY-zlB.cjs.map} +1 -1
- package/dist/{Card-DDZI9ZIj.js → Card-DkW8wedR.js} +2 -2
- package/dist/{Card-DDZI9ZIj.js.map → Card-DkW8wedR.js.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/index-BFZ-G_jg.cjs +2 -0
- package/dist/index-BFZ-G_jg.cjs.map +1 -0
- package/dist/{index-DskHxSVN.js → index-CxGxAIhL.js} +1568 -1527
- package/dist/index-CxGxAIhL.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +2 -14
- package/dist/index.js +1 -1
- package/dist/testing.d.ts +1 -3
- package/package.json +2 -2
- package/src/components/ChannelView.tsx +1 -6
- package/src/components/CustomMessage/CustomMessage.stories.tsx +0 -40
- package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +4 -76
- package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +7 -75
- package/src/components/CustomMessage/index.tsx +1 -4
- package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +77 -0
- package/src/components/MessageAttachment/Image/index.tsx +117 -8
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +177 -0
- package/src/components/MessageAttachment/Video/index.tsx +1 -5
- package/src/components/MessageAttachment/_shared/Bubble.tsx +40 -7
- package/src/components/MessageAttachment/_shared/ImageViewer.tsx +25 -16
- package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +105 -7
- package/src/components/MessagingShell/index.tsx +0 -2
- package/src/index.ts +0 -1
- package/src/stream-custom-data.ts +0 -3
- package/src/styles.css +5 -48
- package/src/types.ts +0 -12
- package/dist/index-DBjALsli.cjs +0 -2
- package/dist/index-DBjALsli.cjs.map +0 -1
- 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
|
|
58
|
-
index
|
|
59
|
-
totalCount
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
|
|
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}
|