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 4cc79069a4..39d3dd39f1 100644
--- a/package/src/components/Attachment/FileAttachment.tsx
+++ b/package/src/components/Attachment/FileAttachment.tsx
@@ -1,8 +1,9 @@
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';
+import { AttachmentFileUploadProgressIndicator } from './AttachmentFileUploadProgressIndicator';
import { openUrlSafely } from './utils/openUrlSafely';
import { FileIconProps } from '../../components/Attachment/FileIcon';
@@ -16,6 +17,8 @@ import {
useMessagesContext,
} from '../../contexts/messagesContext/MessagesContext';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
+import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload';
+import type { DefaultAttachmentData } from '../../types/types';
export type FileAttachmentPropsWithContext = Pick<
MessageContextValue,
@@ -49,6 +52,9 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => {
styles: stylesProp = styles,
} = props;
+ const localId = (attachment as DefaultAttachmentData).localId;
+ const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId);
+
const defaultOnPress = () => openUrlSafely(attachment.asset_url);
return (
@@ -86,11 +92,21 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => {
testID='file-attachment'
{...additionalPressableProps}
>
-
+
+
+ ) : undefined
+ }
+ styles={stylesProp}
+ />
+
);
};
@@ -138,6 +154,9 @@ const useStyles = () => {
? 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..813cc682fe 100644
--- a/package/src/components/Attachment/Gallery.tsx
+++ b/package/src/components/Attachment/Gallery.tsx
@@ -3,6 +3,7 @@ import { ImageErrorEvent, Pressable, StyleSheet, Text, View } from 'react-native
import type { Attachment, LocalMessage } from 'stream-chat';
+import { AttachmentUploadIndicator } from './AttachmentUploadIndicator';
import { GalleryImage } from './GalleryImage';
import { buildGallery } from './utils/buildGallery/buildGallery';
@@ -35,6 +36,7 @@ import {
import { useTheme } from '../../contexts/themeContext/ThemeContext';
import { useLoadingImage } from '../../hooks/useLoadingImage';
+import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload';
import { useStableCallback } from '../../hooks/useStableCallback';
import { isVideoPlayerAvailable } from '../../native';
import { primitives } from '../../theme';
@@ -333,6 +335,7 @@ const GalleryThumbnail = ({
>
{thumbnail.type === FileTypes.Video ? (
@@ -387,6 +390,7 @@ const GalleryImageThumbnail = ({
} = useTheme();
const styles = useStyles();
+ const { isUploading, uploadProgress } = usePendingAttachmentUpload(thumbnail.localId);
const onLoadStart = useStableCallback(() => {
setLoadingImageError(false);
@@ -421,6 +425,11 @@ const GalleryImageThumbnail = ({
uri={thumbnail.url}
/>
{isLoadingImage ? : null}
+ {isUploading ? (
+
+
+
+ ) : null}
>
)}
@@ -656,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/VideoThumbnail.tsx b/package/src/components/Attachment/VideoThumbnail.tsx
index f255c32531..1037b3fdb5 100644
--- a/package/src/components/Attachment/VideoThumbnail.tsx
+++ b/package/src/components/Attachment/VideoThumbnail.tsx
@@ -1,10 +1,21 @@
import React from 'react';
-import { ImageBackground, ImageStyle, StyleProp, StyleSheet, 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({
+ uploadProgressContainer: {
+ alignItems: 'flex-start',
+ bottom: primitives.spacingXxs,
+ justifyContent: 'flex-start',
+ left: primitives.spacingXxs,
+ position: 'absolute',
+ },
container: {
alignItems: 'center',
justifyContent: 'center',
@@ -15,6 +26,10 @@ const styles = StyleSheet.create({
export type VideoThumbnailProps = {
imageStyle?: StyleProp;
+ /**
+ * When set, upload state is read from `client.uploadManager` for this pending attachment id.
+ */
+ localId?: string;
style?: StyleProp;
thumb_url?: string;
};
@@ -27,7 +42,9 @@ export const VideoThumbnail = (props: VideoThumbnailProps) => {
},
},
} = useTheme();
- const { imageStyle, style, thumb_url } = props;
+ const { imageStyle, localId, style, thumb_url } = props;
+ const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId);
+
return (
{
style={[styles.container, container, style]}
>
+ {isUploading ? (
+
+
+
+ ) : null}
);
};
diff --git a/package/src/components/Attachment/__tests__/Attachment.test.js b/package/src/components/Attachment/__tests__/Attachment.test.js
index 8e1d28ff0f..c15205ebdd 100644
--- a/package/src/components/Attachment/__tests__/Attachment.test.js
+++ b/package/src/components/Attachment/__tests__/Attachment.test.js
@@ -24,15 +24,22 @@ jest.mock('../../../native.ts', () => ({
isSoundPackageAvailable: jest.fn(() => false),
}));
+jest.mock('../../../hooks/usePendingAttachmentUpload', () => ({
+ usePendingAttachmentUpload: jest.fn(() => ({
+ isUploading: false,
+ uploadProgress: undefined,
+ })),
+}));
+
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..725b125fcb 100644
--- a/package/src/components/Attachment/__tests__/Giphy.test.js
+++ b/package/src/components/Attachment/__tests__/Giphy.test.js
@@ -40,7 +40,12 @@ 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..075e0b0648 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';
@@ -1345,73 +1344,64 @@ 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';
+ const uploadOne = async (attachment: NonNullable[number]) => {
+ if (
+ (attachment.image_url && !isLocalUrl(attachment.image_url)) ||
+ (attachment.asset_url && !isLocalUrl(attachment.asset_url))
+ ) {
+ return;
+ }
- const uploadResponse = doFileUploadRequest
- ? await doFileUploadRequest(image)
- : await channel.sendImage(compressedUri, filename, contentType);
+ const originalFile = attachment.originalFile;
+ if (!originalFile?.uri) {
+ return;
+ }
- attachment.image_url = uploadResponse.file;
- delete attachment.originalFile;
+ const localId = (attachment as DefaultAttachmentData).localId;
+ if (!localId) {
+ console.warn('uploadPendingAttachments: local attachment missing localId, skipping upload');
+ return;
+ }
- client.offlineDb?.executeQuerySafely(
- (db) =>
- db.updateMessage({
- message: { ...updatedMessage, cid: channel.cid },
- }),
- { method: 'updateMessage' },
- );
- }
+ 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 };
+ }
- 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;
- }
+ const response = await client.uploadManager.upload({
+ channelCid: channel.cid,
+ file: fileForUpload,
+ id: localId,
+ });
- delete attachment.originalFile;
- client.offlineDb?.executeQuerySafely(
- (db) =>
- db.updateMessage({
- message: { ...updatedMessage, cid: channel.cid },
- }),
- { method: 'updateMessage' },
- );
+ if (attachment.type === FileTypes.Image) {
+ attachment.image_url = response.file;
+ } else {
+ attachment.asset_url = response.file;
+ if (response.thumb_url) {
+ attachment.thumb_url = response.thumb_url;
}
}
- }
+
+ 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;
});
diff --git a/package/src/components/index.ts b/package/src/components/index.ts
index 1cf41c4af6..60c48e7173 100644
--- a/package/src/components/index.ts
+++ b/package/src/components/index.ts
@@ -6,6 +6,8 @@ export * from './Attachment/FileAttachmentGroup';
export * from './Attachment/FileIcon';
export * from './Attachment/Gallery';
export * from './Attachment/Giphy';
+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/hooks/index.ts b/package/src/hooks/index.ts
index 503cf5128b..24eaee6af6 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 './usePendingAttachmentUpload';
export * from './useStableCallback';
export * from './useLoadingImage';
export * from './useMessageReminder';
diff --git a/package/src/hooks/usePendingAttachmentUpload.ts b/package/src/hooks/usePendingAttachmentUpload.ts
new file mode 100644
index 0000000000..85654f3de1
--- /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);
+ if (!record) {
+ return idle;
+ }
+ return {
+ isUploading: true,
+ uploadProgress: record.uploadProgress,
+ };
+ },
+ [localId],
+ );
+
+ const result = useStateStore(localId ? client.uploadManager.state : undefined, selector);
+
+ return result ?? idle;
+}
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 {