Skip to content

Commit 2fcd564

Browse files
authored
feat: render BaseImage image fallback within the same img element (#2200)
1 parent e9020c5 commit 2fcd564

File tree

21 files changed

+367
-129
lines changed

21 files changed

+367
-129
lines changed

docusaurus/docs/React/components/contexts/component-context.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,15 @@ Custom UI component to display a user's avatar.
7171

7272
### BaseImage
7373

74-
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:
74+
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:
7575

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

80-
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`.
80+
The `BaseImage` component accepts the same props as `<img/>` element.
8181

82-
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.
82+
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.
8383

8484
| Type | Default |
8585
|-----------|-----------------------------------------------------------------------|

docusaurus/docs/React/components/utility-components/base-image.mdx

Lines changed: 43 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -40,47 +40,55 @@ The default image fallbacks are rendered as follows:
4040

4141
## Usage
4242

43-
The component props are internally forwarded to the underlying `<img/>`. The component supports forwarding `ref` as well.
43+
### Custom image fallback
44+
45+
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:
46+
47+
```css
48+
49+
.str-chat {
50+
--str-chat__image-fallback-icon: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iOSIgdmlld0JveD0iMCAwIDEwIDkiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICA8cGF0aCBkPSJNOS4xOTk0OSAwLjMwNTY3MUM4LjkzOTQ5IDAuMDQ1NjcwNyA4LjUxOTQ5IDAuMDQ1NjcwNyA4LjI1OTQ5IDAuMzA1NjcxTDQuOTk5NDkgMy41NTlMMS43Mzk0OSAwLjI5OTAwNEMxLjQ3OTQ5IDAuMDM5MDAzOSAxLjA1OTQ5IDAuMDM5MDAzOSAwLjc5OTQ5MiAwLjI5OTAwNEMwLjUzOTQ5MiAwLjU1OTAwNCAwLjUzOTQ5MiAwLjk3OTAwNCAwLjc5OTQ5MiAxLjIzOUw0LjA1OTQ5IDQuNDk5TDAuNzk5NDkyIDcuNzU5QzAuNTM5NDkyIDguMDE5IDAuNTM5NDkyIDguNDM5IDAuNzk5NDkyIDguNjk5QzEuMDU5NDkgOC45NTkgMS40Nzk0OSA4Ljk1OSAxLjczOTQ5IDguNjk5TDQuOTk5NDkgNS40MzlMOC4yNTk0OSA4LjY5OUM4LjUxOTQ5IDguOTU5IDguOTM5NDkgOC45NTkgOS4xOTk0OSA4LjY5OUM5LjQ1OTQ5IDguNDM5IDkuNDU5NDkgOC4wMTkgOS4xOTk0OSA3Ljc1OUw1LjkzOTQ5IDQuNDk5TDkuMTk5NDkgMS4yMzlDOS40NTI4MyAwLjk4NTY3MSA5LjQ1MjgzIDAuNTU5MDA0IDkuMTk5NDkgMC4zMDU2NzFaIiBmaWxsPSIjNzI3NjdFIi8+Cjwvc3ZnPgo=");
51+
}
52+
```
53+
54+
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:
55+
56+
```css
57+
:root{
58+
--custom-icon-fill-color: #223344;
59+
--custom-icon-width-and-height: 4rem 4rem;
60+
}
61+
62+
.str-chat__base-image--load-failed {
63+
mask-size: var(--custom-icon-width-and-height);
64+
-webkit-mask-size: var(--custom-icon-width-and-height);
65+
background-color: var(--custom-icon-fill-color);
66+
}
67+
```
68+
69+
### Custom BaseImage
70+
71+
The default `BaseImage` can be overridden by passing a custom component to `Channel` props:
72+
4473

4574
```tsx
46-
import { useRef } from 'react';
47-
import { BaseImage } from 'stream-chat-react';
48-
49-
const CustomImageFallback = () => <img src="unsupported-image-format-fallback.jpg" alt="Unsupported Image Format Fallback" />;
50-
51-
const MyUI = () => {
52-
const imgRef = useRef<HTMLImageElement | null>(null);
53-
const toggleModal = () => {
54-
//...
55-
}
56-
const imageSrc = 'http://link.to/my/image';
57-
const style = {
58-
// custom styles..
59-
}
75+
import {ComponentProps } from 'react';
76+
import { Channel } from 'stream-chat-react';
77+
78+
const CustomBaseImage = (props: ComponentProps<'img'>) => {
79+
// your implementation...
80+
}
81+
82+
export const MyUI = () => {
6083
return (
61-
<BaseImage
62-
alt='My image'
63-
className='custom-class'
64-
data-testid='custom-test-id'
65-
onClick={toggleModal}
66-
src={imageSrc}
67-
style={style}
68-
tabIndex={0}
69-
ref={imgRef}
70-
ImageFallback={ImageFallback}
71-
/>
84+
<Channel BaseImage={CustomBaseImage}>
85+
{{/* more components */ }}
86+
</Channel>
7287
);
73-
}
88+
};
7489
```
7590

7691
## Props
7792

78-
Besides the `img` component props, the component accepts the following:
79-
80-
### ImageFallback
81-
82-
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.
93+
The component accepts the `img` component props.
8394

84-
| Type |
85-
|---------------------|
86-
| `React.ComponentProps<'img'>` |

src/components/Gallery/BaseImage.tsx

Lines changed: 10 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,10 @@ import React, { forwardRef, useEffect, useState } from 'react';
22
import clsx from 'clsx';
33
import { DownloadButton } from '../Attachment/DownloadButton';
44

5-
export type ImageFallbackProps = React.ComponentPropsWithoutRef<'img'>;
6-
7-
const DefaultImageFallback = ({ alt, src, title }: ImageFallbackProps) => (
8-
<div
9-
className='str-chat__image-fallback'
10-
data-testid='str-chat__image-fallback'
11-
title={title ?? alt}
12-
>
13-
<svg
14-
className='str-chat__image-fallback__icon'
15-
fill='none'
16-
viewBox='0 0 18 18'
17-
xmlns='http://www.w3.org/2000/svg'
18-
>
19-
<path
20-
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'
21-
fill='#080707'
22-
/>
23-
</svg>
24-
<DownloadButton assetUrl={src} />
25-
</div>
26-
);
27-
28-
export type BaseImageProps = React.ComponentPropsWithRef<'img'> & {
29-
ImageFallback?: React.ComponentType<ImageFallbackProps>;
30-
};
5+
export type BaseImageProps = React.ComponentPropsWithRef<'img'>;
316

327
export const BaseImage = forwardRef<HTMLImageElement, BaseImageProps>(function BaseImage(
33-
{ ImageFallback = DefaultImageFallback, ...props },
8+
{ ...props },
349
ref,
3510
) {
3611
const { className: propsClassName, onError: propsOnError } = props;
@@ -43,20 +18,21 @@ export const BaseImage = forwardRef<HTMLImageElement, BaseImageProps>(function B
4318
[props.src],
4419
);
4520

46-
if (props.src && !error) {
47-
return (
21+
return (
22+
<>
4823
<img
4924
data-testid='str-chat__base-image'
5025
{...props}
51-
className={clsx(propsClassName, 'str-chat__base-image')}
26+
className={clsx(propsClassName, 'str-chat__base-image', {
27+
'str-chat__base-image--load-failed': error,
28+
})}
5229
onError={(e) => {
5330
setError(true);
5431
propsOnError?.(e);
5532
}}
5633
ref={ref}
5734
/>
58-
);
59-
}
60-
61-
return <ImageFallback {...props} />;
35+
{error && <DownloadButton assetUrl={props.src} />}
36+
</>
37+
);
6238
});

src/components/Gallery/ModalGallery.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useMemo } from 'react';
22
import ImageGallery from 'react-image-gallery';
3+
import { useTranslationContext } from '../../context';
34

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

2325
const formattedArray = useMemo(
2426
() =>
2527
images.map((image) => {
2628
const imageSrc = image.image_url || image.thumb_url || '';
2729
return {
2830
original: imageSrc,
29-
originalAlt: 'User uploaded content',
31+
originalAlt: t('User uploaded content'),
3032
source: imageSrc,
3133
};
3234
}),

src/components/Gallery/__tests__/BaseImage.test.js

Lines changed: 43 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@ const props = {
1010
src: 'src',
1111
};
1212
const t = (val) => val;
13-
const FALLBACK_TEST_ID = 'str-chat__image-fallback';
1413
const BASE_IMAGE_TEST_ID = 'str-chat__base-image';
1514
const getImage = () => screen.queryByTestId(BASE_IMAGE_TEST_ID);
16-
const getFallback = () => screen.queryByTestId(FALLBACK_TEST_ID);
1715

1816
const renderComponent = (props = {}) =>
1917
render(
@@ -48,43 +46,48 @@ describe('BaseImage', () => {
4846
</div>
4947
`);
5048
});
51-
it('should render an image fallback when missing src', () => {
52-
renderComponent();
53-
expect(screen.queryByTestId(FALLBACK_TEST_ID)).toBeInTheDocument();
54-
});
55-
56-
it('should forward img props to fallback', () => {
57-
const props = { alt: 'alt', title: 'title' };
58-
const ImageFallback = (props) => <div>{JSON.stringify(props)}</div>;
59-
renderComponent({ ...props, ImageFallback });
60-
expect(screen.getByText(JSON.stringify(props))).toBeInTheDocument();
61-
});
62-
63-
it('should apply img title to fallback root div title', () => {
64-
const props = { alt: 'alt', title: 'title' };
65-
renderComponent(props);
66-
expect(screen.queryByTitle(props.title)).toBeInTheDocument();
67-
});
68-
69-
it('should apply img alt to fallback root div title if img title is falsy', () => {
70-
const props = { alt: 'alt' };
71-
renderComponent({ alt: 'alt' });
72-
expect(screen.queryByTitle(props.alt)).toBeInTheDocument();
73-
});
7449

