diff --git a/src/components/Attachment/ModalGallery.tsx b/src/components/Attachment/ModalGallery.tsx index 2d2377366..5088b78d9 100644 --- a/src/components/Attachment/ModalGallery.tsx +++ b/src/components/Attachment/ModalGallery.tsx @@ -1,14 +1,43 @@ import React, { useCallback, 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 { LoadingIndicator } from '../Loading'; 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 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 */ @@ -18,10 +47,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 +82,17 @@ export const ModalGallery = ({ className, items, modalClassName }: ModalGalleryP const showOverlay = isLastVisible && overflowCount > 0; return ( - + overflowCount={overflowCount} + showOverlay={showOverlay} + /> ); })} @@ -90,3 +107,140 @@ 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 imageUrl = item.imageUrl; + const [isLoadFailed, setIsLoadFailed] = useState(false); + const [isImageLoading, setIsImageLoading] = useState(Boolean(imageUrl)); + const [retryCount, setRetryCount] = useState(0); + + 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; + } + + 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 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; + } + } + + return baseImageProps as BaseImagePropsWithoutSrc; +}; diff --git a/src/components/Attachment/styling/ModalGallery.scss b/src/components/Attachment/styling/ModalGallery.scss index 6ebd665a9..45216303d 100644 --- a/src/components/Attachment/styling/ModalGallery.scss +++ b/src/components/Attachment/styling/ModalGallery.scss @@ -1,5 +1,16 @@ @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__modal-gallery-loading-background: var(--chat-bg-incoming); + --str-chat__modal-gallery-loading-highlight: var(--base-white); +} + +.str-chat__message--me { + --str-chat__modal-gallery-loading-background: var(--chat-bg-outgoing); +} + .str-chat__attachment-list { .str-chat__message-attachment--gallery { $max-width: var(--str-chat__attachment-max-width); @@ -11,13 +22,19 @@ grid-template-rows: 50% 50%; overflow: hidden; border-radius: var(--radius-lg); - // Safari needs this - width: fit-content; gap: var(--str-chat__spacing-0_5); + 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); + .str-chat__modal-gallery__image { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + } + &.str-chat__modal-gallery--two-images { grid-template-rows: 1fr; } @@ -74,6 +91,7 @@ align-items: center; justify-content: center; margin: 0; + overflow: hidden; position: relative; // todo: we do not know the image background color; background-color: transparent; @@ -86,6 +104,71 @@ cursor: zoom-in; // CDN resize requires max-width to be present on this element max-width: $max-width; + transition: opacity 150ms ease-in-out; + } + + &.str-chat__modal-gallery__image--loading { + img { + opacity: 0; + } + } + + &.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-loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--str-chat__modal-gallery-loading-background); + background-image: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + var(--str-chat__modal-gallery-loading-highlight) 50%, + rgba(255, 255, 255, 0) 100% + ); + background-repeat: no-repeat; + background-size: 200% 100%; + animation: str-chat__modal-gallery-loading-shimmer 1.2s linear infinite; + pointer-events: none; + } + + .str-chat__modal-gallery__image-loading-overlay .str-chat__loading-indicator { + position: relative; + z-index: 1; + } + + .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 { @@ -98,3 +181,13 @@ } } } + +@keyframes str-chat__modal-gallery-loading-shimmer { + from { + background-position: 200% 0; + } + + to { + background-position: -200% 0; + } +} diff --git a/src/components/Gallery/BaseImage.tsx b/src/components/Gallery/BaseImage.tsx index 005eea6f1..b2e1e1b27 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 e43afa8f4..bda2dca10 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 9ae9549b6..3e992977a 100644 --- a/src/components/Gallery/__tests__/ModalGallery.test.js +++ b/src/components/Gallery/__tests__/ModalGallery.test.js @@ -4,21 +4,29 @@ 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, }); -const renderComponent = (props = {}) => +const renderComponent = (props = {}, componentOverrides = {}) => render( - + + open ?
{children}
: null, + ...componentOverrides, + }} + > + +
, ); @@ -43,6 +51,46 @@ 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 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()]; @@ -143,9 +191,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 }); @@ -177,6 +225,22 @@ describe('ModalGallery', () => { }); describe('BaseImage error handling', () => { + it('should render the loading overlay until the image loads', () => { + const items = [makeImageItem()]; + + renderComponent({ items }); + + expect( + screen.getByTestId('str-chat__modal-gallery__image-loading-overlay'), + ).toBeInTheDocument(); + + fireEvent.load(screen.getByTestId('str-chat__base-image')); + + expect( + screen.queryByTestId('str-chat__modal-gallery__image-loading-overlay'), + ).not.toBeInTheDocument(); + }); + it('should display image fallback on error', () => { const items = [makeImageItem(), makeImageItem()]; @@ -191,5 +255,46 @@ 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 }); + const image = 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__modal-gallery__image-loading-overlay'), + ).toBeInTheDocument(); + expect(retriedImage).not.toBe(image); + expect(retriedImage).toHaveAttribute('src', 'http://test-image.jpg'); + + fireEvent.load(retriedImage); + fireEvent.click(container.querySelector('.str-chat__modal-gallery__image')); + + await waitFor(() => { + expect(container.querySelector('.str-chat__gallery-modal')).toBeInTheDocument(); + }); + }); }); });