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 {