7550
it('should render an image fallback on load error', () => {
76-
renderComponent(props);
51+
const { container } = renderComponent(props);
7752
const img = getImage();
78-
expect(getImage()).toBeInTheDocument();
79-
expect(getFallback()).not.toBeInTheDocument();
8053

8154
fireEvent.error(img);
82-
expect(img).not.toBeInTheDocument();
83-
expect(getFallback()).toBeInTheDocument();
55+
expect(container).toMatchInlineSnapshot(`
56+
<div>
57+
<img
58+
alt="alt"
59+
class="str-chat__base-image str-chat__base-image--load-failed"
60+
data-testid="str-chat__base-image"
61+
src="src"
62+
/>
63+
<a
64+
aria-label="Attachment"
65+
class="str-chat__message-attachment-file--item-download"
66+
download=""
67+
href="src"
68+
target="_blank"
69+
>
70+
<svg
71+
class="str-chat__message-attachment-download-icon"
72+
data-testid="download"
73+
fill="none"
74+
height="24"
75+
viewBox="0 0 24 24"
76+
width="24"
77+
xmlns="http://www.w3.org/2000/svg"
78+
>
79+
<path
80+
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"
81+
fill="black"
82+
/>
83+
</svg>
84+
</a>
85+
</div>
86+
`);
8487
});
8588

