Skip to content

Commit 3eaeb77

Browse files
authored
feat: svg aware images (#3616)
## 🎯 Goal This PR provides SVG image awareness to our default `Image` rendering component. Historically, we've never supported SVG rendering within the `ImageGallery`, message images as well as the `ImageGrid`. This PR should address that. The default component used for `ImageComponent` is now `SvgAwareImage` from the SDK. This should of course be non-breaking as it doesn't affect rendering at all. Unfortunately, not all places could default to using `ImageComponent`, specifically `AnimatedGalleryImage.tsx` and the images within `ImageGrid.tsx`. The reason behind this is the fact that also historically, we've only used `ImageComponent` within the confines of the `Chat` component. This means that some integrations might actually depend on this in respect to rendering the `ImageComponent` itself (a popular usecase would be to `useChatContext` to know if we're offline or not, for example). Since the gallery still lives typically outside of `Chat`, we can't safely move this up there without breaking various integrations. This will be addressed in the next major version for sure and is now on my radar. And last, but not least, this PR addresses a similar issue where opening the `ImageGrid` with videos would crash. The reason is of course the usage of `LoadingImage`, which also assumes it lives within `Chat`. ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 33ef01d commit 3eaeb77

9 files changed

Lines changed: 136 additions & 9 deletions

File tree

package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import React, { useMemo } from 'react';
2-
import { View } from 'react-native';
2+
import { StyleSheet, View } from 'react-native';
33
import type { ImageStyle, StyleProp } from 'react-native';
44
import Animated, { SharedValue } from 'react-native-reanimated';
55

66
import { useChatConfigContext } from '../../../contexts/chatConfigContext/ChatConfigContext';
77
import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext';
88
import { useStateStore } from '../../../hooks';
9+
import { useIsSvg } from '../../../hooks/useIsSvg';
910
import {
1011
ImageGalleryAsset,
1112
ImageGalleryState,
1213
} from '../../../state-store/image-gallery-state-store';
1314
import { getResizedImageUrl } from '../../../utils/getResizedImageUrl';
15+
import { SvgAwareImage } from '../../UIComponents/SvgAwareImage';
1416
import { useAnimatedGalleryStyle } from '../hooks/useAnimatedGalleryStyle';
1517

1618
const oneEighth = 1 / 8;
@@ -59,6 +61,7 @@ export const AnimatedGalleryImage = React.memo(
5961
});
6062
}, [photo.uri, resizableCDNHosts, screenHeight, screenWidth]);
6163

64+
const isSvg = useIsSvg(uri);
6265
const selected = currentIndex === index;
6366
const previous = currentIndex > index;
6467
const shouldRender = Math.abs(currentIndex - index) < 4;
@@ -83,6 +86,27 @@ export const AnimatedGalleryImage = React.memo(
8386
return <View style={[style, { transform: [{ scale: oneEighth }] }]} />;
8487
}
8588

