Skip to content

Commit a1cc63f

Browse files
authored
fix: prevent messages being squashed in narrow message lists (#3153)
1 parent cef7fa5 commit a1cc63f

10 files changed

Lines changed: 103 additions & 55 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/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'));

src/components/Message/styling/Message.scss

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -182,17 +182,10 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
182182
@media (max-width: 767px) {
183183
--str-chat-message-options-size: var(--str-chat__message-options-button-size);
184184

185-
& .str-chat__message-bubble {
186-
width: fit-content(var(--str-chat__message-max-width));
187-
max-width: min(100%, var(--str-chat__message-max-width));
188-
}
189-
190185
&.str-chat__message--other,
191186
&.str-chat__message--me {
192187
.str-chat__message-inner {
193188
margin-inline: 0;
194-
width: fit-content;
195-
max-width: min(100%, var(--str-chat__message-max-width));
196189

197190
.str-chat__message-reactions-host {
198191
justify-self: flex-start;
@@ -204,22 +197,6 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
204197
}
205198
}
206199
}
207-
208-
&.str-chat__message--other {
209-
.str-chat__message-inner {
210-
grid-template-columns: auto var(--str-chat-message-options-size);
211-
}
212-
}
213-
214-
&.str-chat__message--me {
215-
.str-chat__message-inner {
216-
grid-template-columns: var(--str-chat-message-options-size) auto;
217-
}
218-
219-
.str-chat__message-bubble {
220-
justify-self: flex-end;
221-
}
222-
}
223200
}
224201

225202
a {
@@ -272,9 +249,10 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
272249
'reactions .'
273250
'message-bubble options'
274251
'replies replies';
275-
grid-template-columns: auto 1fr;
252+
grid-template-columns: fit-content(var(--str-chat__message-max-width)) auto;
276253
column-gap: var(--str-chat__space-8);
277254
position: relative;
255+
width: fit-content;
278256

279257
.str-chat__message-reactions-host {
280258
display: flex;
@@ -302,7 +280,7 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
302280

303281
.str-chat__message-bubble {
304282
width: fit-content(var(--str-chat__message-max-width));
305-
max-width: var(--str-chat__message-max-width);
283+
max-width: min(var(--str-chat__message-max-width), 100%);
306284
min-width: 0;
307285
display: flex;
308286
flex-direction: column;
@@ -465,7 +443,7 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
465443
'. reactions'
466444
'options message-bubble'
467445
'replies replies';
468-
grid-template-columns: 1fr auto;
446+
grid-template-columns: auto fit-content(var(--str-chat__message-max-width));
469447

470448
margin-inline-start: var(--str-chat-message-options-size);
471449

@@ -540,6 +518,10 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
540518

541519
&.str-chat__message--has-attachment {
542520
--str-chat__message-max-width: var(--str-chat__message-with-attachment-max-width);
521+
522+
.str-chat__message-bubble {
523+
width: 100%;
524+
}
543525
}
544526

545527
&.str-chat__message--has-single-attachment.str-chat__message--has-giphy-attachment {

src/components/Poll/styling/Poll.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
)
1313
);
1414
max-width: 100%;
15-
min-width: 260px;
15+
min-width: min(260px, 100%);
1616
font: var(--str-chat__font-caption-default);
1717

1818
button {

src/components/Search/styling/Search.scss

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@
6767
min-height: 0;
6868

6969
.str-chat__search-results-header {
70-
overflow-x: auto;
7170
scrollbar-width: none;
7271

7372
.str-chat__search-results-header__filter-source-buttons {

0 commit comments

Comments
 (0)