Skip to content

Commit

Permalink
feat: render BaseImage image fallback within the same img element (#2200
Browse files Browse the repository at this point in the history
)
  • Loading branch information
MartinCupela authored Dec 1, 2023
1 parent e9020c5 commit 2fcd564
Show file tree
Hide file tree
Showing 21 changed files with 367 additions and 129 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,15 @@ Custom UI component to display a user's avatar.

### BaseImage

Custom UI component to display `<img/>` elements resp. a fallback in case of load error. The default resp. custom (from `ComponentContext`) `BaseImage` component is rendered by:
Custom UI component to display image resp. a fallback in case of load error, in `<img/>` element. The default resp. custom (from `ComponentContext`) `BaseImage` component is rendered by:

- <GHComponentLink text='Image' path='/Gallery/Image.tsx'/> - single image attachment in message list
- <GHComponentLink text='Gallery' path='/Gallery/Gallery.tsx'/> - group of image attachments in message list
- <GHComponentLink text='AttachmentPreviewList' path='/MessageInput/AttachmentPreviewList.tsx'/> - image uploads preview in message input (composer)

The `BaseImage` component accepts the same props as `<img/>` element and one additional prop `ImageFallback`. The custom `ImageFallback` should be a React component that again accepts the `<img/>` element props passed to `BaseImage`.
The `BaseImage` component accepts the same props as `<img/>` element.

The [default `BaseImage` component](../../utility-components/base-image) tries to load and display an image and if the load fails, then the default or custom `ImageFallback` (passed through the prop) is rendered.
The [default `BaseImage` component](../../utility-components/base-image) tries to load and display an image and if the load fails, then an SVG image fallback is applied to the `<img/>` element as a CSS mask targeting attached `str-chat__base-image--load-failed` class.

| Type | Default |
|-----------|-----------------------------------------------------------------------|
Expand Down
78 changes: 43 additions & 35 deletions docusaurus/docs/React/components/utility-components/base-image.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,47 +40,55 @@ The default image fallbacks are rendered as follows:

## Usage

The component props are internally forwarded to the underlying `<img/>`. The component supports forwarding `ref` as well.
### Custom image fallback

The default image fallback can be changed by applying a new CSS data image to the fallback mask in the `BaseImage`'s `<img/>` element. The data image has to be assigned to a CSS variable `--str-chat__image-fallback-icon` within the scope of `.str-chat` class. An example follows:

```css

.str-chat {
--str-chat__image-fallback-icon: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iOSIgdmlld0JveD0iMCAwIDEwIDkiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICA8cGF0aCBkPSJNOS4xOTk0OSAwLjMwNTY3MUM4LjkzOTQ5IDAuMDQ1NjcwNyA4LjUxOTQ5IDAuMDQ1NjcwNyA4LjI1OTQ5IDAuMzA1NjcxTDQuOTk5NDkgMy41NTlMMS43Mzk0OSAwLjI5OTAwNEMxLjQ3OTQ5IDAuMDM5MDAzOSAxLjA1OTQ5IDAuMDM5MDAzOSAwLjc5OTQ5MiAwLjI5OTAwNEMwLjUzOTQ5MiAwLjU1OTAwNCAwLjUzOTQ5MiAwLjk3OTAwNCAwLjc5OTQ5MiAxLjIzOUw0LjA1OTQ5IDQuNDk5TDAuNzk5NDkyIDcuNzU5QzAuNTM5NDkyIDguMDE5IDAuNTM5NDkyIDguNDM5IDAuNzk5NDkyIDguNjk5QzEuMDU5NDkgOC45NTkgMS40Nzk0OSA4Ljk1OSAxLjczOTQ5IDguNjk5TDQuOTk5NDkgNS40MzlMOC4yNTk0OSA4LjY5OUM4LjUxOTQ5IDguOTU5IDguOTM5NDkgOC45NTkgOS4xOTk0OSA4LjY5OUM5LjQ1OTQ5IDguNDM5IDkuNDU5NDkgOC4wMTkgOS4xOTk0OSA3Ljc1OUw1LjkzOTQ5IDQuNDk5TDkuMTk5NDkgMS4yMzlDOS40NTI4MyAwLjk4NTY3MSA5LjQ1MjgzIDAuNTU5MDA0IDkuMTk5NDkgMC4zMDU2NzFaIiBmaWxsPSIjNzI3NjdFIi8+Cjwvc3ZnPgo=");
}
```