89+
if (isSvg) {
90+
// The outer Animated.View is sized at 8× screen so raster images stay
91+
// crisp under pinch zoom (see useAnimatedGalleryStyle). rn-svg on
92+
// Android rasterizes the SVG to a bitmap at its layout size and an
93+
// 8x screen bitmap exceeds RecordingCanvas's per draw byte limit. The
94+
// inner SvgAwareImage is sized at 1x screen with a counter scale of 8 so
95+
// the bitmap stays small while the composed visible scale (1/8 × 8 === 1)
96+
// is unchanged.
97+
return (
98+
<Animated.View
99+
accessibilityLabel={accessibilityLabel}
100+
style={[...animatedStyles, style, styles.svgOuter]}
101+
>
102+
<SvgAwareImage
103+
source={{ uri }}
104+
style={[{ height: screenHeight, width: screenWidth }, styles.svgInner]}
105+
/>
106+
</Animated.View>
107+
);
108+
}
109+
86110
return (
87111
<Animated.Image
88112
accessibilityLabel={accessibilityLabel}
@@ -106,3 +130,13 @@ export const AnimatedGalleryImage = React.memo(
106130
);
107131

108132
AnimatedGalleryImage.displayName = 'AnimatedGalleryImage';
133+
134+
const styles = StyleSheet.create({
135+
svgInner: {
136+
transform: [{ scale: 8 }],
137+
},
138+
svgOuter: {
139+
alignItems: 'center',
140+
justifyContent: 'center',
141+
},
142+
});

package/src/components/ImageGallery/components/ImageGrid.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { Image, Pressable, StyleSheet, View } from 'react-native';
33

44
import type { ImageGalleryGridProps } from './types';
55

6-
import { VideoThumbnail } from '../../../components/Attachment/VideoThumbnail';
76
import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContextBase';
87
import { useTheme } from '../../../contexts/themeContext/ThemeContext';
98
import { useStateStore } from '../../../hooks/useStateStore';
@@ -14,7 +13,9 @@ import type {
1413
} from '../../../state-store/image-gallery-state-store';
1514
import { primitives } from '../../../theme';
1615
import { FileTypes } from '../../../types/types';
16+
import { VideoPlayIndicator } from '../../ui/VideoPlayIndicator';
1717
import { StreamBottomSheetModalFlatList } from '../../UIComponents/StreamBottomSheetModalFlatList';
18+
import { SvgAwareImage } from '../../UIComponents/SvgAwareImage';
1819

1920
export type ImageGalleryGridImageComponent = ({
2021
item,
@@ -42,11 +43,14 @@ const GridImage = ({ item }: { item: GridImageItem }) => {
4243
return (
4344
<Pressable accessibilityLabel='Grid Image' onPress={selectAndClose}>
4445
{type === FileTypes.Video ? (
45-
<View style={[styles.image, { height: size, width: size }]}>
46-
<VideoThumbnail thumb_url={thumb_url} />
46+
<View style={[styles.image, { height: size, width: size }, styles.videoCell]}>
47+
{thumb_url ? <Image source={{ uri: thumb_url }} style={StyleSheet.absoluteFill} /> : null}
48+
<View pointerEvents='none' style={[StyleSheet.absoluteFill, styles.playIndicator]}>
49+
<VideoPlayIndicator size='md' />
50+
</View>
4751
</View>
4852
) : (
49-
<Image source={{ uri }} style={[styles.image, { height: size, width: size }]} />
53+
<SvgAwareImage source={{ uri }} style={[styles.image, { height: size, width: size }]} />
5054
)}
5155
</Pressable>
5256
);
@@ -114,6 +118,13 @@ const useStyles = () => {
114118
...contentContainer,
115119
},
116120
image: { margin: 1, ...gridImage },
121+
playIndicator: {
122+
alignItems: 'center',
123+
justifyContent: 'center',
124+
},
125+
videoCell: {
126+
overflow: 'hidden',
127+
},
117128
});
118129
}, [contentContainer, gridImage, semantics]);
119130
};

package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useCallback, useMemo, useState } from 'react';
22

3-
import { Image, StyleSheet, View } from 'react-native';
3+
import { StyleSheet, View } from 'react-native';
44

55
import { LocalImageAttachment } from 'stream-chat';
66

