diff --git a/package/package.json b/package/package.json index ae2aa00e6b..f974ba610f 100644 --- a/package/package.json +++ b/package/package.json @@ -80,7 +80,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.35.1", + "stream-chat": "^9.36.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/components/Attachment/FileAttachment.tsx b/package/src/components/Attachment/FileAttachment.tsx index 4cc2571ef3..b7c54c4b32 100644 --- a/package/src/components/Attachment/FileAttachment.tsx +++ b/package/src/components/Attachment/FileAttachment.tsx @@ -1,5 +1,12 @@ -import React, { useMemo } from 'react'; -import { Pressable, StyleProp, StyleSheet, TextStyle, ViewStyle } from 'react-native'; +import React, { useCallback, useMemo } from 'react'; +import { + ActivityIndicator, + Pressable, + StyleProp, + StyleSheet, + TextStyle, + ViewStyle, +} from 'react-native'; import type { Attachment } from 'stream-chat'; @@ -16,6 +23,8 @@ import { useMessagesContext, } from '../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; +import type { PendingAttachmentsUploadingState } from '../../state-store/pending-attachments-uploading-state'; export type FileAttachmentPropsWithContext = Pick< MessageContextValue, @@ -32,10 +41,18 @@ export type FileAttachmentPropsWithContext = Pick< size: StyleProp; title: StyleProp; }>; + /** + * Whether the attachment is currently being uploaded. + * This is used to show a loading indicator in the file attachment. + */ + isPendingAttachmentUploading: boolean; }; const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { const styles = useStyles(); + const { + theme: { semantics }, + } = useTheme(); const { additionalPressableProps, @@ -46,10 +63,18 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { onPressIn, preventPress, styles: stylesProp = styles, + isPendingAttachmentUploading, } = props; const defaultOnPress = () => openUrlSafely(attachment.asset_url); + const renderIndicator = useMemo(() => { + if (isPendingAttachmentUploading) { + return ; + } + return null; + }, [isPendingAttachmentUploading, semantics.accentPrimary, styles.activityIndicator]); + return ( { @@ -98,8 +124,25 @@ export type FileAttachmentProps = Partial; export const FileAttachment = (props: FileAttachmentProps) => { - const { onLongPress, onPress, onPressIn, preventPress } = useMessageContext(); - const { additionalPressableProps, FileAttachmentIcon = FileIconDefault } = useMessagesContext(); + const { attachment } = props; + const { onLongPress, onPress, onPressIn, preventPress, message } = useMessageContext(); + const { + additionalPressableProps, + FileAttachmentIcon = FileIconDefault, + pendingAttachmentsUploadingStore, + } = useMessagesContext(); + + const attachmentId = `${message.id}-${attachment.originalFile?.uri}`; + const selector = useCallback( + (state: PendingAttachmentsUploadingState) => ({ + isPendingAttachmentUploading: state.pendingAttachmentsUploading[attachmentId] ?? false, + }), + [attachmentId], + ); + const { isPendingAttachmentUploading } = useStateStore( + pendingAttachmentsUploadingStore.store, + selector, + ); return ( { onPress, onPressIn, preventPress, + isPendingAttachmentUploading, }} {...props} /> @@ -134,6 +178,10 @@ const useStyles = () => { ? semantics.chatBgAttachmentOutgoing : semantics.chatBgAttachmentIncoming, }, + activityIndicator: { + alignItems: 'flex-start', + justifyContent: 'flex-start', + }, }); }, [showBackgroundTransparent, isMyMessage, semantics]); }; diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 41c4731020..9a58f7c554 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import type { Attachment, LocalMessage } from 'stream-chat'; @@ -34,8 +34,10 @@ import { } from '../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks'; import { useLoadingImage } from '../../hooks/useLoadingImage'; import { isVideoPlayerAvailable } from '../../native'; +import { PendingAttachmentsUploadingState } from '../../state-store/pending-attachments-uploading-state'; import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; import { getUrlWithoutParams } from '../../utils/utils'; @@ -60,7 +62,9 @@ export type GalleryPropsWithContext = Pick & Pick & { channelId: string | undefined; @@ -75,6 +79,7 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { ImageLoadingFailedIndicator, ImageLoadingIndicator, ImageReloadIndicator, + ImageUploadingIndicator, images, message, onLongPress, @@ -85,6 +90,7 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { videos, VideoThumbnail, messageHasOnlyOneImage = false, + pendingAttachmentsUploadingStore, } = props; const { resizableCDNHosts } = useChatConfigContext(); @@ -195,6 +201,7 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { ImageLoadingFailedIndicator={ImageLoadingFailedIndicator} ImageLoadingIndicator={ImageLoadingIndicator} ImageReloadIndicator={ImageReloadIndicator} + ImageUploadingIndicator={ImageUploadingIndicator} imagesAndVideos={imagesAndVideos} invertedDirections={invertedDirections || false} key={rowIndex} @@ -209,6 +216,7 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { setOverlay={setOverlay} thumbnail={thumbnail} VideoThumbnail={VideoThumbnail} + pendingAttachmentsUploadingStore={pendingAttachmentsUploadingStore} /> ); })} @@ -236,6 +244,8 @@ type GalleryThumbnailProps = { | 'ImageLoadingIndicator' | 'ImageLoadingFailedIndicator' | 'ImageReloadIndicator' + | 'ImageUploadingIndicator' + | 'pendingAttachmentsUploadingStore' > & Pick & Pick & @@ -249,6 +259,7 @@ const GalleryThumbnail = ({ ImageLoadingFailedIndicator, ImageLoadingIndicator, ImageReloadIndicator, + ImageUploadingIndicator, imagesAndVideos, invertedDirections, message, @@ -262,6 +273,7 @@ const GalleryThumbnail = ({ setOverlay, thumbnail, VideoThumbnail, + pendingAttachmentsUploadingStore, }: GalleryThumbnailProps) => { const { theme: { @@ -274,6 +286,18 @@ const GalleryThumbnail = ({ const { t } = useTranslationContext(); const styles = useStyles(); + const attachmentId = `${message.id}-${thumbnail.url}`; + const selector = useCallback( + (state: PendingAttachmentsUploadingState) => ({ + isPendingAttachmentUploading: state.pendingAttachmentsUploading[attachmentId] ?? false, + }), + [attachmentId], + ); + const { isPendingAttachmentUploading } = useStateStore( + pendingAttachmentsUploadingStore.store, + selector, + ); + const openImageViewer = () => { if (!message) { return; @@ -346,6 +370,7 @@ const GalleryThumbnail = ({ ) : ( )} {colIndex === numOfColumns - 1 && rowIndex === numOfRows - 1 && imagesAndVideos.length > 4 ? ( @@ -381,14 +408,23 @@ const GalleryImageThumbnail = ({ ImageLoadingIndicator, ImageReloadIndicator, thumbnail, + isPendingAttachmentUploading, + ImageUploadingIndicator, }: Pick< GalleryThumbnailProps, | 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' | 'ImageReloadIndicator' + | 'ImageUploadingIndicator' | 'thumbnail' | 'borderRadius' ->) => { +> & { + /** + * Whether the attachment is currently being uploaded. + * This is used to show a loading indicator in the thumbnail. + */ + isPendingAttachmentUploading: boolean; +}) => { const { isLoadingImage, isLoadingImageError, @@ -434,6 +470,11 @@ const GalleryImageThumbnail = ({ )} + {isPendingAttachmentUploading && ( + + + + )} )} @@ -513,6 +554,7 @@ export const Gallery = (props: GalleryProps) => { ImageLoadingFailedIndicator: PropImageLoadingFailedIndicator, ImageLoadingIndicator: PropImageLoadingIndicator, ImageReloadIndicator: PropImageReloadIndicator, + ImageUploadingIndicator: PropImageUploadingIndicator, images: propImages, message: propMessage, myMessageTheme: propMyMessageTheme, @@ -524,6 +566,7 @@ export const Gallery = (props: GalleryProps) => { videos: propVideos, VideoThumbnail: PropVideoThumbnail, messageContentOrder: propMessageContentOrder, + pendingAttachmentsUploadingStore: propPendingAttachmentsLoadingStore, } = props; const { imageGalleryStateStore } = useImageGalleryContext(); @@ -543,8 +586,10 @@ export const Gallery = (props: GalleryProps) => { ImageLoadingFailedIndicator: ContextImageLoadingFailedIndicator, ImageLoadingIndicator: ContextImageLoadingIndicator, ImageReloadIndicator: ContextImageReloadIndicator, + ImageUploadingIndicator: ContextImageUploadingIndicator, myMessageTheme: contextMyMessageTheme, VideoThumbnail: ContextVideoThumnbnail, + pendingAttachmentsUploadingStore: contextPendingAttachmentsLoadingStore, } = useMessagesContext(); const { setOverlay: contextSetOverlay } = useOverlayContext(); @@ -568,8 +613,11 @@ export const Gallery = (props: GalleryProps) => { PropImageLoadingFailedIndicator || ContextImageLoadingFailedIndicator; const ImageLoadingIndicator = PropImageLoadingIndicator || ContextImageLoadingIndicator; const ImageReloadIndicator = PropImageReloadIndicator || ContextImageReloadIndicator; + const ImageUploadingIndicator = PropImageUploadingIndicator || ContextImageUploadingIndicator; const myMessageTheme = propMyMessageTheme || contextMyMessageTheme; const messageContentOrder = propMessageContentOrder || contextMessageContentOrder; + const pendingAttachmentsUploadingStore = + propPendingAttachmentsLoadingStore || contextPendingAttachmentsLoadingStore; const messageHasOnlyOneImage = messageContentOrder?.length === 1 && @@ -586,6 +634,7 @@ export const Gallery = (props: GalleryProps) => { ImageLoadingFailedIndicator, ImageLoadingIndicator, ImageReloadIndicator, + ImageUploadingIndicator, images, message, myMessageTheme, @@ -598,6 +647,7 @@ export const Gallery = (props: GalleryProps) => { VideoThumbnail, messageHasOnlyOneImage, messageContentOrder, + pendingAttachmentsUploadingStore, }} /> ); diff --git a/package/src/components/Attachment/ImageUploadingIndicator.tsx b/package/src/components/Attachment/ImageUploadingIndicator.tsx new file mode 100644 index 0000000000..2c7a91acc4 --- /dev/null +++ b/package/src/components/Attachment/ImageUploadingIndicator.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, View, ViewProps } from 'react-native'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; + +export type ImageUploadingIndicatorProps = ViewProps; + +export const ImageUploadingIndicator = (props: ImageUploadingIndicatorProps) => { + const { + theme: { + messageSimple: { + loadingIndicator: { container }, + }, + semantics, + }, + } = useTheme(); + const { style, ...rest } = props; + return ( + + + + ); +}; diff --git a/package/src/components/Attachment/VideoThumbnail.tsx b/package/src/components/Attachment/VideoThumbnail.tsx index 7c2a19f079..4fc37c6c59 100644 --- a/package/src/components/Attachment/VideoThumbnail.tsx +++ b/package/src/components/Attachment/VideoThumbnail.tsx @@ -1,22 +1,27 @@ import React from 'react'; -import { ImageBackground, ImageStyle, StyleProp, StyleSheet, ViewStyle } from 'react-native'; +import { + ActivityIndicator, + ImageBackground, + ImageStyle, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; import { VideoPlayIndicator } from '../ui'; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - justifyContent: 'center', - flex: 1, - overflow: 'hidden', - }, -}); - export type VideoThumbnailProps = { imageStyle?: StyleProp; style?: StyleProp; thumb_url?: string; + /** + * Whether the attachment is currently being uploaded. + * This is used to show a loading indicator in the thumbnail. + */ + isPendingAttachmentUploading?: boolean; }; export const VideoThumbnail = (props: VideoThumbnailProps) => { @@ -25,9 +30,10 @@ export const VideoThumbnail = (props: VideoThumbnailProps) => { messageSimple: { videoThumbnail: { container }, }, + semantics, }, } = useTheme(); - const { imageStyle, style, thumb_url } = props; + const { imageStyle, style, thumb_url, isPendingAttachmentUploading } = props; return ( { style={[styles.container, container, style]} > + {isPendingAttachmentUploading && ( + + + + )} ); }; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + flex: 1, + overflow: 'hidden', + }, + activityIndicatorContainer: { + position: 'absolute', + left: primitives.spacingXs, + bottom: primitives.spacingXs, + }, + activityIndicator: { + alignItems: 'flex-start', + justifyContent: 'flex-start', + }, +}); diff --git a/package/src/components/Attachment/__tests__/Attachment.test.js b/package/src/components/Attachment/__tests__/Attachment.test.js index 6367153ee9..7a55cdc906 100644 --- a/package/src/components/Attachment/__tests__/Attachment.test.js +++ b/package/src/components/Attachment/__tests__/Attachment.test.js @@ -14,6 +14,7 @@ import { } from '../../../mock-builders/generator/attachment'; import { generateMessage } from '../../../mock-builders/generator/message'; +import { PendingAttachmentsUploadingStateStore } from '../../../state-store/pending-attachments-uploading-state'; import { ImageLoadingFailedIndicator } from '../../Attachment/ImageLoadingFailedIndicator'; import { ImageLoadingIndicator } from '../../Attachment/ImageLoadingIndicator'; import { Attachment } from '../Attachment'; @@ -24,10 +25,17 @@ jest.mock('../../../native.ts', () => ({ })); const getAttachmentComponent = (props) => { + const pendingAttachmentsUploadingStore = new PendingAttachmentsUploadingStateStore(); const message = generateMessage(); return ( - + diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 45091a78aa..881bbb35bd 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -99,6 +99,7 @@ import { ChannelUnreadStateStoreType, } from '../../state-store/channel-unread-state'; import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; +import { PendingAttachmentsUploadingStateStore } from '../../state-store/pending-attachments-uploading-state'; import { FileTypes } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { compressedImageURI } from '../../utils/compressImage'; @@ -120,6 +121,7 @@ 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 { ImageUploadingIndicator as ImageUploadingIndicatorDefault } from '../Attachment/ImageUploadingIndicator'; import { URLPreview as URLPreviewDefault } from '../Attachment/UrlPreview'; import { VideoThumbnail as VideoThumbnailDefault } from '../Attachment/VideoThumbnail'; import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; @@ -352,6 +354,7 @@ export type ChannelPropsWithContext = Pick & | 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' | 'ImageReloadIndicator' + | 'ImageUploadingIndicator' | 'markdownRules' | 'Message' | 'MessageActionList' @@ -648,6 +651,7 @@ const ChannelWithContext = (props: PropsWithChildren) = ImageLoadingFailedIndicator = ImageLoadingFailedIndicatorDefault, ImageLoadingIndicator = ImageLoadingIndicatorDefault, ImageReloadIndicator = ImageReloadIndicatorDefault, + ImageUploadingIndicator = ImageUploadingIndicatorDefault, initialScrollToFirstUnreadMessage = false, InlineDateSeparator = InlineDateSeparatorDefault, InlineUnreadIndicator = InlineUnreadIndicatorDefault, @@ -778,6 +782,7 @@ const ChannelWithContext = (props: PropsWithChildren) = const [threadLoadingMore, setThreadLoadingMore] = useState(false); const [channelUnreadStateStore] = useState(new ChannelUnreadStateStore()); const [messageInputHeightStore] = useState(new MessageInputHeightStore()); + const [pendingAttachmentsUploadingStore] = useState(new PendingAttachmentsUploadingStateStore()); // TODO: Think if we can remove this and just rely on the channelUnreadStateStore everywhere. const setChannelUnreadState = useCallback( (data: ChannelUnreadStateStoreType['channelUnreadState']) => { @@ -1316,71 +1321,89 @@ const ChannelWithContext = (props: PropsWithChildren) = const uploadPendingAttachments = useStableCallback(async (message: LocalMessage) => { const updatedMessage = { ...message }; if (updatedMessage.attachments?.length) { - for (let i = 0; i < updatedMessage.attachments?.length; i++) { - const attachment = updatedMessage.attachments[i]; + pendingAttachmentsUploadingStore.addPendingAttachment({ message: updatedMessage }); + const uploadPromises = updatedMessage.attachments.map(async (attachment) => { // If the attachment is already uploaded, skip it. if ( (attachment.image_url && !isLocalUrl(attachment.image_url)) || (attachment.asset_url && !isLocalUrl(attachment.asset_url)) ) { - continue; + return; } const image = attachment.originalFile; const file = attachment.originalFile; - if (attachment.type === FileTypes.Image && image?.uri) { - const filename = image.name ?? getFileNameFromPath(image.uri); - // if any upload is in progress, cancel it - const controller = uploadAbortControllerRef.current.get(filename); - if (controller) { - controller.abort(); - uploadAbortControllerRef.current.delete(filename); + const attachmentUri = attachment.originalFile?.uri; + + const removeFromLoadingStore = () => { + if (attachmentUri) { + pendingAttachmentsUploadingStore.removePendingAttachment( + `${message.id}-${attachmentUri}`, + ); } - const compressedUri = await compressedImageURI(image, compressImageQuality); - const contentType = lookup(filename) || 'multipart/form-data'; + }; - const uploadResponse = doFileUploadRequest - ? await doFileUploadRequest(image) - : await channel.sendImage(compressedUri, filename, contentType); + try { + if (attachment.type === FileTypes.Image && image?.uri) { + const filename = image.name ?? getFileNameFromPath(image.uri); + // if any upload is in progress, cancel it + const controller = uploadAbortControllerRef.current.get(filename); + if (controller) { + controller.abort(); + uploadAbortControllerRef.current.delete(filename); + } + const compressedUri = await compressedImageURI(image, compressImageQuality); + const contentType = lookup(filename) || 'multipart/form-data'; + + const uploadResponse = doFileUploadRequest + ? await doFileUploadRequest(image) + : await channel.sendImage(compressedUri, filename, contentType); + + removeFromLoadingStore(); + + attachment.image_url = uploadResponse.file; + delete attachment.originalFile; + + client.offlineDb?.executeQuerySafely( + (db) => + db.updateMessage({ + message: { ...updatedMessage, cid: channel.cid }, + }), + { method: 'updateMessage' }, + ); + } else if (attachment.type !== FileTypes.Image && file?.uri) { + // if any upload is in progress, cancel it + const controller = uploadAbortControllerRef.current.get(file.name); + if (controller) { + controller.abort(); + uploadAbortControllerRef.current.delete(file.name); + } + const response = doFileUploadRequest + ? await doFileUploadRequest(file) + : await channel.sendFile(file.uri, file.name, file.type); + attachment.asset_url = response.file; + if (response.thumb_url) { + attachment.thumb_url = response.thumb_url; + } - attachment.image_url = uploadResponse.file; - delete attachment.originalFile; + removeFromLoadingStore(); - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...updatedMessage, cid: channel.cid }, - }), - { method: 'updateMessage' }, - ); - } - - if (attachment.type !== FileTypes.Image && file?.uri) { - // if any upload is in progress, cancel it - const controller = uploadAbortControllerRef.current.get(file.name); - if (controller) { - controller.abort(); - uploadAbortControllerRef.current.delete(file.name); - } - const response = doFileUploadRequest - ? await doFileUploadRequest(file) - : await channel.sendFile(file.uri, file.name, file.type); - attachment.asset_url = response.file; - if (response.thumb_url) { - attachment.thumb_url = response.thumb_url; + delete attachment.originalFile; + client.offlineDb?.executeQuerySafely( + (db) => + db.updateMessage({ + message: { ...updatedMessage, cid: channel.cid }, + }), + { method: 'updateMessage' }, + ); } - - delete attachment.originalFile; - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...updatedMessage, cid: channel.cid }, - }), - { method: 'updateMessage' }, - ); + } catch { + // TODO: Handle failed attachment upload. We don't have a way to handle this yet. } - } + }); + + await Promise.allSettled(uploadPromises); } return updatedMessage; @@ -1933,6 +1956,7 @@ const ChannelWithContext = (props: PropsWithChildren) = ImageLoadingFailedIndicator, ImageLoadingIndicator, ImageReloadIndicator, + ImageUploadingIndicator, initialScrollToFirstUnreadMessage: !messageId && initialScrollToFirstUnreadMessage, // when messageId is set, we scroll to the messageId instead of first unread InlineDateSeparator, InlineUnreadIndicator, @@ -1977,6 +2001,7 @@ const ChannelWithContext = (props: PropsWithChildren) = onLongPressMessage, onPressInMessage, onPressMessage, + pendingAttachmentsUploadingStore, PollContent, ReactionListBottom, reactionListPosition, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 3db83d4d65..d8f87c63c8 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -42,6 +42,7 @@ export const useCreateMessagesContext = ({ hasCreatePoll, ImageLoadingFailedIndicator, ImageLoadingIndicator, + ImageUploadingIndicator, ImageReloadIndicator, initialScrollToFirstUnreadMessage, InlineDateSeparator, @@ -108,6 +109,7 @@ export const useCreateMessagesContext = ({ updateMessage, UrlPreview, VideoThumbnail, + pendingAttachmentsUploadingStore, }: MessagesContextValue & { /** * To ensure we allow re-render, when channel is changed @@ -159,6 +161,7 @@ export const useCreateMessagesContext = ({ hasCreatePoll, ImageLoadingFailedIndicator, ImageLoadingIndicator, + ImageUploadingIndicator, ImageReloadIndicator, initialScrollToFirstUnreadMessage, InlineDateSeparator, @@ -225,6 +228,7 @@ export const useCreateMessagesContext = ({ updateMessage, UrlPreview, VideoThumbnail, + pendingAttachmentsUploadingStore, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 0de8b3e356..e9538f9165 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -1088,7 +1088,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => layout={LinearTransition.duration(200)} style={[ styles.scrollToBottomButtonContainer, - { bottom: messageInputFloating ? messageInputHeight : primitives.spacingMd }, + { + bottom: messageInputFloating + ? messageInputHeight + primitives.spacingMd + : primitives.spacingMd, + }, ]} > { diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 52910b4088..06d7cc2b0b 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -27,6 +27,7 @@ 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 { ImageUploadingIndicatorProps } from '../../components/Attachment/ImageUploadingIndicator'; import type { URLPreviewProps } from '../../components/Attachment/UrlPreview'; import type { VideoThumbnailProps } from '../../components/Attachment/VideoThumbnail'; import type { @@ -78,6 +79,7 @@ import { MessageUserReactionsItemProps } from '../../components/MessageMenu/Mess import type { ReplyProps } from '../../components/Reply/Reply'; import { NativeHandlers } from '../../native'; +import { PendingAttachmentsUploadingStateStore } from '../../state-store/pending-attachments-uploading-state'; import type { ReactionData } from '../../utils/utils'; import type { Alignment, MessageContextValue } from '../messageContext/MessageContext'; import type { DeepPartial } from '../themeContext/ThemeContext'; @@ -126,6 +128,8 @@ export type MessagesContextValue = Pick; + /** + * The indicator rendered when image is uploading. By default renders + */ + ImageUploadingIndicator: React.ComponentType; + /** * When true, messageList will be scrolled at first unread message, when opened. */ diff --git a/package/src/state-store/pending-attachments-uploading-state.ts b/package/src/state-store/pending-attachments-uploading-state.ts new file mode 100644 index 0000000000..ad9b04a322 --- /dev/null +++ b/package/src/state-store/pending-attachments-uploading-state.ts @@ -0,0 +1,44 @@ +import { LocalMessage, StateStore } from 'stream-chat'; + +export type PendingAttachmentsUploadingState = { + pendingAttachmentsUploading: Record; +}; + +const INITIAL_STATE: PendingAttachmentsUploadingState = { + pendingAttachmentsUploading: {}, +}; + +export class PendingAttachmentsUploadingStateStore { + public store = new StateStore(INITIAL_STATE); + + constructor() { + this.store.next({ pendingAttachmentsUploading: {} }); + } + + addPendingAttachment({ message }: { message: LocalMessage }) { + const attachments = message.attachments ?? []; + for (const attachment of attachments) { + const uri = attachment.originalFile?.uri; + const attachmentId = `${message.id}-${uri}`; + this.store.next({ + pendingAttachmentsUploading: { + ...this.store.getLatestValue().pendingAttachmentsUploading, + [attachmentId]: true, + }, + }); + } + } + + removePendingAttachment(attachmentId: string) { + const pendingAttachmentsUploading = this.store.getLatestValue().pendingAttachmentsUploading; + delete pendingAttachmentsUploading[attachmentId]; + this.store.next({ + pendingAttachmentsUploading, + }); + } + + isPendingAttachmentUploading(attachmentId: string) { + const pendingAttachmentsUploading = this.store.getLatestValue().pendingAttachmentsUploading; + return pendingAttachmentsUploading[attachmentId] ?? false; + } +} diff --git a/package/yarn.lock b/package/yarn.lock index b472c39fcc..62948c478c 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -8352,10 +8352,10 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -stream-chat@^9.35.1: - version "9.35.1" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.35.1.tgz#d828854a9c27ea7e45e6642d9107966c6606f552" - integrity sha512-649sgO7+llFuW+y/Ja0K4d94aUC+EMxYUVo5mq5AFGT86vUAIXmRIMVHYHA/jw4MYoqfWAFrDK6L9Rhyn/eMkQ== +stream-chat@^9.36.0: + version "9.36.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.36.0.tgz#154e0d6bdf8b15e97a6d9718c655d2ede34f6f25" + integrity sha512-D1b5THI4UbnvsEcJyUv1tUIgK6lCYT+aStrV+87mdrM9owX+WUpKaWFkxz/Ug+DOrJtTazvfuzvpJMyDi82NXA== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14"