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 {