@@ -26,6 +26,7 @@ export const ImageAttachmentUploadPreview = ({
2626
const [loading, setLoading] = useState(true);
2727
const { allowSendBeforeAttachmentsUpload } = useMessageInputContext();
2828
const {
29+
ImageComponent,
2930
ImageLoadingIndicator,
3031
ImageUploadInProgressIndicator,
3132
ImageUploadRetryIndicator,
@@ -67,7 +68,7 @@ export const ImageAttachmentUploadPreview = ({
6768
return (
6869
<View style={[styles.wrapper, wrapper]} testID={'image-attachment-upload-preview'}>
6970
<View style={[styles.image, upload]}>
70-
<Image
71+
<ImageComponent
7172
onError={onErrorHandler}
7273
onLoadEnd={onLoadEndHandler}
7374
source={{ uri: previewUri }}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React from 'react';
2+
import { Image, ImageProps, View } from 'react-native';
3+
4+
import { SvgUri } from 'react-native-svg';
5+
6+
import { useIsSvg } from '../../hooks/useIsSvg';
7+
8+
const getSourceUri = (source: ImageProps['source']): string | undefined => {
9+
if (!source || typeof source !== 'object' || Array.isArray(source)) {
10+
return undefined;
11+
}
12+
return source.uri;
13+
};
14+
15+
/**
16+
* Default `ImageComponent` for the SDK. Behaves exactly like RN's `Image` for
17+
* raster sources, but transparently renders SVG URIs (`.svg`, `image/svg+xml`
18+
* data URIs) via `SvgUri` from `react-native-svg`. Integrators who override
19+
* `ImageComponent` with a custom image library (e.g. FastImage) are
20+
* responsible for SVG handling in their override.
21+
*/
22+
export const SvgAwareImage = (props: ImageProps) => {
23+
const uri = getSourceUri(props.source);
24+
const isSvg = useIsSvg(uri);
25+
26+
if (!isSvg || !uri) {
27+
return <Image {...props} />;
28+
}
29+
30+
const { accessibilityLabel, onError, onLoad, onLoadEnd, style, testID } = props;
31+
32+
return (
33+
<View accessibilityLabel={accessibilityLabel} style={style} testID={testID}>
34+
<SvgUri
35+
height='100%'
36+
onError={(error) => {
37+
onError?.({ nativeEvent: { error } } as never);
38+
onLoadEnd?.();
39+
}}
40+
onLoad={() => {
41+
onLoad?.({} as never);
42+
onLoadEnd?.();
43+
}}
44+
uri={uri}
45+
width='100%'
46+
/>
47+
</View>
48+
);
49+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './BottomSheetModal';
22
export * from './StreamBottomSheetModalFlatList';
33
export * from './ImageBackground';
4+
export * from './SvgAwareImage';
45
export * from './Spinner';
56
export * from './SwipableWrapper';
67
export * from './PortalWhileClosingView';

package/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ export * from './ui';
188188
export * from './UIComponents/BottomSheetModal';
189189
export * from './UIComponents/StreamBottomSheetModalFlatList';
190190
export * from './UIComponents/ImageBackground';
191+
export * from './UIComponents/SvgAwareImage';
191192
export * from './UIComponents/Spinner';
192193
export * from './UIComponents/SwipableWrapper';
193194
export * from './UIComponents/PortalWhileClosingView';

package/src/contexts/componentsContext/defaultComponents.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { Image, ImageProps, TextInputProps } from 'react-native';
2+
import { TextInputProps } from 'react-native';
33

44
import type { LocalMessage, UserResponse } from 'stream-chat';
55

@@ -149,6 +149,7 @@ import { ThreadListItemMessagePreview } from '../../components/ThreadList/Thread
149149
import { ThreadListUnreadBanner } from '../../components/ThreadList/ThreadListUnreadBanner';
150150
import { ThreadMessagePreviewDeliveryStatus } from '../../components/ThreadList/ThreadMessagePreviewDeliveryStatus';
151151
import { ChannelAvatar } from '../../components/ui/Avatar/ChannelAvatar';
152+
import { SvgAwareImage } from '../../components/UIComponents/SvgAwareImage';
152153
import { DefaultMessageOverlayBackground } from '../../contexts/overlayContext/MessageOverlayHostLayer';
153154
import type { MessageActionsProps } from '../../contexts/overlayContext/MessageOverlayHostLayer';
154155

@@ -317,7 +318,7 @@ const components = {
317318
MessageOverlayBackground: DefaultMessageOverlayBackground,
318319

319320
// Image
320-
ImageComponent: Image as React.ComponentType<ImageProps>,
321+
ImageComponent: SvgAwareImage,
321322
};
322323

323324
/**

package/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './useStateStore';
77
export * from './usePendingAttachmentUpload';
88
export * from './useStableCallback';
99
export * from './useLoadingImage';
10+
export * from './useIsSvg';
1011
export * from './useMessageReminder';
1112
export * from './useQueryReminders';
1213
export * from './useAfterKeyboardOpenCallback';

package/src/hooks/useIsSvg.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useMemo } from 'react';
2+
3+
/**
4+
* Returns true if `uri` points to an SVG (either by `.svg` extension or by
5+
* `image/svg+xml` data-URI prefix). Exported as a pure function for non-React
6+
* callers; React components should prefer `useIsSvg` so the check is memoized
7+
* per URI.
8+
*/
9+
export const isSvgUri = (uri: string | null | undefined): boolean => {
10+
if (typeof uri !== 'string' || uri.length === 0) {
11+
return false;
12+
}
13+
const lower = uri.toLowerCase();
14+
if (lower.startsWith('data:image/svg+xml')) {
15+
return true;
16+
}
17+
const pathOnly = lower.split('#')[0].split('?')[0];
18+
return pathOnly.endsWith('.svg');
19+
};
20+
21+
/**
22+
* Memoized variant of `isSvgUri`. Re-runs the string check only when `uri`
23+
* actually changes — useful in hot render paths like the gallery where the
24+
* surrounding component re-renders frequently (animated styles, swipe state)
25+
* but the URI is stable.
26+
*/
27+
export const useIsSvg = (uri: string | null | undefined): boolean =>
28+
useMemo(() => isSvgUri(uri), [uri]);

0 commit comments

Comments
 (0)