diff --git a/examples/vite/index.html b/examples/vite/index.html
index 0c2e031ff..4fbb263d7 100644
--- a/examples/vite/index.html
+++ b/examples/vite/index.html
@@ -9,7 +9,6 @@
-
diff --git a/src/components/Attachment/ModalGallery.tsx b/src/components/Attachment/ModalGallery.tsx
index 89fb98d61..3557c68be 100644
--- a/src/components/Attachment/ModalGallery.tsx
+++ b/src/components/Attachment/ModalGallery.tsx
@@ -147,7 +147,10 @@ const ThumbnailButton = ({
const imageUrl = item.imageUrl;
const [isLoadFailed, setIsLoadFailed] = useState(false);
const [isImageLoading, setIsImageLoading] = useState(Boolean(imageUrl));
- const [retryCount, setRetryCount] = useState(0);
+ // Cache-busting suffix appended to image src on retry. Using a suffix instead of
+ // a React key remount keeps the component (and its placeholder) mounted, preventing
+ // layout shifts and height collapse during the reload attempt.
+ const [retrySuffix, setRetrySuffix] = useState('');
const {
onError: itemOnError,
@@ -161,7 +164,7 @@ const ThumbnailButton = ({
if (showRetryIndicator) {
setIsLoadFailed(false);
setIsImageLoading(true);
- setRetryCount((currentRetryCount) => currentRetryCount + 1);
+ setRetrySuffix(`&retry=${Date.now()}`);
return;
}
@@ -186,9 +189,6 @@ const ThumbnailButton = ({
) : (
{
@@ -201,7 +201,7 @@ const ThumbnailButton = ({
setIsLoadFailed(false);
itemOnLoad?.(event);
}}
- src={imageUrl}
+ src={imageUrl ? `${imageUrl}${retrySuffix}` : imageUrl}
{...(baseImageUsesDefaultBehavior ? { showDownloadButtonOnError: false } : {})}
/>
)}
diff --git a/src/components/Attachment/styling/LinkPreview.scss b/src/components/Attachment/styling/LinkPreview.scss
index e455e8405..7d756e2f7 100644
--- a/src/components/Attachment/styling/LinkPreview.scss
+++ b/src/components/Attachment/styling/LinkPreview.scss
@@ -90,13 +90,17 @@
padding: 0;
img {
- height: var(--str-chat__scraped-image-height);
+ aspect-ratio: 1.91 / 1;
width: 100%;
+ height: auto;
+ // CDN resize requires max-height to be present on this element
+ max-height: var(--str-chat__scraped-image-height);
border-radius: 0;
}
.str-chat__message-attachment-card--header:has(.str-chat__image-placeholder) {
- height: var(--str-chat__scraped-image-height);
+ aspect-ratio: 1.91 / 1;
+ height: auto;
.str-chat__image-placeholder {
border-radius: 0;
diff --git a/src/components/Attachment/styling/ModalGallery.scss b/src/components/Attachment/styling/ModalGallery.scss
index fa37a432d..50547c9a0 100644
--- a/src/components/Attachment/styling/ModalGallery.scss
+++ b/src/components/Attachment/styling/ModalGallery.scss
@@ -1,11 +1,14 @@
@use '../../../styling/utils';
.str-chat__attachment-list {
- .str-chat__message-attachment--gallery {
+ .str-chat__message-attachment--gallery,
+ .str-chat__message-attachment--image {
$max-width: var(--str-chat__attachment-max-width);
+ min-width: 0;
+ max-width: 100%;
.str-chat__modal-gallery {
- background: transparent;
+ background-color: var(--chat-bg);
color: var(--str-chat__text-primary);
border-radius: calc(
var(--str-chat__message-bubble-radius-group-bottom) - var(
@@ -13,41 +16,79 @@
)
);
display: grid;
- grid-template-columns: 50% 50%;
- grid-template-rows: 50% 50%;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
overflow: hidden;
border-radius: var(--str-chat__radius-lg);
- gap: var(--str-chat__space-2);
+ gap: var(--str-chat__spacing-xxs);
width: $max-width;
- max-width: $max-width;
- // CDN resize requires height/max-height to be present on the img element, this rule ensures that
- height: var(--str-chat__attachment-max-width);
+ max-width: 100%;
+ aspect-ratio: 4 / 3;
+
+ $outer-radius: var(--str-chat__radius-lg);
+ $inner-radius: var(--str-chat__radius-md);
.str-chat__modal-gallery__image {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
+ border-radius: $inner-radius;
+
+ &.str-chat__modal-gallery__image--loading,
+ &.str-chat__modal-gallery__image--load-failed {
+ min-height: 0;
+ }
+
+ &:only-child {
+ grid-column: 1 / -1;
+ grid-row: 1 / -1;
+ border-radius: $outer-radius;
+ }
+
+ &:nth-child(1) {
+ border-start-start-radius: $outer-radius;
+ }
+ &:nth-child(2) {
+ border-start-end-radius: $outer-radius;
+ }
+ &:nth-child(3) {
+ border-end-start-radius: $outer-radius;
+ }
+ &:nth-child(4) {
+ border-end-end-radius: $outer-radius;
+ }
}
&.str-chat__modal-gallery--two-images {
grid-template-rows: 1fr;
+
+ .str-chat__modal-gallery__image:nth-child(1) {
+ border-end-start-radius: $outer-radius;
+ }
+
+ .str-chat__modal-gallery__image:nth-child(2) {
+ border-end-end-radius: $outer-radius;
+ }
}
&.str-chat__modal-gallery--three-images {
.str-chat__modal-gallery__image:nth-child(1) {
grid-column: 1;
- grid-row: 1 / span 2; /* Span two rows */
+ grid-row: 1 / span 2;
+ border-end-start-radius: $outer-radius;
}
.str-chat__modal-gallery__image:nth-child(2) {
grid-column: 2;
grid-row: 1;
+ border-start-end-radius: $outer-radius;
}
.str-chat__modal-gallery__image:nth-child(3) {
grid-column: 2;
grid-row: 2;
+ border-end-end-radius: $outer-radius;
}
}
@@ -97,25 +138,38 @@
height: 100%;
object-fit: cover;
cursor: zoom-in;
- // CDN resize requires max-width to be present on this element
+ // CDN resize requires max-width and max-height to be present on this element
max-width: $max-width;
+ max-height: $max-width;
transition: opacity 150ms ease-in-out;
}
&.str-chat__modal-gallery__image--loading {
+ min-height: 200px;
+ align-items: stretch;
+
img {
+ position: absolute;
opacity: 0;
}
+
+ .str-chat__modal-gallery__image-loading-overlay {
+ position: static;
+ flex: 1;
+ min-width: 0;
+ height: auto;
+ }
}
&.str-chat__modal-gallery__image--load-failed {
cursor: pointer;
min-height: 200px;
+ align-items: stretch;
.str-chat__image-placeholder.str-chat__base-image--load-failed {
- width: 100%;
- min-height: 200px;
- align-self: stretch;
+ flex: 1;
+ min-width: 0;
+ height: auto;
}
img {
@@ -139,7 +193,7 @@
display: flex;
align-items: center;
justify-content: center;
- background-color: var(--chat-bg);
+ background-color: var(--str-chat__background-core-overlay-light);
background-image: linear-gradient(
90deg,
var(--str-chat__skeleton-loading-base) 0%,
diff --git a/src/components/BaseImage/BaseImage.tsx b/src/components/BaseImage/BaseImage.tsx
index 77ea3e9a1..70ddea3ce 100644
--- a/src/components/BaseImage/BaseImage.tsx
+++ b/src/components/BaseImage/BaseImage.tsx
@@ -20,14 +20,20 @@ export const BaseImage = forwardRef(function B
showDownloadButtonOnError = false,
...imgProps
} = props;
- const [error, setError] = useState(false);
+ // Store the failed URL rather than a boolean so that when src changes (e.g. retry
+ // with a cache-busting param), the error state clears synchronously via the derived
+ // `error` check below. A boolean would require a useEffect to reset, causing a
+ // 1-frame flash of the error placeholder before the loading state kicks in.
+ const [failedSrc, setFailedSrc] = useState(null);
const { ImagePlaceholder: ImagePlaceholderComponent = DefaultImagePlaceholder } =
useComponentContext();
const sanitizedUrl = useMemo(() => sanitizeUrl(src), [src]);
+ const error = failedSrc === sanitizedUrl;
+
useEffect(
() => () => {
- setError(false);
+ setFailedSrc(null);
},
[sanitizedUrl],
);
@@ -50,7 +56,7 @@ export const BaseImage = forwardRef(function B
alt={propsAlt ?? ''}
className={clsx(propsClassName, 'str-chat__base-image')}
onError={(e) => {
- setError(true);
+ setFailedSrc(sanitizedUrl);
propsOnError?.(e);
}}
ref={ref}
diff --git a/src/components/BaseImage/styling/ImagePlaceholder.scss b/src/components/BaseImage/styling/ImagePlaceholder.scss
index 1d1397364..10170d545 100644
--- a/src/components/BaseImage/styling/ImagePlaceholder.scss
+++ b/src/components/BaseImage/styling/ImagePlaceholder.scss
@@ -7,12 +7,14 @@
min-width: 0;
min-height: 0;
@include utils.flex-col-center;
+ overflow: hidden;
background-color: var(--str-chat__background-core-overlay-light);
svg {
fill: var(--str-chat__accent-neutral);
width: min(var(--str-chat__icon-size-lg, 32px), 50%);
height: min(var(--str-chat__icon-size-lg, 32px), 50%);
+ flex-shrink: 0;
}
}
}
diff --git a/src/components/Gallery/__tests__/ModalGallery.test.tsx b/src/components/Gallery/__tests__/ModalGallery.test.tsx
index f2ef4541f..625a4ea81 100644
--- a/src/components/Gallery/__tests__/ModalGallery.test.tsx
+++ b/src/components/Gallery/__tests__/ModalGallery.test.tsx
@@ -365,7 +365,9 @@ describe('ModalGallery', () => {
screen.getByTestId('str-chat__modal-gallery__image-loading-overlay'),
).toBeInTheDocument();
expect(retriedImage).not.toBe(image);
- expect(retriedImage).toHaveAttribute('src', 'http://test-image.jpg');
+ expect(retriedImage.getAttribute('src')).toMatch(
+ /^http:\/\/test-image\.jpg&retry=\d+$/,
+ );
fireEvent.load(retriedImage);
fireEvent.click(container.querySelector('.str-chat__modal-gallery__image'));
diff --git a/src/components/Message/styling/Message.scss b/src/components/Message/styling/Message.scss
index 989f3c180..271e674fa 100644
--- a/src/components/Message/styling/Message.scss
+++ b/src/components/Message/styling/Message.scss
@@ -182,17 +182,10 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
@media (max-width: 767px) {
--str-chat-message-options-size: var(--str-chat__message-options-button-size);
- & .str-chat__message-bubble {
- width: fit-content(var(--str-chat__message-max-width));
- max-width: min(100%, var(--str-chat__message-max-width));
- }
-
&.str-chat__message--other,
&.str-chat__message--me {
.str-chat__message-inner {
margin-inline: 0;
- width: fit-content;
- max-width: min(100%, var(--str-chat__message-max-width));
.str-chat__message-reactions-host {
justify-self: flex-start;
@@ -204,22 +197,6 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
}
}
}
-
- &.str-chat__message--other {
- .str-chat__message-inner {
- grid-template-columns: auto var(--str-chat-message-options-size);
- }
- }
-
- &.str-chat__message--me {
- .str-chat__message-inner {
- grid-template-columns: var(--str-chat-message-options-size) auto;
- }
-
- .str-chat__message-bubble {
- justify-self: flex-end;
- }
- }
}
a {
@@ -272,9 +249,10 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
'reactions .'
'message-bubble options'
'replies replies';
- grid-template-columns: auto 1fr;
+ grid-template-columns: fit-content(var(--str-chat__message-max-width)) auto;
column-gap: var(--str-chat__space-8);
position: relative;
+ width: fit-content;
.str-chat__message-reactions-host {
display: flex;
@@ -302,7 +280,7 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
.str-chat__message-bubble {
width: fit-content(var(--str-chat__message-max-width));
- max-width: var(--str-chat__message-max-width);
+ max-width: min(var(--str-chat__message-max-width), 100%);
min-width: 0;
display: flex;
flex-direction: column;
@@ -465,7 +443,7 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
'. reactions'
'options message-bubble'
'replies replies';
- grid-template-columns: 1fr auto;
+ grid-template-columns: auto fit-content(var(--str-chat__message-max-width));
margin-inline-start: var(--str-chat-message-options-size);
@@ -540,6 +518,10 @@ $message-bubble-padding: var(--str-chat__spacing-xs);
&.str-chat__message--has-attachment {
--str-chat__message-max-width: var(--str-chat__message-with-attachment-max-width);
+
+ .str-chat__message-bubble {
+ width: 100%;
+ }
}
&.str-chat__message--has-single-attachment.str-chat__message--has-giphy-attachment {
diff --git a/src/components/Poll/styling/Poll.scss b/src/components/Poll/styling/Poll.scss
index 9fc0669c1..91bd22a9d 100644
--- a/src/components/Poll/styling/Poll.scss
+++ b/src/components/Poll/styling/Poll.scss
@@ -12,7 +12,7 @@
)
);
max-width: 100%;
- min-width: 260px;
+ min-width: min(260px, 100%);
font: var(--str-chat__font-caption-default);
button {
diff --git a/src/components/Search/styling/Search.scss b/src/components/Search/styling/Search.scss
index e84876600..f19ad4f3a 100644
--- a/src/components/Search/styling/Search.scss
+++ b/src/components/Search/styling/Search.scss
@@ -67,7 +67,6 @@
min-height: 0;
.str-chat__search-results-header {
- overflow-x: auto;
scrollbar-width: none;
.str-chat__search-results-header__filter-source-buttons {