We can change the mask dimensions or color by applying the following rules to the image's class `.str-chat__base-image--load-failed`, that signals the image load has failed:

```css
:root{
--custom-icon-fill-color: #223344;
--custom-icon-width-and-height: 4rem 4rem;
}

.str-chat__base-image--load-failed {
mask-size: var(--custom-icon-width-and-height);
-webkit-mask-size: var(--custom-icon-width-and-height);
background-color: var(--custom-icon-fill-color);
}
```

### Custom BaseImage

The default `BaseImage` can be overridden by passing a custom component to `Channel` props:


```tsx
import { useRef } from 'react';
import { BaseImage } from 'stream-chat-react';

const CustomImageFallback = () => <img src="unsupported-image-format-fallback.jpg" alt="Unsupported Image Format Fallback" />;

const MyUI = () => {
const imgRef = useRef<HTMLImageElement | null>(null);
const toggleModal = () => {
//...
}
const imageSrc = 'http://link.to/my/image';
const style = {
// custom styles..
}
import {ComponentProps } from 'react';
import { Channel } from 'stream-chat-react';

const CustomBaseImage = (props: ComponentProps<'img'>) => {
// your implementation...
}

export const MyUI = () => {
return (
<BaseImage
alt='My image'
className='custom-class'
data-testid='custom-test-id'
onClick={toggleModal}
src={imageSrc}
style={style}
tabIndex={0}
ref={imgRef}
ImageFallback={ImageFallback}
/>
<Channel BaseImage={CustomBaseImage}>
{{/* more components */ }}
</Channel>
);
}
};
```

## Props

Besides the `img` component props, the component accepts the following:

### ImageFallback

A custom React component to be displayed as a fallback to an image failing to load. The component accepts the `img` props that have originally been passed to `BaseImage` component.
The component accepts the `img` component props.

| Type |
|---------------------|
| `React.ComponentProps<'img'>` |
44 changes: 10 additions & 34 deletions src/components/Gallery/BaseImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,10 @@ import React, { forwardRef, useEffect, useState } from 'react';
import clsx from 'clsx';
import { DownloadButton } from '../Attachment/DownloadButton';

export type ImageFallbackProps = React.ComponentPropsWithoutRef<'img'>;

const DefaultImageFallback = ({ alt, src, title }: ImageFallbackProps) => (
<div
className='str-chat__image-fallback'
data-testid='str-chat__image-fallback'
title={title ?? alt}
>
<svg
className='str-chat__image-fallback__icon'
fill='none'
viewBox='0 0 18 18'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M16 2V16H2V2H16ZM16 0H2C0.9 0 0 0.9 0 2V16C0 17.1 0.9 18 2 18H16C17.1 18 18 17.1 18 16V2C18 0.9 17.1 0 16 0ZM11.14 8.86L8.14 12.73L6 10.14L3 14H15L11.14 8.86Z'
fill='#080707'
/>
</svg>
<DownloadButton assetUrl={src} />
</div>
);

export type BaseImageProps = React.ComponentPropsWithRef<'img'> & {
ImageFallback?: React.ComponentType<ImageFallbackProps>;
};
export type BaseImageProps = React.ComponentPropsWithRef<'img'>;

export const BaseImage = forwardRef<HTMLImageElement, BaseImageProps>(function BaseImage(
{ ImageFallback = DefaultImageFallback, ...props },
{ ...props },
ref,
) {
const { className: propsClassName, onError: propsOnError } = props;
Expand All @@ -43,20 +18,21 @@ export const BaseImage = forwardRef<HTMLImageElement, BaseImageProps>(function B
[props.src],
);

if (props.src && !error) {
return (
return (
<>
<img
data-testid='str-chat__base-image'
{...props}
className={clsx(propsClassName, 'str-chat__base-image')}
className={clsx(propsClassName, 'str-chat__base-image', {
'str-chat__base-image--load-failed': error,
})}
onError={(e) => {
setError(true);
propsOnError?.(e);
}}
ref={ref}
/>
);
}

return <ImageFallback {...props} />;
{error && <DownloadButton assetUrl={props.src} />}
</>
);
});
4 changes: 3 additions & 1 deletion src/components/Gallery/ModalGallery.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import ImageGallery from 'react-image-gallery';
import { useTranslationContext } from '../../context';