8689
it('should reset error state on image src change', () => {
87-
const { rerender } = renderComponent(props);
90+
const { container, rerender } = renderComponent(props);
8891

8992
fireEvent.error(getImage());
9093

@@ -93,8 +96,15 @@ describe('BaseImage', () => {
9396
<BaseImage src={'new-src'} />
9497
</TranslationProvider>,
9598
);
96-
expect(getImage()).toBeInTheDocument();
97-
expect(getFallback()).not.toBeInTheDocument();
99+
expect(container).toMatchInlineSnapshot(`
100+
<div>
101+
<img
102+
class="str-chat__base-image"
103+
data-testid="str-chat__base-image"
104+
src="new-src"
105+
/>
106+
</div>
107+
`);
98108
});
99109

100110
it('should execute a custom onError callback on load error', () => {
@@ -104,14 +114,4 @@ describe('BaseImage', () => {
104114
fireEvent.error(getImage());
105115
expect(onError).toHaveBeenCalledTimes(1);
106116
});
107-
it('should render a custom image fallback on load error', () => {
108-
const testId = 'custom-fallback';
109-
const ImageFallback = () => <div data-testid={testId}>Custom Fallback</div>;
110-
renderComponent({ ...props, ImageFallback });
111-
112-
fireEvent.error(getImage());
113-
expect(screen.queryByTestId(testId)).toBeInTheDocument();
114-
expect(getImage()).not.toBeInTheDocument();
115-
expect(getFallback()).not.toBeInTheDocument();
116-
});
117117
});

src/components/MessageInput/AttachmentPreviewList.tsx

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import React, { useCallback } from 'react';
1+
import clsx from 'clsx';
2+
import React, { useCallback, useState } from 'react';
23

34
import { BaseImage as DefaultBaseImage } from '../Gallery';
45
import { FileIcon } from '../ReactFileUtilities';
5-
import { useComponentContext } from '../../context';
6-
import { useMessageInputContext } from '../../context/MessageInputContext';
6+
import { useComponentContext, useMessageInputContext } from '../../context';
77
import { useFileState } from './hooks/useFileState';
88

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

3131
type PreviewItemProps = { id: string };
3232

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

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

46-
const image = imageUploads[id];
47-
const state = useFileState(image);
47+
const handleLoadError = useCallback(() => setPreviewError(true), []);
4848

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

5253
return (
53-
<div className='str-chat__attachment-preview-image' data-testid='attachment-preview-image'>
54+
<div
55+
className={clsx('str-chat__attachment-preview-image', {
56+
'str-chat__attachment-preview-image--error': previewError,
57+
})}
58+
data-testid='attachment-preview-image'
59+
>
5460
<button
5561
className='str-chat__attachment-preview-delete'
5662
data-testid='image-preview-item-delete-button'
57-
disabled={state.uploading}
63+
disabled={image.state === 'uploading'}
5864
onClick={handleRemove}
5965
>
6066
<CloseIcon />
6167
</button>
6268

63-
{state.failed && (
69+
{image.state === 'failed' && (
6470
<button
6571
className='str-chat__attachment-preview-error str-chat__attachment-preview-error-image'
6672
data-testid='image-preview-item-retry-button'
@@ -70,7 +76,7 @@ const ImagePreviewItem = ({ id }: PreviewItemProps) => {
7076
</button>
7177
)}
7278

73-
{state.uploading && (
79+
{image.state === 'uploading' && (
7480
<div className='str-chat__attachment-preview-image-loading'>
7581
<LoadingIndicatorIcon size={17} />
7682
</div>
@@ -80,6 +86,7 @@ const ImagePreviewItem = ({ id }: PreviewItemProps) => {
8086
<BaseImage
8187
alt={image.file.name}
8288
className='str-chat__attachment-preview-thumbnail'
89+
onError={handleLoadError}
8390
src={image.previewUri ?? image.url}
8491
title={image.file.name}
8592
/>

0 commit comments

Comments
 (0)