diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 41c4731020..aa99cf3ed5 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -1,5 +1,12 @@ -import React, { useMemo } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import React, { useEffect, useMemo, useRef } from 'react'; +import { + ImageErrorEventData, + NativeSyntheticEvent, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; import type { Attachment, LocalMessage } from 'stream-chat'; @@ -35,6 +42,7 @@ import { import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useLoadingImage } from '../../hooks/useLoadingImage'; +import { useStableCallback } from '../../hooks/useStableCallback'; import { isVideoPlayerAvailable } from '../../native'; import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; @@ -59,7 +67,6 @@ export type GalleryPropsWithContext = Pick & Pick & { @@ -67,6 +74,8 @@ export type GalleryPropsWithContext = Pick { const { additionalPressableProps, @@ -74,7 +83,6 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, images, message, onLongPress, @@ -148,8 +156,8 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { images.length !== 1 ? { width: gridWidth, height: gridHeight } : { - height, - width, + minHeight: height, + minWidth: width, }, galleryContainer, ]} @@ -194,7 +202,6 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { imageGalleryStateStore={imageGalleryStateStore} ImageLoadingFailedIndicator={ImageLoadingFailedIndicator} ImageLoadingIndicator={ImageLoadingIndicator} - ImageReloadIndicator={ImageReloadIndicator} imagesAndVideos={imagesAndVideos} invertedDirections={invertedDirections || false} key={rowIndex} @@ -235,7 +242,6 @@ type GalleryThumbnailProps = { | 'VideoThumbnail' | 'ImageLoadingIndicator' | 'ImageLoadingFailedIndicator' - | 'ImageReloadIndicator' > & Pick & Pick & @@ -248,7 +254,6 @@ const GalleryThumbnail = ({ imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, imagesAndVideos, invertedDirections, message, @@ -352,7 +357,6 @@ const GalleryThumbnail = ({ borderRadius={imageBorderRadius ?? borderRadius} ImageLoadingFailedIndicator={ImageLoadingFailedIndicator} ImageLoadingIndicator={ImageLoadingIndicator} - ImageReloadIndicator={ImageReloadIndicator} thumbnail={thumbnail} /> )} @@ -379,15 +383,10 @@ const GalleryImageThumbnail = ({ borderRadius, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, thumbnail, }: Pick< GalleryThumbnailProps, - | 'ImageLoadingFailedIndicator' - | 'ImageLoadingIndicator' - | 'ImageReloadIndicator' - | 'thumbnail' - | 'borderRadius' + 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' | 'thumbnail' | 'borderRadius' >) => { const { isLoadingImage, @@ -396,6 +395,7 @@ const GalleryImageThumbnail = ({ setLoadingImage, setLoadingImageError, } = useLoadingImage(); + const loadingIndicatorTimerRef = useRef | null>(null); const { theme: { @@ -405,35 +405,54 @@ const GalleryImageThumbnail = ({ const styles = useStyles(); + const clearLoadingIndicatorTimer = useStableCallback(() => { + if (loadingIndicatorTimerRef.current) { + clearTimeout(loadingIndicatorTimerRef.current); + loadingIndicatorTimerRef.current = null; + } + }); + + const onLoadStart = useStableCallback(() => { + clearLoadingIndicatorTimer(); + setLoadingImageError(false); + loadingIndicatorTimerRef.current = setTimeout(() => { + setLoadingImage(true); + loadingIndicatorTimerRef.current = null; + }, IMAGE_LOADING_INDICATOR_DELAY_MS); + }); + + const onLoadEnd = useStableCallback(() => { + clearLoadingIndicatorTimer(); + setLoadingImage(false); + setLoadingImageError(false); + }); + + const onError = useStableCallback( + ({ nativeEvent: { error } }: NativeSyntheticEvent) => { + clearLoadingIndicatorTimer(); + console.warn(error); + setLoadingImage(false); + setLoadingImageError(true); + }, + ); + + useEffect(() => clearLoadingIndicatorTimer, [clearLoadingIndicatorTimer]); + return ( - + {isLoadingImageError ? ( - <> - - - + ) : ( <> { - console.warn(error); - setLoadingImage(false); - setLoadingImageError(true); - }} - onLoadEnd={() => setTimeout(() => setLoadingImage(false), 0)} - onLoadStart={() => setLoadingImage(true)} + onError={onError} + onLoadEnd={onLoadEnd} + onLoadStart={onLoadStart} resizeMode={thumbnail.resizeMode} - style={[borderRadius, gallery.image]} + style={gallery.image} uri={thumbnail.url} /> - {isLoadingImage && ( - - - - )} + {isLoadingImage ? : null} )} @@ -512,7 +531,6 @@ export const Gallery = (props: GalleryProps) => { additionalPressableProps: propAdditionalPressableProps, ImageLoadingFailedIndicator: PropImageLoadingFailedIndicator, ImageLoadingIndicator: PropImageLoadingIndicator, - ImageReloadIndicator: PropImageReloadIndicator, images: propImages, message: propMessage, myMessageTheme: propMyMessageTheme, @@ -542,7 +560,6 @@ export const Gallery = (props: GalleryProps) => { additionalPressableProps: contextAdditionalPressableProps, ImageLoadingFailedIndicator: ContextImageLoadingFailedIndicator, ImageLoadingIndicator: ContextImageLoadingIndicator, - ImageReloadIndicator: ContextImageReloadIndicator, myMessageTheme: contextMyMessageTheme, VideoThumbnail: ContextVideoThumnbnail, } = useMessagesContext(); @@ -567,7 +584,6 @@ export const Gallery = (props: GalleryProps) => { const ImageLoadingFailedIndicator = PropImageLoadingFailedIndicator || ContextImageLoadingFailedIndicator; const ImageLoadingIndicator = PropImageLoadingIndicator || ContextImageLoadingIndicator; - const ImageReloadIndicator = PropImageReloadIndicator || ContextImageReloadIndicator; const myMessageTheme = propMyMessageTheme || contextMyMessageTheme; const messageContentOrder = propMessageContentOrder || contextMessageContentOrder; @@ -585,7 +601,6 @@ export const Gallery = (props: GalleryProps) => { imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, images, message, myMessageTheme, @@ -607,6 +622,7 @@ const useStyles = () => { const { theme: { semantics }, } = useTheme(); + const { isMyMessage } = useMessageContext(); return useMemo(() => { return StyleSheet.create({ errorTextSize: { @@ -626,11 +642,15 @@ const useStyles = () => { imageContainer: {}, image: { flex: 1, + backgroundColor: isMyMessage + ? semantics.chatBgAttachmentOutgoing + : semantics.chatBgAttachmentIncoming, + overflow: 'hidden', }, imageLoadingErrorIndicatorStyle: { - bottom: 4, - left: 4, - position: 'absolute', + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', }, imageLoadingIndicatorContainer: { height: '100%', @@ -658,8 +678,17 @@ const useStyles = () => { lineHeight: primitives.typographyLineHeightRelaxed, fontWeight: primitives.typographyFontWeightSemiBold, }, + imageLoadingErrorContainer: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + }, + imageLoadingErrorWrapper: { + ...StyleSheet.absoluteFillObject, + overflow: 'hidden', + }, }); - }, [semantics]); + }, [semantics, isMyMessage]); }; Gallery.displayName = 'Gallery{messageSimple{gallery}}'; diff --git a/package/src/components/Attachment/Giphy/GiphyImage.tsx b/package/src/components/Attachment/Giphy/GiphyImage.tsx index d3f4dc551a..f9c016283f 100644 --- a/package/src/components/Attachment/Giphy/GiphyImage.tsx +++ b/package/src/components/Attachment/Giphy/GiphyImage.tsx @@ -39,8 +39,13 @@ const GiphyImageWithContext = (props: GiphyImagePropsWithContext) => { const { giphy: giphyData, image_url, thumb_url, type } = attachment; - const { isLoadingImage, isLoadingImageError, setLoadingImage, setLoadingImageError } = - useLoadingImage(); + const { + isLoadingImage, + isLoadingImageError, + setLoadingImage, + setLoadingImageError, + onReloadImage, + } = useLoadingImage(); const { theme: { @@ -93,7 +98,7 @@ const GiphyImageWithContext = (props: GiphyImagePropsWithContext) => { /> {isLoadingImageError && ( - + )} {isLoadingImage && ( diff --git a/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx b/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx index 2f135835e8..aef29c352d 100644 --- a/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx +++ b/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx @@ -1,56 +1,45 @@ -import React from 'react'; -import { StyleSheet, Text, View, ViewProps } from 'react-native'; +import React, { useMemo } from 'react'; +import { Pressable, StyleSheet, ViewProps } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; - -import { Warning } from '../../icons/Warning'; - -const WARNING_ICON_SIZE = 14; +import { RetryBadge } from '../ui/Badge/RetryBadge'; + +export type ImageLoadingFailedIndicatorProps = ViewProps & { + /** + * Callback to reload the image + * @returns Callback to reload the image + */ + onReloadImage: () => void; +}; -const styles = StyleSheet.create({ - container: { - alignContent: 'center', - alignItems: 'center', - borderRadius: 20, - flexDirection: 'row', - justifyContent: 'center', - }, - errorText: { - fontSize: 8, - justifyContent: 'center', - paddingHorizontal: 8, - }, - warningIconStyle: { - borderRadius: 24, - marginLeft: 4, - marginTop: 6, - }, -}); +export const ImageLoadingFailedIndicator = ({ + onReloadImage, +}: ImageLoadingFailedIndicatorProps) => { + const styles = useStyles(); -export type ImageLoadingFailedIndicatorProps = ViewProps; + return ( + + + + ); +}; -export const ImageLoadingFailedIndicator = (props: ImageLoadingFailedIndicatorProps) => { +const useStyles = () => { const { - theme: { - colors: { accent_red, overlay, white }, - }, + theme: { semantics }, } = useTheme(); - - const { t } = useTranslationContext(); - - const { style, ...rest } = props; - return ( - - - - {t('Error loading')} - - - ); + return useMemo(() => { + return StyleSheet.create({ + imageLoadingErrorIndicatorStyle: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: semantics.backgroundCoreOverlayLight, + }, + }); + }, [semantics]); }; diff --git a/package/src/components/Attachment/ImageLoadingIndicator.tsx b/package/src/components/Attachment/ImageLoadingIndicator.tsx index 019aa420d4..b42200c9af 100644 --- a/package/src/components/Attachment/ImageLoadingIndicator.tsx +++ b/package/src/components/Attachment/ImageLoadingIndicator.tsx @@ -1,31 +1,14 @@ import React from 'react'; -import { ActivityIndicator, StyleSheet, View, ViewProps } from 'react-native'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { ShimmerView } from '../UIComponents/Shimmer/ShimmerView'; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - display: 'flex', - justifyContent: 'center', - width: '100%', - }, -}); - -export type ImageLoadingIndicatorProps = ViewProps; - -export const ImageLoadingIndicator = (props: ImageLoadingIndicatorProps) => { - const { - theme: { - messageSimple: { - loadingIndicator: { container }, - }, - }, - } = useTheme(); - const { style, ...rest } = props; +export const ImageLoadingIndicator = () => { return ( - - + + + + ); }; diff --git a/package/src/components/Attachment/ImageReloadIndicator.tsx b/package/src/components/Attachment/ImageReloadIndicator.tsx deleted file mode 100644 index 9f3b2e24a0..0000000000 --- a/package/src/components/Attachment/ImageReloadIndicator.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import { Pressable } from 'react-native'; - -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { Refresh } from '../../icons'; - -const REFRESH_ICON_SIZE = 24; - -export type ImageReloadIndicatorProps = { - onReloadImage: () => void; - style: React.ComponentProps['style']; -}; - -export const ImageReloadIndicator = ({ onReloadImage, style }: ImageReloadIndicatorProps) => { - const { - theme: { - colors: { grey_dark }, - }, - } = useTheme(); - - return ( - - - - ); -}; diff --git a/package/src/components/Attachment/__tests__/Gallery.test.js b/package/src/components/Attachment/__tests__/Gallery.test.js index 57a06a0f3c..1301834c85 100644 --- a/package/src/components/Attachment/__tests__/Gallery.test.js +++ b/package/src/components/Attachment/__tests__/Gallery.test.js @@ -272,7 +272,7 @@ describe('Gallery', () => { fireEvent(screen.getByLabelText('Gallery Image'), 'error', { nativeEvent: { error: 'error loading image' }, }); - expect(screen.getByAccessibilityHint('image-loading-error')).toBeTruthy(); + expect(screen.getByLabelText('Image Loading Error Indicator')).toBeTruthy(); }); it('should render a loading indicator and when successful render the image', async () => { @@ -289,10 +289,10 @@ describe('Gallery', () => { }); fireEvent(screen.getByLabelText('Gallery Image'), 'onLoadStart'); - expect(screen.getByAccessibilityHint('image-loading')).toBeTruthy(); + expect(screen.getByLabelText('Image Loading Indicator')).toBeTruthy(); fireEvent(screen.getByLabelText('Gallery Image'), 'onLoadFinish'); - waitForElementToBeRemoved(() => screen.getByAccessibilityHint('image-loading')); + waitForElementToBeRemoved(() => screen.getByLabelText('Image Loading Indicator')); expect(screen.getByLabelText('Gallery Image')).toBeTruthy(); }); }); diff --git a/package/src/components/Attachment/__tests__/Giphy.test.js b/package/src/components/Attachment/__tests__/Giphy.test.js index 2aed7e6385..0cc42b819a 100644 --- a/package/src/components/Attachment/__tests__/Giphy.test.js +++ b/package/src/components/Attachment/__tests__/Giphy.test.js @@ -321,7 +321,7 @@ describe('Giphy', () => { }); await waitFor(() => { - expect(screen.getByAccessibilityHint('image-loading-error')).toBeTruthy(); + expect(screen.getByLabelText('Image Loading Error Indicator')).toBeTruthy(); }); }); @@ -336,7 +336,7 @@ describe('Giphy', () => { , ); await waitFor(() => { - expect(screen.getByAccessibilityHint('image-loading')).toBeTruthy(); + expect(screen.getByLabelText('Image Loading Indicator')).toBeTruthy(); }); act(() => { @@ -344,14 +344,14 @@ describe('Giphy', () => { }); await waitFor(() => { - expect(screen.getByAccessibilityHint('image-loading')).toBeTruthy(); + expect(screen.getByLabelText('Image Loading Indicator')).toBeTruthy(); }); act(() => { fireEvent(screen.getByLabelText('Giphy Attachment Image'), 'onLoad'); }); - waitForElementToBeRemoved(() => screen.getByAccessibilityHint('image-loading')); + waitForElementToBeRemoved(() => screen.getByLabelText('Image Loading Indicator')); await waitFor(() => { expect(screen.getByLabelText('Giphy Attachment Image')).toBeTruthy(); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 45091a78aa..03a2c3a0b9 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -119,7 +119,6 @@ import { Gallery as GalleryDefault } from '../Attachment/Gallery'; import { Giphy as GiphyDefault } from '../Attachment/Giphy'; import { ImageLoadingFailedIndicator as ImageLoadingFailedIndicatorDefault } from '../Attachment/ImageLoadingFailedIndicator'; import { ImageLoadingIndicator as ImageLoadingIndicatorDefault } from '../Attachment/ImageLoadingIndicator'; -import { ImageReloadIndicator as ImageReloadIndicatorDefault } from '../Attachment/ImageReloadIndicator'; import { URLPreview as URLPreviewDefault } from '../Attachment/UrlPreview'; import { VideoThumbnail as VideoThumbnailDefault } from '../Attachment/VideoThumbnail'; import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; @@ -351,7 +350,6 @@ export type ChannelPropsWithContext = Pick & | 'isAttachmentEqual' | 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' - | 'ImageReloadIndicator' | 'markdownRules' | 'Message' | 'MessageActionList' @@ -647,7 +645,6 @@ const ChannelWithContext = (props: PropsWithChildren) = ImageAttachmentUploadPreview = ImageAttachmentUploadPreviewDefault, ImageLoadingFailedIndicator = ImageLoadingFailedIndicatorDefault, ImageLoadingIndicator = ImageLoadingIndicatorDefault, - ImageReloadIndicator = ImageReloadIndicatorDefault, initialScrollToFirstUnreadMessage = false, InlineDateSeparator = InlineDateSeparatorDefault, InlineUnreadIndicator = InlineUnreadIndicatorDefault, @@ -1932,7 +1929,6 @@ const ChannelWithContext = (props: PropsWithChildren) = hasCreatePoll === undefined ? pollCreationEnabled : hasCreatePoll && pollCreationEnabled, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, initialScrollToFirstUnreadMessage: !messageId && initialScrollToFirstUnreadMessage, // when messageId is set, we scroll to the messageId instead of first unread InlineDateSeparator, InlineUnreadIndicator, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 3db83d4d65..178ce6a077 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -42,7 +42,6 @@ export const useCreateMessagesContext = ({ hasCreatePoll, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, initialScrollToFirstUnreadMessage, InlineDateSeparator, InlineUnreadIndicator, @@ -159,7 +158,6 @@ export const useCreateMessagesContext = ({ hasCreatePoll, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, initialScrollToFirstUnreadMessage, InlineDateSeparator, InlineUnreadIndicator, diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index caf378e4cf..cbd6fa1569 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -169,7 +169,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { messageGroupedSingleOrBottomContainer, messageGroupedTopContainer, replyContainer, - textWrapper, wrapper, }, }, @@ -363,11 +362,9 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { } })} - - {(otherAttachments.length && otherAttachments[0].actions) || isAIGenerated ? null : ( - - )} - + {(otherAttachments.length && otherAttachments[0].actions) || isAIGenerated ? null : ( + + )} @@ -665,7 +662,5 @@ const styles = StyleSheet.create({ rightAlignItems: { alignItems: 'flex-end', }, - textWrapper: { - paddingHorizontal: primitives.spacingSm, - }, + textWrapper: {}, }); diff --git a/package/src/components/Message/MessageSimple/MessageTextContainer.tsx b/package/src/components/Message/MessageSimple/MessageTextContainer.tsx index e3af2215f1..9af8b5747d 100644 --- a/package/src/components/Message/MessageSimple/MessageTextContainer.tsx +++ b/package/src/components/Message/MessageSimple/MessageTextContainer.tsx @@ -17,9 +17,10 @@ import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import type { MarkdownStyle, Theme } from '../../../contexts/themeContext/utils/theme'; import { useTranslatedMessage } from '../../../hooks/useTranslatedMessage'; +import { primitives } from '../../../theme'; const styles = StyleSheet.create({ - textContainer: { maxWidth: 256 }, + textContainer: { maxWidth: 256, paddingHorizontal: primitives.spacingSm }, }); export type MessageTextProps = MessageTextContainerProps & { diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx index 919ee1c1aa..7ea9b848fe 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx @@ -5,9 +5,9 @@ import { LocalAttachmentUploadMetadata } from 'stream-chat'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { ExclamationCircle } from '../../../../icons/ExclamationCircle'; -import { RotateCircle } from '../../../../icons/RotateCircle'; import { Warning } from '../../../../icons/Warning'; import { primitives } from '../../../../theme'; +import { RetryBadge } from '../../../ui/Badge/RetryBadge'; export const FileUploadInProgressIndicator = () => { const { @@ -128,20 +128,9 @@ export type ImageUploadRetryIndicatorProps = { }; export const ImageUploadRetryIndicator = ({ onRetryHandler }: ImageUploadRetryIndicatorProps) => { - const styles = useImageUploadRetryIndicatorStyles(); - const { - theme: { - semantics, - messageInput: { imageUploadRetryIndicator }, - }, - } = useTheme(); return ( - - + + ); }; @@ -174,25 +163,6 @@ const useImageUploadInProgressIndicatorStyles = () => { }); }; -const useImageUploadRetryIndicatorStyles = () => { - const { - theme: { semantics }, - } = useTheme(); - return StyleSheet.create({ - container: { - backgroundColor: semantics.accentError, - alignItems: 'center', - justifyContent: 'center', - borderRadius: primitives.radiusMax, - borderWidth: 2, - borderColor: semantics.textOnAccent, - alignSelf: 'center', - width: 32, - height: 32, - }, - }); -}; - const useImageUploadNotSupportedIndicatorStyles = () => { const { theme: { semantics }, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 0de8b3e356..4e2bb64ee2 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -8,7 +8,7 @@ import { ViewToken, } from 'react-native'; -import Animated, { LinearTransition } from 'react-native-reanimated'; +import Animated, { LinearTransition, useSharedValue } from 'react-native-reanimated'; import { FlashListProps, FlashListRef, useFlashListContext } from '@shopify/flash-list'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; @@ -56,6 +56,7 @@ import { useStableCallback, useStateStore } from '../../hooks'; import { MessageInputHeightState } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; +import { ShimmerProvider } from '../UIComponents/Shimmer/ShimmerContext'; let FlashList; @@ -700,6 +701,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }); + const visibleMessages = useSharedValue([]); + /** * FlatList doesn't accept changeable function for onViewableItemsChanged prop. * Thus useRef. @@ -709,6 +712,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }: { viewableItems: ViewToken[] | undefined; }) => { + visibleMessages.value = viewableItems?.map((viewToken) => viewToken.item.message.id) ?? []; if (!viewableItems) { return; } @@ -1041,72 +1045,74 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } return ( - - {processedMessageList.length === 0 && !thread ? ( - - {EmptyStateIndicator ? : null} + + + {processedMessageList.length === 0 && !thread ? ( + + {EmptyStateIndicator ? : null} + + ) : ( + + + + )} + + {messageListLengthAfterUpdate && StickyHeader ? ( + + ) : null} - ) : ( - - + - - )} - - {messageListLengthAfterUpdate && StickyHeader ? ( - + + + {isUnreadNotificationOpen && !threadList ? ( + + + ) : null} - - - - - {isUnreadNotificationOpen && !threadList ? ( - - - - ) : null} - + ); }; diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 4766bc8564..09a351ef28 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -11,7 +11,7 @@ import { ViewToken, } from 'react-native'; -import Animated, { LinearTransition } from 'react-native-reanimated'; +import Animated, { LinearTransition, useSharedValue } from 'react-native-reanimated'; import debounce from 'lodash/debounce'; @@ -69,6 +69,7 @@ import { bumpOverlayLayoutRevision } from '../../state-store'; import { MessageInputHeightState } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; +import { ShimmerProvider } from '../UIComponents/Shimmer/ShimmerContext'; // This is just to make sure that the scrolling happens in a different task queue. // TODO: Think if we really need this and strive to remove it if we can. @@ -579,6 +580,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } }); + const visibleMessages = useSharedValue([]); + /** * FlatList doesn't accept changeable function for onViewableItemsChanged prop. * Thus useRef. @@ -588,6 +591,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }: { viewableItems: ViewToken[] | undefined; }) => { + visibleMessages.value = viewableItems?.map((viewToken) => viewToken.item.message.id) ?? []; viewabilityChangedCallback({ inverted, viewableItems }); if (!viewableItems) { @@ -1253,87 +1257,89 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { // TODO: Make sure this is actually overridable as the previous FlatList was. return ( - - {/* Don't show the empty list indicator for Thread messages */} - {processedMessageList.length === 0 && !thread ? ( - - {EmptyStateIndicator ? : null} - - ) : ( - - + + {/* Don't show the empty list indicator for Thread messages */} + {processedMessageList.length === 0 && !thread ? ( + + {EmptyStateIndicator ? : null} + + ) : ( + + - - )} - - {messageListLengthAfterUpdate && StickyHeader ? ( - + maintainVisibleContentPosition={maintainVisibleContentPosition} + maxToRenderPerBatch={30} + onContentSizeChange={onContentSizeChange} + onLayout={onLayout} + onMomentumScrollEnd={onUserScrollEvent} + onScroll={handleScroll} + onScrollBeginDrag={onScrollBeginDrag} + onScrollEndDrag={onScrollEndDrag} + onScrollToIndexFailed={onScrollToIndexFailedRef.current} + onTouchEnd={dismissImagePicker} + onViewableItemsChanged={stableOnViewableItemsChanged} + ref={refCallback} + renderItem={renderItem} + scrollEventThrottle={isLiveStreaming ? 16 : undefined} + showsVerticalScrollIndicator={false} + // @ts-expect-error Safe to do for now + strictMode={isLiveStreaming} + style={flatListStyle} + testID='message-flat-list' + viewabilityConfig={flatListViewabilityConfig} + {...additionalFlatListPropsExcludingStyle} + /> + + )} + + {messageListLengthAfterUpdate && StickyHeader ? ( + + ) : null} + + {scrollToBottomButtonVisible ? ( + + + + ) : null} + + + {isUnreadNotificationOpen && !threadList ? ( + + + ) : null} - {scrollToBottomButtonVisible ? ( - - - - ) : null} - - - {isUnreadNotificationOpen && !threadList ? ( - - - - ) : null} - + ); }; diff --git a/package/src/components/UIComponents/Shimmer/ShimmerContext.tsx b/package/src/components/UIComponents/Shimmer/ShimmerContext.tsx new file mode 100644 index 0000000000..ff1d9d8022 --- /dev/null +++ b/package/src/components/UIComponents/Shimmer/ShimmerContext.tsx @@ -0,0 +1,29 @@ +import React, { createContext, PropsWithChildren, useContext, useEffect, useMemo } from 'react'; +import { SharedValue, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated'; + +type ShimmerContextValue = { + progress: SharedValue; + visibleMessages: SharedValue; +}; + +const ShimmerContext = createContext({} as ShimmerContextValue); + +const SHIMMER_WIDTH = 150; + +type ShimmerProviderProps = PropsWithChildren<{ + visibleMessages: SharedValue; +}>; + +export const ShimmerProvider = ({ children, visibleMessages }: ShimmerProviderProps) => { + const progress = useSharedValue(-SHIMMER_WIDTH); + + useEffect(() => { + progress.value = withRepeat(withTiming(SHIMMER_WIDTH, { duration: 1200 }), -1, true); + }, [progress]); + + const contextValue = useMemo(() => ({ progress, visibleMessages }), [progress, visibleMessages]); + + return {children}; +}; + +export const useShimmerContext = () => useContext(ShimmerContext); diff --git a/package/src/components/UIComponents/Shimmer/ShimmerView.tsx b/package/src/components/UIComponents/Shimmer/ShimmerView.tsx new file mode 100644 index 0000000000..b1403932df --- /dev/null +++ b/package/src/components/UIComponents/Shimmer/ShimmerView.tsx @@ -0,0 +1,78 @@ +import React, { PropsWithChildren } from 'react'; +import { View, StyleSheet, StyleProp, ViewStyle } from 'react-native'; +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; +import Svg, { Rect, Defs, LinearGradient, Stop } from 'react-native-svg'; + +import { useShimmerContext } from './ShimmerContext'; + +import { useMessageContext } from '../../../contexts/messageContext/MessageContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; + +type Props = PropsWithChildren<{ + style?: StyleProp; +}>; + +export const ShimmerView = ({ children, style }: Props) => { + const { progress, visibleMessages } = useShimmerContext(); + const { message } = useMessageContext(); + + const messageId = message?.id; + + const { + theme: { + shimmer: { width, height }, + }, + } = useTheme(); + const styles = useStyles(); + + const animatedStyle = useAnimatedStyle(() => { + return visibleMessages.value.includes(messageId) + ? { + transform: [{ translateX: progress?.value ?? 0 }], + } + : {}; + }, [messageId]); + + return ( + + + + + + + + + + + + + + + + {children} + + ); +}; + +const useStyles = () => { + const { + theme: { + shimmer: { width, height }, + }, + } = useTheme(); + return StyleSheet.create({ + container: { + flex: 1, + overflow: 'hidden', + }, + shimmerContainer: { + width, + height, + }, + content: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + }, + }); +}; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 9c0c493935..b5402f95a1 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -7,7 +7,6 @@ export * from './Attachment/FileIcon'; export * from './Attachment/Gallery'; export * from './Attachment/Giphy'; export * from './Attachment/VideoThumbnail'; -export * from './Attachment/ImageReloadIndicator'; export * from './Attachment/UrlPreview'; export * from './Attachment/utils/buildGallery/buildGallery'; diff --git a/package/src/components/ui/Badge/RetryBadge.tsx b/package/src/components/ui/Badge/RetryBadge.tsx new file mode 100644 index 0000000000..2b6876bca3 --- /dev/null +++ b/package/src/components/ui/Badge/RetryBadge.tsx @@ -0,0 +1,76 @@ +import React, { useMemo } from 'react'; +import { StyleProp, StyleSheet, View, ViewProps, ViewStyle } from 'react-native'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { RotateCircle } from '../../../icons/RotateCircle'; +import { primitives } from '../../../theme'; + +const sizes = { + lg: { + height: 32, + width: 32, + }, + md: { + height: 24, + width: 24, + }, +}; + +const iconSizes = { + lg: { + height: 16, + width: 16, + }, + md: { + height: 12, + width: 12, + }, +}; + +export type RetryBadgeProps = ViewProps & { + /** + * The size of the badge + * @default 'md' + * @type {'lg' | 'md'} + */ + size: 'lg' | 'md'; + /** + * The style of the badge + * @default undefined + * @type {StyleProp} + */ + style?: StyleProp; +}; + +export const RetryBadge = (props: RetryBadgeProps) => { + const { size = 'md', style, ...rest } = props; + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + return ( + + + + ); +}; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo(() => { + return StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + borderRadius: primitives.radiusMax, + backgroundColor: semantics.badgeBgError, + }, + }); + }, [semantics]); +}; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 52910b4088..53adab5444 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -25,8 +25,6 @@ import type { FileIconProps } from '../../components/Attachment/FileIcon'; import type { GalleryProps } from '../../components/Attachment/Gallery'; import type { GiphyProps } from '../../components/Attachment/Giphy'; import type { ImageLoadingFailedIndicatorProps } from '../../components/Attachment/ImageLoadingFailedIndicator'; -import type { ImageLoadingIndicatorProps } from '../../components/Attachment/ImageLoadingIndicator'; -import { ImageReloadIndicatorProps } from '../../components/Attachment/ImageReloadIndicator'; import type { URLPreviewProps } from '../../components/Attachment/UrlPreview'; import type { VideoThumbnailProps } from '../../components/Attachment/VideoThumbnail'; import type { @@ -162,15 +160,10 @@ export type MessagesContextValue = Pick; - /** - * The indicator rendered at the center of an image whenever its loading fails, used to trigger retries. - */ - ImageReloadIndicator: React.ComponentType; - /** * The indicator rendered when image is loading. By default renders */ - ImageLoadingIndicator: React.ComponentType; + ImageLoadingIndicator: React.ComponentType; /** * When true, messageList will be scrolled at first unread message, when opened. diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index a5b2e9a131..df4e447f31 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -594,7 +594,6 @@ export type Theme = { container: ViewStyle; containerInner: ViewStyle; contentContainer: ViewStyle; - textWrapper: ViewStyle; editedTimestampContainer: ViewStyle; errorContainer: ViewStyle; errorIcon: IconProps; @@ -992,6 +991,10 @@ export type Theme = { waveform: ViewStyle; }; semantics: typeof lightSemantics; // themed semantics have the same type + shimmer: { + width: number; + height: number; + }; }; export const defaultTheme: Theme = { @@ -1482,7 +1485,6 @@ export const defaultTheme: Theme = { container: {}, containerInner: {}, contentContainer: {}, - textWrapper: {}, editedTimestampContainer: {}, errorContainer: { paddingRight: 12, @@ -1871,4 +1873,8 @@ export const defaultTheme: Theme = { thumb: {}, waveform: {}, }, + shimmer: { + width: 300, + height: 192, + }, }; diff --git a/package/src/hooks/useLoadingImage.tsx b/package/src/hooks/useLoadingImage.tsx index 98b8bdda75..bb7d44022e 100644 --- a/package/src/hooks/useLoadingImage.tsx +++ b/package/src/hooks/useLoadingImage.tsx @@ -15,14 +15,23 @@ type Action = function reducer(prevState: ImageState, action: Action) { switch (action.type) { case 'reloadImage': + if (prevState.isLoadingImage && !prevState.isLoadingImageError) { + return prevState; + } return { ...prevState, isLoadingImage: true, isLoadingImageError: false, }; case 'setLoadingImage': + if (prevState.isLoadingImage === action.isLoadingImage) { + return prevState; + } return { ...prevState, isLoadingImage: action.isLoadingImage }; case 'setLoadingImageError': + if (prevState.isLoadingImageError === action.isLoadingImageError) { + return prevState; + } return { ...prevState, isLoadingImageError: action.isLoadingImageError }; default: return prevState; @@ -30,7 +39,7 @@ function reducer(prevState: ImageState, action: Action) { } export const useLoadingImage = () => { const [imageState, dispatch] = useReducer(reducer, { - isLoadingImage: true, + isLoadingImage: false, isLoadingImageError: false, }); const { isLoadingImage, isLoadingImageError } = imageState;