diff --git a/package/src/components/Attachment/FileAttachment.tsx b/package/src/components/Attachment/FileAttachment.tsx index 4cc79069a4..c8e8d90649 100644 --- a/package/src/components/Attachment/FileAttachment.tsx +++ b/package/src/components/Attachment/FileAttachment.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { Pressable, StyleProp, StyleSheet, TextStyle, ViewStyle } from 'react-native'; +import { Pressable, StyleProp, StyleSheet, TextStyle, View, ViewStyle } from 'react-native'; import type { Attachment } from 'stream-chat'; @@ -16,12 +16,17 @@ import { useMessagesContext, } from '../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useIsPendingAttachmentUploading } from '../../hooks/useIsPendingAttachmentUploading'; +import type { DefaultAttachmentData } from '../../types/types'; export type FileAttachmentPropsWithContext = Pick< MessageContextValue, 'onLongPress' | 'onPress' | 'onPressIn' | 'preventPress' > & - Pick & { + Pick< + MessagesContextValue, + 'additionalPressableProps' | 'FilePreview' | 'ImageUploadingIndicator' + > & { /** The attachment to render */ attachment: Attachment; attachmentIconSize?: FileIconProps['size']; @@ -42,6 +47,7 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { attachment, attachmentIconSize, FilePreview, + ImageUploadingIndicator, onLongPress, onPress, onPressIn, @@ -49,6 +55,9 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { styles: stylesProp = styles, } = props; + const localId = (attachment as DefaultAttachmentData).localId; + const isPendingAttachmentUploading = useIsPendingAttachmentUploading(localId); + const defaultOnPress = () => openUrlSafely(attachment.asset_url); return ( @@ -86,11 +95,14 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { testID='file-attachment' {...additionalPressableProps} > - + + + {isPendingAttachmentUploading ? : null} + ); }; @@ -101,15 +113,21 @@ export type FileAttachmentProps = Partial { const { FilePreview: PropFilePreview } = props; const { onLongPress, onPress, onPressIn, preventPress } = useMessageContext(); - const { additionalPressableProps, FilePreview: ContextFilePreview } = useMessagesContext(); + const { + additionalPressableProps, + FilePreview: ContextFilePreview, + ImageUploadingIndicator: ContextImageUploadingIndicator, + } = useMessagesContext(); const FilePreview = PropFilePreview || ContextFilePreview; + const ImageUploadingIndicator = props.ImageUploadingIndicator || ContextImageUploadingIndicator; return ( { ? semantics.chatBgAttachmentOutgoing : semantics.chatBgAttachmentIncoming, }, + previewWrap: { + position: 'relative', + }, }); }, [showBackgroundTransparent, isMyMessage, semantics]); }; diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 9d50977b03..2952585404 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -34,6 +34,7 @@ import { } from '../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useIsPendingAttachmentUploading } from '../../hooks/useIsPendingAttachmentUploading'; import { useLoadingImage } from '../../hooks/useLoadingImage'; import { useStableCallback } from '../../hooks/useStableCallback'; import { isVideoPlayerAvailable } from '../../native'; @@ -60,6 +61,7 @@ export type GalleryPropsWithContext = Pick & Pick & { @@ -74,6 +76,7 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, + ImageUploadingIndicator, images, message, onLongPress, @@ -193,6 +196,7 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { imageGalleryStateStore={imageGalleryStateStore} ImageLoadingFailedIndicator={ImageLoadingFailedIndicator} ImageLoadingIndicator={ImageLoadingIndicator} + ImageUploadingIndicator={ImageUploadingIndicator} imagesAndVideos={imagesAndVideos} invertedDirections={invertedDirections || false} key={rowIndex} @@ -233,6 +237,7 @@ type GalleryThumbnailProps = { | 'VideoThumbnail' | 'ImageLoadingIndicator' | 'ImageLoadingFailedIndicator' + | 'ImageUploadingIndicator' > & Pick & Pick & @@ -245,6 +250,7 @@ const GalleryThumbnail = ({ imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, + ImageUploadingIndicator, imagesAndVideos, invertedDirections, message, @@ -269,6 +275,7 @@ const GalleryThumbnail = ({ } = useTheme(); const { t } = useTranslationContext(); const styles = useStyles(); + const isPendingAttachmentUploading = useIsPendingAttachmentUploading(thumbnail.localId); const openImageViewer = () => { if (!message) { @@ -333,6 +340,7 @@ const GalleryThumbnail = ({ > {thumbnail.type === FileTypes.Video ? ( @@ -341,6 +349,8 @@ const GalleryThumbnail = ({ borderRadius={imageBorderRadius ?? borderRadius} ImageLoadingFailedIndicator={ImageLoadingFailedIndicator} ImageLoadingIndicator={ImageLoadingIndicator} + ImageUploadingIndicator={ImageUploadingIndicator} + isPendingAttachmentUploading={isPendingAttachmentUploading} thumbnail={thumbnail} /> )} @@ -367,11 +377,19 @@ const GalleryImageThumbnail = ({ borderRadius, ImageLoadingFailedIndicator, ImageLoadingIndicator, + ImageUploadingIndicator, + isPendingAttachmentUploading, thumbnail, }: Pick< GalleryThumbnailProps, - 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' | 'thumbnail' | 'borderRadius' ->) => { + | 'ImageLoadingFailedIndicator' + | 'ImageLoadingIndicator' + | 'ImageUploadingIndicator' + | 'thumbnail' + | 'borderRadius' +> & { + isPendingAttachmentUploading: boolean; +}) => { const { isLoadingImage, isLoadingImageError, @@ -421,6 +439,7 @@ const GalleryImageThumbnail = ({ uri={thumbnail.url} /> {isLoadingImage ? : null} + {isPendingAttachmentUploading ? : null} )} @@ -499,6 +518,7 @@ export const Gallery = (props: GalleryProps) => { additionalPressableProps: propAdditionalPressableProps, ImageLoadingFailedIndicator: PropImageLoadingFailedIndicator, ImageLoadingIndicator: PropImageLoadingIndicator, + ImageUploadingIndicator: PropImageUploadingIndicator, images: propImages, message: propMessage, myMessageTheme: propMyMessageTheme, @@ -528,6 +548,7 @@ export const Gallery = (props: GalleryProps) => { additionalPressableProps: contextAdditionalPressableProps, ImageLoadingFailedIndicator: ContextImageLoadingFailedIndicator, ImageLoadingIndicator: ContextImageLoadingIndicator, + ImageUploadingIndicator: ContextImageUploadingIndicator, myMessageTheme: contextMyMessageTheme, VideoThumbnail: ContextVideoThumnbnail, } = useMessagesContext(); @@ -553,6 +574,7 @@ export const Gallery = (props: GalleryProps) => { const ImageLoadingFailedIndicator = PropImageLoadingFailedIndicator || ContextImageLoadingFailedIndicator; const ImageLoadingIndicator = PropImageLoadingIndicator || ContextImageLoadingIndicator; + const ImageUploadingIndicator = PropImageUploadingIndicator || ContextImageUploadingIndicator; const myMessageTheme = propMyMessageTheme || contextMyMessageTheme; const messageContentOrder = propMessageContentOrder || contextMessageContentOrder; @@ -570,6 +592,7 @@ export const Gallery = (props: GalleryProps) => { imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, + ImageUploadingIndicator, images, message, myMessageTheme, diff --git a/package/src/components/Attachment/ImageUploadingIndicator.tsx b/package/src/components/Attachment/ImageUploadingIndicator.tsx new file mode 100644 index 0000000000..3cf560e6e9 --- /dev/null +++ b/package/src/components/Attachment/ImageUploadingIndicator.tsx @@ -0,0 +1,28 @@ +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 = ({ style, ...rest }: ImageUploadingIndicatorProps) => { + const { + theme: { semantics }, + } = useTheme(); + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + centered: { + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/package/src/components/Attachment/VideoThumbnail.tsx b/package/src/components/Attachment/VideoThumbnail.tsx index f255c32531..31c2da4d9e 100644 --- a/package/src/components/Attachment/VideoThumbnail.tsx +++ b/package/src/components/Attachment/VideoThumbnail.tsx @@ -1,10 +1,28 @@ 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/VideoPlayIndicator'; const styles = StyleSheet.create({ + activityIndicator: { + alignItems: 'flex-start', + justifyContent: 'flex-start', + }, + activityIndicatorContainer: { + bottom: primitives.spacingXs, + left: primitives.spacingXs, + position: 'absolute', + }, container: { alignItems: 'center', justifyContent: 'center', @@ -15,6 +33,8 @@ const styles = StyleSheet.create({ export type VideoThumbnailProps = { imageStyle?: StyleProp; + /** When true, shows upload progress over the thumbnail */ + isPendingAttachmentUploading?: boolean; style?: StyleProp; thumb_url?: string; }; @@ -25,9 +45,10 @@ export const VideoThumbnail = (props: VideoThumbnailProps) => { messageItemView: { videoThumbnail: { container }, }, + semantics, }, } = useTheme(); - const { imageStyle, style, thumb_url } = props; + const { imageStyle, isPendingAttachmentUploading, style, thumb_url } = props; return ( { style={[styles.container, container, style]} > + {isPendingAttachmentUploading ? ( + + + + ) : null} ); }; diff --git a/package/src/components/Attachment/__tests__/Attachment.test.js b/package/src/components/Attachment/__tests__/Attachment.test.js index 8e1d28ff0f..2e48234a26 100644 --- a/package/src/components/Attachment/__tests__/Attachment.test.js +++ b/package/src/components/Attachment/__tests__/Attachment.test.js @@ -16,6 +16,7 @@ import { generateMessage } from '../../../mock-builders/generator/message'; import { ImageLoadingFailedIndicator } from '../../Attachment/ImageLoadingFailedIndicator'; import { ImageLoadingIndicator } from '../../Attachment/ImageLoadingIndicator'; +import { ImageUploadingIndicator } from '../../Attachment/ImageUploadingIndicator'; import { Attachment } from '../Attachment'; import { FilePreview as FilePreviewDefault } from '../FilePreview'; @@ -24,15 +25,20 @@ jest.mock('../../../native.ts', () => ({ isSoundPackageAvailable: jest.fn(() => false), })); +jest.mock('../../../hooks/useIsPendingAttachmentUploading', () => ({ + useIsPendingAttachmentUploading: jest.fn(() => false), +})); + const getAttachmentComponent = (props) => { const message = generateMessage(); return ( diff --git a/package/src/components/Attachment/__tests__/Giphy.test.js b/package/src/components/Attachment/__tests__/Giphy.test.js index d35e5839bd..8e1bdb9eb2 100644 --- a/package/src/components/Attachment/__tests__/Giphy.test.js +++ b/package/src/components/Attachment/__tests__/Giphy.test.js @@ -26,6 +26,7 @@ import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Streami18n } from '../../../utils/i18n/Streami18n'; import { ImageLoadingFailedIndicator } from '../../Attachment/ImageLoadingFailedIndicator'; import { ImageLoadingIndicator } from '../../Attachment/ImageLoadingIndicator'; +import { ImageUploadingIndicator } from '../../Attachment/ImageUploadingIndicator'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; import { MessageList } from '../../MessageList/MessageList'; @@ -40,7 +41,13 @@ describe('Giphy', () => { const message = generateMessage(); return ( - + diff --git a/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts b/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts index 323a346b77..c69b682808 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts @@ -5,6 +5,7 @@ import type { Attachment } from 'stream-chat'; import type { Thumbnail } from './types'; import { ChatConfigContextValue } from '../../../../contexts/chatConfigContext/ChatConfigContext'; +import type { DefaultAttachmentData } from '../../../../types/types'; import { getResizedImageUrl } from '../../../../utils/getResizedImageUrl'; import { getUrlOfImageAttachment } from '../../../../utils/getUrlOfImageAttachment'; @@ -33,9 +34,11 @@ export function buildThumbnail({ ? originalImageHeight + originalImageWidth > height + width : true; const imageUrl = getUrlOfImageAttachment(image) as string; + const localId = (image as Attachment & DefaultAttachmentData).localId; return { flex, + localId, resizeMode: resizeMode ? resizeMode : ((image.original_height && image.original_width ? 'contain' : 'cover') as ImageResizeMode), diff --git a/package/src/components/Attachment/utils/buildGallery/types.ts b/package/src/components/Attachment/utils/buildGallery/types.ts index 1a066779f0..ceefd60b5a 100644 --- a/package/src/components/Attachment/utils/buildGallery/types.ts +++ b/package/src/components/Attachment/utils/buildGallery/types.ts @@ -4,6 +4,8 @@ export type Thumbnail = { resizeMode: ImageResizeMode; url: string; id?: string; + /** Same as attachment `localId` for correlating with `client.uploadManager` */ + localId?: string; thumb_url?: string; type?: string; flex?: number; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index ec851251d1..22041f345f 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -4,7 +4,6 @@ import { StyleSheet, Text, View } from 'react-native'; import debounce from 'lodash/debounce'; import throttle from 'lodash/throttle'; -import { lookup } from 'mime-types'; import { Channel as ChannelClass, ChannelState, @@ -100,7 +99,7 @@ import { } from '../../state-store/channel-unread-state'; import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; -import { FileTypes } from '../../types/types'; +import { DefaultAttachmentData, FileTypes } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { compressedImageURI } from '../../utils/compressImage'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; @@ -121,6 +120,7 @@ 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 { ImageUploadingIndicator as ImageUploadingIndicatorDefault } from '../Attachment/ImageUploadingIndicator'; import { UnsupportedAttachment as UnsupportedAttachmentDefault } from '../Attachment/UnsupportedAttachment'; import { URLPreview as URLPreviewDefault } from '../Attachment/UrlPreview'; import { URLPreviewCompact as URLPreviewCompactDefault } from '../Attachment/UrlPreview/URLPreviewCompact'; @@ -360,6 +360,7 @@ export type ChannelPropsWithContext = Pick & | 'isAttachmentEqual' | 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' + | 'ImageUploadingIndicator' | 'markdownRules' | 'Message' | 'MessageActionList' @@ -664,6 +665,7 @@ const ChannelWithContext = (props: PropsWithChildren) = ImageAttachmentUploadPreview = ImageAttachmentUploadPreviewDefault, ImageLoadingFailedIndicator = ImageLoadingFailedIndicatorDefault, ImageLoadingIndicator = ImageLoadingIndicatorDefault, + ImageUploadingIndicator = ImageUploadingIndicatorDefault, initialScrollToFirstUnreadMessage = false, InlineDateSeparator = InlineDateSeparatorDefault, InlineUnreadIndicator = InlineUnreadIndicatorDefault, @@ -1345,73 +1347,68 @@ 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]; - - // If the attachment is already uploaded, skip it. - if ( - (attachment.image_url && !isLocalUrl(attachment.image_url)) || - (attachment.asset_url && !isLocalUrl(attachment.asset_url)) - ) { - continue; - } + if (!updatedMessage.attachments?.length || !channel?.cid) { + return updatedMessage; + } - 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 compressedUri = await compressedImageURI(image, compressImageQuality); - const contentType = lookup(filename) || 'multipart/form-data'; + type UploadResponse = { file: string; thumb_url?: string }; - const uploadResponse = doFileUploadRequest - ? await doFileUploadRequest(image) - : await channel.sendImage(compressedUri, filename, contentType); + const uploadOne = async (attachment: NonNullable[number]) => { + if ( + (attachment.image_url && !isLocalUrl(attachment.image_url)) || + (attachment.asset_url && !isLocalUrl(attachment.asset_url)) + ) { + return; + } - attachment.image_url = uploadResponse.file; - delete attachment.originalFile; + const originalFile = attachment.originalFile; + if (!originalFile?.uri) { + return; + } - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...updatedMessage, cid: channel.cid }, - }), - { method: 'updateMessage' }, - ); - } + const localId = (attachment as DefaultAttachmentData).localId; + if (!localId) { + console.warn('uploadPendingAttachments: local attachment missing localId, skipping upload'); + return; + } - 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; - } + let fileForUpload = originalFile; + if (attachment.type === FileTypes.Image && !doFileUploadRequest) { + const filename = originalFile.name ?? getFileNameFromPath(originalFile.uri); + const compressedUri = await compressedImageURI(originalFile, compressImageQuality); + fileForUpload = { ...originalFile, name: filename, uri: compressedUri }; + } - delete attachment.originalFile; - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...updatedMessage, cid: channel.cid }, - }), - { method: 'updateMessage' }, - ); + const rawResponse = await client.uploadManager.upload({ + channelCid: channel.cid, + file: fileForUpload, + id: localId, + }); + const uploadResponse = rawResponse as UploadResponse; + + if (attachment.type === FileTypes.Image) { + attachment.image_url = uploadResponse.file; + } else { + attachment.asset_url = uploadResponse.file; + if (uploadResponse.thumb_url) { + attachment.thumb_url = uploadResponse.thumb_url; } } - } + + client.uploadManager.deleteUploadRecords((u) => u.id === localId); + delete attachment.originalFile; + delete (attachment as DefaultAttachmentData).localId; + + client.offlineDb?.executeQuerySafely( + (db) => + db.updateMessage({ + message: { ...updatedMessage, cid: channel.cid }, + }), + { method: 'updateMessage' }, + ); + }; + + await Promise.all(updatedMessage.attachments.map((att) => uploadOne(att))); return updatedMessage; }); @@ -1966,6 +1963,7 @@ const ChannelWithContext = (props: PropsWithChildren) = hasCreatePoll === undefined ? pollCreationEnabled : hasCreatePoll && pollCreationEnabled, ImageLoadingFailedIndicator, ImageLoadingIndicator, + ImageUploadingIndicator, 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 2f21318b6d..999f4e3473 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -43,6 +43,7 @@ export const useCreateMessagesContext = ({ hasCreatePoll, ImageLoadingFailedIndicator, ImageLoadingIndicator, + ImageUploadingIndicator, initialScrollToFirstUnreadMessage, InlineDateSeparator, InlineUnreadIndicator, @@ -172,6 +173,7 @@ export const useCreateMessagesContext = ({ hasCreatePoll, ImageLoadingFailedIndicator, ImageLoadingIndicator, + ImageUploadingIndicator, initialScrollToFirstUnreadMessage, InlineDateSeparator, InlineUnreadIndicator, diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 1cf41c4af6..a617a8c479 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -6,6 +6,7 @@ export * from './Attachment/FileAttachmentGroup'; export * from './Attachment/FileIcon'; export * from './Attachment/Gallery'; export * from './Attachment/Giphy'; +export * from './Attachment/ImageUploadingIndicator'; export * from './Attachment/VideoThumbnail'; export * from './Attachment/UrlPreview'; export * from './Attachment/utils/buildGallery/buildGallery'; diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index b729b5d862..ab04462841 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -11,6 +11,7 @@ import { Alert, Linking, Platform, TextInput, TextInputProps } from 'react-nativ import { lookup as lookupMimeType } from 'mime-types'; import { + createUploadManagerCleanUpMiddleware, LocalMessage, MessageComposer, SendMessageOptions, @@ -424,6 +425,9 @@ export const MessageInputProvider = ({ attachmentManager.setCustomUploadFn(value.doFileUploadRequest); } + const postUploadExecutor = attachmentManager.postUploadMiddlewareExecutor; + const uploadManagerCleanUpId = 'stream-io/attachment-manager-middleware/uploadManagerCleanUp'; + if (allowSendBeforeAttachmentsUpload) { messageComposer.compositionMiddlewareExecutor.replace([ createAttachmentsCompositionMiddleware(messageComposer), @@ -432,6 +436,15 @@ export const MessageInputProvider = ({ messageComposer.draftCompositionMiddlewareExecutor.replace([ createDraftAttachmentsCompositionMiddleware(messageComposer), ]); + + postUploadExecutor.remove(uploadManagerCleanUpId); + } else { + const middleware = (postUploadExecutor as unknown as { middleware: { id: string }[] }) + .middleware; + const hasCleanUp = middleware.some((m) => m.id === uploadManagerCleanUpId); + if (!hasCleanUp) { + postUploadExecutor.use(createUploadManagerCleanUpMiddleware(messageComposer)); + } } }, [ value.doFileUploadRequest, diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 5b1b3c9572..46f81e1c59 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -26,6 +26,7 @@ import { FilePreviewProps } from '../../components/Attachment/FilePreview'; import type { GalleryProps } from '../../components/Attachment/Gallery'; import type { GiphyProps } from '../../components/Attachment/Giphy'; import type { ImageLoadingFailedIndicatorProps } from '../../components/Attachment/ImageLoadingFailedIndicator'; +import type { ImageUploadingIndicatorProps } from '../../components/Attachment/ImageUploadingIndicator'; import { UnsupportedAttachmentProps } from '../../components/Attachment/UnsupportedAttachment'; import type { URLPreviewCompactProps, @@ -194,6 +195,11 @@ export type MessagesContextValue = Pick; + /** * When true, messageList will be scrolled at first unread message, when opened. */ diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index 503cf5128b..017995dfff 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -3,6 +3,7 @@ export * from './useStreami18n'; export * from './useViewport'; export * from './useScreenDimensions'; export * from './useStateStore'; +export * from './useIsPendingAttachmentUploading'; export * from './useStableCallback'; export * from './useLoadingImage'; export * from './useMessageReminder'; diff --git a/package/src/hooks/useIsPendingAttachmentUploading.ts b/package/src/hooks/useIsPendingAttachmentUploading.ts new file mode 100644 index 0000000000..83c743fa9b --- /dev/null +++ b/package/src/hooks/useIsPendingAttachmentUploading.ts @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; + +import type { UploadManagerState } from 'stream-chat'; + +import { useStateStore } from './useStateStore'; + +import { useChatContext } from '../contexts/chatContext/ChatContext'; + +/** + * True when `client.uploadManager` has an in-flight upload for this attachment local id. + */ +export function useIsPendingAttachmentUploading(localId: string | undefined): boolean { + const { client } = useChatContext(); + const selector = useCallback( + (state: UploadManagerState) => ({ + isPending: + !!localId && state.uploads.some((u) => u.id === localId && u.state === 'uploading'), + }), + [localId], + ); + + const result = useStateStore(localId ? client.uploadManager.state : undefined, selector); + + return result?.isPending ?? false; +} diff --git a/package/src/middlewares/attachments.ts b/package/src/middlewares/attachments.ts index 33c5531727..82611a634e 100644 --- a/package/src/middlewares/attachments.ts +++ b/package/src/middlewares/attachments.ts @@ -24,6 +24,7 @@ export const localAttachmentToAttachment = (localAttachment: LocalAttachment) => return { ...attachment, image_url: localMetadata?.previewUri, + localId: localMetadata?.id, originalFile: localMetadata.file, } as Attachment; } else { @@ -33,6 +34,7 @@ export const localAttachmentToAttachment = (localAttachment: LocalAttachment) => return { ...attachment, asset_url: (localMetadata.file as FileReference).uri, + localId: localMetadata?.id, originalFile: localMetadata.file, } as Attachment; } diff --git a/package/src/types/types.ts b/package/src/types/types.ts index f6f36837a9..c372b9fe8b 100644 --- a/package/src/types/types.ts +++ b/package/src/types/types.ts @@ -43,6 +43,8 @@ export type UploadAttachmentPreviewProps = { export interface DefaultAttachmentData { originalFile?: File; + /** Matches `LocalAttachment.localMetadata.id` / `uploadManager` record id for pending uploads */ + localId?: string; } export interface DefaultUserData {