From 84965748355611e4787075a8bdf14d84666d019f Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 11 Mar 2026 14:09:15 +0100 Subject: [PATCH 1/4] feat: redesign failed image placeholder --- src/components/Attachment/ModalGallery.tsx | 214 +++++++++++++++--- .../Attachment/styling/ModalGallery.scss | 39 ++++ src/components/Gallery/BaseImage.tsx | 16 +- .../Gallery/__tests__/BaseImage.test.js | 21 +- .../Gallery/__tests__/ModalGallery.test.js | 76 ++++++- 5 files changed, 324 insertions(+), 42 deletions(-) diff --git a/src/components/Attachment/ModalGallery.tsx b/src/components/Attachment/ModalGallery.tsx index 2d23773665..1ae9e65d90 100644 --- a/src/components/Attachment/ModalGallery.tsx +++ b/src/components/Attachment/ModalGallery.tsx @@ -1,14 +1,20 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import clsx from 'clsx'; -import type { GalleryItem } from '../Gallery'; -import { BaseImage, Gallery as DefaultGallery, GalleryUI } from '../Gallery'; +import type { BaseImageProps, GalleryItem } from '../Gallery'; +import { + BaseImage as DefaultBaseImage, + Gallery as DefaultGallery, + GalleryUI, +} from '../Gallery'; import { GlobalModal } from '../Modal'; import { useComponentContext, useTranslationContext } from '../../context'; +import { IconArrowRotateClockwise } from '../Icons'; import { VideoThumbnail } from '../VideoPlayer/VideoThumbnail'; import { CloseButtonOnModalOverlay } from '../Modal/CloseButtonOnModalOverlay'; const MAX_VISIBLE_THUMBNAILS = 4; +const RETRY_QUERY_KEY = 'str-chat-retry'; export type ModalGalleryProps = { /** Array of media attachments to display */ @@ -18,10 +24,14 @@ export type ModalGalleryProps = { }; export const ModalGallery = ({ className, items, modalClassName }: ModalGalleryProps) => { - const { t } = useTranslationContext(); - const { Gallery = DefaultGallery, Modal = GlobalModal } = useComponentContext(); + const { + BaseImage = DefaultBaseImage, + Gallery = DefaultGallery, + Modal = GlobalModal, + } = useComponentContext(); const [modalOpen, setModalOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); + const usesDefaultBaseImage = BaseImage === DefaultBaseImage; const closeModal = useCallback(() => { setModalOpen(false); @@ -49,33 +59,17 @@ export const ModalGallery = ({ className, items, modalClassName }: ModalGalleryP const showOverlay = isLastVisible && overflowCount > 0; return ( - + overflowCount={overflowCount} + showOverlay={showOverlay} + /> ); })} @@ -90,3 +84,163 @@ export const ModalGallery = ({ className, items, modalClassName }: ModalGalleryP ); }; + +type ThumbnailButtonProps = { + BaseImage: React.ComponentType; + baseImageUsesDefaultBehavior: boolean; + index: number; + item: GalleryItem; + itemCount: number; + onClick: () => void; + overflowCount: number; + showOverlay: boolean; +}; + +const ThumbnailButton = ({ + BaseImage, + baseImageUsesDefaultBehavior, + index, + item, + itemCount, + onClick, + overflowCount, + showOverlay, +}: ThumbnailButtonProps) => { + const { t } = useTranslationContext(); + const [isLoadFailed, setIsLoadFailed] = useState(false); + const [retryCount, setRetryCount] = useState(0); + + const imageUrl = item.imageUrl; + const { + onError: itemOnError, + onLoad: itemOnLoad, + ...baseImageProps + } = getBaseImageProps(item); + const retryImageUrl = useMemo( + () => getRetryImageUrl(imageUrl, retryCount), + [imageUrl, retryCount], + ); + const showRetryIndicator = isLoadFailed && !showOverlay; + + const handleButtonClick = () => { + if (showRetryIndicator) { + setIsLoadFailed(false); + setRetryCount((currentRetryCount) => currentRetryCount + 1); + return; + } + + onClick(); + }; + + const buttonLabel = showRetryIndicator + ? t('aria/Retry upload') + : itemCountAwareLabel({ imageIndex: index + 1, itemCount, t }); + + return ( + + ); +}; + +const itemCountAwareLabel = ({ + imageIndex, + itemCount, + t, +}: { + imageIndex: number; + itemCount: number; + t: ReturnType['t']; +}) => + itemCount === 1 + ? t('Open image in gallery') + : t('Open gallery at image {{ index }}', { + index: imageIndex, + }); + +const getRetryImageUrl = (imageUrl?: string, retryCount = 0) => { + if (!imageUrl || retryCount === 0 || /^(blob:|data:)/i.test(imageUrl)) return imageUrl; + + const [baseUrl, hash = ''] = imageUrl.split('#'); + const separator = baseUrl.includes('?') ? '&' : '?'; + + return `${baseUrl}${separator}${RETRY_QUERY_KEY}=${retryCount}${hash ? `#${hash}` : ''}`; +}; + +const getBaseImageProps = (item: GalleryItem): Omit => { + const { + className, + crossOrigin, + decoding, + draggable, + fetchPriority, + height, + loading, + onError, + onLoad, + ref, + sizes, + srcSet, + style, + title, + useMap, + width, + } = item; + + return { + className, + crossOrigin, + decoding, + draggable, + fetchPriority, + height, + loading, + onError, + onLoad, + ref, + sizes, + srcSet, + style, + title, + useMap, + width, + }; +}; diff --git a/src/components/Attachment/styling/ModalGallery.scss b/src/components/Attachment/styling/ModalGallery.scss index 6ebd665a94..2ba14969b8 100644 --- a/src/components/Attachment/styling/ModalGallery.scss +++ b/src/components/Attachment/styling/ModalGallery.scss @@ -1,5 +1,10 @@ @use '../../../styling/utils'; +.str-chat__message { + --str-chat__modal-gallery-load-failed-indicator-background: var(--accent-error); + --str-chat__modal-gallery-load-failed-indicator-color: var(--text-inverse); +} + .str-chat__attachment-list { .str-chat__message-attachment--gallery { $max-width: var(--str-chat__attachment-max-width); @@ -88,6 +93,40 @@ max-width: $max-width; } + &.str-chat__modal-gallery__image--load-failed { + cursor: pointer; + + img { + opacity: 0; + cursor: pointer; + } + } + + .str-chat__modal-gallery__image-load-failed-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + + .str-chat__modal-gallery__image-retry-indicator { + @include utils.flex-row-center; + position: relative; + z-index: 1; + width: 2.75rem; + height: 2.75rem; + border-radius: var(--radius-max); + background-color: var(--str-chat__modal-gallery-load-failed-indicator-background); + color: var(--str-chat__modal-gallery-load-failed-indicator-color); + + svg { + width: 1.125rem; + height: 1.125rem; + } + } + &:hover::after { background-color: rgba(0, 0, 0, 0.1); } diff --git a/src/components/Gallery/BaseImage.tsx b/src/components/Gallery/BaseImage.tsx index 005eea6f12..b2e1e1b277 100644 --- a/src/components/Gallery/BaseImage.tsx +++ b/src/components/Gallery/BaseImage.tsx @@ -3,13 +3,20 @@ import clsx from 'clsx'; import { DownloadButton } from '../Attachment'; import { sanitizeUrl } from '@braintree/sanitize-url'; -export type BaseImageProps = React.ComponentPropsWithRef<'img'>; +export type BaseImageProps = React.ComponentPropsWithRef<'img'> & { + showDownloadButtonOnError?: boolean; +}; export const BaseImage = forwardRef(function BaseImage( { src, ...props }, ref, ) { - const { className: propsClassName, onError: propsOnError } = props; + const { + className: propsClassName, + onError: propsOnError, + showDownloadButtonOnError = true, + ...imgProps + } = props; const [error, setError] = useState(false); const sanitizedUrl = useMemo(() => sanitizeUrl(src), [src]); @@ -24,7 +31,7 @@ export const BaseImage = forwardRef(function B <> (function B ref={ref} src={sanitizedUrl} /> - {/* todo: should we keep the download button?*/} - {error && } + {error && showDownloadButtonOnError && } ); }); diff --git a/src/components/Gallery/__tests__/BaseImage.test.js b/src/components/Gallery/__tests__/BaseImage.test.js index e43afa8f4a..bda2dca106 100644 --- a/src/components/Gallery/__tests__/BaseImage.test.js +++ b/src/components/Gallery/__tests__/BaseImage.test.js @@ -61,7 +61,6 @@ describe('BaseImage', () => { src="src" /> { `); }); + it('should allow disabling the download fallback on load error', () => { + const { container } = renderComponent({ + ...props, + showDownloadButtonOnError: false, + }); + + fireEvent.error(getImage()); + + expect(container).toMatchInlineSnapshot(` +
+ alt +
+ `); + }); + it('should reset error state on image src change', () => { const { container, rerender } = renderComponent(props); diff --git a/src/components/Gallery/__tests__/ModalGallery.test.js b/src/components/Gallery/__tests__/ModalGallery.test.js index 9ae9549b6b..0a85c54b46 100644 --- a/src/components/Gallery/__tests__/ModalGallery.test.js +++ b/src/components/Gallery/__tests__/ModalGallery.test.js @@ -4,13 +4,13 @@ import '@testing-library/jest-dom'; import { ModalGallery } from '../../Attachment/ModalGallery'; import { TranslationProvider } from '../../../context'; +import { ComponentProvider } from '../../../context/ComponentContext'; import { mockTranslationContext } from '../../../mock-builders'; const makeImageItem = (overrides = {}) => ({ fallback: 'test.png', - image_url: 'http://test-image.jpg', + imageUrl: 'http://test-image.jpg', localMetadata: { id: `img-${Math.random()}` }, - thumb_url: 'http://test-thumb.jpg', type: 'image', ...overrides, }); @@ -18,7 +18,14 @@ const makeImageItem = (overrides = {}) => ({ const renderComponent = (props = {}) => render( - + + open ?
{children}
: null, + }} + > + +
, ); @@ -43,6 +50,26 @@ describe('ModalGallery', () => { expect(screen.getAllByTestId('str-chat__base-image')).toHaveLength(2); }); + it('should forward image sizing props to BaseImage', () => { + const imageRef = React.createRef(); + const items = [ + makeImageItem({ + ref: imageRef, + style: { '--original-height': 240, '--original-width': 320 }, + }), + ]; + + renderComponent({ items }); + + const image = screen.getByTestId('str-chat__base-image'); + + expect(image).toHaveStyle({ + '--original-height': '240', + '--original-width': '320', + }); + expect(imageRef.current).toBe(image); + }); + it('should apply --two-images modifier for 2 images', () => { const items = [makeImageItem(), makeImageItem()]; @@ -143,9 +170,9 @@ describe('ModalGallery', () => { it('should pass correct initialIndex to Gallery when clicking second thumbnail', async () => { const items = [ - makeImageItem({ image_url: 'http://img0.jpg' }), - makeImageItem({ image_url: 'http://img1.jpg' }), - makeImageItem({ image_url: 'http://img2.jpg' }), + makeImageItem({ imageUrl: 'http://img0.jpg' }), + makeImageItem({ imageUrl: 'http://img1.jpg' }), + makeImageItem({ imageUrl: 'http://img2.jpg' }), ]; const { container } = renderComponent({ items }); @@ -191,5 +218,42 @@ describe('ModalGallery', () => { const fallbacks = container.querySelectorAll('.str-chat__base-image--load-failed'); expect(fallbacks).toHaveLength(items.length); }); + + it('should render the retry indicator and suppress the legacy download fallback on error', () => { + const items = [makeImageItem()]; + + const { container } = renderComponent({ items }); + + fireEvent.error(screen.getByTestId('str-chat__base-image')); + + expect( + screen.getByTestId('str-chat__modal-gallery__image-load-failed-overlay'), + ).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__message-attachment-file--item-download'), + ).not.toBeInTheDocument(); + }); + + it('should retry loading the image instead of opening the modal when the thumbnail is in error state', async () => { + const items = [makeImageItem()]; + + const { container } = renderComponent({ items }); + + fireEvent.error(screen.getByTestId('str-chat__base-image')); + fireEvent.click(container.querySelector('.str-chat__modal-gallery__image')); + + expect(screen.queryByTitle('Close')).not.toBeInTheDocument(); + expect(screen.getByTestId('str-chat__base-image')).toHaveAttribute( + 'src', + 'http://test-image.jpg?str-chat-retry=1', + ); + + fireEvent.load(screen.getByTestId('str-chat__base-image')); + fireEvent.click(container.querySelector('.str-chat__modal-gallery__image')); + + await waitFor(() => { + expect(container.querySelector('.str-chat__gallery-modal')).toBeInTheDocument(); + }); + }); }); }); From 71e17b93aa4913d7b66ca14cc642951e843d2555 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 11 Mar 2026 14:35:34 +0100 Subject: [PATCH 2/4] fix: remount modal gallery image retries --- src/components/Attachment/ModalGallery.tsx | 90 ++++++++----------- .../Gallery/__tests__/ModalGallery.test.js | 36 ++++++-- 2 files changed, 66 insertions(+), 60 deletions(-) diff --git a/src/components/Attachment/ModalGallery.tsx b/src/components/Attachment/ModalGallery.tsx index 1ae9e65d90..37c59ffda9 100644 --- a/src/components/Attachment/ModalGallery.tsx +++ b/src/components/Attachment/ModalGallery.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import clsx from 'clsx'; import type { BaseImageProps, GalleryItem } from '../Gallery'; @@ -14,7 +14,29 @@ import { VideoThumbnail } from '../VideoPlayer/VideoThumbnail'; import { CloseButtonOnModalOverlay } from '../Modal/CloseButtonOnModalOverlay'; const MAX_VISIBLE_THUMBNAILS = 4; -const RETRY_QUERY_KEY = 'str-chat-retry'; +const BASE_IMAGE_PROP_KEYS = [ + 'className', + 'crossOrigin', + 'decoding', + 'draggable', + 'fetchPriority', + 'height', + 'loading', + 'onError', + 'onLoad', + 'ref', + 'showDownloadButtonOnError', + 'sizes', + 'srcSet', + 'style', + 'title', + 'useMap', + 'width', +] as const satisfies ReadonlyArray>; +type BaseImagePropsWithoutSrc = Omit; +type PartialBaseImagePropMap = Partial< + Record<(typeof BASE_IMAGE_PROP_KEYS)[number], unknown> +>; export type ModalGalleryProps = { /** Array of media attachments to display */ @@ -116,10 +138,6 @@ const ThumbnailButton = ({ onLoad: itemOnLoad, ...baseImageProps } = getBaseImageProps(item); - const retryImageUrl = useMemo( - () => getRetryImageUrl(imageUrl, retryCount), - [imageUrl, retryCount], - ); const showRetryIndicator = isLoadFailed && !showOverlay; const handleButtonClick = () => { @@ -149,6 +167,9 @@ const ThumbnailButton = ({ ) : ( { @@ -159,7 +180,7 @@ const ThumbnailButton = ({ setIsLoadFailed(false); itemOnLoad?.(event); }} - src={retryImageUrl} + src={imageUrl} {...(baseImageUsesDefaultBehavior ? { showDownloadButtonOnError: false } : {})} /> )} @@ -196,51 +217,14 @@ const itemCountAwareLabel = ({ index: imageIndex, }); -const getRetryImageUrl = (imageUrl?: string, retryCount = 0) => { - if (!imageUrl || retryCount === 0 || /^(blob:|data:)/i.test(imageUrl)) return imageUrl; - - const [baseUrl, hash = ''] = imageUrl.split('#'); - const separator = baseUrl.includes('?') ? '&' : '?'; - - return `${baseUrl}${separator}${RETRY_QUERY_KEY}=${retryCount}${hash ? `#${hash}` : ''}`; -}; +const getBaseImageProps = (item: GalleryItem): BaseImagePropsWithoutSrc => { + const baseImageProps: PartialBaseImagePropMap = {}; + for (const key of BASE_IMAGE_PROP_KEYS) { + const value = item[key]; + if (value !== undefined) { + baseImageProps[key] = value; + } + } -const getBaseImageProps = (item: GalleryItem): Omit => { - const { - className, - crossOrigin, - decoding, - draggable, - fetchPriority, - height, - loading, - onError, - onLoad, - ref, - sizes, - srcSet, - style, - title, - useMap, - width, - } = item; - - return { - className, - crossOrigin, - decoding, - draggable, - fetchPriority, - height, - loading, - onError, - onLoad, - ref, - sizes, - srcSet, - style, - title, - useMap, - width, - }; + return baseImageProps as BaseImagePropsWithoutSrc; }; diff --git a/src/components/Gallery/__tests__/ModalGallery.test.js b/src/components/Gallery/__tests__/ModalGallery.test.js index 0a85c54b46..0134f6482c 100644 --- a/src/components/Gallery/__tests__/ModalGallery.test.js +++ b/src/components/Gallery/__tests__/ModalGallery.test.js @@ -15,13 +15,14 @@ const makeImageItem = (overrides = {}) => ({ ...overrides, }); -const renderComponent = (props = {}) => +const renderComponent = (props = {}, componentOverrides = {}) => render( open ?
{children}
: null, + ...componentOverrides, }} > @@ -70,6 +71,26 @@ describe('ModalGallery', () => { expect(imageRef.current).toBe(image); }); + it('should forward supported custom BaseImage props from the gallery item', () => { + const receivedProps = []; + const items = [makeImageItem({ showDownloadButtonOnError: true })]; + const CustomBaseImage = (props) => { + receivedProps.push(props); + + return
; + }; + + renderComponent({ items }, { BaseImage: CustomBaseImage }); + + expect(screen.getByTestId('custom-base-image')).toBeInTheDocument(); + expect(receivedProps[0]).toMatchObject({ + alt: 'User uploaded content', + showDownloadButtonOnError: true, + src: 'http://test-image.jpg', + }); + expect(receivedProps[0]).not.toHaveProperty('localMetadata'); + }); + it('should apply --two-images modifier for 2 images', () => { const items = [makeImageItem(), makeImageItem()]; @@ -238,17 +259,18 @@ describe('ModalGallery', () => { const items = [makeImageItem()]; const { container } = renderComponent({ items }); + const image = screen.getByTestId('str-chat__base-image'); - fireEvent.error(screen.getByTestId('str-chat__base-image')); + fireEvent.error(image); fireEvent.click(container.querySelector('.str-chat__modal-gallery__image')); + const retriedImage = screen.getByTestId('str-chat__base-image'); + expect(screen.queryByTitle('Close')).not.toBeInTheDocument(); - expect(screen.getByTestId('str-chat__base-image')).toHaveAttribute( - 'src', - 'http://test-image.jpg?str-chat-retry=1', - ); + expect(retriedImage).not.toBe(image); + expect(retriedImage).toHaveAttribute('src', 'http://test-image.jpg'); - fireEvent.load(screen.getByTestId('str-chat__base-image')); + fireEvent.load(retriedImage); fireEvent.click(container.querySelector('.str-chat__modal-gallery__image')); await waitFor(() => { From cf2d9db1368f6290814770bdbe341e0a2943806b Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 11 Mar 2026 15:02:22 +0100 Subject: [PATCH 3/4] feat: add modal gallery image loading state --- src/components/Attachment/ModalGallery.tsx | 18 ++++++- .../Attachment/styling/ModalGallery.scss | 48 +++++++++++++++++++ .../Gallery/__tests__/ModalGallery.test.js | 19 ++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/components/Attachment/ModalGallery.tsx b/src/components/Attachment/ModalGallery.tsx index 37c59ffda9..5088b78d9f 100644 --- a/src/components/Attachment/ModalGallery.tsx +++ b/src/components/Attachment/ModalGallery.tsx @@ -7,6 +7,7 @@ import { Gallery as DefaultGallery, GalleryUI, } from '../Gallery'; +import { LoadingIndicator } from '../Loading'; import { GlobalModal } from '../Modal'; import { useComponentContext, useTranslationContext } from '../../context'; import { IconArrowRotateClockwise } from '../Icons'; @@ -129,20 +130,23 @@ const ThumbnailButton = ({ showOverlay, }: ThumbnailButtonProps) => { const { t } = useTranslationContext(); + const imageUrl = item.imageUrl; const [isLoadFailed, setIsLoadFailed] = useState(false); + const [isImageLoading, setIsImageLoading] = useState(Boolean(imageUrl)); const [retryCount, setRetryCount] = useState(0); - const imageUrl = item.imageUrl; const { onError: itemOnError, onLoad: itemOnLoad, ...baseImageProps } = getBaseImageProps(item); const showRetryIndicator = isLoadFailed && !showOverlay; + const showLoadingIndicator = isImageLoading && !showRetryIndicator && !showOverlay; const handleButtonClick = () => { if (showRetryIndicator) { setIsLoadFailed(false); + setIsImageLoading(true); setRetryCount((currentRetryCount) => currentRetryCount + 1); return; } @@ -159,6 +163,7 @@ const ThumbnailButton = ({ aria-label={buttonLabel} className={clsx('str-chat__modal-gallery__image', { 'str-chat__modal-gallery__image--load-failed': showRetryIndicator, + 'str-chat__modal-gallery__image--loading': showLoadingIndicator, })} onClick={handleButtonClick} type='button' @@ -173,10 +178,12 @@ const ThumbnailButton = ({ {...baseImageProps} alt={item.alt ?? t('User uploaded content')} onError={(event) => { + setIsImageLoading(false); setIsLoadFailed(true); itemOnError?.(event); }} onLoad={(event) => { + setIsImageLoading(false); setIsLoadFailed(false); itemOnLoad?.(event); }} @@ -184,6 +191,15 @@ const ThumbnailButton = ({ {...(baseImageUsesDefaultBehavior ? { showDownloadButtonOnError: false } : {})} /> )} + {showLoadingIndicator && ( + + )} {showRetryIndicator && (