From 30ed9b83b4f5a6a9cbed74acb5e09eb8893fe5f1 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 2 Apr 2026 15:54:05 +0200 Subject: [PATCH 1/2] feat: track attachment uploads outside of message composer - in progress --- .../components/Attachment/FileAttachment.tsx | 37 ++++-- package/src/components/Attachment/Gallery.tsx | 27 +++- .../Attachment/ImageUploadingIndicator.tsx | 28 ++++ .../components/Attachment/VideoThumbnail.tsx | 30 ++++- .../Attachment/__tests__/Attachment.test.js | 8 +- .../Attachment/__tests__/Giphy.test.js | 9 +- .../utils/buildGallery/buildThumbnail.ts | 3 + .../Attachment/utils/buildGallery/types.ts | 2 + package/src/components/Channel/Channel.tsx | 120 +++++++++--------- .../Channel/hooks/useCreateMessagesContext.ts | 2 + package/src/components/index.ts | 1 + .../MessageInputContext.tsx | 13 ++ .../messagesContext/MessagesContext.tsx | 6 + package/src/hooks/index.ts | 1 + .../hooks/useIsPendingAttachmentUploading.ts | 25 ++++ package/src/middlewares/attachments.ts | 2 + package/src/types/types.ts | 2 + 17 files changed, 241 insertions(+), 75 deletions(-) create mode 100644 package/src/components/Attachment/ImageUploadingIndicator.tsx create mode 100644 package/src/hooks/useIsPendingAttachmentUploading.ts 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 { From e2106e6f48a3263ce0223b0eeb748240f738e363 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 7 Apr 2026 14:13:01 +0200 Subject: [PATCH 2/2] track upload progress --- .../src/components/Attachment/Attachment.tsx | 54 ++++++++- .../AttachmentFileUploadProgressIndicator.tsx | 80 +++++++++++++ .../Attachment/AttachmentUploadIndicator.tsx | 61 ++++++++++ .../Attachment/CircularProgressIndicator.tsx | 113 ++++++++++++++++++ .../components/Attachment/FileAttachment.tsx | 28 ++--- package/src/components/Attachment/Gallery.tsx | 41 +++---- .../Attachment/ImageUploadingIndicator.tsx | 28 ----- .../components/Attachment/VideoThumbnail.tsx | 38 +++--- .../Attachment/__tests__/Attachment.test.js | 9 +- .../Attachment/__tests__/Giphy.test.js | 2 - package/src/components/Channel/Channel.tsx | 4 - .../Channel/hooks/useCreateMessagesContext.ts | 2 - package/src/components/index.ts | 3 +- .../messagesContext/MessagesContext.tsx | 6 - package/src/hooks/index.ts | 2 +- .../hooks/useIsPendingAttachmentUploading.ts | 25 ---- .../src/hooks/usePendingAttachmentUpload.ts | 49 ++++++++ 17 files changed, 406 insertions(+), 139 deletions(-) create mode 100644 package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx create mode 100644 package/src/components/Attachment/AttachmentUploadIndicator.tsx create mode 100644 package/src/components/Attachment/CircularProgressIndicator.tsx delete mode 100644 package/src/components/Attachment/ImageUploadingIndicator.tsx delete mode 100644 package/src/hooks/useIsPendingAttachmentUploading.ts create mode 100644 package/src/hooks/usePendingAttachmentUpload.ts diff --git a/package/src/components/Attachment/Attachment.tsx b/package/src/components/Attachment/Attachment.tsx index b4039db3cd..a4a755f26a 100644 --- a/package/src/components/Attachment/Attachment.tsx +++ b/package/src/components/Attachment/Attachment.tsx @@ -9,14 +9,17 @@ import { isVideoAttachment, isVoiceRecordingAttachment, type Attachment as AttachmentType, + type LocalMessage, } from 'stream-chat'; import { AudioAttachment as AudioAttachmentDefault } from './Audio'; +import type { AudioAttachmentProps } from './Audio/AudioAttachment'; import { UnsupportedAttachment as UnsupportedAttachmentDefault } from './UnsupportedAttachment'; import { URLPreview as URLPreviewDefault } from './UrlPreview'; import { URLPreviewCompact as URLPreviewCompactDefault } from './UrlPreview/URLPreviewCompact'; +import { AttachmentFileUploadProgressIndicator } from '../../components/Attachment/AttachmentFileUploadProgressIndicator'; import { FileAttachment as FileAttachmentDefault } from '../../components/Attachment/FileAttachment'; import { Gallery as GalleryDefault } from '../../components/Attachment/Gallery'; import { Giphy as GiphyDefault } from '../../components/Attachment/Giphy'; @@ -30,9 +33,11 @@ import { MessagesContextValue, useMessagesContext, } from '../../contexts/messagesContext/MessagesContext'; +import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload'; import { isSoundPackageAvailable, isVideoPlayerAvailable } from '../../native'; import { primitives } from '../../theme'; +import type { DefaultAttachmentData } from '../../types/types'; import { FileTypes } from '../../types/types'; export type ActionHandler = (name: string, value: string) => void; @@ -104,12 +109,12 @@ const AttachmentWithContext = (props: AttachmentPropsWithContext) => { if (isAudioAttachment(attachment) || isVoiceRecordingAttachment(attachment)) { if (isSoundPackageAvailable()) { return ( - ); } @@ -228,6 +233,45 @@ export const Attachment = (props: AttachmentProps) => { ); }; +type MessageAudioAttachmentProps = { + AudioAttachment: React.ComponentType; + attachment: AttachmentType; + audioAttachmentStyles: AudioAttachmentProps['styles']; + index?: number; + message: LocalMessage | undefined; +}; + +const MessageAudioAttachment = ({ + AudioAttachment: AudioAttachmentComponent, + attachment, + audioAttachmentStyles, + index, + message, +}: MessageAudioAttachmentProps) => { + const localId = (attachment as DefaultAttachmentData).localId; + const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId); + + const indicator = isUploading ? ( + + ) : undefined; + + const audioItemType = isVoiceRecordingAttachment(attachment) ? 'voiceRecording' : 'audio'; + + return ( + + ); +}; + const useAudioAttachmentStyles = () => { const { theme: { semantics }, diff --git a/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx b/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx new file mode 100644 index 0000000000..6dae9297cc --- /dev/null +++ b/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { AttachmentUploadIndicator } from './AttachmentUploadIndicator'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; + +export type AttachmentFileUploadProgressIndicatorProps = { + totalBytes?: number | string | null; + uploadProgress: number | undefined; +}; + +const parseTotalBytes = (value: number | string | null | undefined): number | null => { + if (value == null) { + return null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const n = parseFloat(value); + return Number.isFinite(n) ? n : null; + } + return null; +}; + +const formatMegabytesOneDecimal = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0.0 MB'; + } + return `${(bytes / (1000 * 1000)).toFixed(1)} MB`; +}; + +/** + * Circular progress plus `uploaded / total` for file and audio attachments during upload. + */ +export const AttachmentFileUploadProgressIndicator = ({ + totalBytes, + uploadProgress, +}: AttachmentFileUploadProgressIndicatorProps) => { + const { + theme: { semantics }, + } = useTheme(); + + const progressLabel = useMemo(() => { + const bytes = parseTotalBytes(totalBytes); + if (bytes == null || bytes <= 0) { + return null; + } + const uploaded = ((uploadProgress ?? 0) / 100) * bytes; + return `${formatMegabytesOneDecimal(uploaded)} / ${formatMegabytesOneDecimal(bytes)}`; + }, [totalBytes, uploadProgress]); + + return ( + + + {progressLabel ? ( + + {progressLabel} + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + label: { + flex: 1, + flexShrink: 1, + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightTight, + }, + row: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXxs, + }, +}); diff --git a/package/src/components/Attachment/AttachmentUploadIndicator.tsx b/package/src/components/Attachment/AttachmentUploadIndicator.tsx new file mode 100644 index 0000000000..4f2041c375 --- /dev/null +++ b/package/src/components/Attachment/AttachmentUploadIndicator.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; + +import { CircularProgressIndicator } from './CircularProgressIndicator'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; + +export type AttachmentUploadIndicatorProps = { + size?: number; + strokeWidth?: number; + style?: StyleProp; + testID?: string; + /** When set, shows determinate `CircularProgressIndicator`; otherwise a generic spinner. */ + uploadProgress: number | undefined; +}; + +/** + * Upload state for attachment previews: determinate ring when progress is known, otherwise `ActivityIndicator`. + */ +export const AttachmentUploadIndicator = ({ + size = 16, + strokeWidth = 2, + style, + testID, + uploadProgress, +}: AttachmentUploadIndicatorProps) => { + const { + theme: { semantics }, + } = useTheme(); + + if (uploadProgress === undefined) { + return ( + + + + ); + } + + return ( + + ); +}; + +const styles = StyleSheet.create({ + indeterminateWrap: { + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/package/src/components/Attachment/CircularProgressIndicator.tsx b/package/src/components/Attachment/CircularProgressIndicator.tsx new file mode 100644 index 0000000000..18d9f4b6a3 --- /dev/null +++ b/package/src/components/Attachment/CircularProgressIndicator.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import type { ColorValue } from 'react-native'; +import { Animated, Easing, StyleProp, ViewStyle } from 'react-native'; +import Svg, { Circle } from 'react-native-svg'; + +export type CircularProgressIndicatorProps = { + /** Upload percent **0–100**. */ + progress: number; + color: ColorValue; + size?: number; + strokeWidth?: number; + style?: StyleProp; + testID?: string; +}; + +/** + * Circular upload progress ring (determinate) or rotating arc (indeterminate). + */ +export const CircularProgressIndicator = ({ + color, + progress, + size = 16, + strokeWidth = 2, + style, + testID, +}: CircularProgressIndicatorProps) => { + const spin = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const loop = Animated.loop( + Animated.timing(spin, { + toValue: 1, + duration: 900, + easing: Easing.linear, + useNativeDriver: true, + }), + ); + loop.start(); + return () => { + loop.stop(); + spin.setValue(0); + }; + }, [progress, spin]); + + const rotate = useMemo( + () => + spin.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'], + }), + [spin], + ); + + const { cx, cy, r, circumference } = useMemo(() => { + const pad = strokeWidth / 2; + const rInner = size / 2 - pad; + return { + cx: size / 2, + cy: size / 2, + r: rInner, + circumference: 2 * Math.PI * rInner, + }; + }, [size, strokeWidth]); + + const fraction = + progress === undefined || Number.isNaN(progress) + ? undefined + : Math.min(100, Math.max(0, progress)) / 100; + + if (fraction !== undefined) { + const offset = circumference * (1 - fraction); + return ( + + + + ); + } + + const arc = circumference * 0.22; + const gap = circumference - arc; + + return ( + + + + + + ); +}; diff --git a/package/src/components/Attachment/FileAttachment.tsx b/package/src/components/Attachment/FileAttachment.tsx index c8e8d90649..39d3dd39f1 100644 --- a/package/src/components/Attachment/FileAttachment.tsx +++ b/package/src/components/Attachment/FileAttachment.tsx @@ -3,6 +3,7 @@ import { Pressable, StyleProp, StyleSheet, TextStyle, View, ViewStyle } from 're import type { Attachment } from 'stream-chat'; +import { AttachmentFileUploadProgressIndicator } from './AttachmentFileUploadProgressIndicator'; import { openUrlSafely } from './utils/openUrlSafely'; import { FileIconProps } from '../../components/Attachment/FileIcon'; @@ -16,17 +17,14 @@ import { useMessagesContext, } from '../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useIsPendingAttachmentUploading } from '../../hooks/useIsPendingAttachmentUploading'; +import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload'; import type { DefaultAttachmentData } from '../../types/types'; export type FileAttachmentPropsWithContext = Pick< MessageContextValue, 'onLongPress' | 'onPress' | 'onPressIn' | 'preventPress' > & - Pick< - MessagesContextValue, - 'additionalPressableProps' | 'FilePreview' | 'ImageUploadingIndicator' - > & { + Pick & { /** The attachment to render */ attachment: Attachment; attachmentIconSize?: FileIconProps['size']; @@ -47,7 +45,6 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { attachment, attachmentIconSize, FilePreview, - ImageUploadingIndicator, onLongPress, onPress, onPressIn, @@ -56,7 +53,7 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { } = props; const localId = (attachment as DefaultAttachmentData).localId; - const isPendingAttachmentUploading = useIsPendingAttachmentUploading(localId); + const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId); const defaultOnPress = () => openUrlSafely(attachment.asset_url); @@ -99,9 +96,16 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { + ) : undefined + } styles={stylesProp} /> - {isPendingAttachmentUploading ? : null} ); @@ -113,21 +117,15 @@ export type FileAttachmentProps = Partial { const { FilePreview: PropFilePreview } = props; const { onLongPress, onPress, onPressIn, preventPress } = useMessageContext(); - const { - additionalPressableProps, - FilePreview: ContextFilePreview, - ImageUploadingIndicator: ContextImageUploadingIndicator, - } = useMessagesContext(); + const { additionalPressableProps, FilePreview: ContextFilePreview } = useMessagesContext(); const FilePreview = PropFilePreview || ContextFilePreview; - const ImageUploadingIndicator = props.ImageUploadingIndicator || ContextImageUploadingIndicator; return ( & Pick & { @@ -76,7 +76,6 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageUploadingIndicator, images, message, onLongPress, @@ -196,7 +195,6 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { imageGalleryStateStore={imageGalleryStateStore} ImageLoadingFailedIndicator={ImageLoadingFailedIndicator} ImageLoadingIndicator={ImageLoadingIndicator} - ImageUploadingIndicator={ImageUploadingIndicator} imagesAndVideos={imagesAndVideos} invertedDirections={invertedDirections || false} key={rowIndex} @@ -237,7 +235,6 @@ type GalleryThumbnailProps = { | 'VideoThumbnail' | 'ImageLoadingIndicator' | 'ImageLoadingFailedIndicator' - | 'ImageUploadingIndicator' > & Pick & Pick & @@ -250,7 +247,6 @@ const GalleryThumbnail = ({ imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageUploadingIndicator, imagesAndVideos, invertedDirections, message, @@ -275,7 +271,6 @@ const GalleryThumbnail = ({ } = useTheme(); const { t } = useTranslationContext(); const styles = useStyles(); - const isPendingAttachmentUploading = useIsPendingAttachmentUploading(thumbnail.localId); const openImageViewer = () => { if (!message) { @@ -340,7 +335,7 @@ const GalleryThumbnail = ({ > {thumbnail.type === FileTypes.Video ? ( @@ -349,8 +344,6 @@ const GalleryThumbnail = ({ borderRadius={imageBorderRadius ?? borderRadius} ImageLoadingFailedIndicator={ImageLoadingFailedIndicator} ImageLoadingIndicator={ImageLoadingIndicator} - ImageUploadingIndicator={ImageUploadingIndicator} - isPendingAttachmentUploading={isPendingAttachmentUploading} thumbnail={thumbnail} /> )} @@ -377,19 +370,11 @@ const GalleryImageThumbnail = ({ borderRadius, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageUploadingIndicator, - isPendingAttachmentUploading, thumbnail, }: Pick< GalleryThumbnailProps, - | 'ImageLoadingFailedIndicator' - | 'ImageLoadingIndicator' - | 'ImageUploadingIndicator' - | 'thumbnail' - | 'borderRadius' -> & { - isPendingAttachmentUploading: boolean; -}) => { + 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' | 'thumbnail' | 'borderRadius' +>) => { const { isLoadingImage, isLoadingImageError, @@ -405,6 +390,7 @@ const GalleryImageThumbnail = ({ } = useTheme(); const styles = useStyles(); + const { isUploading, uploadProgress } = usePendingAttachmentUpload(thumbnail.localId); const onLoadStart = useStableCallback(() => { setLoadingImageError(false); @@ -439,7 +425,11 @@ const GalleryImageThumbnail = ({ uri={thumbnail.url} /> {isLoadingImage ? : null} - {isPendingAttachmentUploading ? : null} + {isUploading ? ( + + + + ) : null} )} @@ -518,7 +508,6 @@ export const Gallery = (props: GalleryProps) => { additionalPressableProps: propAdditionalPressableProps, ImageLoadingFailedIndicator: PropImageLoadingFailedIndicator, ImageLoadingIndicator: PropImageLoadingIndicator, - ImageUploadingIndicator: PropImageUploadingIndicator, images: propImages, message: propMessage, myMessageTheme: propMyMessageTheme, @@ -548,7 +537,6 @@ export const Gallery = (props: GalleryProps) => { additionalPressableProps: contextAdditionalPressableProps, ImageLoadingFailedIndicator: ContextImageLoadingFailedIndicator, ImageLoadingIndicator: ContextImageLoadingIndicator, - ImageUploadingIndicator: ContextImageUploadingIndicator, myMessageTheme: contextMyMessageTheme, VideoThumbnail: ContextVideoThumnbnail, } = useMessagesContext(); @@ -574,7 +562,6 @@ 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; @@ -592,7 +579,6 @@ export const Gallery = (props: GalleryProps) => { imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageUploadingIndicator, images, message, myMessageTheme, @@ -679,6 +665,11 @@ const useStyles = () => { ...StyleSheet.absoluteFillObject, overflow: 'hidden', }, + uploadProgressOnImage: { + bottom: primitives.spacingXxs, + left: primitives.spacingXxs, + position: 'absolute', + }, }); }, [semantics, isMyMessage]); }; diff --git a/package/src/components/Attachment/ImageUploadingIndicator.tsx b/package/src/components/Attachment/ImageUploadingIndicator.tsx deleted file mode 100644 index 3cf560e6e9..0000000000 --- a/package/src/components/Attachment/ImageUploadingIndicator.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 31c2da4d9e..1037b3fdb5 100644 --- a/package/src/components/Attachment/VideoThumbnail.tsx +++ b/package/src/components/Attachment/VideoThumbnail.tsx @@ -1,26 +1,19 @@ import React from 'react'; -import { - ActivityIndicator, - ImageBackground, - ImageStyle, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native'; +import { ImageBackground, ImageStyle, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; + +import { AttachmentUploadIndicator } from './AttachmentUploadIndicator'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload'; import { primitives } from '../../theme'; import { VideoPlayIndicator } from '../ui/VideoPlayIndicator'; const styles = StyleSheet.create({ - activityIndicator: { + uploadProgressContainer: { alignItems: 'flex-start', + bottom: primitives.spacingXxs, justifyContent: 'flex-start', - }, - activityIndicatorContainer: { - bottom: primitives.spacingXs, - left: primitives.spacingXs, + left: primitives.spacingXxs, position: 'absolute', }, container: { @@ -33,8 +26,10 @@ const styles = StyleSheet.create({ export type VideoThumbnailProps = { imageStyle?: StyleProp; - /** When true, shows upload progress over the thumbnail */ - isPendingAttachmentUploading?: boolean; + /** + * When set, upload state is read from `client.uploadManager` for this pending attachment id. + */ + localId?: string; style?: StyleProp; thumb_url?: string; }; @@ -45,10 +40,11 @@ export const VideoThumbnail = (props: VideoThumbnailProps) => { messageItemView: { videoThumbnail: { container }, }, - semantics, }, } = useTheme(); - const { imageStyle, isPendingAttachmentUploading, style, thumb_url } = props; + const { imageStyle, localId, style, thumb_url } = props; + const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId); + return ( { style={[styles.container, container, style]} > - {isPendingAttachmentUploading ? ( - - + {isUploading ? ( + + ) : null} diff --git a/package/src/components/Attachment/__tests__/Attachment.test.js b/package/src/components/Attachment/__tests__/Attachment.test.js index 2e48234a26..c15205ebdd 100644 --- a/package/src/components/Attachment/__tests__/Attachment.test.js +++ b/package/src/components/Attachment/__tests__/Attachment.test.js @@ -16,7 +16,6 @@ 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'; @@ -25,8 +24,11 @@ jest.mock('../../../native.ts', () => ({ isSoundPackageAvailable: jest.fn(() => false), })); -jest.mock('../../../hooks/useIsPendingAttachmentUploading', () => ({ - useIsPendingAttachmentUploading: jest.fn(() => false), +jest.mock('../../../hooks/usePendingAttachmentUpload', () => ({ + usePendingAttachmentUpload: jest.fn(() => ({ + isUploading: false, + uploadProgress: undefined, + })), })); const getAttachmentComponent = (props) => { @@ -38,7 +40,6 @@ const getAttachmentComponent = (props) => { FilePreview: FilePreviewDefault, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageUploadingIndicator, }} > diff --git a/package/src/components/Attachment/__tests__/Giphy.test.js b/package/src/components/Attachment/__tests__/Giphy.test.js index 8e1bdb9eb2..725b125fcb 100644 --- a/package/src/components/Attachment/__tests__/Giphy.test.js +++ b/package/src/components/Attachment/__tests__/Giphy.test.js @@ -26,7 +26,6 @@ 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'; @@ -45,7 +44,6 @@ describe('Giphy', () => { value={{ ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageUploadingIndicator, }} > diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 22041f345f..ceaee83dd4 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -120,7 +120,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 { 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,7 +359,6 @@ export type ChannelPropsWithContext = Pick & | 'isAttachmentEqual' | 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' - | 'ImageUploadingIndicator' | 'markdownRules' | 'Message' | 'MessageActionList' @@ -665,7 +663,6 @@ const ChannelWithContext = (props: PropsWithChildren) = ImageAttachmentUploadPreview = ImageAttachmentUploadPreviewDefault, ImageLoadingFailedIndicator = ImageLoadingFailedIndicatorDefault, ImageLoadingIndicator = ImageLoadingIndicatorDefault, - ImageUploadingIndicator = ImageUploadingIndicatorDefault, initialScrollToFirstUnreadMessage = false, InlineDateSeparator = InlineDateSeparatorDefault, InlineUnreadIndicator = InlineUnreadIndicatorDefault, @@ -1963,7 +1960,6 @@ 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 999f4e3473..2f21318b6d 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -43,7 +43,6 @@ export const useCreateMessagesContext = ({ hasCreatePoll, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageUploadingIndicator, initialScrollToFirstUnreadMessage, InlineDateSeparator, InlineUnreadIndicator, @@ -173,7 +172,6 @@ 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 a617a8c479..60c48e7173 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -6,7 +6,8 @@ export * from './Attachment/FileAttachmentGroup'; export * from './Attachment/FileIcon'; export * from './Attachment/Gallery'; export * from './Attachment/Giphy'; -export * from './Attachment/ImageUploadingIndicator'; +export * from './Attachment/CircularProgressIndicator'; +export * from './Attachment/AttachmentUploadIndicator'; export * from './Attachment/VideoThumbnail'; export * from './Attachment/UrlPreview'; export * from './Attachment/utils/buildGallery/buildGallery'; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 46f81e1c59..5b1b3c9572 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -26,7 +26,6 @@ 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, @@ -195,11 +194,6 @@ 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 017995dfff..24eaee6af6 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -3,7 +3,7 @@ export * from './useStreami18n'; export * from './useViewport'; export * from './useScreenDimensions'; export * from './useStateStore'; -export * from './useIsPendingAttachmentUploading'; +export * from './usePendingAttachmentUpload'; export * from './useStableCallback'; export * from './useLoadingImage'; export * from './useMessageReminder'; diff --git a/package/src/hooks/useIsPendingAttachmentUploading.ts b/package/src/hooks/useIsPendingAttachmentUploading.ts deleted file mode 100644 index 83c743fa9b..0000000000 --- a/package/src/hooks/useIsPendingAttachmentUploading.ts +++ /dev/null @@ -1,25 +0,0 @@ -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/hooks/usePendingAttachmentUpload.ts b/package/src/hooks/usePendingAttachmentUpload.ts new file mode 100644 index 0000000000..bcf38a1463 --- /dev/null +++ b/package/src/hooks/usePendingAttachmentUpload.ts @@ -0,0 +1,49 @@ +import { useCallback } from 'react'; + +import type { UploadManagerState } from 'stream-chat'; + +import { useStateStore } from './useStateStore'; + +import { useChatContext } from '../contexts/chatContext/ChatContext'; + +export type PendingAttachmentUpload = { + /** True when `client.uploadManager` has an in-flight upload for this attachment local id. */ + isUploading: boolean; + /** + * Upload percent **0–100** from `client.uploadManager` (same scale as `attachmentManager` + * `onProgress` / `localMetadata.uploadProgress`). `undefined` when not computable or not uploading. + */ + uploadProgress: number | undefined; +}; + +const idle: PendingAttachmentUpload = { + isUploading: false, + uploadProgress: undefined, +}; + +/** + * Subscribes to `client.uploadManager` for the pending attachment identified by `localId`. + */ +export function usePendingAttachmentUpload(localId: string | undefined): PendingAttachmentUpload { + const { client } = useChatContext(); + const selector = useCallback( + (state: UploadManagerState): PendingAttachmentUpload => { + if (!localId) { + return idle; + } + const record = state.uploads.find((u) => u.id === localId && u.state === 'uploading'); + if (!record) { + return idle; + } + return { + isUploading: true, + uploadProgress: record.uploadProgress, + }; + }, + [localId], + ); + + const result = useStateStore(localId ? client.uploadManager.state : undefined, selector); + + return result ?? idle; +}