Skip to content

Commit a46aeb2

Browse files
committed
Merge branch 'master' into chore/demo/websocket-event-simulation
2 parents 1eb149b + a1cc63f commit a46aeb2

12 files changed

Lines changed: 178 additions & 56 deletions

File tree

examples/vite/index.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
1010
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" />
1111
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" />
12-
<link rel="preload" as="font" type="font/woff2" crossorigin href="https://fonts.gstatic.com/s/geist/v4/gyByhwUxId8gMEwcGFU.woff2" />
1312
</head>
1413
<body>
1514
<div id="root"></div>

src/components/Attachment/ModalGallery.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,10 @@ const ThumbnailButton = ({
147147
const imageUrl = item.imageUrl;
148148
const [isLoadFailed, setIsLoadFailed] = useState(false);
149149
const [isImageLoading, setIsImageLoading] = useState(Boolean(imageUrl));
150-
const [retryCount, setRetryCount] = useState(0);
150+
// Cache-busting suffix appended to image src on retry. Using a suffix instead of
151+
// a React key remount keeps the component (and its placeholder) mounted, preventing
152+
// layout shifts and height collapse during the reload attempt.
153+
const [retrySuffix, setRetrySuffix] = useState('');
151154

152155
const {
153156
onError: itemOnError,
@@ -161,7 +164,7 @@ const ThumbnailButton = ({
161164
if (showRetryIndicator) {
162165
setIsLoadFailed(false);
163166
setIsImageLoading(true);
164-
setRetryCount((currentRetryCount) => currentRetryCount + 1);
167+
setRetrySuffix(`&retry=${Date.now()}`);
165168
return;
166169
}
167170

@@ -186,9 +189,6 @@ const ThumbnailButton = ({
186189
<VideoThumbnail alt={t('User uploaded content')} src={item.videoThumbnailUrl} />
187190
) : (
188191
<BaseImage
189-
// Remount the image on retry so the browser gets a fresh load attempt and
190-
// BaseImage clears its local load-failed state.
191-
key={retryCount}
192192
{...baseImageProps}
193193
alt={item.alt ?? t('User uploaded content')}
194194
onError={(event) => {
@@ -201,7 +201,7 @@ const ThumbnailButton = ({
201201
setIsLoadFailed(false);
202202
itemOnLoad?.(event);
203203
}}
204-
src={imageUrl}
204+
src={imageUrl ? `${imageUrl}${retrySuffix}` : imageUrl}
205205
{...(baseImageUsesDefaultBehavior ? { showDownloadButtonOnError: false } : {})}
206206
/>
207207
)}

src/components/Attachment/styling/LinkPreview.scss

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,17 @@
9090
padding: 0;
9191

9292
img {
93-
height: var(--str-chat__scraped-image-height);
93+
aspect-ratio: 1.91 / 1;
9494
width: 100%;
95+
height: auto;
96+
// CDN resize requires max-height to be present on this element
97+
max-height: var(--str-chat__scraped-image-height);
9598
border-radius: 0;
9699
}
97100

98101
.str-chat__message-attachment-card--header:has(.str-chat__image-placeholder) {
99-
height: var(--str-chat__scraped-image-height);
102+
aspect-ratio: 1.91 / 1;
103+
height: auto;
100104

101105
.str-chat__image-placeholder {
102106
border-radius: 0;

src/components/Attachment/styling/ModalGallery.scss

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,94 @@
11
@use '../../../styling/utils';
22

33
.str-chat__attachment-list {
4-
.str-chat__message-attachment--gallery {
4+
.str-chat__message-attachment--gallery,
5+
.str-chat__message-attachment--image {
56
$max-width: var(--str-chat__attachment-max-width);
7+
min-width: 0;
8+
max-width: 100%;
69

710
.str-chat__modal-gallery {
8-
background: transparent;
11+
background-color: var(--chat-bg);
912
color: var(--str-chat__text-primary);
1013
border-radius: calc(
1114
var(--str-chat__message-bubble-radius-group-bottom) - var(
1215
--str-chat__attachment-margin
1316
)
1417
);
1518
display: grid;
16-
grid-template-columns: 50% 50%;
17-
grid-template-rows: 50% 50%;
19+
grid-template-columns: 1fr 1fr;
20+
grid-template-rows: 1fr 1fr;
1821
overflow: hidden;
1922
border-radius: var(--str-chat__radius-lg);
20-
gap: var(--str-chat__space-2);
23+
gap: var(--str-chat__spacing-xxs);
2124
width: $max-width;
22-
max-width: $max-width;
23-
// CDN resize requires height/max-height to be present on the img element, this rule ensures that
24-
height: var(--str-chat__attachment-max-width);
25+
max-width: 100%;
26+
aspect-ratio: 4 / 3;
27+
28+
$outer-radius: var(--str-chat__radius-lg);
29+
$inner-radius: var(--str-chat__radius-md);
2530

2631
.str-chat__modal-gallery__image {
2732
width: 100%;
2833
height: 100%;
2934
min-width: 0;
3035
min-height: 0;
36+
border-radius: $inner-radius;
37+
38+
&.str-chat__modal-gallery__image--loading,
39+
&.str-chat__modal-gallery__image--load-failed {
40+
min-height: 0;
41+
}
42+
43+
&:only-child {
44+
grid-column: 1 / -1;
45+
grid-row: 1 / -1;
46+
border-radius: $outer-radius;
47+
}
48+
49+
&:nth-child(1) {
50+
border-start-start-radius: $outer-radius;
51+
}
52+
&:nth-child(2) {
53+
border-start-end-radius: $outer-radius;
54+
}
55+
&:nth-child(3) {
56+
border-end-start-radius: $outer-radius;
57+
}
58+
&:nth-child(4) {
59+
border-end-end-radius: $outer-radius;
60+
}
3161
}
3262

3363
&.str-chat__modal-gallery--two-images {
3464
grid-template-rows: 1fr;
65+
66+
.str-chat__modal-gallery__image:nth-child(1) {
67+
border-end-start-radius: $outer-radius;
68+
}
69+
70+
.str-chat__modal-gallery__image:nth-child(2) {
71+
border-end-end-radius: $outer-radius;
72+
}
3573
}
3674

3775
&.str-chat__modal-gallery--three-images {
3876
.str-chat__modal-gallery__image:nth-child(1) {
3977
grid-column: 1;
40-
grid-row: 1 / span 2; /* Span two rows */
78+
grid-row: 1 / span 2;
79+
border-end-start-radius: $outer-radius;
4180
}
4281

4382
.str-chat__modal-gallery__image:nth-child(2) {
4483
grid-column: 2;
4584
grid-row: 1;
85+
border-start-end-radius: $outer-radius;
4686
}
4787

4888
.str-chat__modal-gallery__image:nth-child(3) {
4989
grid-column: 2;
5090
grid-row: 2;
91+
border-end-end-radius: $outer-radius;
5192
}
5293
}
5394

@@ -97,25 +138,38 @@
97138
height: 100%;
98139
object-fit: cover;
99140
cursor: zoom-in;
100-
// CDN resize requires max-width to be present on this element
141+
// CDN resize requires max-width and max-height to be present on this element
101142
max-width: $max-width;
143+
max-height: $max-width;
102144
transition: opacity 150ms ease-in-out;
103145
}
104146

105147
&.str-chat__modal-gallery__image--loading {
148+
min-height: 200px;
149+
align-items: stretch;
150+
106151
img {
152+
position: absolute;
107153
opacity: 0;
108154
}
155+
156+
.str-chat__modal-gallery__image-loading-overlay {
157+
position: static;
158+
flex: 1;
159+
min-width: 0;
160+
height: auto;
161+
}
109162
}
110163

111164
&.str-chat__modal-gallery__image--load-failed {
112165
cursor: pointer;
113166
min-height: 200px;
167+
align-items: stretch;
114168

115169
.str-chat__image-placeholder.str-chat__base-image--load-failed {
116-
width: 100%;
117-
min-height: 200px;
118-
align-self: stretch;
170+
flex: 1;
171+
min-width: 0;
172+
height: auto;
119173
}
120174

121175
img {
@@ -139,7 +193,7 @@
139193
display: flex;
140194
align-items: center;
141195
justify-content: center;
142-
background-color: var(--chat-bg);
196+
background-color: var(--str-chat__background-core-overlay-light);
143197
background-image: linear-gradient(
144198
90deg,
145199
var(--str-chat__skeleton-loading-base) 0%,

src/components/BaseImage/BaseImage.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,20 @@ export const BaseImage = forwardRef<HTMLImageElement, BaseImageProps>(function B
2020
showDownloadButtonOnError = false,
2121
...imgProps
2222
} = props;
23-
const [error, setError] = useState(false);
23+
// Store the failed URL rather than a boolean so that when src changes (e.g. retry
24+
// with a cache-busting param), the error state clears synchronously via the derived
25+
// `error` check below. A boolean would require a useEffect to reset, causing a
26+
// 1-frame flash of the error placeholder before the loading state kicks in.
27+
const [failedSrc, setFailedSrc] = useState<string | null>(null);
2428
const { ImagePlaceholder: ImagePlaceholderComponent = DefaultImagePlaceholder } =
2529
useComponentContext();
2630

2731
const sanitizedUrl = useMemo(() => sanitizeUrl(src), [src]);
32+
const error = failedSrc === sanitizedUrl;
33+
2834
useEffect(
2935
() => () => {
30-
setError(false);
36+
setFailedSrc(null);
3137
},
3238
[sanitizedUrl],
3339
);
@@ -50,7 +56,7 @@ export const BaseImage = forwardRef<HTMLImageElement, BaseImageProps>(function B
5056
alt={propsAlt ?? ''}
5157
className={clsx(propsClassName, 'str-chat__base-image')}
5258
onError={(e) => {
53-
setError(true);
59+
setFailedSrc(sanitizedUrl);
5460
propsOnError?.(e);
5561
}}
5662
ref={ref}

src/components/BaseImage/styling/ImagePlaceholder.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
min-width: 0;
88
min-height: 0;
99
@include utils.flex-col-center;
10+
overflow: hidden;
1011
background-color: var(--str-chat__background-core-overlay-light);
1112

1213
svg {
1314
fill: var(--str-chat__accent-neutral);
1415
width: min(var(--str-chat__icon-size-lg, 32px), 50%);
1516
height: min(var(--str-chat__icon-size-lg, 32px), 50%);
17+
flex-shrink: 0;
1618
}
1719
}
1820
}

src/components/Dialog/__tests__/DialogPortal.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import React from 'react';
22
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import {
4+
Dropdown,
5+
type DropdownTriggerProps,
6+
useDropdownContext,
7+
} from '../../Form/Dropdown';
38
import { useDialogOnNearestManager } from '../hooks';
49
import { DialogAnchor } from '../service';
510
import { DialogManagerProvider } from '../../../context';
@@ -29,6 +34,32 @@ const DialogFixture = ({
2934
);
3035
};
3136

37+
const DropdownTriggerButton = ({
38+
children,
39+
onClick,
40+
referenceRef,
41+
...props
42+
}: DropdownTriggerProps) => (
43+
<button
44+
{...props}
45+
onClick={onClick}
46+
ref={referenceRef as React.Ref<HTMLButtonElement>}
47+
type='button'
48+
>
49+
{children}
50+
</button>
51+
);
52+
53+
const DropdownItem = ({ label }: { label: string }) => {
54+
const { close } = useDropdownContext();
55+
56+
return (
57+
<button onClick={close} role='menuitem' type='button'>
58+
{label}
59+
</button>
60+
);
61+
};
62+
3263
describe('DialogPortal', () => {
3364
it('does not close dialogs from another manager when clicking in a different manager overlay', async () => {
3465
render(
@@ -118,4 +149,47 @@ describe('DialogPortal', () => {
118149
const results = await axe(document.body);
119150
expect(results).toHaveNoViolations();
120151
});
152+
153+
it('does not close the dialog when Escape is handled by a nested dropdown', async () => {
154+
render(
155+
<DialogManagerProvider>
156+
<DialogFixture
157+
dialogId='dialog-with-dropdown'
158+
testId='dialog-dropdown-content'
159+
trapFocus
160+
>
161+
<Dropdown
162+
TriggerComponent={DropdownTriggerButton}
163+
triggerProps={{ children: 'Duration' }}
164+
>
165+
<DropdownItem label='15 minutes' />
166+
<DropdownItem label='1 hour' />
167+
</Dropdown>
168+
</DialogFixture>
169+
</DialogManagerProvider>,
170+
);
171+
172+
fireEvent.click(screen.getByTestId('open-dialog-with-dropdown'));
173+
expect(screen.getByRole('dialog')).toBeInTheDocument();
174+
175+
fireEvent.click(screen.getByRole('button', { name: 'Duration' }));
176+
await screen.findByRole('menu');
177+
178+
const item = screen.getByRole('menuitem', { name: '15 minutes' });
179+
item.focus();
180+
181+
fireEvent.keyDown(item, { key: 'Escape' });
182+
fireEvent.keyUp(item, { key: 'Escape' });
183+
184+
await waitFor(() => {
185+
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
186+
});
187+
expect(screen.getByRole('dialog')).toBeInTheDocument();
188+
189+
fireEvent.keyUp(document, { key: 'Escape' });
190+
191+
await waitFor(() => {
192+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
193+
});
194+
});
121195
});

src/components/Dialog/service/DialogAnchor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export const DialogAnchor = ({
212212
useEffect(() => {
213213
if (!open) return;
214214
const hideOnEscape = (event: KeyboardEvent) => {
215-
if (event.key !== 'Escape') return;
215+
if (event.key !== 'Escape' || event.defaultPrevented) return;
216216
dialog?.close();
217217
};
218218

src/components/Gallery/__tests__/ModalGallery.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,9 @@ describe('ModalGallery', () => {
365365
screen.getByTestId('str-chat__modal-gallery__image-loading-overlay'),
366366
).toBeInTheDocument();
367367
expect(retriedImage).not.toBe(image);
368-
expect(retriedImage).toHaveAttribute('src', 'http://test-image.jpg');
368+
expect(retriedImage.getAttribute('src')).toMatch(
369+
/^http:\/\/test-image\.jpg&retry=\d+$/,
370+
);
369371

370372
fireEvent.load(retriedImage);
371373
fireEvent.click(container.querySelector('.str-chat__modal-gallery__image'));

0 commit comments

Comments
 (0)