import type { Attachment } from 'stream-chat';
import type { DefaultStreamChatGenerics } from '../../types/types';
Expand All @@ -19,14 +20,15 @@ export const ModalGallery = <
props: ModalGalleryProps<StreamChatGenerics>,
) => {
const { images, index } = props;
const { t } = useTranslationContext('ModalGallery');

const formattedArray = useMemo(
() =>
images.map((image) => {
const imageSrc = image.image_url || image.thumb_url || '';
return {
original: imageSrc,
originalAlt: 'User uploaded content',
originalAlt: t('User uploaded content'),
source: imageSrc,
};
}),
Expand Down
86 changes: 43 additions & 43 deletions src/components/Gallery/__tests__/BaseImage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ const props = {
src: 'src',
};
const t = (val) => val;
const FALLBACK_TEST_ID = 'str-chat__image-fallback';
const BASE_IMAGE_TEST_ID = 'str-chat__base-image';
const getImage = () => screen.queryByTestId(BASE_IMAGE_TEST_ID);
const getFallback = () => screen.queryByTestId(FALLBACK_TEST_ID);

const renderComponent = (props = {}) =>
render(
Expand Down Expand Up @@ -48,43 +46,48 @@ describe('BaseImage', () => {
</div>
`);
});
it('should render an image fallback when missing src', () => {
renderComponent();
expect(screen.queryByTestId(FALLBACK_TEST_ID)).toBeInTheDocument();
});

it('should forward img props to fallback', () => {
const props = { alt: 'alt', title: 'title' };
const ImageFallback = (props) => <div>{JSON.stringify(props)}</div>;
renderComponent({ ...props, ImageFallback });
expect(screen.getByText(JSON.stringify(props))).toBeInTheDocument();
});

it('should apply img title to fallback root div title', () => {
const props = { alt: 'alt', title: 'title' };
renderComponent(props);
expect(screen.queryByTitle(props.title)).toBeInTheDocument();
});

it('should apply img alt to fallback root div title if img title is falsy', () => {
const props = { alt: 'alt' };
renderComponent({ alt: 'alt' });
expect(screen.queryByTitle(props.alt)).toBeInTheDocument();
});

it('should render an image fallback on load error', () => {
renderComponent(props);
const { container } = renderComponent(props);
const img = getImage();
expect(getImage()).toBeInTheDocument();
expect(getFallback()).not.toBeInTheDocument();

fireEvent.error(img);
expect(img).not.toBeInTheDocument();
expect(getFallback()).toBeInTheDocument();
expect(container).toMatchInlineSnapshot(`
<div>
<img
alt="alt"
class="str-chat__base-image str-chat__base-image--load-failed"
data-testid="str-chat__base-image"
src="src"
/>
<a
aria-label="Attachment"
class="str-chat__message-attachment-file--item-download"
download=""
href="src"
target="_blank"
>
<svg
class="str-chat__message-attachment-download-icon"
data-testid="download"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.35 10.04C18.67 6.59 15.64 4 12 4C9.11 4 6.6 5.64 5.35 8.04C2.34 8.36 0 10.91 0 14C0 17.31 2.69 20 6 20H19C21.76 20 24 17.76 24 15C24 12.36 21.95 10.22 19.35 10.04ZM19 18H6C3.79 18 2 16.21 2 14C2 11.95 3.53 10.24 5.56 10.03L6.63 9.92L7.13 8.97C8.08 7.14 9.94 6 12 6C14.62 6 16.88 7.86 17.39 10.43L17.69 11.93L19.22 12.04C20.78 12.14 22 13.45 22 15C22 16.65 20.65 18 19 18ZM13.45 10H10.55V13H8L12 17L16 13H13.45V10Z"
fill="black"
/>
</svg>
</a>
</div>
`);
});

it('should reset error state on image src change', () => {
const { rerender } = renderComponent(props);
const { container, rerender } = renderComponent(props);

fireEvent.error(getImage());

Expand All @@ -93,8 +96,15 @@ describe('BaseImage', () => {
<BaseImage src={'new-src'} />
</TranslationProvider>,
);
expect(getImage()).toBeInTheDocument();
expect(getFallback()).not.toBeInTheDocument();
expect(container).toMatchInlineSnapshot(`
<div>
<img
class="str-chat__base-image"
data-testid="str-chat__base-image"
src="new-src"
/>
</div>
`);
});

it('should execute a custom onError callback on load error', () => {
Expand All @@ -104,14 +114,4 @@ describe('BaseImage', () => {
fireEvent.error(getImage());
expect(onError).toHaveBeenCalledTimes(1);
});
it('should render a custom image fallback on load error', () => {
const testId = 'custom-fallback';
const ImageFallback = () => <div data-testid={testId}>Custom Fallback</div>;
renderComponent({ ...props, ImageFallback });

fireEvent.error(getImage());
expect(screen.queryByTestId(testId)).toBeInTheDocument();
expect(getImage()).not.toBeInTheDocument();
expect(getFallback()).not.toBeInTheDocument();
});
});
27 changes: 17 additions & 10 deletions src/components/MessageInput/AttachmentPreviewList.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { useCallback } from 'react';
import clsx from 'clsx';
import React, { useCallback, useState } from 'react';

import { BaseImage as DefaultBaseImage } from '../Gallery';
import { FileIcon } from '../ReactFileUtilities';
import { useComponentContext } from '../../context';
import { useMessageInputContext } from '../../context/MessageInputContext';
import { useComponentContext, useMessageInputContext } from '../../context';
import { useFileState } from './hooks/useFileState';

import { CloseIcon, DownloadIcon, LoadingIndicatorIcon, RetryIcon } from './icons';
Expand All @@ -30,9 +30,10 @@ export const AttachmentPreviewList = () => {

type PreviewItemProps = { id: string };

const ImagePreviewItem = ({ id }: PreviewItemProps) => {
export const ImagePreviewItem = ({ id }: PreviewItemProps) => {
const { BaseImage = DefaultBaseImage } = useComponentContext('ImagePreviewItem');
const { imageUploads, removeImage, uploadImage } = useMessageInputContext('ImagePreviewItem');
const [previewError, setPreviewError] = useState(false);

const handleRemove: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => {
Expand All @@ -43,24 +44,29 @@ const ImagePreviewItem = ({ id }: PreviewItemProps) => {
);
const handleRetry = useCallback(() => uploadImage(id), [uploadImage, id]);

const image = imageUploads[id];
const state = useFileState(image);
const handleLoadError = useCallback(() => setPreviewError(true), []);

const image = imageUploads[id];
// do not display scraped attachments
if (!image || image.og_scrape_url) return null;

return (
<div className='str-chat__attachment-preview-image' data-testid='attachment-preview-image'>
<div
className={clsx('str-chat__attachment-preview-image', {
'str-chat__attachment-preview-image--error': previewError,
})}
data-testid='attachment-preview-image'
>
<button
className='str-chat__attachment-preview-delete'
data-testid='image-preview-item-delete-button'
disabled={state.uploading}
disabled={image.state === 'uploading'}
onClick={handleRemove}
>
<CloseIcon />
</button>

{state.failed && (
{image.state === 'failed' && (
<button
className='str-chat__attachment-preview-error str-chat__attachment-preview-error-image'
data-testid='image-preview-item-retry-button'
Expand All @@ -70,7 +76,7 @@ const ImagePreviewItem = ({ id }: PreviewItemProps) => {
</button>
)}

{state.uploading && (
{image.state === 'uploading' && (
<div className='str-chat__attachment-preview-image-loading'>
<LoadingIndicatorIcon size={17} />
</div>
Expand All @@ -80,6 +86,7 @@ const ImagePreviewItem = ({ id }: PreviewItemProps) => {
<BaseImage
alt={image.file.name}
className='str-chat__attachment-preview-thumbnail'
onError={handleLoadError}
src={image.previewUri ?? image.url}
title={image.file.name}
/>
Expand Down
Loading

0 comments on commit 2fcd564

Please sign in to comment.