From 373dac3b5b0894b265541eaf83b184c4ff6e9835 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 2 Apr 2026 15:54:05 +0200 Subject: [PATCH 01/26] feat: track attachment uploads outside of message composer --- .../src/components/Attachment/Attachment.tsx | 55 ++++++++- .../AttachmentFileUploadProgressIndicator.tsx | 80 +++++++++++++ .../Attachment/AttachmentUploadIndicator.tsx | 61 ++++++++++ .../Attachment/CircularProgressIndicator.tsx | 113 ++++++++++++++++++ .../components/Attachment/FileAttachment.tsx | 31 ++++- package/src/components/Attachment/Gallery.tsx | 15 +++ .../components/Attachment/VideoThumbnail.tsx | 26 +++- .../Attachment/__tests__/Attachment.test.js | 9 +- .../Attachment/__tests__/Giphy.test.js | 7 +- .../utils/buildGallery/buildThumbnail.ts | 3 + .../Attachment/utils/buildGallery/types.ts | 2 + package/src/components/Channel/Channel.tsx | 112 ++++++++--------- package/src/components/index.ts | 2 + package/src/hooks/index.ts | 1 + .../src/hooks/usePendingAttachmentUpload.ts | 49 ++++++++ package/src/middlewares/attachments.ts | 2 + package/src/types/types.ts | 2 + 17 files changed, 494 insertions(+), 76 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 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 02dd621db0..75c8c8e745 100644 --- a/package/src/components/Attachment/Attachment.tsx +++ b/package/src/components/Attachment/Attachment.tsx @@ -9,8 +9,12 @@ import { isVideoAttachment, isVoiceRecordingAttachment, type Attachment as AttachmentType, + type LocalMessage, } from 'stream-chat'; +import type { AudioAttachmentProps } from './Audio/AudioAttachment'; +import { AttachmentFileUploadProgressIndicator } from '../../components/Attachment/AttachmentFileUploadProgressIndicator'; + import { useTheme } from '../../contexts'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { @@ -21,9 +25,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; @@ -83,12 +89,12 @@ const AttachmentWithContext = (props: AttachmentPropsWithContext) => { if (isAudioAttachment(attachment) || isVoiceRecordingAttachment(attachment)) { if (isSoundPackageAvailable()) { return ( - ); } @@ -166,6 +172,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 e7b3def311..bc4c230801 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'; @@ -17,6 +18,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, @@ -50,6 +53,9 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { } = props; const { FilePreview } = useComponentsContext(); + const localId = (attachment as DefaultAttachmentData).localId; + const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId); + const defaultOnPress = () => openUrlSafely(attachment.asset_url); return ( @@ -87,11 +93,21 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { testID='file-attachment' {...additionalPressableProps} > - + + + ) : undefined + } + styles={stylesProp} + /> + ); }; @@ -135,6 +151,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 2157f09dfe..d6b5dd0982 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'; @@ -36,6 +37,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'; @@ -299,6 +301,7 @@ const GalleryThumbnail = ({ > {thumbnail.type === FileTypes.Video ? ( @@ -344,6 +347,8 @@ const GalleryImageThumbnail = ({ }, } = useTheme(); const styles = useStyles(); + const { isUploading, uploadProgress } = usePendingAttachmentUpload(thumbnail.localId); + const onLoadStart = useStableCallback(() => { setLoadingImageError(false); setLoadingImage(true); @@ -374,6 +379,11 @@ const GalleryImageThumbnail = ({ uri={thumbnail.url} /> {isLoadingImage ? : null} + {isUploading ? ( + + + + ) : null} )} @@ -596,6 +606,11 @@ const useStyles = () => { top: 0, 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 a9c24ed483..7a0b134ae2 100644 --- a/package/src/components/Attachment/__tests__/Giphy.test.js +++ b/package/src/components/Attachment/__tests__/Giphy.test.js @@ -42,7 +42,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 d66cdf3d5a..e3b2e592a2 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, @@ -101,7 +100,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'; @@ -1053,73 +1052,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 cb64ee005a..a898402140 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 8e368a9532..cb5e0f9516 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 8996af684e..aa40204dac 100644 --- a/package/src/middlewares/attachments.ts +++ b/package/src/middlewares/attachments.ts @@ -26,6 +26,7 @@ export const localAttachmentToAttachment = (localAttachment: LocalAttachment) => return { ...attachment, image_url: localMetadata?.previewUri, + localId: localMetadata?.id, originalFile: localMetadata.file, } as Attachment; } else { @@ -35,6 +36,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 d85b3587f64d7d1d6bd40a048e5380583381bd98 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 21 Apr 2026 10:54:30 +0200 Subject: [PATCH 02/26] feat: implement native multipart uploads --- package/expo-package/android/build.gradle | 9 +- .../streamchatexpo/StreamChatExpoPackage.java | 16 + .../StreamMultipartUploaderModule.kt | 93 +++++ package/expo-package/package.json | 1 + package/expo-package/src/index.js | 2 + .../native/NativeStreamMultipartUploader.ts | 51 +++ .../src/native/multipartUploader.ts | 139 ++++++++ .../src/optionalDependencies/index.ts | 1 + .../optionalDependencies/multipartUpload.ts | 58 +++ package/native-package/android/build.gradle | 9 +- .../StreamChatReactNativePackage.java | 17 + .../StreamMultipartUploaderModule.kt | 93 +++++ package/native-package/package.json | 1 + package/native-package/src/index.js | 2 + .../native/NativeStreamMultipartUploader.ts | 51 +++ .../src/native/multipartUploader.ts | 139 ++++++++ .../src/optionalDependencies/index.ts | 1 + .../optionalDependencies/multipartUpload.ts | 58 +++ .../StreamMultipartUploadFileRequestBody.kt | 25 ++ .../upload/StreamMultipartUploadModels.kt | 38 ++ .../upload/StreamMultipartUploadProgress.kt | 79 +++++ .../StreamMultipartUploadRequestParser.kt | 108 ++++++ .../StreamMultipartUploadSourceResolver.kt | 99 ++++++ .../android/upload/StreamMultipartUploader.kt | 95 +++++ .../ios/StreamMultipartUploadBodyStream.swift | 233 ++++++++++++ .../ios/StreamMultipartUploadManager.swift | 331 ++++++++++++++++++ .../ios/StreamMultipartUploadModels.swift | 62 ++++ .../ios/StreamMultipartUploadProgress.swift | 48 +++ .../StreamMultipartUploadSourceResolver.swift | 206 +++++++++++ .../ios/StreamMultipartUploader.h | 16 + .../ios/StreamMultipartUploader.mm | 107 ++++++ .../ios/StreamMultipartUploaderBridge.swift | 78 +++++ package/src/components/Chat/Chat.tsx | 3 + package/src/native.ts | 48 +++ .../installNativeMultipartInterceptor.test.ts | 214 +++++++++++ .../installNativeMultipartInterceptor.ts | 269 ++++++++++++++ 36 files changed, 2792 insertions(+), 8 deletions(-) create mode 100644 package/expo-package/android/src/newarch/com/streamchatexpo/StreamMultipartUploaderModule.kt create mode 100644 package/expo-package/src/native/NativeStreamMultipartUploader.ts create mode 100644 package/expo-package/src/native/multipartUploader.ts create mode 100644 package/expo-package/src/optionalDependencies/multipartUpload.ts create mode 100644 package/native-package/android/src/newarch/com/streamchatreactnative/StreamMultipartUploaderModule.kt create mode 100644 package/native-package/src/native/NativeStreamMultipartUploader.ts create mode 100644 package/native-package/src/native/multipartUploader.ts create mode 100644 package/native-package/src/optionalDependencies/multipartUpload.ts create mode 100644 package/shared-native/android/upload/StreamMultipartUploadFileRequestBody.kt create mode 100644 package/shared-native/android/upload/StreamMultipartUploadModels.kt create mode 100644 package/shared-native/android/upload/StreamMultipartUploadProgress.kt create mode 100644 package/shared-native/android/upload/StreamMultipartUploadRequestParser.kt create mode 100644 package/shared-native/android/upload/StreamMultipartUploadSourceResolver.kt create mode 100644 package/shared-native/android/upload/StreamMultipartUploader.kt create mode 100644 package/shared-native/ios/StreamMultipartUploadBodyStream.swift create mode 100644 package/shared-native/ios/StreamMultipartUploadManager.swift create mode 100644 package/shared-native/ios/StreamMultipartUploadModels.swift create mode 100644 package/shared-native/ios/StreamMultipartUploadProgress.swift create mode 100644 package/shared-native/ios/StreamMultipartUploadSourceResolver.swift create mode 100644 package/shared-native/ios/StreamMultipartUploader.h create mode 100644 package/shared-native/ios/StreamMultipartUploader.mm create mode 100644 package/shared-native/ios/StreamMultipartUploaderBridge.swift create mode 100644 package/src/utils/__tests__/installNativeMultipartInterceptor.test.ts create mode 100644 package/src/utils/installNativeMultipartInterceptor.ts diff --git a/package/expo-package/android/build.gradle b/package/expo-package/android/build.gradle index 0790bb703f..63e6799460 100644 --- a/package/expo-package/android/build.gradle +++ b/package/expo-package/android/build.gradle @@ -29,8 +29,9 @@ if (isNewArchitectureEnabled()) { def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["StreamChatExpo_" + name]).toInteger() } +def canonicalProjectDir = projectDir.getCanonicalFile() def localSharedNativeRootDir = new File(projectDir, "src/main/java/com/streamchatreactnative/shared") -def sharedNativeRootDir = new File(projectDir, "../../shared-native/android") +def sharedNativeRootDir = new File(canonicalProjectDir, "../../shared-native/android") def hasNativeSources = { File dir -> dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty() } @@ -88,10 +89,10 @@ tasks.register("syncSharedShimmerSources") { outputs.upToDateWhen { false } doLast { def sourceRootDir = null - if (hasNativeSources(localSharedNativeRootDir)) { - sourceRootDir = localSharedNativeRootDir - } else if (hasNativeSources(sharedNativeRootDir)) { + if (hasNativeSources(sharedNativeRootDir)) { sourceRootDir = sharedNativeRootDir + } else if (hasNativeSources(localSharedNativeRootDir)) { + sourceRootDir = localSharedNativeRootDir } if (sourceRootDir == null) { diff --git a/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java index 20fa4cab28..7f620dadde 100644 --- a/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java +++ b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java @@ -14,11 +14,16 @@ import java.util.Map; public class StreamChatExpoPackage extends TurboReactPackage { + private static final String STREAM_MULTIPART_UPLOADER_MODULE = "StreamMultipartUploader"; private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail"; @Nullable @Override public NativeModule getModule(String name, ReactApplicationContext reactContext) { + if (name.equals(STREAM_MULTIPART_UPLOADER_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + return createNewArchModule("com.streamchatexpo.StreamMultipartUploaderModule", reactContext); + } + if (name.equals(STREAM_VIDEO_THUMBNAIL_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { return createNewArchModule("com.streamchatexpo.StreamVideoThumbnailModule", reactContext); } @@ -31,6 +36,17 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { return () -> { final Map moduleInfos = new HashMap<>(); boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; + moduleInfos.put( + STREAM_MULTIPART_UPLOADER_MODULE, + new ReactModuleInfo( + STREAM_MULTIPART_UPLOADER_MODULE, + STREAM_MULTIPART_UPLOADER_MODULE, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + isTurboModule // isTurboModule + )); moduleInfos.put( STREAM_VIDEO_THUMBNAIL_MODULE, new ReactModuleInfo( diff --git a/package/expo-package/android/src/newarch/com/streamchatexpo/StreamMultipartUploaderModule.kt b/package/expo-package/android/src/newarch/com/streamchatexpo/StreamMultipartUploaderModule.kt new file mode 100644 index 0000000000..b0f4ceccb7 --- /dev/null +++ b/package/expo-package/android/src/newarch/com/streamchatexpo/StreamMultipartUploaderModule.kt @@ -0,0 +1,93 @@ +package com.streamchatexpo + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.streamchatreactnative.shared.upload.StreamMultipartUploadRequestParser +import com.streamchatreactnative.shared.upload.StreamMultipartUploader +import java.util.concurrent.Executors + +class StreamMultipartUploaderModule( + reactContext: ReactApplicationContext, +) : NativeStreamMultipartUploaderSpec(reactContext) { + override fun getName(): String = NAME + + override fun addListener(eventType: String) = Unit + + override fun removeListeners(count: Double) = Unit + + override fun cancelUpload(uploadId: String, promise: Promise) { + StreamMultipartUploader.cancel(uploadId) + promise.resolve(null) + } + + override fun uploadMultipart( + uploadId: String, + url: String, + method: String, + headers: ReadableArray, + parts: ReadableArray, + progress: com.facebook.react.bridge.ReadableMap?, + promise: Promise, + ) { + executor.execute { + try { + val request = StreamMultipartUploadRequestParser.parse( + uploadId = uploadId, + url = url, + method = method, + headers = headers, + parts = parts, + progress = progress, + ) + val response = + StreamMultipartUploader.upload(reactApplicationContext, request) { loaded, total -> + emitProgress(uploadId, loaded, total) + } + + val payload = Arguments.createMap().apply { + putString("body", response.body) + putArray("headers", Arguments.createArray().apply { + response.headers.forEach { (name, value) -> + pushMap( + Arguments.createMap().apply { + putString("name", name) + putString("value", value) + }, + ) + } + }) + putDouble("status", response.status.toDouble()) + putString("statusText", response.statusText) + } + promise.resolve(payload) + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) + } + } + } + + private fun emitProgress(uploadId: String, loaded: Long, total: Long?) { + val payload = Arguments.createMap().apply { + putDouble("loaded", loaded.toDouble()) + if (total != null) { + putDouble("total", total.toDouble()) + } else { + putNull("total") + } + putString("uploadId", uploadId) + } + + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(PROGRESS_EVENT_NAME, payload) + } + + companion object { + const val NAME = "StreamMultipartUploader" + private const val PROGRESS_EVENT_NAME = "streamMultipartUploadProgress" + private val executor = Executors.newCachedThreadPool() + } +} diff --git a/package/expo-package/package.json b/package/expo-package/package.json index 03dba3e651..cddbfb94e5 100644 --- a/package/expo-package/package.json +++ b/package/expo-package/package.json @@ -97,6 +97,7 @@ }, "ios": { "modulesProvider": { + "StreamMultipartUploader": "StreamMultipartUploader", "StreamVideoThumbnail": "StreamVideoThumbnail" }, "componentProvider": { diff --git a/package/expo-package/src/index.js b/package/expo-package/src/index.js index 53960194f9..471bea7ac4 100644 --- a/package/expo-package/src/index.js +++ b/package/expo-package/src/index.js @@ -10,6 +10,7 @@ import { getLocalAssetUri, getPhotos, iOS14RefreshGallerySelection, + multipartUpload, NativeShimmerView, oniOS14GalleryLibrarySelectionChange, overrideAudioRecordingConfiguration, @@ -32,6 +33,7 @@ registerNativeHandlers({ getLocalAssetUri, getPhotos, iOS14RefreshGallerySelection, + multipartUpload, NativeShimmerView, oniOS14GalleryLibrarySelectionChange, overrideAudioRecordingConfiguration, diff --git a/package/expo-package/src/native/NativeStreamMultipartUploader.ts b/package/expo-package/src/native/NativeStreamMultipartUploader.ts new file mode 100644 index 0000000000..d8f6aa01ca --- /dev/null +++ b/package/expo-package/src/native/NativeStreamMultipartUploader.ts @@ -0,0 +1,51 @@ +import type { TurboModule } from 'react-native'; + +import { TurboModuleRegistry } from 'react-native'; + +export type UploadHeader = { + name: string; + value: string; +}; + +export type UploadPart = { + fieldName: string; + fileName?: string | null; + kind: string; + mimeType?: string | null; + uri?: string | null; + value?: string | null; +}; + +export type UploadProgressConfig = { + count?: number | null; + intervalMs?: number | null; +}; + +export type UploadProgressEvent = { + loaded: number; + total?: number | null; + uploadId: string; +}; + +export type UploadResponse = { + body: string; + headers?: ReadonlyArray | null; + status: number; + statusText?: string | null; +}; + +export interface Spec extends TurboModule { + addListener(eventType: string): void; + cancelUpload(uploadId: string): Promise; + removeListeners(count: number): void; + uploadMultipart( + uploadId: string, + url: string, + method: string, + headers: ReadonlyArray, + parts: ReadonlyArray, + progress?: UploadProgressConfig | null, + ): Promise; +} + +export default TurboModuleRegistry.getEnforcing('StreamMultipartUploader'); diff --git a/package/expo-package/src/native/multipartUploader.ts b/package/expo-package/src/native/multipartUploader.ts new file mode 100644 index 0000000000..74bcf2095a --- /dev/null +++ b/package/expo-package/src/native/multipartUploader.ts @@ -0,0 +1,139 @@ +import { NativeEventEmitter } from 'react-native'; + +import NativeStreamMultipartUploader, { + type UploadHeader, + type UploadPart, + type UploadProgressConfig, + type UploadProgressEvent, + type UploadResponse as NativeUploadResponse, +} from './NativeStreamMultipartUploader'; + +const STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT = 'streamMultipartUploadProgress'; + +const multipartUploadEventEmitter = new NativeEventEmitter(NativeStreamMultipartUploader); + +const toUploadHeaders = (headers: Record): UploadHeader[] => + Object.entries(headers).map(([name, value]) => ({ name, value })); + +const fromUploadHeaders = ( + headers?: ReadonlyArray | null, +): Record | undefined => { + if (!headers?.length) { + return undefined; + } + + return headers.reduce>((acc, header) => { + acc[header.name] = header.value; + return acc; + }, {}); +}; + +const createAbortError = () => { + const error = new Error('Request aborted'); + error.name = 'CanceledError'; + return error; +}; + +type MultipartUploadRequest = { + headers: Record; + method: string; + onProgress?: (event: { loaded: number; total?: number }) => void; + parts: UploadPart[]; + progress?: UploadProgressConfig; + signal?: AbortSignal; + uploadId: string; + url: string; +}; + +type MultipartUploadResponse = Omit & { + headers?: Record; +}; + +export const uploadMultipart = async ({ + headers, + method, + onProgress, + parts, + progress, + signal, + uploadId, + url, +}: MultipartUploadRequest): Promise => { + let progressSubscription: + | { + remove: () => void; + } + | undefined; + let removedAbortListener = false; + + const abortUpload = async () => { + try { + await NativeStreamMultipartUploader.cancelUpload(uploadId); + } catch { + // Ignore cancellation races for already-finished uploads. + } + }; + + const removeAbortListener = () => { + if (!removedAbortListener) { + signal?.removeEventListener('abort', handleAbort); + removedAbortListener = true; + } + }; + + const handleAbort = () => { + abortUpload().catch(() => undefined); + }; + + if (signal?.aborted) { + await abortUpload(); + throw createAbortError(); + } + + if (onProgress) { + progressSubscription = multipartUploadEventEmitter.addListener( + STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT, + (event: UploadProgressEvent) => { + if (event.uploadId !== uploadId) { + return; + } + + onProgress({ + loaded: event.loaded, + total: typeof event.total === 'number' ? event.total : undefined, + }); + }, + ); + } + + signal?.addEventListener('abort', handleAbort, { once: true }); + + try { + const response = await NativeStreamMultipartUploader.uploadMultipart( + uploadId, + url, + method, + toUploadHeaders(headers), + parts, + progress ?? {}, + ); + + if (signal?.aborted) { + throw createAbortError(); + } + + return { + ...response, + headers: fromUploadHeaders(response.headers), + }; + } catch (error) { + if (signal?.aborted) { + throw createAbortError(); + } + + throw error; + } finally { + progressSubscription?.remove(); + removeAbortListener(); + } +}; diff --git a/package/expo-package/src/optionalDependencies/index.ts b/package/expo-package/src/optionalDependencies/index.ts index 9f2cc0d87f..270bbbd6bf 100644 --- a/package/expo-package/src/optionalDependencies/index.ts +++ b/package/expo-package/src/optionalDependencies/index.ts @@ -4,6 +4,7 @@ export * from './generateThumbnail'; export * from './getLocalAssetUri'; export * from './getPhotos'; export * from './iOS14RefreshGallerySelection'; +export * from './multipartUpload'; export * from './NativeShimmerView'; export * from './oniOS14GalleryLibrarySelectionChange'; export * from './pickDocument'; diff --git a/package/expo-package/src/optionalDependencies/multipartUpload.ts b/package/expo-package/src/optionalDependencies/multipartUpload.ts new file mode 100644 index 0000000000..0a6e6a1954 --- /dev/null +++ b/package/expo-package/src/optionalDependencies/multipartUpload.ts @@ -0,0 +1,58 @@ +import type { + NativeMultipartUploadPart, + NativeMultipartUploadRequest, +} from 'stream-chat-react-native-core'; + +import { getLocalAssetUri } from './getLocalAssetUri'; + +import { uploadMultipart } from '../native/multipartUploader'; + +const sanitizeResolvedFileUri = (uri: string) => { + const normalizedUri = uri.startsWith('/') ? `file://${uri}` : uri; + + if (!normalizedUri.startsWith('file://')) { + return normalizedUri; + } + + return normalizedUri.split('#')[0].split('?')[0]; +}; + +const resolvePartUri = async (part: NativeMultipartUploadPart) => { + if ( + part.kind !== 'file' || + typeof getLocalAssetUri !== 'function' || + !(part.uri.startsWith('ph://') || part.uri.startsWith('assets-library://')) + ) { + return part; + } + + const resolvedUri = await getLocalAssetUri(part.uri); + + return { + ...part, + uri: resolvedUri ? sanitizeResolvedFileUri(resolvedUri) : part.uri, + }; +}; + +export const multipartUpload = async ({ + headers, + method, + onProgress, + parts, + progress, + signal, + url, +}: NativeMultipartUploadRequest) => { + const resolvedParts = await Promise.all(parts.map(resolvePartUri)); + + return uploadMultipart({ + headers, + method, + onProgress, + parts: resolvedParts, + progress, + signal, + uploadId: `stream-upload-${Date.now()}-${Math.random().toString(16).slice(2)}`, + url, + }); +}; diff --git a/package/native-package/android/build.gradle b/package/native-package/android/build.gradle index ef113dedfe..a7e6e30000 100644 --- a/package/native-package/android/build.gradle +++ b/package/native-package/android/build.gradle @@ -36,8 +36,9 @@ def getExtOrDefault(name) { def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["ImageResizer_" + name]).toInteger() } +def canonicalProjectDir = projectDir.getCanonicalFile() def localSharedNativeRootDir = new File(projectDir, "src/main/java/com/streamchatreactnative/shared") -def sharedNativeRootDir = new File(projectDir, "../../shared-native/android") +def sharedNativeRootDir = new File(canonicalProjectDir, "../../shared-native/android") def hasNativeSources = { File dir -> dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty() } @@ -101,10 +102,10 @@ tasks.register("syncSharedShimmerSources") { outputs.upToDateWhen { false } doLast { def sourceRootDir = null - if (hasNativeSources(localSharedNativeRootDir)) { - sourceRootDir = localSharedNativeRootDir - } else if (hasNativeSources(sharedNativeRootDir)) { + if (hasNativeSources(sharedNativeRootDir)) { sourceRootDir = sharedNativeRootDir + } else if (hasNativeSources(localSharedNativeRootDir)) { + sourceRootDir = localSharedNativeRootDir } if (sourceRootDir == null) { diff --git a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java index ec32749c90..80d42e2224 100644 --- a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java +++ b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java @@ -14,6 +14,7 @@ import java.util.Map; public class StreamChatReactNativePackage extends TurboReactPackage { + private static final String STREAM_MULTIPART_UPLOADER_MODULE = "StreamMultipartUploader"; private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail"; @Nullable @@ -21,6 +22,11 @@ public class StreamChatReactNativePackage extends TurboReactPackage { public NativeModule getModule(String name, ReactApplicationContext reactContext) { if (name.equals(StreamChatReactNativeModule.NAME)) { return new StreamChatReactNativeModule(reactContext); + } else if (name.equals(STREAM_MULTIPART_UPLOADER_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + return createNewArchModule( + "com.streamchatreactnative.StreamMultipartUploaderModule", + reactContext + ); } else if (name.equals(STREAM_VIDEO_THUMBNAIL_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { return createNewArchModule( "com.streamchatreactnative.StreamVideoThumbnailModule", @@ -47,6 +53,17 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // isCxxModule isTurboModule // isTurboModule )); + moduleInfos.put( + STREAM_MULTIPART_UPLOADER_MODULE, + new ReactModuleInfo( + STREAM_MULTIPART_UPLOADER_MODULE, + STREAM_MULTIPART_UPLOADER_MODULE, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + isTurboModule // isTurboModule + )); moduleInfos.put( STREAM_VIDEO_THUMBNAIL_MODULE, new ReactModuleInfo( diff --git a/package/native-package/android/src/newarch/com/streamchatreactnative/StreamMultipartUploaderModule.kt b/package/native-package/android/src/newarch/com/streamchatreactnative/StreamMultipartUploaderModule.kt new file mode 100644 index 0000000000..7d3ab1d7f0 --- /dev/null +++ b/package/native-package/android/src/newarch/com/streamchatreactnative/StreamMultipartUploaderModule.kt @@ -0,0 +1,93 @@ +package com.streamchatreactnative + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.streamchatreactnative.shared.upload.StreamMultipartUploadRequestParser +import com.streamchatreactnative.shared.upload.StreamMultipartUploader +import java.util.concurrent.Executors + +class StreamMultipartUploaderModule( + reactContext: ReactApplicationContext, +) : NativeStreamMultipartUploaderSpec(reactContext) { + override fun getName(): String = NAME + + override fun addListener(eventType: String) = Unit + + override fun removeListeners(count: Double) = Unit + + override fun cancelUpload(uploadId: String, promise: Promise) { + StreamMultipartUploader.cancel(uploadId) + promise.resolve(null) + } + + override fun uploadMultipart( + uploadId: String, + url: String, + method: String, + headers: ReadableArray, + parts: ReadableArray, + progress: com.facebook.react.bridge.ReadableMap?, + promise: Promise, + ) { + executor.execute { + try { + val request = StreamMultipartUploadRequestParser.parse( + uploadId = uploadId, + url = url, + method = method, + headers = headers, + parts = parts, + progress = progress, + ) + val response = + StreamMultipartUploader.upload(reactApplicationContext, request) { loaded, total -> + emitProgress(uploadId, loaded, total) + } + + val payload = Arguments.createMap().apply { + putString("body", response.body) + putArray("headers", Arguments.createArray().apply { + response.headers.forEach { (name, value) -> + pushMap( + Arguments.createMap().apply { + putString("name", name) + putString("value", value) + }, + ) + } + }) + putDouble("status", response.status.toDouble()) + putString("statusText", response.statusText) + } + promise.resolve(payload) + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) + } + } + } + + private fun emitProgress(uploadId: String, loaded: Long, total: Long?) { + val payload = Arguments.createMap().apply { + putDouble("loaded", loaded.toDouble()) + if (total != null) { + putDouble("total", total.toDouble()) + } else { + putNull("total") + } + putString("uploadId", uploadId) + } + + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(PROGRESS_EVENT_NAME, payload) + } + + companion object { + const val NAME = "StreamMultipartUploader" + private const val PROGRESS_EVENT_NAME = "streamMultipartUploadProgress" + private val executor = Executors.newCachedThreadPool() + } +} diff --git a/package/native-package/package.json b/package/native-package/package.json index 6fa36c871b..c2323ac413 100644 --- a/package/native-package/package.json +++ b/package/native-package/package.json @@ -95,6 +95,7 @@ "ios": { "modulesProvider": { "StreamChatReactNative": "StreamChatReactNative", + "StreamMultipartUploader": "StreamMultipartUploader", "StreamVideoThumbnail": "StreamVideoThumbnail" }, "componentProvider": { diff --git a/package/native-package/src/index.js b/package/native-package/src/index.js index ee090a1cc6..09b663e42e 100644 --- a/package/native-package/src/index.js +++ b/package/native-package/src/index.js @@ -11,6 +11,7 @@ import { getLocalAssetUri, getPhotos, iOS14RefreshGallerySelection, + multipartUpload, NativeShimmerView, oniOS14GalleryLibrarySelectionChange, overrideAudioRecordingConfiguration, @@ -33,6 +34,7 @@ registerNativeHandlers({ getLocalAssetUri, getPhotos, iOS14RefreshGallerySelection, + multipartUpload, NativeShimmerView, oniOS14GalleryLibrarySelectionChange, overrideAudioRecordingConfiguration, diff --git a/package/native-package/src/native/NativeStreamMultipartUploader.ts b/package/native-package/src/native/NativeStreamMultipartUploader.ts new file mode 100644 index 0000000000..d8f6aa01ca --- /dev/null +++ b/package/native-package/src/native/NativeStreamMultipartUploader.ts @@ -0,0 +1,51 @@ +import type { TurboModule } from 'react-native'; + +import { TurboModuleRegistry } from 'react-native'; + +export type UploadHeader = { + name: string; + value: string; +}; + +export type UploadPart = { + fieldName: string; + fileName?: string | null; + kind: string; + mimeType?: string | null; + uri?: string | null; + value?: string | null; +}; + +export type UploadProgressConfig = { + count?: number | null; + intervalMs?: number | null; +}; + +export type UploadProgressEvent = { + loaded: number; + total?: number | null; + uploadId: string; +}; + +export type UploadResponse = { + body: string; + headers?: ReadonlyArray | null; + status: number; + statusText?: string | null; +}; + +export interface Spec extends TurboModule { + addListener(eventType: string): void; + cancelUpload(uploadId: string): Promise; + removeListeners(count: number): void; + uploadMultipart( + uploadId: string, + url: string, + method: string, + headers: ReadonlyArray, + parts: ReadonlyArray, + progress?: UploadProgressConfig | null, + ): Promise; +} + +export default TurboModuleRegistry.getEnforcing('StreamMultipartUploader'); diff --git a/package/native-package/src/native/multipartUploader.ts b/package/native-package/src/native/multipartUploader.ts new file mode 100644 index 0000000000..74bcf2095a --- /dev/null +++ b/package/native-package/src/native/multipartUploader.ts @@ -0,0 +1,139 @@ +import { NativeEventEmitter } from 'react-native'; + +import NativeStreamMultipartUploader, { + type UploadHeader, + type UploadPart, + type UploadProgressConfig, + type UploadProgressEvent, + type UploadResponse as NativeUploadResponse, +} from './NativeStreamMultipartUploader'; + +const STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT = 'streamMultipartUploadProgress'; + +const multipartUploadEventEmitter = new NativeEventEmitter(NativeStreamMultipartUploader); + +const toUploadHeaders = (headers: Record): UploadHeader[] => + Object.entries(headers).map(([name, value]) => ({ name, value })); + +const fromUploadHeaders = ( + headers?: ReadonlyArray | null, +): Record | undefined => { + if (!headers?.length) { + return undefined; + } + + return headers.reduce>((acc, header) => { + acc[header.name] = header.value; + return acc; + }, {}); +}; + +const createAbortError = () => { + const error = new Error('Request aborted'); + error.name = 'CanceledError'; + return error; +}; + +type MultipartUploadRequest = { + headers: Record; + method: string; + onProgress?: (event: { loaded: number; total?: number }) => void; + parts: UploadPart[]; + progress?: UploadProgressConfig; + signal?: AbortSignal; + uploadId: string; + url: string; +}; + +type MultipartUploadResponse = Omit & { + headers?: Record; +}; + +export const uploadMultipart = async ({ + headers, + method, + onProgress, + parts, + progress, + signal, + uploadId, + url, +}: MultipartUploadRequest): Promise => { + let progressSubscription: + | { + remove: () => void; + } + | undefined; + let removedAbortListener = false; + + const abortUpload = async () => { + try { + await NativeStreamMultipartUploader.cancelUpload(uploadId); + } catch { + // Ignore cancellation races for already-finished uploads. + } + }; + + const removeAbortListener = () => { + if (!removedAbortListener) { + signal?.removeEventListener('abort', handleAbort); + removedAbortListener = true; + } + }; + + const handleAbort = () => { + abortUpload().catch(() => undefined); + }; + + if (signal?.aborted) { + await abortUpload(); + throw createAbortError(); + } + + if (onProgress) { + progressSubscription = multipartUploadEventEmitter.addListener( + STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT, + (event: UploadProgressEvent) => { + if (event.uploadId !== uploadId) { + return; + } + + onProgress({ + loaded: event.loaded, + total: typeof event.total === 'number' ? event.total : undefined, + }); + }, + ); + } + + signal?.addEventListener('abort', handleAbort, { once: true }); + + try { + const response = await NativeStreamMultipartUploader.uploadMultipart( + uploadId, + url, + method, + toUploadHeaders(headers), + parts, + progress ?? {}, + ); + + if (signal?.aborted) { + throw createAbortError(); + } + + return { + ...response, + headers: fromUploadHeaders(response.headers), + }; + } catch (error) { + if (signal?.aborted) { + throw createAbortError(); + } + + throw error; + } finally { + progressSubscription?.remove(); + removeAbortListener(); + } +}; diff --git a/package/native-package/src/optionalDependencies/index.ts b/package/native-package/src/optionalDependencies/index.ts index 1b1ddee508..419f2c03b0 100644 --- a/package/native-package/src/optionalDependencies/index.ts +++ b/package/native-package/src/optionalDependencies/index.ts @@ -4,6 +4,7 @@ export * from './FlatList'; export * from './getLocalAssetUri'; export * from './getPhotos'; export * from './iOS14RefreshGallerySelection'; +export * from './multipartUpload'; export * from './NativeShimmerView'; export * from './oniOS14GalleryLibrarySelectionChange'; export * from './pickDocument'; diff --git a/package/native-package/src/optionalDependencies/multipartUpload.ts b/package/native-package/src/optionalDependencies/multipartUpload.ts new file mode 100644 index 0000000000..0a6e6a1954 --- /dev/null +++ b/package/native-package/src/optionalDependencies/multipartUpload.ts @@ -0,0 +1,58 @@ +import type { + NativeMultipartUploadPart, + NativeMultipartUploadRequest, +} from 'stream-chat-react-native-core'; + +import { getLocalAssetUri } from './getLocalAssetUri'; + +import { uploadMultipart } from '../native/multipartUploader'; + +const sanitizeResolvedFileUri = (uri: string) => { + const normalizedUri = uri.startsWith('/') ? `file://${uri}` : uri; + + if (!normalizedUri.startsWith('file://')) { + return normalizedUri; + } + + return normalizedUri.split('#')[0].split('?')[0]; +}; + +const resolvePartUri = async (part: NativeMultipartUploadPart) => { + if ( + part.kind !== 'file' || + typeof getLocalAssetUri !== 'function' || + !(part.uri.startsWith('ph://') || part.uri.startsWith('assets-library://')) + ) { + return part; + } + + const resolvedUri = await getLocalAssetUri(part.uri); + + return { + ...part, + uri: resolvedUri ? sanitizeResolvedFileUri(resolvedUri) : part.uri, + }; +}; + +export const multipartUpload = async ({ + headers, + method, + onProgress, + parts, + progress, + signal, + url, +}: NativeMultipartUploadRequest) => { + const resolvedParts = await Promise.all(parts.map(resolvePartUri)); + + return uploadMultipart({ + headers, + method, + onProgress, + parts: resolvedParts, + progress, + signal, + uploadId: `stream-upload-${Date.now()}-${Math.random().toString(16).slice(2)}`, + url, + }); +}; diff --git a/package/shared-native/android/upload/StreamMultipartUploadFileRequestBody.kt b/package/shared-native/android/upload/StreamMultipartUploadFileRequestBody.kt new file mode 100644 index 0000000000..f9cf7d35f3 --- /dev/null +++ b/package/shared-native/android/upload/StreamMultipartUploadFileRequestBody.kt @@ -0,0 +1,25 @@ +package com.streamchatreactnative.shared.upload + +import android.content.Context +import okhttp3.RequestBody +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okio.BufferedSink +import okio.source + +class StreamMultipartUploadFileRequestBody( + private val context: Context, + private val filePart: StreamMultipartFilePart, +) : RequestBody() { + private val resolvedMimeType = StreamMultipartUploadSourceResolver.mimeType(context, filePart) + private val resolvedContentLength = StreamMultipartUploadSourceResolver.contentLength(context, filePart.uri) + + override fun contentLength(): Long = resolvedContentLength ?: -1L + + override fun contentType() = resolvedMimeType.toMediaTypeOrNull() + + override fun writeTo(sink: BufferedSink) { + StreamMultipartUploadSourceResolver.openInputStream(context, filePart.uri).use { inputStream -> + sink.writeAll(inputStream.source()) + } + } +} diff --git a/package/shared-native/android/upload/StreamMultipartUploadModels.kt b/package/shared-native/android/upload/StreamMultipartUploadModels.kt new file mode 100644 index 0000000000..a9123c376d --- /dev/null +++ b/package/shared-native/android/upload/StreamMultipartUploadModels.kt @@ -0,0 +1,38 @@ +package com.streamchatreactnative.shared.upload + +data class StreamMultipartUploadRequest( + val headers: Map, + val method: String, + val parts: List, + val progress: StreamMultipartUploadProgressOptions?, + val uploadId: String, + val url: String, +) + +sealed interface StreamMultipartUploadPart { + val fieldName: String +} + +data class StreamMultipartFilePart( + override val fieldName: String, + val fileName: String, + val mimeType: String?, + val uri: String, +) : StreamMultipartUploadPart + +data class StreamMultipartTextPart( + override val fieldName: String, + val value: String, +) : StreamMultipartUploadPart + +data class StreamMultipartUploadProgressOptions( + val count: Int?, + val intervalMs: Long?, +) + +data class StreamMultipartUploadResponse( + val body: String, + val headers: Map, + val status: Int, + val statusText: String?, +) diff --git a/package/shared-native/android/upload/StreamMultipartUploadProgress.kt b/package/shared-native/android/upload/StreamMultipartUploadProgress.kt new file mode 100644 index 0000000000..bba001e852 --- /dev/null +++ b/package/shared-native/android/upload/StreamMultipartUploadProgress.kt @@ -0,0 +1,79 @@ +package com.streamchatreactnative.shared.upload + +import android.os.SystemClock +import okhttp3.RequestBody +import okio.Buffer +import okio.BufferedSink +import okio.ForwardingSink +import okio.Sink +import okio.buffer +import kotlin.math.floor + +class StreamMultipartUploadProgressThrottler( + options: StreamMultipartUploadProgressOptions?, + private val onProgress: (loaded: Long, total: Long?) -> Unit, +) { + private val intervalMs = options?.intervalMs ?: 16L + private val count = options?.count ?: 20 + private var emittedBuckets = -1 + private var lastEventAtMs = 0L + + fun dispatch(loaded: Long, total: Long?) { + val now = SystemClock.elapsedRealtime() + val isTerminal = total != null && total >= 0 && loaded >= total + + if (isTerminal) { + onProgress(loaded, total) + return + } + + val passesInterval = now - lastEventAtMs >= intervalMs + val passesCount = + if (count > 0 && total != null && total > 0) { + val nextBucket = floor((loaded.toDouble() / total.toDouble()) * count.toDouble()).toInt() + if (nextBucket > emittedBuckets) { + emittedBuckets = nextBucket + true + } else { + false + } + } else { + true + } + + if (!passesInterval || !passesCount) { + return + } + + lastEventAtMs = now + onProgress(loaded, total) + } +} + +class StreamMultipartUploadProgressRequestBody( + private val requestBody: RequestBody, + private val throttler: StreamMultipartUploadProgressThrottler, +) : RequestBody() { + override fun contentLength(): Long = requestBody.contentLength() + + override fun contentType() = requestBody.contentType() + + override fun writeTo(sink: BufferedSink) { + val countingSink = + object : ForwardingSink(sink as Sink) { + private var bytesWritten = 0L + + override fun write(source: Buffer, byteCount: Long) { + super.write(source, byteCount) + + bytesWritten += byteCount + val total = requestBody.contentLength().takeIf { it >= 0L } + throttler.dispatch(bytesWritten, total) + } + } + + val bufferedSink = countingSink.buffer() + requestBody.writeTo(bufferedSink) + bufferedSink.flush() + } +} diff --git a/package/shared-native/android/upload/StreamMultipartUploadRequestParser.kt b/package/shared-native/android/upload/StreamMultipartUploadRequestParser.kt new file mode 100644 index 0000000000..8470d4cd05 --- /dev/null +++ b/package/shared-native/android/upload/StreamMultipartUploadRequestParser.kt @@ -0,0 +1,108 @@ +package com.streamchatreactnative.shared.upload + +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType + +object StreamMultipartUploadRequestParser { + fun parse( + uploadId: String, + url: String, + method: String, + headers: ReadableArray, + parts: ReadableArray, + progress: ReadableMap?, + ): StreamMultipartUploadRequest { + return StreamMultipartUploadRequest( + headers = headers.toStringMap(), + method = method, + parts = parts.toUploadParts(), + progress = progress?.toProgressOptions(), + uploadId = uploadId, + url = url, + ) + } + + private fun ReadableArray.toUploadParts(): List { + val parsedParts = mutableListOf() + + for (index in 0 until size()) { + val part = getMap(index) ?: throw IllegalArgumentException("Missing multipart part at index $index") + val fieldName = + part.getString("fieldName") ?: throw IllegalArgumentException("Multipart part $index is missing fieldName") + val kind = + part.getString("kind") ?: throw IllegalArgumentException("Multipart part $index is missing kind") + + when (kind) { + "file" -> { + val uri = + part.getString("uri") ?: throw IllegalArgumentException("Multipart file part $index is missing uri") + val fileName = + part.getString("fileName") + ?: throw IllegalArgumentException("Multipart file part $index is missing fileName") + + parsedParts += StreamMultipartFilePart( + fieldName = fieldName, + fileName = fileName, + mimeType = part.getString("mimeType"), + uri = uri, + ) + } + + "text" -> { + val value = + part.getString("value") ?: throw IllegalArgumentException("Multipart text part $index is missing value") + parsedParts += StreamMultipartTextPart(fieldName = fieldName, value = value) + } + + else -> throw IllegalArgumentException("Unsupported multipart part kind: $kind") + } + } + + if (parsedParts.none { it is StreamMultipartFilePart }) { + throw IllegalArgumentException("Multipart upload must contain at least one file part") + } + + return parsedParts + } + + private fun ReadableArray.toStringMap(): Map { + val parsed = mutableMapOf() + + for (index in 0 until size()) { + val header = getMap(index) ?: throw IllegalArgumentException("Missing multipart header at index $index") + val name = + header.getString("name") ?: throw IllegalArgumentException("Multipart header $index is missing name") + if (header.getType("value") == ReadableType.Null) { + continue + } + val value = + header.getString("value") + ?: header.getDynamic("value").asString() + ?: throw IllegalArgumentException("Multipart header $index is missing value") + parsed[name] = value + } + + return parsed + } + + private fun ReadableMap.toProgressOptions(): StreamMultipartUploadProgressOptions { + val count = + if (hasKey("count") && !isNull("count")) { + getDouble("count").toInt() + } else { + null + } + val intervalMs = + if (hasKey("intervalMs") && !isNull("intervalMs")) { + getDouble("intervalMs").toLong() + } else { + null + } + + return StreamMultipartUploadProgressOptions( + count = count, + intervalMs = intervalMs, + ) + } +} diff --git a/package/shared-native/android/upload/StreamMultipartUploadSourceResolver.kt b/package/shared-native/android/upload/StreamMultipartUploadSourceResolver.kt new file mode 100644 index 0000000000..6aedd98039 --- /dev/null +++ b/package/shared-native/android/upload/StreamMultipartUploadSourceResolver.kt @@ -0,0 +1,99 @@ +package com.streamchatreactnative.shared.upload + +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.OpenableColumns +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.net.URLConnection + +object StreamMultipartUploadSourceResolver { + fun contentLength(context: Context, uriString: String): Long? { + val uri = normalizeUri(uriString) + + return when (uri.scheme?.lowercase()) { + null, "file" -> { + val file = toFile(uri, uriString) + if (!file.exists()) { + throw IllegalArgumentException("File does not exist for upload: $uriString") + } + file.length() + } + + "content" -> { + context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { descriptor -> + descriptor.length.takeIf { it >= 0L } + } ?: queryLongColumn(context, uri, OpenableColumns.SIZE) + } + + else -> throw IllegalArgumentException("Unsupported upload URI scheme: ${uri.scheme}") + } + } + + fun mimeType(context: Context, part: StreamMultipartFilePart): String { + val explicitMimeType = part.mimeType?.takeIf { it.isNotBlank() } + if (explicitMimeType != null) { + return explicitMimeType + } + + val uri = normalizeUri(part.uri) + val contentResolverMime = context.contentResolver.getType(uri) + if (!contentResolverMime.isNullOrBlank()) { + return contentResolverMime + } + + return URLConnection.guessContentTypeFromName(part.fileName) ?: "application/octet-stream" + } + + fun openInputStream(context: Context, uriString: String): InputStream { + val uri = normalizeUri(uriString) + + return when (uri.scheme?.lowercase()) { + null, "file" -> FileInputStream(toFile(uri, uriString)) + "content" -> + context.contentResolver.openInputStream(uri) + ?: throw IllegalArgumentException("Failed to open content URI for upload: $uriString") + else -> throw IllegalArgumentException("Unsupported upload URI scheme: ${uri.scheme}") + } + } + + private fun normalizeUri(uriString: String): Uri { + if (uriString.startsWith("/")) { + return Uri.fromFile(File(uriString)) + } + + val parsed = Uri.parse(uriString) + + if (parsed.scheme.isNullOrBlank()) { + return Uri.fromFile(File(uriString)) + } + + return parsed + } + + private fun queryLongColumn(context: Context, uri: Uri, columnName: String): Long? { + val projection = arrayOf(columnName) + val cursor: Cursor = + context.contentResolver.query(uri, projection, null, null, null) ?: return null + + cursor.use { + if (!it.moveToFirst()) { + return null + } + + val columnIndex = it.getColumnIndex(columnName) + if (columnIndex == -1 || it.isNull(columnIndex)) { + return null + } + + return it.getLong(columnIndex) + } + } + + private fun toFile(uri: Uri, original: String): File { + val path = uri.path ?: original + return File(path) + } +} diff --git a/package/shared-native/android/upload/StreamMultipartUploader.kt b/package/shared-native/android/upload/StreamMultipartUploader.kt new file mode 100644 index 0000000000..7ef28cc77a --- /dev/null +++ b/package/shared-native/android/upload/StreamMultipartUploader.kt @@ -0,0 +1,95 @@ +package com.streamchatreactnative.shared.upload + +import android.content.Context +import okhttp3.Call +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import java.util.concurrent.ConcurrentHashMap + +object StreamMultipartUploader { + private val client: OkHttpClient = OkHttpClient.Builder().retryOnConnectionFailure(true).build() + private val inFlightCalls = ConcurrentHashMap() + + fun cancel(uploadId: String) { + inFlightCalls.remove(uploadId)?.cancel() + } + + fun upload( + context: Context, + request: StreamMultipartUploadRequest, + onProgress: (loaded: Long, total: Long?) -> Unit, + ): StreamMultipartUploadResponse { + val httpRequest = createRequest(context, request, onProgress) + val call = client.newCall(httpRequest) + inFlightCalls[request.uploadId] = call + + try { + call.execute().use { response -> + return StreamMultipartUploadResponse( + body = response.body?.string().orEmpty(), + headers = + response.headers.names().associateWith { name -> + response.headers(name).joinToString(", ") + }, + status = response.code, + statusText = response.message, + ) + } + } finally { + inFlightCalls.remove(request.uploadId) + } + } + + private fun createMultipartBody( + context: Context, + request: StreamMultipartUploadRequest, + onProgress: (loaded: Long, total: Long?) -> Unit, + ): RequestBody { + val multipartBodyBuilder = MultipartBody.Builder().setType(MultipartBody.FORM) + + request.parts.forEach { part -> + when (part) { + is StreamMultipartFilePart -> { + multipartBodyBuilder.addFormDataPart( + part.fieldName, + part.fileName, + StreamMultipartUploadFileRequestBody(context, part), + ) + } + + is StreamMultipartTextPart -> { + multipartBodyBuilder.addFormDataPart(part.fieldName, part.value) + } + } + } + + val multipartBody = multipartBodyBuilder.build() + val throttler = StreamMultipartUploadProgressThrottler(request.progress, onProgress) + + return StreamMultipartUploadProgressRequestBody(multipartBody, throttler) + } + + private fun createRequest( + context: Context, + request: StreamMultipartUploadRequest, + onProgress: (loaded: Long, total: Long?) -> Unit, + ): Request { + val requestBuilder = Request.Builder().url(request.url) + + request.headers.forEach { (key, value) -> + if ( + key.equals("Content-Type", ignoreCase = true) || + key.equals("Content-Length", ignoreCase = true) + ) { + return@forEach + } + + requestBuilder.header(key, value) + } + + val body = createMultipartBody(context, request, onProgress) + return requestBuilder.method(request.method, body).build() + } +} diff --git a/package/shared-native/ios/StreamMultipartUploadBodyStream.swift b/package/shared-native/ios/StreamMultipartUploadBodyStream.swift new file mode 100644 index 0000000000..a556254842 --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploadBodyStream.swift @@ -0,0 +1,233 @@ +import Foundation + +private enum StreamMultipartBodyElement { + case data(Data) + case file(URL) +} + +final class StreamMultipartUploadBodyStreamFactory { + let boundary: String + let contentLength: Int64? + + private let elements: [StreamMultipartBodyElement] + + private init( + boundary: String, + contentLength: Int64?, + elements: [StreamMultipartBodyElement] + ) { + self.boundary = boundary + self.contentLength = contentLength + self.elements = elements + } + + static func create(parts: [StreamMultipartUploadPart]) async throws -> StreamMultipartUploadBodyStreamFactory { + let boundary = "stream-upload-\(UUID().uuidString)" + var elements = [StreamMultipartBodyElement]() + var totalLength: Int64 = 0 + var canComputeLength = true + + for part in parts { + switch part { + case .text(let textPart): + let data = multipartTextData(boundary: boundary, part: textPart) + elements.append(.data(data)) + totalLength += Int64(data.count) + case .file(let filePart): + let resolvedPart = try await StreamMultipartUploadSourceResolver.resolve(filePart) + let headerData = multipartFileHeaderData(boundary: boundary, part: resolvedPart) + let footerData = "\r\n".data(using: .utf8) ?? Data() + + elements.append(.data(headerData)) + elements.append(.file(resolvedPart.fileURL)) + elements.append(.data(footerData)) + + totalLength += Int64(headerData.count) + Int64(footerData.count) + if let size = resolvedPart.size { + totalLength += size + } else { + canComputeLength = false + } + } + } + + let closingBoundary = "--\(boundary)--\r\n".data(using: .utf8) ?? Data() + elements.append(.data(closingBoundary)) + totalLength += Int64(closingBoundary.count) + + return StreamMultipartUploadBodyStreamFactory( + boundary: boundary, + contentLength: canComputeLength ? totalLength : nil, + elements: elements + ) + } + + func makeStream() -> InputStream { + StreamMultipartSequentialInputStream(elements: elements) + } + + private static func multipartTextData(boundary: String, part: StreamMultipartTextPart) -> Data { + let payload = [ + "--\(boundary)", + "Content-Disposition: form-data; name=\"\(part.fieldName)\"", + "", + part.value, + "", + ].joined(separator: "\r\n") + + return payload.data(using: .utf8) ?? Data() + } + + private static func multipartFileHeaderData( + boundary: String, + part: StreamMultipartResolvedFilePart + ) -> Data { + let payload = [ + "--\(boundary)", + "Content-Disposition: form-data; name=\"\(part.fieldName)\"; filename=\"\(part.fileName)\"", + "Content-Type: \(part.mimeType)", + "", + ].joined(separator: "\r\n") + "\r\n" + + return payload.data(using: .utf8) ?? Data() + } +} + +private final class StreamMultipartSequentialInputStream: InputStream { + private let elements: [StreamMultipartBodyElement] + private var currentIndex = 0 + private var currentStream: InputStream? + private weak var internalDelegate: StreamDelegate? + private var internalStatus: Stream.Status = .notOpen + private var internalError: Error? + private var scheduledRunLoops: [(runLoop: RunLoop, mode: RunLoop.Mode)] = [] + + init(elements: [StreamMultipartBodyElement]) { + self.elements = elements + super.init(data: Data()) + } + + override var delegate: StreamDelegate? { + get { + internalDelegate + } + set { + internalDelegate = newValue + currentStream?.delegate = newValue + } + } + + override var hasBytesAvailable: Bool { + guard internalStatus != .closed, internalStatus != .error else { + return false + } + + if let currentStream, currentStream.hasBytesAvailable { + return true + } + + return currentIndex < elements.count + } + + override var streamError: Error? { + internalError + } + + override var streamStatus: Stream.Status { + internalStatus + } + + override func open() { + guard internalStatus == .notOpen else { + return + } + + internalStatus = .opening + advanceStreamIfNeeded() + internalStatus = currentStream == nil ? .atEnd : .open + } + + override func close() { + currentStream?.close() + currentStream = nil + internalStatus = .closed + } + + override func schedule(in aRunLoop: RunLoop, forMode mode: RunLoop.Mode) { + scheduledRunLoops.append((runLoop: aRunLoop, mode: mode)) + currentStream?.schedule(in: aRunLoop, forMode: mode) + } + + override func remove(from aRunLoop: RunLoop, forMode mode: RunLoop.Mode) { + scheduledRunLoops.removeAll { $0.runLoop == aRunLoop && $0.mode == mode } + currentStream?.remove(from: aRunLoop, forMode: mode) + } + + override func read(_ buffer: UnsafeMutablePointer, maxLength len: Int) -> Int { + guard internalStatus != .closed else { + return 0 + } + + if internalStatus == .notOpen { + open() + } + + while true { + guard let currentStream else { + internalStatus = .atEnd + return 0 + } + + let bytesRead = currentStream.read(buffer, maxLength: len) + + if bytesRead > 0 { + internalStatus = .open + return bytesRead + } + + if bytesRead < 0 { + internalError = currentStream.streamError + internalStatus = .error + return -1 + } + + currentStream.close() + self.currentStream = nil + advanceStreamIfNeeded() + + if self.currentStream == nil { + internalStatus = .atEnd + return 0 + } + } + } + + private func advanceStreamIfNeeded() { + guard currentStream == nil else { + return + } + + while currentIndex < elements.count { + let nextElement = elements[currentIndex] + currentIndex += 1 + + let nextStream: InputStream? + switch nextElement { + case .data(let data): + nextStream = InputStream(data: data) + case .file(let url): + nextStream = InputStream(url: url) + } + + if let nextStream { + nextStream.delegate = internalDelegate + for scheduled in scheduledRunLoops { + nextStream.schedule(in: scheduled.runLoop, forMode: scheduled.mode) + } + nextStream.open() + currentStream = nextStream + return + } + } + } +} diff --git a/package/shared-native/ios/StreamMultipartUploadManager.swift b/package/shared-native/ios/StreamMultipartUploadManager.swift new file mode 100644 index 0000000000..b8ddfeb516 --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploadManager.swift @@ -0,0 +1,331 @@ +import Foundation + +private final class StreamMultipartUploadTaskState { + let bodyFactory: StreamMultipartUploadBodyStreamFactory + let progressThrottler: StreamMultipartUploadProgressThrottler + let task: URLSessionUploadTask + let uploadId: String + var completion: + ((Result) -> Void)? + var response: HTTPURLResponse? + var responseData = Data() + + init( + bodyFactory: StreamMultipartUploadBodyStreamFactory, + progressThrottler: StreamMultipartUploadProgressThrottler, + task: URLSessionUploadTask, + uploadId: String, + completion: @escaping (Result) -> Void + ) { + self.bodyFactory = bodyFactory + self.progressThrottler = progressThrottler + self.task = task + self.uploadId = uploadId + self.completion = completion + } +} + +final class StreamMultipartUploadManager: NSObject { + static let shared = StreamMultipartUploadManager() + + private lazy var session: URLSession = { + let delegateQueue = OperationQueue() + delegateQueue.qualityOfService = .userInitiated + let configuration = URLSessionConfiguration.ephemeral + configuration.waitsForConnectivity = false + return URLSession(configuration: configuration, delegate: self, delegateQueue: delegateQueue) + }() + + private let lock = NSLock() + private var statesByTaskIdentifier = [Int: StreamMultipartUploadTaskState]() + private var taskIdentifiersByUploadId = [String: Int]() + + func cancel(uploadId: String) { + lock.lock() + let taskIdentifier = taskIdentifiersByUploadId[uploadId] + let task: URLSessionUploadTask? + if let taskIdentifier { + task = statesByTaskIdentifier[taskIdentifier]?.task + } else { + task = nil + } + lock.unlock() + + task?.cancel() + } + + func uploadMultipart( + uploadId: String, + url: String, + method: String, + headers: [String: String], + parts: [[String: Any]], + progress: [String: Any]?, + onProgress: @escaping (Int64, Int64?) -> Void + ) async throws -> StreamMultipartUploadResponse { + let request = try parseRequest( + uploadId: uploadId, + url: url, + method: method, + headers: headers, + parts: parts, + progress: progress + ) + + let bodyFactory = try await StreamMultipartUploadBodyStreamFactory.create(parts: request.parts) + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method + + request.headers.forEach { key, value in + if + key.caseInsensitiveCompare("Content-Type") == .orderedSame || + key.caseInsensitiveCompare("Content-Length") == .orderedSame + { + return + } + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + urlRequest.setValue( + "multipart/form-data; boundary=\(bodyFactory.boundary)", + forHTTPHeaderField: "Content-Type" + ) + + if let contentLength = bodyFactory.contentLength { + urlRequest.setValue(String(contentLength), forHTTPHeaderField: "Content-Length") + } + + let progressThrottler = + StreamMultipartUploadProgressThrottler(options: request.progress, onProgress: onProgress) + + return try await withCheckedThrowingContinuation { continuation in + let task = session.uploadTask(withStreamedRequest: urlRequest) + let state = StreamMultipartUploadTaskState( + bodyFactory: bodyFactory, + progressThrottler: progressThrottler, + task: task, + uploadId: uploadId + ) { result in + continuation.resume(with: result) + } + + register(state) + task.resume() + } + } + + private func parseRequest( + uploadId: String, + url: String, + method: String, + headers: [String: String], + parts: [[String: Any]], + progress: [String: Any]? + ) throws -> StreamMultipartUploadRequest { + guard let parsedURL = URL(string: url) else { + throw StreamMultipartUploadError.invalidURL(url) + } + + let uploadParts = try parts.enumerated().map { index, rawPart -> StreamMultipartUploadPart in + guard let fieldName = rawPart["fieldName"] as? String else { + throw StreamMultipartUploadError.invalidRequest( + "Multipart part \(index) is missing fieldName" + ) + } + + guard let kind = rawPart["kind"] as? String else { + throw StreamMultipartUploadError.invalidRequest("Multipart part \(index) is missing kind") + } + + switch kind { + case "text": + guard let value = rawPart["value"] as? String else { + throw StreamMultipartUploadError.invalidRequest( + "Multipart text part \(index) is missing value" + ) + } + return .text( + StreamMultipartTextPart(fieldName: fieldName, value: value) + ) + case "file": + guard let uri = rawPart["uri"] as? String else { + throw StreamMultipartUploadError.invalidRequest( + "Multipart file part \(index) is missing uri" + ) + } + guard let fileName = rawPart["fileName"] as? String else { + throw StreamMultipartUploadError.invalidRequest( + "Multipart file part \(index) is missing fileName" + ) + } + return .file( + StreamMultipartFilePart( + fieldName: fieldName, + fileName: fileName, + mimeType: rawPart["mimeType"] as? String, + uri: uri + ) + ) + default: + throw StreamMultipartUploadError.invalidRequest("Unsupported multipart kind: \(kind)") + } + } + + if !uploadParts.contains(where: { + if case .file = $0 { + return true + } + return false + }) { + throw StreamMultipartUploadError.invalidRequest( + "Multipart upload must contain at least one file part" + ) + } + + let progressOptions = StreamMultipartUploadProgressOptions( + count: progress?["count"] as? Int ?? (progress?["count"] as? NSNumber)?.intValue, + intervalMs: progress?["intervalMs"] as? Double ?? (progress?["intervalMs"] as? NSNumber)?.doubleValue + ) + + return StreamMultipartUploadRequest( + headers: headers, + method: method, + parts: uploadParts, + progress: progress == nil ? nil : progressOptions, + uploadId: uploadId, + url: parsedURL + ) + } + + private func register(_ state: StreamMultipartUploadTaskState) { + lock.lock() + statesByTaskIdentifier[state.task.taskIdentifier] = state + taskIdentifiersByUploadId[state.uploadId] = state.task.taskIdentifier + lock.unlock() + } + + private func removeState(taskIdentifier: Int) -> StreamMultipartUploadTaskState? { + lock.lock() + let state = statesByTaskIdentifier.removeValue(forKey: taskIdentifier) + if let uploadId = state?.uploadId { + taskIdentifiersByUploadId.removeValue(forKey: uploadId) + } + lock.unlock() + return state + } + + private func state(taskIdentifier: Int) -> StreamMultipartUploadTaskState? { + lock.lock() + let state = statesByTaskIdentifier[taskIdentifier] + lock.unlock() + return state + } +} + +extension StreamMultipartUploadManager: URLSessionDataDelegate, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + guard let state = state(taskIdentifier: dataTask.taskIdentifier) else { + return + } + + state.responseData.append(data) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + state(taskIdentifier: dataTask.taskIdentifier)?.response = response as? HTTPURLResponse + completionHandler(.allow) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard let state = removeState(taskIdentifier: task.taskIdentifier) else { + return + } + + if let error { + let nsError = error as NSError + + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { + state.completion?(.failure(StreamMultipartUploadError.cancelled)) + } else { + state.completion?(.failure(nsError)) + } + state.completion = nil + return + } + + guard let response = state.response else { + state.completion?(.failure(StreamMultipartUploadError.missingHTTPResponse)) + state.completion = nil + return + } + + let headers = + response.allHeaderFields.reduce(into: [String: String]()) { partialResult, entry in + guard let key = entry.key as? String else { + return + } + + let value = String(describing: entry.value) + if let existingValue = partialResult[key] { + partialResult[key] = "\(existingValue), \(value)" + } else { + partialResult[key] = value + } + } + + let body = String(decoding: state.responseData, as: UTF8.self) + + state.completion?( + .success( + StreamMultipartUploadResponse( + body: body, + headers: headers, + status: response.statusCode, + statusText: HTTPURLResponse.localizedString(forStatusCode: response.statusCode) + ) + ) + ) + state.completion = nil + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + let total: Int64? + if totalBytesExpectedToSend > 0 { + total = totalBytesExpectedToSend + } else { + total = nil + } + + state(taskIdentifier: task.taskIdentifier)?.progressThrottler.dispatch( + loaded: totalBytesSent, + total: total + ) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + needNewBodyStream completionHandler: @escaping (InputStream?) -> Void + ) { + completionHandler(state(taskIdentifier: task.taskIdentifier)?.bodyFactory.makeStream()) + } +} diff --git a/package/shared-native/ios/StreamMultipartUploadModels.swift b/package/shared-native/ios/StreamMultipartUploadModels.swift new file mode 100644 index 0000000000..de513037c9 --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploadModels.swift @@ -0,0 +1,62 @@ +import Foundation + +struct StreamMultipartUploadRequest { + let headers: [String: String] + let method: String + let parts: [StreamMultipartUploadPart] + let progress: StreamMultipartUploadProgressOptions? + let uploadId: String + let url: URL +} + +enum StreamMultipartUploadPart { + case file(StreamMultipartFilePart) + case text(StreamMultipartTextPart) +} + +struct StreamMultipartFilePart { + let fieldName: String + let fileName: String + let mimeType: String? + let uri: String +} + +struct StreamMultipartTextPart { + let fieldName: String + let value: String +} + +struct StreamMultipartUploadProgressOptions { + let count: Int? + let intervalMs: TimeInterval? +} + +struct StreamMultipartUploadResponse { + let body: String + let headers: [String: String] + let status: Int + let statusText: String? +} + +enum StreamMultipartUploadError: LocalizedError { + case cancelled + case invalidRequest(String) + case invalidURL(String) + case missingHTTPResponse + case unsupportedSource(String) + + var errorDescription: String? { + switch self { + case .cancelled: + return "Request aborted" + case .invalidRequest(let message): + return message + case .invalidURL(let value): + return "Invalid upload URL: \(value)" + case .missingHTTPResponse: + return "Upload completed without an HTTP response" + case .unsupportedSource(let uri): + return "Unsupported upload URI: \(uri)" + } + } +} diff --git a/package/shared-native/ios/StreamMultipartUploadProgress.swift b/package/shared-native/ios/StreamMultipartUploadProgress.swift new file mode 100644 index 0000000000..e14a47e0a8 --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploadProgress.swift @@ -0,0 +1,48 @@ +import Foundation + +final class StreamMultipartUploadProgressThrottler { + private let count: Int + private let intervalMs: TimeInterval + private let onProgress: (Int64, Int64?) -> Void + private var emittedBucket = -1 + private var lastEventAt: TimeInterval = 0 + + init( + options: StreamMultipartUploadProgressOptions?, + onProgress: @escaping (Int64, Int64?) -> Void + ) { + self.count = options?.count ?? 20 + self.intervalMs = options?.intervalMs ?? 16 + self.onProgress = onProgress + } + + func dispatch(loaded: Int64, total: Int64?) { + if let total, loaded >= total { + onProgress(loaded, total) + return + } + + let now = Date().timeIntervalSince1970 * 1000 + let passesInterval = now - lastEventAt >= intervalMs + let passesCount: Bool + + if count > 0, let total = total, total > 0 { + let nextBucket = Int(floor((Double(loaded) / Double(total)) * Double(count))) + if nextBucket > emittedBucket { + emittedBucket = nextBucket + passesCount = true + } else { + passesCount = false + } + } else { + passesCount = true + } + + guard passesInterval, passesCount else { + return + } + + lastEventAt = now + onProgress(loaded, total) + } +} diff --git a/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift b/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift new file mode 100644 index 0000000000..b33180b199 --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift @@ -0,0 +1,206 @@ +import AVFoundation +import Foundation +import MobileCoreServices +import Photos +import UniformTypeIdentifiers + +struct StreamMultipartResolvedFilePart { + let fieldName: String + let fileName: String + let fileURL: URL + let mimeType: String + let size: Int64? +} + +enum StreamMultipartUploadSourceResolver { + static func resolve(_ part: StreamMultipartFilePart) async throws -> StreamMultipartResolvedFilePart { + let fileURL = sanitizeFileURL(try await resolveFileURL(from: part.uri)) + let mimeType = part.mimeType ?? guessMimeType(fileURL: fileURL, fallbackFileName: part.fileName) + let size = fileSize(url: fileURL) + + return StreamMultipartResolvedFilePart( + fieldName: part.fieldName, + fileName: part.fileName, + fileURL: fileURL, + mimeType: mimeType, + size: size + ) + } + + private static func resolveFileURL(from uri: String) async throws -> URL { + if uri.lowercased().hasPrefix("ph://") { + return try await resolvePhotoLibraryURL(from: uri) + } + + if uri.lowercased().hasPrefix("assets-library://") { + return try await resolveAssetsLibraryURL(from: uri) + } + + if uri.hasPrefix("/") { + return URL(fileURLWithPath: uri) + } + + guard let parsedURL = URL(string: uri) else { + throw StreamMultipartUploadError.unsupportedSource(uri) + } + + if parsedURL.isFileURL { + return parsedURL + } + + throw StreamMultipartUploadError.unsupportedSource(uri) + } + + private static func sanitizeFileURL(_ url: URL) -> URL { + guard url.isFileURL else { + return url + } + + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } + + components.fragment = nil + components.query = nil + + return components.url ?? url + } + + private static func resolvePhotoLibraryURL(from uri: String) async throws -> URL { + let identifier = photoLibraryIdentifier(from: uri) + guard !identifier.isEmpty else { + throw StreamMultipartUploadError.unsupportedSource(uri) + } + + let result = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil) + guard let asset = result.firstObject else { + throw StreamMultipartUploadError.unsupportedSource(uri) + } + + return try await resolveAssetURL(asset) + } + + @available(iOS, deprecated: 11.0) + private static func resolveAssetsLibraryURL(from uri: String) async throws -> URL { + guard let assetURL = URL(string: uri) else { + throw StreamMultipartUploadError.unsupportedSource(uri) + } + + let result = PHAsset.fetchAssets(withALAssetURLs: [assetURL], options: nil) + guard let asset = result.firstObject else { + throw StreamMultipartUploadError.unsupportedSource(uri) + } + + return try await resolveAssetURL(asset) + } + + private static func resolveAssetURL(_ asset: PHAsset) async throws -> URL { + switch asset.mediaType { + case .video: + return try await requestVideoAssetURL(asset) + case .image: + return try await requestImageAssetURL(asset) + default: + throw StreamMultipartUploadError.unsupportedSource(asset.localIdentifier) + } + } + + private static func requestImageAssetURL(_ asset: PHAsset) async throws -> URL { + let options = PHContentEditingInputRequestOptions() + options.isNetworkAccessAllowed = true + + return try await withCheckedThrowingContinuation { continuation in + asset.requestContentEditingInput(with: options) { input, _ in + if let url = input?.fullSizeImageURL { + continuation.resume(returning: url) + return + } + + continuation.resume( + throwing: StreamMultipartUploadError.unsupportedSource(asset.localIdentifier) + ) + } + } + } + + private static func requestVideoAssetURL(_ asset: PHAsset) async throws -> URL { + let options = PHVideoRequestOptions() + options.deliveryMode = .highQualityFormat + options.isNetworkAccessAllowed = true + options.version = .current + + return try await withCheckedThrowingContinuation { continuation in + PHImageManager.default().requestAVAsset(forVideo: asset, options: options) { avAsset, _, info in + if let isCancelled = (info?[PHImageCancelledKey] as? NSNumber)?.boolValue, isCancelled { + continuation.resume(throwing: StreamMultipartUploadError.cancelled) + return + } + + if let error = info?[PHImageErrorKey] as? Error { + continuation.resume(throwing: error) + return + } + + if let url = (avAsset as? AVURLAsset)?.url { + continuation.resume(returning: url) + return + } + + continuation.resume( + throwing: StreamMultipartUploadError.unsupportedSource(asset.localIdentifier) + ) + } + } + } + + private static func guessMimeType(fileURL: URL, fallbackFileName: String) -> String { + if #available(iOS 14.0, *), let type = UTType(filenameExtension: fileURL.pathExtension) { + return type.preferredMIMEType ?? "application/octet-stream" + } + + let fileName = fileURL.lastPathComponent.isEmpty ? fallbackFileName : fileURL.lastPathComponent + return mimeTypeFromExtension(fileName) ?? "application/octet-stream" + } + + private static func mimeTypeFromExtension(_ fileName: String) -> String? { + let pathExtension = (fileName as NSString).pathExtension + guard !pathExtension.isEmpty else { + return nil + } + + if let unmanaged = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassFilenameExtension, + pathExtension as CFString, + nil + )?.takeRetainedValue(), + let mime = UTTypeCopyPreferredTagWithClass(unmanaged, kUTTagClassMIMEType)?.takeRetainedValue() + { + return mime as String + } + + return nil + } + + private static func fileSize(url: URL) -> Int64? { + let values = try? url.resourceValues(forKeys: [.fileSizeKey]) + guard let fileSize = values?.fileSize else { + return nil + } + return Int64(fileSize) + } + + private static func photoLibraryIdentifier(from url: String) -> String { + guard let parsedURL = URL(string: url), parsedURL.scheme?.lowercased() == "ph" else { + return url + .replacingOccurrences(of: "ph://", with: "", options: [.caseInsensitive]) + .removingPercentEncoding? + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" + } + + let host = parsedURL.host ?? "" + let path = parsedURL.path + let combined = host.isEmpty ? path : "\(host)\(path)" + return combined.removingPercentEncoding? + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" + } +} diff --git a/package/shared-native/ios/StreamMultipartUploader.h b/package/shared-native/ios/StreamMultipartUploader.h new file mode 100644 index 0000000000..bf565134ce --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploader.h @@ -0,0 +1,16 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#import + +#if __has_include("StreamChatReactNativeSpec.h") +#import "StreamChatReactNativeSpec.h" +#elif __has_include("StreamChatExpoSpec.h") +#import "StreamChatExpoSpec.h" +#else +#error "Unable to find generated codegen spec header for StreamMultipartUploader." +#endif + +@interface StreamMultipartUploader : RCTEventEmitter +@end + +#endif diff --git a/package/shared-native/ios/StreamMultipartUploader.mm b/package/shared-native/ios/StreamMultipartUploader.mm new file mode 100644 index 0000000000..11535c4923 --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploader.mm @@ -0,0 +1,107 @@ +#import "StreamMultipartUploader.h" + +#ifdef RCT_NEW_ARCH_ENABLED + +#if __has_include() +#import +#elif __has_include() +#import +#elif __has_include("stream_chat_react_native-Swift.h") +#import "stream_chat_react_native-Swift.h" +#elif __has_include("stream_chat_expo-Swift.h") +#import "stream_chat_expo-Swift.h" +#else +#error "Unable to import generated Swift header for StreamMultipartUploader." +#endif + +static NSString *const StreamMultipartUploadProgressEventName = @"streamMultipartUploadProgress"; + +static NSDictionary *StreamMultipartUploadProgressDictionary( + const JS::NativeStreamMultipartUploader::UploadProgressConfig &progress) +{ + NSMutableDictionary *payload = [NSMutableDictionary dictionaryWithCapacity:2]; + + if (progress.count().has_value()) { + payload[@"count"] = @(progress.count().value()); + } + + if (progress.intervalMs().has_value()) { + payload[@"intervalMs"] = @(progress.intervalMs().value()); + } + + return payload; +} + +@implementation StreamMultipartUploader + +RCT_EXPORT_MODULE(StreamMultipartUploader) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (NSArray *)supportedEvents +{ + return @[ StreamMultipartUploadProgressEventName ]; +} + +- (void)uploadMultipart:(NSString *)uploadId + url:(NSString *)url + method:(NSString *)method + headers:(NSArray *> *)headers + parts:(NSArray *> *)parts + progress:(JS::NativeStreamMultipartUploader::UploadProgressConfig &)progress + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + __weak __typeof__(self) weakSelf = self; + NSDictionary *progressOptions = StreamMultipartUploadProgressDictionary(progress); + + [StreamMultipartUploaderBridge uploadMultipartWithUploadId:uploadId + url:url + method:method + headers:headers + parts:parts + progress:progressOptions + onProgress:^(NSNumber *loaded, NSNumber * _Nullable total) { + __strong __typeof__(weakSelf) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + NSMutableDictionary *payload = [NSMutableDictionary dictionaryWithCapacity:3]; + payload[@"uploadId"] = uploadId; + payload[@"loaded"] = loaded; + payload[@"total"] = total ?: [NSNull null]; + [strongSelf sendEventWithName:StreamMultipartUploadProgressEventName body:payload]; + }); + } + completion:^(NSDictionary * _Nullable response, NSError * _Nullable error) { + if (error != nil) { + reject(@"stream_multipart_upload_error", error.localizedDescription, error); + return; + } + + resolve(response ?: @{}); + }]; +} + +- (void)cancelUpload:(NSString *)uploadId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [StreamMultipartUploaderBridge cancelUploadWithUploadId:uploadId]; + resolve(nil); +} + +- (std::shared_ptr)getTurboModule: +(const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + +@end + +#endif diff --git a/package/shared-native/ios/StreamMultipartUploaderBridge.swift b/package/shared-native/ios/StreamMultipartUploaderBridge.swift new file mode 100644 index 0000000000..6f3cbec050 --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploaderBridge.swift @@ -0,0 +1,78 @@ +import Foundation + +@objcMembers +public final class StreamMultipartUploaderBridge: NSObject { + @objc(uploadMultipartWithUploadId:url:method:headers:parts:progress:onProgress:completion:) + public static func uploadMultipart( + uploadId: String, + url: String, + method: String, + headers: [[String: String]], + parts: [[String: Any]], + progress: [String: Any]?, + onProgress: @escaping (NSNumber, NSNumber?) -> Void, + completion: @escaping (NSDictionary?, NSError?) -> Void + ) { + Task(priority: .userInitiated) { + do { + let response = try await StreamMultipartUploadManager.shared.uploadMultipart( + uploadId: uploadId, + url: url, + method: method, + headers: dictionary(from: headers), + parts: parts, + progress: progress, + onProgress: { loaded, total in + onProgress(NSNumber(value: loaded), total.map { NSNumber(value: $0) }) + } + ) + + let payload = NSMutableDictionary(capacity: 4) + payload["body"] = response.body + payload["headers"] = headerEntries(from: response.headers) + payload["status"] = NSNumber(value: response.status) + payload["statusText"] = response.statusText ?? NSNull() + + completion(payload, nil) + } catch { + completion(nil, error.asStreamMultipartNSError()) + } + } + } + + @objc(cancelUploadWithUploadId:) + public static func cancelUpload(uploadId: String) { + StreamMultipartUploadManager.shared.cancel(uploadId: uploadId) + } + + private static func dictionary(from headers: [[String: String]]) -> [String: String] { + headers.reduce(into: [String: String]()) { result, header in + guard let name = header["name"], let value = header["value"] else { + return + } + result[name] = value + } + } + + private static func headerEntries(from headers: [String: String]) -> [[String: String]] { + headers.map { name, value in + ["name": name, "value": value] + } + } +} + +private extension Error { + func asStreamMultipartNSError() -> NSError { + let nsError = self as NSError + + if nsError.domain != NSCocoaErrorDomain || nsError.code != 0 { + return nsError + } + + return NSError( + domain: "StreamMultipartUploader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: localizedDescription] + ) + } +} diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 1648abbbd2..7b1a189320 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -26,6 +26,7 @@ import { NativeHandlers } from '../../native'; import { OfflineDB } from '../../store/OfflineDB'; import type { Streami18n } from '../../utils/i18n/Streami18n'; +import { installNativeMultipartInterceptor } from '../../utils/installNativeMultipartInterceptor'; import { version } from '../../version.json'; init(); @@ -241,6 +242,8 @@ const ChatWithContext = (props: PropsWithChildren) => { }; }, [client]); + useEffect(() => installNativeMultipartInterceptor(client), [client]); + const initialisedDatabase = !!offlineDbInitialized && userID === offlineDbUserId; const appSettings = useAppSettings(client, isOnline, enableOfflineSupport, initialisedDatabase); diff --git a/package/src/native.ts b/package/src/native.ts index f151487703..53e81c1a7a 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -28,6 +28,46 @@ type CompressImage = ({ type DeleteFile = ({ uri }: { uri: string }) => Promise | never; +export type NativeMultipartUploadRequest = { + headers: Record; + method: string; + onProgress?: (progress: { loaded: number; total?: number }) => void; + parts: NativeMultipartUploadPart[]; + progress?: NativeMultipartUploadProgressConfig; + signal?: AbortSignal; + url: string; +}; + +export type NativeMultipartUploadPart = + | { + fieldName: string; + kind: 'file'; + fileName: string; + mimeType?: string; + uri: string; + } + | { + fieldName: string; + kind: 'text'; + value: string; + }; + +export type NativeMultipartUploadProgressConfig = { + count?: number; + intervalMs?: number; +}; + +export type NativeMultipartUploadResult = { + body: string; + headers?: Record; + status: number; + statusText?: string; +}; + +type MultipartUpload = ( + request: NativeMultipartUploadRequest, +) => Promise | never; + type GetLocalAssetUri = (uriOrAssetId: string) => Promise | never; type OniOS14LibrarySelectionChange = (callback: () => void) => { unsubscribe: () => void }; @@ -308,6 +348,7 @@ type Handlers = { getLocalAssetUri?: GetLocalAssetUri; getPhotos?: GetPhotos; iOS14RefreshGallerySelection?: iOS14RefreshGallerySelection; + multipartUpload?: MultipartUpload; oniOS14GalleryLibrarySelectionChange?: OniOS14LibrarySelectionChange; overrideAudioRecordingConfiguration?: ( audioRecordingConfiguration: AudioRecordingConfiguration, @@ -338,6 +379,7 @@ export const NativeHandlers: Pick< | 'getLocalAssetUri' | 'getPhotos' | 'iOS14RefreshGallerySelection' + | 'multipartUpload' | 'oniOS14GalleryLibrarySelectionChange' | 'pickDocument' | 'pickImage' @@ -355,6 +397,7 @@ export const NativeHandlers: Pick< getLocalAssetUri: fail, getPhotos: fail, iOS14RefreshGallerySelection: fail, + multipartUpload: fail, oniOS14GalleryLibrarySelectionChange: fail, pickDocument: fail, pickImage: fail, @@ -404,6 +447,10 @@ export const registerNativeHandlers = (handlers: Handlers) => { NativeHandlers.iOS14RefreshGallerySelection = handlers.iOS14RefreshGallerySelection; } + if (handlers.multipartUpload !== undefined) { + NativeHandlers.multipartUpload = handlers.multipartUpload; + } + if (handlers.oniOS14GalleryLibrarySelectionChange !== undefined) { NativeHandlers.oniOS14GalleryLibrarySelectionChange = handlers.oniOS14GalleryLibrarySelectionChange; @@ -469,3 +516,4 @@ export const isImageMediaLibraryAvailable = () => !!NativeHandlers.iOS14RefreshGallerySelection && !!NativeHandlers.oniOS14GalleryLibrarySelectionChange && !!NativeHandlers.getLocalAssetUri; +export const isNativeMultipartUploadAvailable = () => NativeHandlers.multipartUpload !== fail; diff --git a/package/src/utils/__tests__/installNativeMultipartInterceptor.test.ts b/package/src/utils/__tests__/installNativeMultipartInterceptor.test.ts new file mode 100644 index 0000000000..2c035c76d1 --- /dev/null +++ b/package/src/utils/__tests__/installNativeMultipartInterceptor.test.ts @@ -0,0 +1,214 @@ +import { getTestClient } from '../../mock-builders/mock'; +import { NativeHandlers } from '../../native'; +import { installNativeMultipartInterceptor } from '../installNativeMultipartInterceptor'; + +describe('installNativeMultipartInterceptor', () => { + const originalMultipartUpload = NativeHandlers.multipartUpload; + + beforeEach(() => { + NativeHandlers.multipartUpload = jest.fn().mockResolvedValue({ + body: JSON.stringify({ file: 'https://example.com/file.jpg' }), + headers: { 'content-type': 'application/json' }, + status: 200, + statusText: 'OK', + }); + }); + + afterEach(() => { + NativeHandlers.multipartUpload = originalMultipartUpload; + jest.clearAllMocks(); + }); + + it('routes multipart requests through the native handler', async () => { + const client = getTestClient(); + const defaultAdapter = jest.fn().mockResolvedValue({ + config: {}, + data: 'default', + headers: {}, + status: 200, + statusText: 'OK', + }); + + client.axiosInstance.defaults.adapter = defaultAdapter; + + const dispose = installNativeMultipartInterceptor(client); + const formData = { + _parts: [ + [ + 'file', + { + name: 'test.jpg', + type: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }, + ], + ['user', JSON.stringify({ id: 'john' })], + ], + }; + + const response = await client.axiosInstance.post('/uploads/image', formData, { + headers: { + Authorization: 'token', + 'Content-Type': 'multipart/form-data', + 'X-Stream-Client': 'stream-test', + }, + params: { + api_key: 'test-key', + }, + }); + + expect(defaultAdapter).not.toHaveBeenCalled(); + expect(NativeHandlers.multipartUpload).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'token', + 'Content-Type': 'multipart/form-data', + 'X-Stream-Client': 'stream-test', + }), + parts: [ + { + fieldName: 'file', + fileName: 'test.jpg', + kind: 'file', + mimeType: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }, + { + fieldName: 'user', + kind: 'text', + value: JSON.stringify({ id: 'john' }), + }, + ], + }), + ); + expect(response.status).toBe(200); + + dispose(); + }); + + it('leaves non-multipart requests on the default adapter', async () => { + const client = getTestClient(); + const defaultAdapter = jest.fn().mockResolvedValue({ + config: {}, + data: 'default', + headers: {}, + status: 200, + statusText: 'OK', + }); + + client.axiosInstance.defaults.adapter = defaultAdapter; + + const dispose = installNativeMultipartInterceptor(client); + + await client.axiosInstance.post('/messages', { text: 'hello' }); + + expect(defaultAdapter).toHaveBeenCalled(); + expect(NativeHandlers.multipartUpload).not.toHaveBeenCalled(); + + dispose(); + }); + + it('forwards native upload progress to axios upload progress callbacks', async () => { + NativeHandlers.multipartUpload = jest.fn().mockImplementation(({ onProgress }) => { + onProgress?.({ + loaded: '50' as unknown as number, + total: '100' as unknown as number, + }); + + return { + body: JSON.stringify({ file: 'https://example.com/file.jpg' }), + headers: { 'content-type': 'application/json' }, + status: 200, + statusText: 'OK', + }; + }); + + const client = getTestClient(); + const dispose = installNativeMultipartInterceptor(client); + const onUploadProgress = jest.fn(); + const uploadProgress = jest.fn(); + const formData = { + _parts: [ + [ + 'file', + { + name: 'test.jpg', + type: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }, + ], + ], + }; + + await client.axiosInstance.post('/uploads/image', formData, { + onUploadProgress, + uploadProgressOptions: { + count: 10, + intervalMs: 25, + }, + uploadProgress, + }); + + expect(onUploadProgress).toHaveBeenCalledWith( + expect.objectContaining({ + lengthComputable: true, + loaded: 50, + progress: 0.5, + total: 100, + }), + ); + expect(uploadProgress).toHaveBeenCalledWith( + expect.objectContaining({ + lengthComputable: true, + loaded: 50, + progress: 0.5, + total: 100, + }), + ); + expect(NativeHandlers.multipartUpload).toHaveBeenCalledWith( + expect.objectContaining({ + progress: { + count: 10, + intervalMs: 25, + }, + }), + ); + + dispose(); + }); + + it('removes the interceptor on dispose', async () => { + const client = getTestClient(); + const defaultAdapter = jest.fn().mockResolvedValue({ + config: {}, + data: 'default', + headers: {}, + status: 200, + statusText: 'OK', + }); + + client.axiosInstance.defaults.adapter = defaultAdapter; + + const dispose = installNativeMultipartInterceptor(client); + + dispose(); + + const formData = { + _parts: [ + [ + 'file', + { + name: 'test.jpg', + type: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }, + ], + ], + }; + + await client.axiosInstance.post('/uploads/image', formData); + + expect(defaultAdapter).toHaveBeenCalled(); + expect(NativeHandlers.multipartUpload).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/utils/installNativeMultipartInterceptor.ts b/package/src/utils/installNativeMultipartInterceptor.ts new file mode 100644 index 0000000000..a00f9ea5ef --- /dev/null +++ b/package/src/utils/installNativeMultipartInterceptor.ts @@ -0,0 +1,269 @@ +import type { StreamChat } from 'stream-chat'; + +import { + isNativeMultipartUploadAvailable, + NativeHandlers, + NativeMultipartUploadProgressConfig, + NativeMultipartUploadRequest, +} from '../native'; + +type FormDataPartValue = + | string + | { + contentType?: string; + name?: string; + type?: string; + uri: string; + }; + +type UploadProgressEvent = { + lengthComputable: boolean; + loaded: number; + progress?: number; + total?: number; +}; + +type AxiosHeadersLike = { + toJSON?: () => Record; +}; + +type AxiosLikeRequestConfig = { + adapter?: unknown; + data?: unknown; + headers?: AxiosHeadersLike | Record; + method?: string; + onUploadProgress?: (event: UploadProgressEvent) => void; + uploadProgressOptions?: NativeMultipartUploadProgressConfig; + uploadProgress?: (event: UploadProgressEvent) => void; + params?: unknown; + signal?: AbortSignal; + url?: string; +}; + +const STREAM_NATIVE_MULTIPART_REQUEST = Symbol('stream-native-multipart-request'); + +const installedInterceptors = new WeakMap< + StreamChat, + { + count: number; + interceptorId: number; + } +>(); + +const getFormDataEntries = (data: unknown): [string, FormDataPartValue][] | null => { + if (!data || typeof data !== 'object') { + return null; + } + + if ('entries' in data && typeof data.entries === 'function') { + return Array.from(data.entries()) as [string, FormDataPartValue][]; + } + + const parts = Reflect.get(data, '_parts'); + + if (Array.isArray(parts)) { + return parts as [string, FormDataPartValue][]; + } + + return null; +}; + +const normalizeHeaders = (headers: AxiosLikeRequestConfig['headers']) => { + const rawHeaders = + headers && typeof headers === 'object' && 'toJSON' in headers && headers.toJSON + ? headers.toJSON() + : headers; + + const normalizedHeaders: Record = {}; + + Object.entries(rawHeaders ?? {}).forEach(([key, value]) => { + if (value == null) { + return; + } + + normalizedHeaders[key] = Array.isArray(value) ? value.join(', ') : String(value); + }); + + return normalizedHeaders; +}; + +const getFileNameFromUri = (uri: string) => uri.split('/').filter(Boolean).pop() || 'file'; + +const createNativeMultipartRequest = ( + client: StreamChat, + config: AxiosLikeRequestConfig, +): NativeMultipartUploadRequest | null => { + const entries = getFormDataEntries(config.data); + + if (!entries) { + return null; + } + + const parts: NativeMultipartUploadRequest['parts'] = []; + + for (const [fieldName, value] of entries) { + if (typeof value === 'string') { + parts.push({ + fieldName, + kind: 'text', + value, + }); + continue; + } + + if (value && typeof value === 'object' && 'uri' in value && typeof value.uri === 'string') { + parts.push({ + fieldName, + fileName: value.name || getFileNameFromUri(value.uri), + kind: 'file', + mimeType: value.type || value.contentType, + uri: value.uri, + }); + continue; + } + + return null; + } + + if (!parts.some((part) => part.kind === 'file')) { + return null; + } + + return { + headers: normalizeHeaders(config.headers), + method: (config.method || 'POST').toUpperCase(), + parts, + progress: config.uploadProgressOptions, + signal: config.signal, + url: client.axiosInstance.getUri(config), + }; +}; + +const toFiniteNumber = (value: unknown) => { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined; + } + + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + + return undefined; +}; + +const getUploadProgressCallbacks = (config: AxiosLikeRequestConfig) => { + const callbacks = [config.onUploadProgress, config.uploadProgress].filter( + (callback): callback is NonNullable => + typeof callback === 'function', + ); + + return Array.from(new Set(callbacks)); +}; + +const createUploadProgressEvent = ({ loaded, total }: { loaded: unknown; total?: unknown }) => { + const normalizedLoaded = toFiniteNumber(loaded) ?? 0; + const normalizedTotal = toFiniteNumber(total); + + return { + lengthComputable: typeof normalizedTotal === 'number' && normalizedTotal > 0, + loaded: normalizedLoaded, + progress: + typeof normalizedTotal === 'number' && normalizedTotal > 0 + ? normalizedLoaded / normalizedTotal + : undefined, + total: normalizedTotal, + }; +}; + +const nativeMultipartAxiosAdapter = async (config: AxiosLikeRequestConfig) => { + const request = ( + config as AxiosLikeRequestConfig & { + [STREAM_NATIVE_MULTIPART_REQUEST]?: NativeMultipartUploadRequest; + } + )[STREAM_NATIVE_MULTIPART_REQUEST]; + + if (!request) { + throw new Error('Missing native multipart upload request'); + } + + const uploadProgressCallbacks = getUploadProgressCallbacks(config); + + const response = await NativeHandlers.multipartUpload({ + ...request, + onProgress: uploadProgressCallbacks.length + ? ({ loaded, total }) => { + const event = createUploadProgressEvent({ loaded, total }); + uploadProgressCallbacks.forEach((callback) => callback(event)); + } + : undefined, + }); + + if (!response) { + throw new Error('Native multipart upload did not return a response'); + } + + return { + config, + data: response.body, + headers: response.headers ?? {}, + request: null, + status: response.status, + statusText: response.statusText ?? '', + }; +}; + +export const installNativeMultipartInterceptor = (client: StreamChat) => { + if (!isNativeMultipartUploadAvailable()) { + return () => undefined; + } + + const existing = installedInterceptors.get(client); + + if (existing) { + existing.count += 1; + + return () => { + existing.count -= 1; + + if (existing.count === 0) { + client.axiosInstance.interceptors.request.eject(existing.interceptorId); + installedInterceptors.delete(client); + } + }; + } + + const interceptorId = client.axiosInstance.interceptors.request.use((config) => { + const nativeMultipartRequest = createNativeMultipartRequest( + client, + config as AxiosLikeRequestConfig, + ); + + if (!nativeMultipartRequest) { + return config; + } + + return { + ...config, + adapter: nativeMultipartAxiosAdapter, + [STREAM_NATIVE_MULTIPART_REQUEST]: nativeMultipartRequest, + }; + }); + + installedInterceptors.set(client, { count: 1, interceptorId }); + + return () => { + const current = installedInterceptors.get(client); + + if (!current) { + return; + } + + current.count -= 1; + + if (current.count === 0) { + client.axiosInstance.interceptors.request.eject(current.interceptorId); + installedInterceptors.delete(client); + } + }; +}; From bf5e0a7fcc6b00623dbe3c8d86790f36ba90457a Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 21 Apr 2026 10:56:38 +0200 Subject: [PATCH 03/26] refactor: uploadManager state change --- package/src/hooks/usePendingAttachmentUpload.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/src/hooks/usePendingAttachmentUpload.ts b/package/src/hooks/usePendingAttachmentUpload.ts index 85654f3de1..efcd145885 100644 --- a/package/src/hooks/usePendingAttachmentUpload.ts +++ b/package/src/hooks/usePendingAttachmentUpload.ts @@ -31,7 +31,7 @@ export function usePendingAttachmentUpload(localId: string | undefined): Pending if (!localId) { return idle; } - const record = state.uploads.find((u) => u.id === localId); + const record = state.uploads[localId]; if (!record) { return idle; } From 796a708755c038abe8c5e350c236e81c20759e17 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 21 Apr 2026 12:52:25 +0200 Subject: [PATCH 04/26] chore: cleanup --- .../src/native/multipartUploader.ts | 4 +- .../src/native/multipartUploader.ts | 4 +- package/src/components/Chat/Chat.tsx | 6 +- package/src/native.ts | 11 +- ... => installNativeMultipartAdapter.test.ts} | 135 +++++++++++++-- ...or.ts => installNativeMultipartAdapter.ts} | 163 ++++++++---------- 6 files changed, 212 insertions(+), 111 deletions(-) rename package/src/utils/__tests__/{installNativeMultipartInterceptor.test.ts => installNativeMultipartAdapter.test.ts} (58%) rename package/src/utils/{installNativeMultipartInterceptor.ts => installNativeMultipartAdapter.ts} (61%) diff --git a/package/expo-package/src/native/multipartUploader.ts b/package/expo-package/src/native/multipartUploader.ts index 74bcf2095a..bc58b7eee4 100644 --- a/package/expo-package/src/native/multipartUploader.ts +++ b/package/expo-package/src/native/multipartUploader.ts @@ -8,6 +8,8 @@ import NativeStreamMultipartUploader, { type UploadResponse as NativeUploadResponse, } from './NativeStreamMultipartUploader'; +import type { NativeMultipartAbortSignal } from '../../../src/native'; + const STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT = 'streamMultipartUploadProgress'; const multipartUploadEventEmitter = new NativeEventEmitter(NativeStreamMultipartUploader); @@ -40,7 +42,7 @@ type MultipartUploadRequest = { onProgress?: (event: { loaded: number; total?: number }) => void; parts: UploadPart[]; progress?: UploadProgressConfig; - signal?: AbortSignal; + signal?: NativeMultipartAbortSignal; uploadId: string; url: string; }; diff --git a/package/native-package/src/native/multipartUploader.ts b/package/native-package/src/native/multipartUploader.ts index 74bcf2095a..bc58b7eee4 100644 --- a/package/native-package/src/native/multipartUploader.ts +++ b/package/native-package/src/native/multipartUploader.ts @@ -8,6 +8,8 @@ import NativeStreamMultipartUploader, { type UploadResponse as NativeUploadResponse, } from './NativeStreamMultipartUploader'; +import type { NativeMultipartAbortSignal } from '../../../src/native'; + const STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT = 'streamMultipartUploadProgress'; const multipartUploadEventEmitter = new NativeEventEmitter(NativeStreamMultipartUploader); @@ -40,7 +42,7 @@ type MultipartUploadRequest = { onProgress?: (event: { loaded: number; total?: number }) => void; parts: UploadPart[]; progress?: UploadProgressConfig; - signal?: AbortSignal; + signal?: NativeMultipartAbortSignal; uploadId: string; url: string; }; diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 7b1a189320..5086162587 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -26,7 +26,7 @@ import { NativeHandlers } from '../../native'; import { OfflineDB } from '../../store/OfflineDB'; import type { Streami18n } from '../../utils/i18n/Streami18n'; -import { installNativeMultipartInterceptor } from '../../utils/installNativeMultipartInterceptor'; +import { installNativeMultipartAdapter } from '../../utils/installNativeMultipartAdapter'; import { version } from '../../version.json'; init(); @@ -242,7 +242,9 @@ const ChatWithContext = (props: PropsWithChildren) => { }; }, [client]); - useEffect(() => installNativeMultipartInterceptor(client), [client]); + useEffect(() => { + installNativeMultipartAdapter(client); + }, [client]); const initialisedDatabase = !!offlineDbInitialized && userID === offlineDbUserId; diff --git a/package/src/native.ts b/package/src/native.ts index 53e81c1a7a..47de83290b 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -28,13 +28,22 @@ type CompressImage = ({ type DeleteFile = ({ uri }: { uri: string }) => Promise | never; +// Axios uses a looser GenericAbortSignal type than the DOM AbortSignal and +// the native multipart path only needs this shared subset for cancellation +export type NativeMultipartAbortSignal = { + aborted: boolean; + addEventListener?: (...args: unknown[]) => unknown; + onabort?: ((...args: unknown[]) => unknown) | null; + removeEventListener?: (...args: unknown[]) => unknown; +}; + export type NativeMultipartUploadRequest = { headers: Record; method: string; onProgress?: (progress: { loaded: number; total?: number }) => void; parts: NativeMultipartUploadPart[]; progress?: NativeMultipartUploadProgressConfig; - signal?: AbortSignal; + signal?: NativeMultipartAbortSignal; url: string; }; diff --git a/package/src/utils/__tests__/installNativeMultipartInterceptor.test.ts b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts similarity index 58% rename from package/src/utils/__tests__/installNativeMultipartInterceptor.test.ts rename to package/src/utils/__tests__/installNativeMultipartAdapter.test.ts index 2c035c76d1..58b701a810 100644 --- a/package/src/utils/__tests__/installNativeMultipartInterceptor.test.ts +++ b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts @@ -1,8 +1,11 @@ import { getTestClient } from '../../mock-builders/mock'; import { NativeHandlers } from '../../native'; -import { installNativeMultipartInterceptor } from '../installNativeMultipartInterceptor'; +import { + installNativeMultipartAdapter, + wrapAxiosAdapterWithNativeMultipart, +} from '../installNativeMultipartAdapter'; -describe('installNativeMultipartInterceptor', () => { +describe('installNativeMultipartAdapter', () => { const originalMultipartUpload = NativeHandlers.multipartUpload; beforeEach(() => { @@ -14,6 +17,10 @@ describe('installNativeMultipartInterceptor', () => { }); }); + const preserveRequestData = (client: ReturnType) => { + client.axiosInstance.defaults.transformRequest = [(data) => data]; + }; + afterEach(() => { NativeHandlers.multipartUpload = originalMultipartUpload; jest.clearAllMocks(); @@ -21,6 +28,7 @@ describe('installNativeMultipartInterceptor', () => { it('routes multipart requests through the native handler', async () => { const client = getTestClient(); + preserveRequestData(client); const defaultAdapter = jest.fn().mockResolvedValue({ config: {}, data: 'default', @@ -31,7 +39,7 @@ describe('installNativeMultipartInterceptor', () => { client.axiosInstance.defaults.adapter = defaultAdapter; - const dispose = installNativeMultipartInterceptor(client); + installNativeMultipartAdapter(client); const formData = { _parts: [ [ @@ -82,12 +90,11 @@ describe('installNativeMultipartInterceptor', () => { }), ); expect(response.status).toBe(200); - - dispose(); }); - it('leaves non-multipart requests on the default adapter', async () => { + it('leaves non-multipart requests on the fallback adapter', async () => { const client = getTestClient(); + preserveRequestData(client); const defaultAdapter = jest.fn().mockResolvedValue({ config: {}, data: 'default', @@ -98,14 +105,12 @@ describe('installNativeMultipartInterceptor', () => { client.axiosInstance.defaults.adapter = defaultAdapter; - const dispose = installNativeMultipartInterceptor(client); + installNativeMultipartAdapter(client); await client.axiosInstance.post('/messages', { text: 'hello' }); expect(defaultAdapter).toHaveBeenCalled(); expect(NativeHandlers.multipartUpload).not.toHaveBeenCalled(); - - dispose(); }); it('forwards native upload progress to axios upload progress callbacks', async () => { @@ -124,7 +129,8 @@ describe('installNativeMultipartInterceptor', () => { }); const client = getTestClient(); - const dispose = installNativeMultipartInterceptor(client); + preserveRequestData(client); + installNativeMultipartAdapter(client); const onUploadProgress = jest.fn(); const uploadProgress = jest.fn(); const formData = { @@ -173,12 +179,11 @@ describe('installNativeMultipartInterceptor', () => { }, }), ); - - dispose(); }); - it('removes the interceptor on dispose', async () => { + it('uses the final config after user request interceptors run', async () => { const client = getTestClient(); + preserveRequestData(client); const defaultAdapter = jest.fn().mockResolvedValue({ config: {}, data: 'default', @@ -189,9 +194,65 @@ describe('installNativeMultipartInterceptor', () => { client.axiosInstance.defaults.adapter = defaultAdapter; - const dispose = installNativeMultipartInterceptor(client); + const interceptorId = client.axiosInstance.interceptors.request.use((config) => ({ + ...config, + headers: { + ...config.headers, + 'X-CDN-Route': 'custom-cdn', + }, + url: '/uploads/file', + })); - dispose(); + installNativeMultipartAdapter(client); + const formData = { + _parts: [ + [ + 'file', + { + name: 'test.jpg', + type: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }, + ], + ], + }; + + await client.axiosInstance.post('/uploads/image', formData, { + headers: { + Authorization: 'token', + }, + }); + + expect(NativeHandlers.multipartUpload).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'token', + 'X-CDN-Route': 'custom-cdn', + }), + url: expect.stringContaining('/uploads/file'), + }), + ); + expect(defaultAdapter).not.toHaveBeenCalled(); + + client.axiosInstance.interceptors.request.eject(interceptorId); + }); + + it('installs only once per client', async () => { + const client = getTestClient(); + preserveRequestData(client); + const defaultAdapter = jest.fn().mockResolvedValue({ + config: {}, + data: 'default', + headers: {}, + status: 200, + statusText: 'OK', + }); + + client.axiosInstance.defaults.adapter = defaultAdapter; + + installNativeMultipartAdapter(client); + const installedAdapter = client.axiosInstance.defaults.adapter; + installNativeMultipartAdapter(client); const formData = { _parts: [ @@ -208,7 +269,47 @@ describe('installNativeMultipartInterceptor', () => { await client.axiosInstance.post('/uploads/image', formData); - expect(defaultAdapter).toHaveBeenCalled(); - expect(NativeHandlers.multipartUpload).not.toHaveBeenCalled(); + expect(client.axiosInstance.defaults.adapter).toBe(installedAdapter); + expect(defaultAdapter).not.toHaveBeenCalled(); + expect(NativeHandlers.multipartUpload).toHaveBeenCalled(); + }); + + it('composes explicitly with a custom adapter', async () => { + const client = getTestClient(); + preserveRequestData(client); + const customAdapter = jest.fn().mockResolvedValue({ + config: {}, + data: 'custom', + headers: {}, + status: 200, + statusText: 'OK', + }); + + client.axiosInstance.defaults.adapter = wrapAxiosAdapterWithNativeMultipart( + client, + customAdapter, + ); + + const multipartFormData = { + _parts: [ + [ + 'file', + { + name: 'test.jpg', + type: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }, + ], + ], + }; + + await client.axiosInstance.post('/uploads/image', multipartFormData); + + expect(NativeHandlers.multipartUpload).toHaveBeenCalled(); + expect(customAdapter).not.toHaveBeenCalled(); + + await client.axiosInstance.post('/messages', { text: 'hello' }); + + expect(customAdapter).toHaveBeenCalled(); }); }); diff --git a/package/src/utils/installNativeMultipartInterceptor.ts b/package/src/utils/installNativeMultipartAdapter.ts similarity index 61% rename from package/src/utils/installNativeMultipartInterceptor.ts rename to package/src/utils/installNativeMultipartAdapter.ts index a00f9ea5ef..b151681fdd 100644 --- a/package/src/utils/installNativeMultipartInterceptor.ts +++ b/package/src/utils/installNativeMultipartAdapter.ts @@ -1,3 +1,5 @@ +import axios from 'axios'; +import type { AxiosAdapter, AxiosProgressEvent, InternalAxiosRequestConfig } from 'axios'; import type { StreamChat } from 'stream-chat'; import { @@ -16,39 +18,15 @@ type FormDataPartValue = uri: string; }; -type UploadProgressEvent = { - lengthComputable: boolean; - loaded: number; - progress?: number; - total?: number; -}; - -type AxiosHeadersLike = { - toJSON?: () => Record; -}; - -type AxiosLikeRequestConfig = { - adapter?: unknown; - data?: unknown; - headers?: AxiosHeadersLike | Record; - method?: string; - onUploadProgress?: (event: UploadProgressEvent) => void; +type NativeMultipartAxiosRequestConfig = InternalAxiosRequestConfig & { + onUploadProgress?: (event: AxiosProgressEvent) => void; uploadProgressOptions?: NativeMultipartUploadProgressConfig; - uploadProgress?: (event: UploadProgressEvent) => void; - params?: unknown; - signal?: AbortSignal; - url?: string; + uploadProgress?: (event: AxiosProgressEvent) => void; }; -const STREAM_NATIVE_MULTIPART_REQUEST = Symbol('stream-native-multipart-request'); +type ResolvableAxiosAdapter = Parameters[0]; -const installedInterceptors = new WeakMap< - StreamChat, - { - count: number; - interceptorId: number; - } ->(); +const installedAdapters = new WeakSet(); const getFormDataEntries = (data: unknown): [string, FormDataPartValue][] | null => { if (!data || typeof data !== 'object') { @@ -68,12 +46,10 @@ const getFormDataEntries = (data: unknown): [string, FormDataPartValue][] | null return null; }; -const normalizeHeaders = (headers: AxiosLikeRequestConfig['headers']) => { - const rawHeaders = - headers && typeof headers === 'object' && 'toJSON' in headers && headers.toJSON - ? headers.toJSON() - : headers; - +const normalizeHeaders = ( + headers: NativeMultipartAxiosRequestConfig['headers'], +): Record => { + const rawHeaders = headers?.toJSON() ?? {}; const normalizedHeaders: Record = {}; Object.entries(rawHeaders ?? {}).forEach(([key, value]) => { @@ -91,7 +67,7 @@ const getFileNameFromUri = (uri: string) => uri.split('/').filter(Boolean).pop() const createNativeMultipartRequest = ( client: StreamChat, - config: AxiosLikeRequestConfig, + config: NativeMultipartAxiosRequestConfig, ): NativeMultipartUploadRequest | null => { const entries = getFormDataEntries(config.data); @@ -152,7 +128,7 @@ const toFiniteNumber = (value: unknown) => { return undefined; }; -const getUploadProgressCallbacks = (config: AxiosLikeRequestConfig) => { +const getUploadProgressCallbacks = (config: NativeMultipartAxiosRequestConfig) => { const callbacks = [config.onUploadProgress, config.uploadProgress].filter( (callback): callback is NonNullable => typeof callback === 'function', @@ -161,11 +137,23 @@ const getUploadProgressCallbacks = (config: AxiosLikeRequestConfig) => { return Array.from(new Set(callbacks)); }; -const createUploadProgressEvent = ({ loaded, total }: { loaded: unknown; total?: unknown }) => { +const createUploadProgressEvent = ({ + bytes, + loaded, + total, +}: { + bytes: unknown; + loaded: unknown; + total?: unknown; +}) => { + const normalizedBytes = toFiniteNumber(bytes) ?? 0; const normalizedLoaded = toFiniteNumber(loaded) ?? 0; const normalizedTotal = toFiniteNumber(total); return { + bytes: normalizedBytes, + download: false, + event: undefined, lengthComputable: typeof normalizedTotal === 'number' && normalizedTotal > 0, loaded: normalizedLoaded, progress: @@ -173,27 +161,28 @@ const createUploadProgressEvent = ({ loaded, total }: { loaded: unknown; total?: ? normalizedLoaded / normalizedTotal : undefined, total: normalizedTotal, + upload: true, }; }; -const nativeMultipartAxiosAdapter = async (config: AxiosLikeRequestConfig) => { - const request = ( - config as AxiosLikeRequestConfig & { - [STREAM_NATIVE_MULTIPART_REQUEST]?: NativeMultipartUploadRequest; - } - )[STREAM_NATIVE_MULTIPART_REQUEST]; - - if (!request) { - throw new Error('Missing native multipart upload request'); - } - +const nativeMultipartAxiosAdapter = async ( + request: NativeMultipartUploadRequest, + config: NativeMultipartAxiosRequestConfig, +) => { const uploadProgressCallbacks = getUploadProgressCallbacks(config); + let lastLoaded = 0; const response = await NativeHandlers.multipartUpload({ ...request, onProgress: uploadProgressCallbacks.length ? ({ loaded, total }) => { - const event = createUploadProgressEvent({ loaded, total }); + const normalizedLoaded = toFiniteNumber(loaded) ?? 0; + const event = createUploadProgressEvent({ + bytes: Math.max(0, normalizedLoaded - lastLoaded), + loaded: normalizedLoaded, + total, + }); + lastLoaded = normalizedLoaded; uploadProgressCallbacks.forEach((callback) => callback(event)); } : undefined, @@ -213,57 +202,53 @@ const nativeMultipartAxiosAdapter = async (config: AxiosLikeRequestConfig) => { }; }; -export const installNativeMultipartInterceptor = (client: StreamChat) => { - if (!isNativeMultipartUploadAvailable()) { - return () => undefined; - } - - const existing = installedInterceptors.get(client); +const resolveAxiosAdapter = (adapter: ResolvableAxiosAdapter): AxiosAdapter => + axios.getAdapter(adapter); - if (existing) { - existing.count += 1; - - return () => { - existing.count -= 1; - - if (existing.count === 0) { - client.axiosInstance.interceptors.request.eject(existing.interceptorId); - installedInterceptors.delete(client); - } - }; - } +const createNativeMultipartAwareAdapter = ( + client: StreamChat, + fallbackAdapter: ResolvableAxiosAdapter, +): AxiosAdapter => { + const resolvedFallbackAdapter = resolveAxiosAdapter(fallbackAdapter); - const interceptorId = client.axiosInstance.interceptors.request.use((config) => { + return (config) => { const nativeMultipartRequest = createNativeMultipartRequest( client, - config as AxiosLikeRequestConfig, + config as NativeMultipartAxiosRequestConfig, ); if (!nativeMultipartRequest) { - return config; + return resolvedFallbackAdapter(config); } - return { - ...config, - adapter: nativeMultipartAxiosAdapter, - [STREAM_NATIVE_MULTIPART_REQUEST]: nativeMultipartRequest, - }; - }); + return nativeMultipartAxiosAdapter(nativeMultipartRequest, config); + }; +}; - installedInterceptors.set(client, { count: 1, interceptorId }); +export const wrapAxiosAdapterWithNativeMultipart = ( + client: StreamChat, + fallbackAdapter: ResolvableAxiosAdapter, +): AxiosAdapter => { + if (!isNativeMultipartUploadAvailable()) { + return resolveAxiosAdapter(fallbackAdapter); + } - return () => { - const current = installedInterceptors.get(client); + return createNativeMultipartAwareAdapter(client, fallbackAdapter); +}; - if (!current) { - return; - } +export const installNativeMultipartAdapter = (client: StreamChat) => { + if (!isNativeMultipartUploadAvailable()) { + return; + } - current.count -= 1; + if (installedAdapters.has(client)) { + return; + } - if (current.count === 0) { - client.axiosInstance.interceptors.request.eject(current.interceptorId); - installedInterceptors.delete(client); - } - }; + const previousAdapter = client.axiosInstance.defaults.adapter; + client.axiosInstance.defaults.adapter = wrapAxiosAdapterWithNativeMultipart( + client, + previousAdapter, + ); + installedAdapters.add(client); }; From 9a6eeefcbbdaacec0bbbeac76101c9633050ed9e Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 21 Apr 2026 15:42:50 +0200 Subject: [PATCH 05/26] fix: file picking --- .../__tests__/pickDocument.test.ts | 88 +++++++++++++++++++ .../src/optionalDependencies/getPhotos.ts | 13 +-- .../src/optionalDependencies/pickDocument.ts | 40 ++++++--- .../__tests__/pickDocument.test.ts | 86 ++++++++++++++++++ .../src/optionalDependencies/pickDocument.ts | 30 +++++-- .../ios/StreamVideoThumbnailGenerator.swift | 15 +++- 6 files changed, 247 insertions(+), 25 deletions(-) create mode 100644 package/expo-package/src/optionalDependencies/__tests__/pickDocument.test.ts create mode 100644 package/native-package/src/optionalDependencies/__tests__/pickDocument.test.ts diff --git a/package/expo-package/src/optionalDependencies/__tests__/pickDocument.test.ts b/package/expo-package/src/optionalDependencies/__tests__/pickDocument.test.ts new file mode 100644 index 0000000000..69f82a534c --- /dev/null +++ b/package/expo-package/src/optionalDependencies/__tests__/pickDocument.test.ts @@ -0,0 +1,88 @@ +describe('expo pickDocument', () => { + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + it('adds a thumbnail for picked video files', async () => { + const generateThumbnails = jest.fn().mockResolvedValue({ + 'file:///video.mp4': { uri: 'file:///video-thumb.jpg' }, + }); + + jest.doMock( + 'expo-document-picker', + () => ({ + getDocumentAsync: jest.fn().mockResolvedValue({ + assets: [ + { + mimeType: 'video/mp4', + name: 'video.mp4', + uri: 'file:///video.mp4', + }, + ], + canceled: false, + }), + }), + { virtual: true }, + ); + jest.doMock('../generateThumbnail', () => ({ + generateThumbnails, + })); + + const { pickDocument } = require('../pickDocument'); + + await expect(pickDocument()).resolves.toEqual({ + assets: [ + { + mimeType: 'video/mp4', + name: 'video.mp4', + thumb_url: 'file:///video-thumb.jpg', + type: 'video/mp4', + uri: 'file:///video.mp4', + }, + ], + cancelled: false, + }); + expect(generateThumbnails).toHaveBeenCalledWith(['file:///video.mp4']); + }); + + it('does not generate thumbnails for non-video files', async () => { + const generateThumbnails = jest.fn().mockResolvedValue({}); + + jest.doMock( + 'expo-document-picker', + () => ({ + getDocumentAsync: jest.fn().mockResolvedValue({ + assets: [ + { + mimeType: 'application/pdf', + name: 'doc.pdf', + uri: 'file:///doc.pdf', + }, + ], + canceled: false, + }), + }), + { virtual: true }, + ); + jest.doMock('../generateThumbnail', () => ({ + generateThumbnails, + })); + + const { pickDocument } = require('../pickDocument'); + + await expect(pickDocument()).resolves.toEqual({ + assets: [ + { + mimeType: 'application/pdf', + name: 'doc.pdf', + thumb_url: undefined, + type: 'application/pdf', + uri: 'file:///doc.pdf', + }, + ], + cancelled: false, + }); + expect(generateThumbnails).toHaveBeenCalledWith([]); + }); +}); diff --git a/package/expo-package/src/optionalDependencies/getPhotos.ts b/package/expo-package/src/optionalDependencies/getPhotos.ts index 50a742e77e..0e4f2bd728 100644 --- a/package/expo-package/src/optionalDependencies/getPhotos.ts +++ b/package/expo-package/src/optionalDependencies/getPhotos.ts @@ -59,23 +59,26 @@ export const getPhotos = MediaLibrary const mimeType = mime.getType(asset.filename || asset.uri) || (asset.mediaType === MediaLibrary.MediaType.video ? 'video/*' : 'image/*'); - const uri = localUri || asset.uri; + const originalUri = asset.uri; + const uri = localUri || originalUri; return { asset, isVideo: asset.mediaType === MediaLibrary.MediaType.video, mimeType, + originalUri, uri, }; }), ); const videoUris = assetEntries - .filter(({ isVideo, uri }) => isVideo && !!uri) - .map(({ uri }) => uri); + .filter(({ isVideo, originalUri }) => isVideo && !!originalUri) + .map(({ originalUri }) => originalUri); const videoThumbnailResults = await generateThumbnails(videoUris); - const assets = assetEntries.map(({ asset, isVideo, mimeType, uri }) => { - const thumbnailResult = isVideo && uri ? videoThumbnailResults[uri] : undefined; + const assets = assetEntries.map(({ asset, isVideo, mimeType, originalUri, uri }) => { + const thumbnailResult = + isVideo && originalUri ? videoThumbnailResults[originalUri] : undefined; return { duration: asset.duration * 1000, diff --git a/package/expo-package/src/optionalDependencies/pickDocument.ts b/package/expo-package/src/optionalDependencies/pickDocument.ts index b906fcdbbf..0227bcbdcf 100644 --- a/package/expo-package/src/optionalDependencies/pickDocument.ts +++ b/package/expo-package/src/optionalDependencies/pickDocument.ts @@ -1,5 +1,7 @@ import mime from 'mime'; +import { generateThumbnails } from './generateThumbnail'; + let DocumentPicker; try { @@ -17,6 +19,20 @@ if (!DocumentPicker) { export const pickDocument = DocumentPicker ? async () => { try { + const addVideoThumbnails = async ( + assets: T[], + ) => { + const videoUris = assets + .filter(({ type, uri }) => type?.startsWith('video/') && !!uri) + .map(({ uri }) => uri as string); + const thumbnailResults = await generateThumbnails(videoUris); + + return assets.map((asset) => ({ + ...asset, + thumb_url: asset.uri ? thumbnailResults[asset.uri]?.uri || undefined : undefined, + })); + }; + const result = await DocumentPicker.getDocumentAsync(); // New data from latest version of expo-document-picker @@ -40,27 +56,27 @@ export const pickDocument = DocumentPicker // Applicable to latest version of expo-document-picker if (assets) { return { - assets: assets.map((asset) => ({ - ...asset, - type: - asset.mimeType || - mime.getType(asset.name || asset.uri) || - 'application/octet-stream', - })), + assets: await addVideoThumbnails( + assets.map((asset) => ({ + ...asset, + type: + asset.mimeType || + mime.getType(asset.name || asset.uri) || + 'application/octet-stream', + })), + ), cancelled: false, }; } // Applicable to older version of expo-document-picker return { - assets: [ + assets: await addVideoThumbnails([ { ...rest, type: - rest.mimeType || - mime.getType(rest.name || rest.uri) || - 'application/octet-stream', + rest.mimeType || mime.getType(rest.name || rest.uri) || 'application/octet-stream', }, - ], + ]), cancelled: false, }; } catch (err) { diff --git a/package/native-package/src/optionalDependencies/__tests__/pickDocument.test.ts b/package/native-package/src/optionalDependencies/__tests__/pickDocument.test.ts new file mode 100644 index 0000000000..50807cd8f0 --- /dev/null +++ b/package/native-package/src/optionalDependencies/__tests__/pickDocument.test.ts @@ -0,0 +1,86 @@ +describe('native pickDocument', () => { + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + it('adds a thumbnail for picked video files', async () => { + const generateThumbnails = jest.fn().mockResolvedValue({ + 'file:///video.mp4': { uri: 'file:///video-thumb.jpg' }, + }); + + jest.doMock( + '@react-native-documents/picker', + () => ({ + pick: jest.fn().mockResolvedValue([ + { + name: 'video.mp4', + size: 42, + type: 'video/mp4', + uri: 'file:///video.mp4', + }, + ]), + types: { allFiles: '*/*' }, + }), + { virtual: true }, + ); + jest.doMock('../generateThumbnail', () => ({ + generateThumbnails, + })); + + const { pickDocument } = require('../pickDocument'); + + await expect(pickDocument({ maxNumberOfFiles: 2 })).resolves.toEqual({ + assets: [ + { + name: 'video.mp4', + size: 42, + thumb_url: 'file:///video-thumb.jpg', + type: 'video/mp4', + uri: 'file:///video.mp4', + }, + ], + cancelled: false, + }); + expect(generateThumbnails).toHaveBeenCalledWith(['file:///video.mp4']); + }); + + it('does not generate thumbnails for non-video files', async () => { + const generateThumbnails = jest.fn().mockResolvedValue({}); + + jest.doMock( + '@react-native-documents/picker', + () => ({ + pick: jest.fn().mockResolvedValue([ + { + name: 'doc.pdf', + size: 42, + type: 'application/pdf', + uri: 'file:///doc.pdf', + }, + ]), + types: { allFiles: '*/*' }, + }), + { virtual: true }, + ); + jest.doMock('../generateThumbnail', () => ({ + generateThumbnails, + })); + + const { pickDocument } = require('../pickDocument'); + + await expect(pickDocument({ maxNumberOfFiles: 2 })).resolves.toEqual({ + assets: [ + { + name: 'doc.pdf', + size: 42, + thumb_url: undefined, + type: 'application/pdf', + uri: 'file:///doc.pdf', + }, + ], + cancelled: false, + }); + expect(generateThumbnails).toHaveBeenCalledWith([]); + }); +}); diff --git a/package/native-package/src/optionalDependencies/pickDocument.ts b/package/native-package/src/optionalDependencies/pickDocument.ts index 42aebe01bb..6ac60b9b60 100644 --- a/package/native-package/src/optionalDependencies/pickDocument.ts +++ b/package/native-package/src/optionalDependencies/pickDocument.ts @@ -3,6 +3,8 @@ * * For its full API, see https://github.com/react-native-documents/document-picker/blob/main/packages/document-picker/src/index.ts * */ +import { generateThumbnails } from './generateThumbnail'; + type ResponseValue = { name: string; size: number; @@ -31,6 +33,20 @@ try { export const pickDocument = DocumentPicker ? async ({ maxNumberOfFiles }: { maxNumberOfFiles: number }) => { try { + const addVideoThumbnails = async ( + assets: T[], + ) => { + const videoUris = assets + .filter(({ type, uri }) => type?.startsWith('video/') && !!uri) + .map(({ uri }) => uri as string); + const thumbnailResults = await generateThumbnails(videoUris); + + return assets.map((asset) => ({ + ...asset, + thumb_url: asset.uri ? thumbnailResults[asset.uri]?.uri || undefined : undefined, + })); + }; + if (!DocumentPicker) return { cancelled: true }; let res: ResponseValue[] = await DocumentPicker.pick({ allowMultiSelection: true, @@ -42,12 +58,14 @@ export const pickDocument = DocumentPicker } return { - assets: res.map(({ name, size, type, uri }) => ({ - name, - size, - type, - uri, - })), + assets: await addVideoThumbnails( + res.map(({ name, size, type, uri }) => ({ + name, + size, + type, + uri, + })), + ), cancelled: false, }; } catch (err) { diff --git a/package/shared-native/ios/StreamVideoThumbnailGenerator.swift b/package/shared-native/ios/StreamVideoThumbnailGenerator.swift index 71336dbe41..6b5fa51974 100644 --- a/package/shared-native/ios/StreamVideoThumbnailGenerator.swift +++ b/package/shared-native/ios/StreamVideoThumbnailGenerator.swift @@ -314,13 +314,24 @@ public final class StreamVideoThumbnailGenerator: NSObject { private static func normalizeLocalURL(_ url: String) -> URL? { if let parsedURL = URL(string: url), let scheme = parsedURL.scheme?.lowercased() { if scheme == "file" { - return parsedURL + return sanitizedFileURL(parsedURL) } return nil } - return URL(fileURLWithPath: url) + return sanitizedFileURL(URL(fileURLWithPath: url)) + } + + private static func sanitizedFileURL(_ url: URL) -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } + + components.fragment = nil + components.query = nil + + return components.url ?? url } private static func thumbnailError( From 8c2b9af5a9931274b36c8c8899f7494812e656fc Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 21 Apr 2026 23:28:00 +0200 Subject: [PATCH 06/26] fix: cancellation --- .../streamchatexpo/StreamChatExpoPackage.java | 9 +++--- .../StreamChatReactNativePackage.java | 11 ++++--- .../android/upload/StreamMultipartUploader.kt | 13 ++++++++ .../ios/StreamMultipartUploadBodyStream.swift | 13 ++++++-- .../ios/StreamMultipartUploadManager.swift | 30 +++++++++++++++++-- 5 files changed, 61 insertions(+), 15 deletions(-) diff --git a/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java index 7f620dadde..8f0d071417 100644 --- a/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java +++ b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java @@ -20,11 +20,11 @@ public class StreamChatExpoPackage extends TurboReactPackage { @Nullable @Override public NativeModule getModule(String name, ReactApplicationContext reactContext) { - if (name.equals(STREAM_MULTIPART_UPLOADER_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + if (name.equals(STREAM_MULTIPART_UPLOADER_MODULE)) { return createNewArchModule("com.streamchatexpo.StreamMultipartUploaderModule", reactContext); } - if (name.equals(STREAM_VIDEO_THUMBNAIL_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + if (name.equals(STREAM_VIDEO_THUMBNAIL_MODULE)) { return createNewArchModule("com.streamchatexpo.StreamVideoThumbnailModule", reactContext); } @@ -35,7 +35,6 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext) public ReactModuleInfoProvider getReactModuleInfoProvider() { return () -> { final Map moduleInfos = new HashMap<>(); - boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; moduleInfos.put( STREAM_MULTIPART_UPLOADER_MODULE, new ReactModuleInfo( @@ -45,7 +44,7 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // needsEagerInit false, // hasConstants false, // isCxxModule - isTurboModule // isTurboModule + true // isTurboModule )); moduleInfos.put( STREAM_VIDEO_THUMBNAIL_MODULE, @@ -56,7 +55,7 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // needsEagerInit false, // hasConstants false, // isCxxModule - isTurboModule // isTurboModule + true // isTurboModule )); return moduleInfos; }; diff --git a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java index 80d42e2224..fc3b5e060e 100644 --- a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java +++ b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java @@ -22,12 +22,12 @@ public class StreamChatReactNativePackage extends TurboReactPackage { public NativeModule getModule(String name, ReactApplicationContext reactContext) { if (name.equals(StreamChatReactNativeModule.NAME)) { return new StreamChatReactNativeModule(reactContext); - } else if (name.equals(STREAM_MULTIPART_UPLOADER_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + } else if (name.equals(STREAM_MULTIPART_UPLOADER_MODULE)) { return createNewArchModule( "com.streamchatreactnative.StreamMultipartUploaderModule", reactContext ); - } else if (name.equals(STREAM_VIDEO_THUMBNAIL_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + } else if (name.equals(STREAM_VIDEO_THUMBNAIL_MODULE)) { return createNewArchModule( "com.streamchatreactnative.StreamVideoThumbnailModule", reactContext @@ -41,7 +41,6 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext) public ReactModuleInfoProvider getReactModuleInfoProvider() { return () -> { final Map moduleInfos = new HashMap<>(); - boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; moduleInfos.put( StreamChatReactNativeModule.NAME, new ReactModuleInfo( @@ -51,7 +50,7 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // needsEagerInit true, // hasConstants false, // isCxxModule - isTurboModule // isTurboModule + true // isTurboModule )); moduleInfos.put( STREAM_MULTIPART_UPLOADER_MODULE, @@ -62,7 +61,7 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // needsEagerInit false, // hasConstants false, // isCxxModule - isTurboModule // isTurboModule + true // isTurboModule )); moduleInfos.put( STREAM_VIDEO_THUMBNAIL_MODULE, @@ -73,7 +72,7 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // needsEagerInit false, // hasConstants false, // isCxxModule - isTurboModule // isTurboModule + true // isTurboModule )); return moduleInfos; }; diff --git a/package/shared-native/android/upload/StreamMultipartUploader.kt b/package/shared-native/android/upload/StreamMultipartUploader.kt index 7ef28cc77a..f92979d2b4 100644 --- a/package/shared-native/android/upload/StreamMultipartUploader.kt +++ b/package/shared-native/android/upload/StreamMultipartUploader.kt @@ -6,13 +6,16 @@ import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import java.io.InterruptedIOException import java.util.concurrent.ConcurrentHashMap object StreamMultipartUploader { private val client: OkHttpClient = OkHttpClient.Builder().retryOnConnectionFailure(true).build() + private val cancelledUploadIds = ConcurrentHashMap.newKeySet() private val inFlightCalls = ConcurrentHashMap() fun cancel(uploadId: String) { + cancelledUploadIds.add(uploadId) inFlightCalls.remove(uploadId)?.cancel() } @@ -21,11 +24,20 @@ object StreamMultipartUploader { request: StreamMultipartUploadRequest, onProgress: (loaded: Long, total: Long?) -> Unit, ): StreamMultipartUploadResponse { + if (cancelledUploadIds.contains(request.uploadId)) { + cancelledUploadIds.remove(request.uploadId) + throw InterruptedIOException("Request aborted") + } + val httpRequest = createRequest(context, request, onProgress) val call = client.newCall(httpRequest) inFlightCalls[request.uploadId] = call try { + if (cancelledUploadIds.remove(request.uploadId)) { + call.cancel() + } + call.execute().use { response -> return StreamMultipartUploadResponse( body = response.body?.string().orEmpty(), @@ -39,6 +51,7 @@ object StreamMultipartUploader { } } finally { inFlightCalls.remove(request.uploadId) + cancelledUploadIds.remove(request.uploadId) } } diff --git a/package/shared-native/ios/StreamMultipartUploadBodyStream.swift b/package/shared-native/ios/StreamMultipartUploadBodyStream.swift index a556254842..faffb0726e 100644 --- a/package/shared-native/ios/StreamMultipartUploadBodyStream.swift +++ b/package/shared-native/ios/StreamMultipartUploadBodyStream.swift @@ -69,7 +69,7 @@ final class StreamMultipartUploadBodyStreamFactory { private static func multipartTextData(boundary: String, part: StreamMultipartTextPart) -> Data { let payload = [ "--\(boundary)", - "Content-Disposition: form-data; name=\"\(part.fieldName)\"", + "Content-Disposition: form-data; name=\(multipartQuotedParameter(part.fieldName))", "", part.value, "", @@ -84,13 +84,22 @@ final class StreamMultipartUploadBodyStreamFactory { ) -> Data { let payload = [ "--\(boundary)", - "Content-Disposition: form-data; name=\"\(part.fieldName)\"; filename=\"\(part.fileName)\"", + "Content-Disposition: form-data; name=\(multipartQuotedParameter(part.fieldName)); filename=\(multipartQuotedParameter(part.fileName))", "Content-Type: \(part.mimeType)", "", ].joined(separator: "\r\n") + "\r\n" return payload.data(using: .utf8) ?? Data() } + + private static func multipartQuotedParameter(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\r", with: "%0D") + .replacingOccurrences(of: "\n", with: "%0A") + .replacingOccurrences(of: "\"", with: "%22") + + return "\"\(escaped)\"" + } } private final class StreamMultipartSequentialInputStream: InputStream { diff --git a/package/shared-native/ios/StreamMultipartUploadManager.swift b/package/shared-native/ios/StreamMultipartUploadManager.swift index b8ddfeb516..ff015e8606 100644 --- a/package/shared-native/ios/StreamMultipartUploadManager.swift +++ b/package/shared-native/ios/StreamMultipartUploadManager.swift @@ -37,11 +37,13 @@ final class StreamMultipartUploadManager: NSObject { }() private let lock = NSLock() + private var cancelledUploadIds = Set() private var statesByTaskIdentifier = [Int: StreamMultipartUploadTaskState]() private var taskIdentifiersByUploadId = [String: Int]() func cancel(uploadId: String) { lock.lock() + cancelledUploadIds.insert(uploadId) let taskIdentifier = taskIdentifiersByUploadId[uploadId] let task: URLSessionUploadTask? if let taskIdentifier { @@ -72,7 +74,9 @@ final class StreamMultipartUploadManager: NSObject { progress: progress ) + try throwIfCancelled(uploadId: uploadId) let bodyFactory = try await StreamMultipartUploadBodyStreamFactory.create(parts: request.parts) + try throwIfCancelled(uploadId: uploadId) var urlRequest = URLRequest(url: request.url) urlRequest.httpMethod = request.method @@ -109,7 +113,12 @@ final class StreamMultipartUploadManager: NSObject { continuation.resume(with: result) } - register(state) + guard register(state) else { + task.cancel() + continuation.resume(throwing: StreamMultipartUploadError.cancelled) + return + } + task.resume() } } @@ -197,11 +206,27 @@ final class StreamMultipartUploadManager: NSObject { ) } - private func register(_ state: StreamMultipartUploadTaskState) { + private func throwIfCancelled(uploadId: String) throws { + lock.lock() + let wasCancelled = cancelledUploadIds.remove(uploadId) != nil + lock.unlock() + + if wasCancelled { + throw StreamMultipartUploadError.cancelled + } + } + + private func register(_ state: StreamMultipartUploadTaskState) -> Bool { lock.lock() + if cancelledUploadIds.remove(state.uploadId) != nil { + lock.unlock() + return false + } + statesByTaskIdentifier[state.task.taskIdentifier] = state taskIdentifiersByUploadId[state.uploadId] = state.task.taskIdentifier lock.unlock() + return true } private func removeState(taskIdentifier: Int) -> StreamMultipartUploadTaskState? { @@ -209,6 +234,7 @@ final class StreamMultipartUploadManager: NSObject { let state = statesByTaskIdentifier.removeValue(forKey: taskIdentifier) if let uploadId = state?.uploadId { taskIdentifiersByUploadId.removeValue(forKey: uploadId) + cancelledUploadIds.remove(uploadId) } lock.unlock() return state From eeb0d4f4e863ce2fbdb3cb8da43ed89424aee7b3 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 21 Apr 2026 23:56:41 +0200 Subject: [PATCH 07/26] fix: too fast upload progress ui glitch --- .../src/components/Attachment/Attachment.tsx | 1 + .../Attachment/CircularProgressIndicator.tsx | 94 ++++++++++++------- .../AttachmentUploadPreviewList.test.js | 1 + .../AudioAttachmentUploadPreview.test.js | 1 + 4 files changed, 64 insertions(+), 33 deletions(-) diff --git a/package/src/components/Attachment/Attachment.tsx b/package/src/components/Attachment/Attachment.tsx index 75c8c8e745..7831e1db51 100644 --- a/package/src/components/Attachment/Attachment.tsx +++ b/package/src/components/Attachment/Attachment.tsx @@ -13,6 +13,7 @@ import { } from 'stream-chat'; import type { AudioAttachmentProps } from './Audio/AudioAttachment'; + import { AttachmentFileUploadProgressIndicator } from '../../components/Attachment/AttachmentFileUploadProgressIndicator'; import { useTheme } from '../../contexts'; diff --git a/package/src/components/Attachment/CircularProgressIndicator.tsx b/package/src/components/Attachment/CircularProgressIndicator.tsx index 18d9f4b6a3..26d650d7bb 100644 --- a/package/src/components/Attachment/CircularProgressIndicator.tsx +++ b/package/src/components/Attachment/CircularProgressIndicator.tsx @@ -1,8 +1,20 @@ -import React, { useEffect, useMemo, useRef } from 'react'; -import type { ColorValue } from 'react-native'; -import { Animated, Easing, StyleProp, ViewStyle } from 'react-native'; +import React, { useEffect, useMemo } from 'react'; +import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; +import Animated, { + cancelAnimation, + Easing, + useAnimatedProps, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; import Svg, { Circle } from 'react-native-svg'; +const AnimatedCircle = Animated.createAnimatedComponent(Circle); +const SPIN_DURATION_MS = 900; +const PROGRESS_ANIMATION_DURATION_MS = 1200; + export type CircularProgressIndicatorProps = { /** Upload percent **0–100**. */ progress: number; @@ -24,32 +36,8 @@ export const CircularProgressIndicator = ({ 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 animatedProgress = useSharedValue(0); + const rotation = useSharedValue(0); const { cx, cy, r, circumference } = useMemo(() => { const pad = strokeWidth / 2; @@ -67,18 +55,58 @@ export const CircularProgressIndicator = ({ ? undefined : Math.min(100, Math.max(0, progress)) / 100; + useEffect(() => { + if (fraction === undefined) { + animatedProgress.value = 0; + return; + } + + animatedProgress.value = withTiming(fraction, { + duration: PROGRESS_ANIMATION_DURATION_MS, + easing: Easing.out(Easing.cubic), + }); + }, [animatedProgress, fraction]); + + useEffect(() => { + if (fraction !== undefined) { + cancelAnimation(rotation); + rotation.value = 0; + return; + } + + rotation.value = withRepeat( + withTiming(360, { + duration: SPIN_DURATION_MS, + easing: Easing.linear, + }), + -1, + false, + ); + + return () => { + cancelAnimation(rotation); + }; + }, [fraction, rotation]); + + const animatedCircleProps = useAnimatedProps(() => ({ + strokeDashoffset: circumference * (1 - animatedProgress.value), + })); + + const animatedSpinStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + if (fraction !== undefined) { - const offset = circumference * (1 - fraction); return ( - diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js index d5b9adf7b6..77a46ea529 100644 --- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js @@ -24,6 +24,7 @@ jest.mock('../../../native.ts', () => { isDocumentPickerAvailable: jest.fn(() => true), isImageMediaLibraryAvailable: jest.fn(() => true), isImagePickerAvailable: jest.fn(() => true), + isNativeMultipartUploadAvailable: jest.fn(() => false), isSoundPackageAvailable: jest.fn(() => false), NativeHandlers: { Sound: { diff --git a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js index 8eaad78233..1c6bbb8d2a 100644 --- a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js +++ b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js @@ -19,6 +19,7 @@ jest.mock('../../../native.ts', () => { isDocumentPickerAvailable: jest.fn(() => true), isImageMediaLibraryAvailable: jest.fn(() => true), isImagePickerAvailable: jest.fn(() => true), + isNativeMultipartUploadAvailable: jest.fn(() => false), isSoundPackageAvailable: jest.fn(() => true), NativeHandlers: { Sound: { From 878dc6a44d550845e063d6d487db90c1f8ad280d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 22 Apr 2026 00:47:17 +0200 Subject: [PATCH 08/26] fix: upload progress animations --- .../usePendingAttachmentUpload.test.tsx | 99 ++++++++++++++++ .../src/hooks/usePendingAttachmentUpload.ts | 86 +++++++++++++- package/src/native.ts | 7 ++ .../installNativeMultipartAdapter.test.ts | 106 ++++++++++++++++++ .../utils/installNativeMultipartAdapter.ts | 57 +++++++++- 5 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 package/src/hooks/__tests__/usePendingAttachmentUpload.test.tsx diff --git a/package/src/hooks/__tests__/usePendingAttachmentUpload.test.tsx b/package/src/hooks/__tests__/usePendingAttachmentUpload.test.tsx new file mode 100644 index 0000000000..29c782c680 --- /dev/null +++ b/package/src/hooks/__tests__/usePendingAttachmentUpload.test.tsx @@ -0,0 +1,99 @@ +import React, { PropsWithChildren } from 'react'; + +import { act, renderHook } from '@testing-library/react-native'; +import { StateStore } from 'stream-chat'; + +import { ChatProvider } from '../../contexts/chatContext/ChatContext'; +import { usePendingAttachmentUpload } from '../usePendingAttachmentUpload'; + +type UploadManagerState = { + uploads: Array<{ + id: string; + uploadProgress?: number; + }>; +}; + +const createWrapper = (state: StateStore) => { + const client = { + uploadManager: { + state, + }, + }; + + return ({ children }: PropsWithChildren) => ( + {children} + ); +}; + +describe('usePendingAttachmentUpload', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('briefly holds completed upload progress after a ready upload record disappears', () => { + const state = new StateStore({ uploads: [] }); + const { result } = renderHook(() => usePendingAttachmentUpload('upload-id'), { + wrapper: createWrapper(state), + }); + + expect(result.current).toEqual({ + isUploading: false, + uploadProgress: undefined, + }); + + act(() => { + state.partialNext({ + uploads: [{ id: 'upload-id', uploadProgress: 90 }], + }); + }); + + expect(result.current).toEqual({ + isUploading: true, + uploadProgress: 90, + }); + + act(() => { + state.partialNext({ uploads: [] }); + }); + + expect(result.current).toEqual({ + isUploading: true, + uploadProgress: 100, + }); + + act(() => { + jest.advanceTimersByTime(350); + }); + + expect(result.current).toEqual({ + isUploading: false, + uploadProgress: undefined, + }); + }); + + it('does not hold completed progress when an upload record disappears before reaching the ready threshold', () => { + const state = new StateStore({ uploads: [] }); + const { result } = renderHook(() => usePendingAttachmentUpload('upload-id'), { + wrapper: createWrapper(state), + }); + + act(() => { + state.partialNext({ + uploads: [{ id: 'upload-id', uploadProgress: 50 }], + }); + }); + + act(() => { + state.partialNext({ uploads: [] }); + }); + + expect(result.current).toEqual({ + isUploading: false, + uploadProgress: undefined, + }); + }); +}); diff --git a/package/src/hooks/usePendingAttachmentUpload.ts b/package/src/hooks/usePendingAttachmentUpload.ts index efcd145885..048e8e0a19 100644 --- a/package/src/hooks/usePendingAttachmentUpload.ts +++ b/package/src/hooks/usePendingAttachmentUpload.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { UploadManagerState } from 'stream-chat'; @@ -21,11 +21,28 @@ const idle: PendingAttachmentUpload = { uploadProgress: undefined, }; +const completed: PendingAttachmentUpload = { + isUploading: true, + uploadProgress: 100, +}; + +const COMPLETION_HOLD_MS = 350; +const COMPLETION_READY_PROGRESS = 90; + +const now = () => Date.now(); + /** * Subscribes to `client.uploadManager` for the pending attachment identified by `localId`. */ export function usePendingAttachmentUpload(localId: string | undefined): PendingAttachmentUpload { const { client } = useChatContext(); + const [, setRenderTick] = useState(0); + const completedHoldUntilRef = useRef(0); + const holdTimeoutRef = useRef | undefined>(undefined); + const lastUploadProgressRef = useRef(undefined); + const previousLocalIdRef = useRef(localId); + const wasUploadingRef = useRef(false); + const selector = useCallback( (state: UploadManagerState): PendingAttachmentUpload => { if (!localId) { @@ -44,6 +61,71 @@ export function usePendingAttachmentUpload(localId: string | undefined): Pending ); const result = useStateStore(localId ? client.uploadManager.state : undefined, selector); + const isUploading = result?.isUploading ?? false; + const uploadProgress = result?.uploadProgress; + + if (previousLocalIdRef.current !== localId) { + previousLocalIdRef.current = localId; + completedHoldUntilRef.current = 0; + wasUploadingRef.current = false; + lastUploadProgressRef.current = undefined; + } + + let pendingAttachmentUpload = result ?? idle; + if (localId && isUploading) { + completedHoldUntilRef.current = 0; + wasUploadingRef.current = true; + if (typeof uploadProgress === 'number') { + lastUploadProgressRef.current = uploadProgress; + } + } else if (localId && completedHoldUntilRef.current > now()) { + pendingAttachmentUpload = completed; + } else if (localId) { + const shouldStartCompletionHold = + wasUploadingRef.current && + typeof lastUploadProgressRef.current === 'number' && + lastUploadProgressRef.current >= COMPLETION_READY_PROGRESS; + + wasUploadingRef.current = false; + lastUploadProgressRef.current = undefined; + + if (shouldStartCompletionHold) { + completedHoldUntilRef.current = now() + COMPLETION_HOLD_MS; + pendingAttachmentUpload = completed; + } else { + completedHoldUntilRef.current = 0; + } + } else { + completedHoldUntilRef.current = 0; + wasUploadingRef.current = false; + lastUploadProgressRef.current = undefined; + } + + useEffect(() => { + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current); + holdTimeoutRef.current = undefined; + } + + const holdForMs = completedHoldUntilRef.current - now(); + if (holdForMs <= 0) { + return; + } + + holdTimeoutRef.current = setTimeout(() => { + holdTimeoutRef.current = undefined; + setRenderTick((tick) => tick + 1); + }, holdForMs); + }, [localId, pendingAttachmentUpload]); + + useEffect( + () => () => { + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current); + } + }, + [], + ); - return result ?? idle; + return pendingAttachmentUpload; } diff --git a/package/src/native.ts b/package/src/native.ts index 47de83290b..8487467305 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -62,6 +62,13 @@ export type NativeMultipartUploadPart = }; export type NativeMultipartUploadProgressConfig = { + /** + * Maximum progress percentage reported while the native request body is still being sent. + * Completion is represented by the upload request resolving and the upload indicator being removed. + * + * @default 90 + */ + completionProgressCap?: number; count?: number; intervalMs?: number; }; diff --git a/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts index 58b701a810..5e9e54bc83 100644 --- a/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts +++ b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts @@ -181,6 +181,112 @@ describe('installNativeMultipartAdapter', () => { ); }); + it('caps native multipart body progress to 90 percent while waiting for the response', async () => { + NativeHandlers.multipartUpload = jest.fn().mockImplementation(({ onProgress }) => { + onProgress?.({ + loaded: 100, + total: 100, + }); + + return { + body: JSON.stringify({ file: 'https://example.com/file.jpg' }), + headers: { 'content-type': 'application/json' }, + status: 200, + statusText: 'OK', + }; + }); + + const client = getTestClient(); + preserveRequestData(client); + installNativeMultipartAdapter(client); + const onUploadProgress = jest.fn(); + const formData = { + _parts: [ + [ + 'file', + { + name: 'test.jpg', + type: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }, + ], + ], + }; + + await client.axiosInstance.post('/uploads/image', formData, { onUploadProgress }); + + expect(onUploadProgress).toHaveBeenCalledTimes(1); + expect(onUploadProgress).toHaveBeenCalledWith( + expect.objectContaining({ + bytes: 90, + lengthComputable: true, + loaded: 90, + progress: 0.9, + total: 100, + }), + ); + }); + + it('allows overriding the native multipart completion progress cap', async () => { + NativeHandlers.multipartUpload = jest.fn().mockImplementation(({ onProgress }) => { + onProgress?.({ + loaded: 100, + total: 100, + }); + + return { + body: JSON.stringify({ file: 'https://example.com/file.jpg' }), + headers: { 'content-type': 'application/json' }, + status: 200, + statusText: 'OK', + }; + }); + + const client = getTestClient(); + preserveRequestData(client); + installNativeMultipartAdapter(client); + const onUploadProgress = jest.fn(); + const formData = { + _parts: [ + [ + 'file', + { + name: 'test.jpg', + type: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }, + ], + ], + }; + + await client.axiosInstance.post('/uploads/image', formData, { + onUploadProgress, + uploadProgressOptions: { + completionProgressCap: 75, + count: 10, + intervalMs: 25, + }, + }); + + expect(onUploadProgress).toHaveBeenCalledTimes(1); + expect(onUploadProgress).toHaveBeenCalledWith( + expect.objectContaining({ + bytes: 75, + loaded: 75, + progress: 0.75, + total: 100, + }), + ); + expect(NativeHandlers.multipartUpload).toHaveBeenCalledWith( + expect.objectContaining({ + progress: { + count: 10, + intervalMs: 25, + }, + }), + ); + }); + it('uses the final config after user request interceptors run', async () => { const client = getTestClient(); preserveRequestData(client); diff --git a/package/src/utils/installNativeMultipartAdapter.ts b/package/src/utils/installNativeMultipartAdapter.ts index b151681fdd..2987e6b774 100644 --- a/package/src/utils/installNativeMultipartAdapter.ts +++ b/package/src/utils/installNativeMultipartAdapter.ts @@ -26,6 +26,8 @@ type NativeMultipartAxiosRequestConfig = InternalAxiosRequestConfig & { type ResolvableAxiosAdapter = Parameters[0]; +const DEFAULT_COMPLETION_PROGRESS_CAP = 90; + const installedAdapters = new WeakSet(); const getFormDataEntries = (data: unknown): [string, FormDataPartValue][] | null => { @@ -65,6 +67,19 @@ const normalizeHeaders = ( const getFileNameFromUri = (uri: string) => uri.split('/').filter(Boolean).pop() || 'file'; +const getNativeProgressOptions = ( + progress?: NativeMultipartUploadProgressConfig, +): NativeMultipartUploadProgressConfig | undefined => { + if (!progress) { + return undefined; + } + + const nativeProgressOptions = { ...progress }; + delete nativeProgressOptions.completionProgressCap; + + return Object.keys(nativeProgressOptions).length ? nativeProgressOptions : undefined; +}; + const createNativeMultipartRequest = ( client: StreamChat, config: NativeMultipartAxiosRequestConfig, @@ -109,7 +124,7 @@ const createNativeMultipartRequest = ( headers: normalizeHeaders(config.headers), method: (config.method || 'POST').toUpperCase(), parts, - progress: config.uploadProgressOptions, + progress: getNativeProgressOptions(config.uploadProgressOptions), signal: config.signal, url: client.axiosInstance.getUri(config), }; @@ -128,6 +143,31 @@ const toFiniteNumber = (value: unknown) => { return undefined; }; +const getCompletionProgressCap = (config: NativeMultipartAxiosRequestConfig) => { + const cap = toFiniteNumber(config.uploadProgressOptions?.completionProgressCap); + if (cap === undefined) { + return DEFAULT_COMPLETION_PROGRESS_CAP; + } + + return Math.min(100, Math.max(0, cap)); +}; + +const getDisplayLoaded = ({ + completionProgressCap, + loaded, + total, +}: { + completionProgressCap: number; + loaded: number; + total?: number; +}) => { + if (typeof total !== 'number' || total <= 0) { + return loaded; + } + + return Math.min(loaded, total * (completionProgressCap / 100)); +}; + const getUploadProgressCallbacks = (config: NativeMultipartAxiosRequestConfig) => { const callbacks = [config.onUploadProgress, config.uploadProgress].filter( (callback): callback is NonNullable => @@ -170,6 +210,7 @@ const nativeMultipartAxiosAdapter = async ( config: NativeMultipartAxiosRequestConfig, ) => { const uploadProgressCallbacks = getUploadProgressCallbacks(config); + const completionProgressCap = getCompletionProgressCap(config); let lastLoaded = 0; const response = await NativeHandlers.multipartUpload({ @@ -177,12 +218,18 @@ const nativeMultipartAxiosAdapter = async ( onProgress: uploadProgressCallbacks.length ? ({ loaded, total }) => { const normalizedLoaded = toFiniteNumber(loaded) ?? 0; - const event = createUploadProgressEvent({ - bytes: Math.max(0, normalizedLoaded - lastLoaded), + const normalizedTotal = toFiniteNumber(total); + const displayLoaded = getDisplayLoaded({ + completionProgressCap, loaded: normalizedLoaded, - total, + total: normalizedTotal, + }); + const event = createUploadProgressEvent({ + bytes: Math.max(0, displayLoaded - lastLoaded), + loaded: displayLoaded, + total: normalizedTotal, }); - lastLoaded = normalizedLoaded; + lastLoaded = displayLoaded; uploadProgressCallbacks.forEach((callback) => callback(event)); } : undefined, From 63e1c23ecbe3ab98209beaf136a877bc774bc062 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 22 Apr 2026 02:54:55 +0200 Subject: [PATCH 09/26] fix: cleanup and edge cases --- .../StreamMultipartUploaderModule.kt | 101 +++++++++----- .../native/NativeStreamMultipartUploader.ts | 1 + .../src/native/multipartUploader.ts | 3 + .../optionalDependencies/multipartUpload.ts | 2 + .../StreamMultipartUploaderModule.kt | 101 +++++++++----- .../native/NativeStreamMultipartUploader.ts | 1 + .../src/native/multipartUploader.ts | 3 + .../optionalDependencies/multipartUpload.ts | 2 + .../upload/StreamMultipartUploadModels.kt | 1 + .../upload/StreamMultipartUploadProgress.kt | 9 +- .../StreamMultipartUploadRequestParser.kt | 6 +- .../android/upload/StreamMultipartUploader.kt | 31 +++- .../ios/StreamMultipartUploadBodyStream.swift | 9 ++ .../ios/StreamMultipartUploadManager.swift | 28 +++- .../ios/StreamMultipartUploadModels.swift | 7 + .../ios/StreamMultipartUploadProgress.swift | 4 +- .../StreamMultipartUploadSourceResolver.swift | 132 ++++++++++++++---- .../ios/StreamMultipartUploader.mm | 6 +- .../ios/StreamMultipartUploaderBridge.swift | 66 ++++++++- package/src/native.ts | 1 + .../installNativeMultipartAdapter.test.ts | 2 + .../utils/installNativeMultipartAdapter.ts | 1 + 22 files changed, 399 insertions(+), 118 deletions(-) diff --git a/package/expo-package/android/src/newarch/com/streamchatexpo/StreamMultipartUploaderModule.kt b/package/expo-package/android/src/newarch/com/streamchatexpo/StreamMultipartUploaderModule.kt index b0f4ceccb7..11ec5fc4af 100644 --- a/package/expo-package/android/src/newarch/com/streamchatexpo/StreamMultipartUploaderModule.kt +++ b/package/expo-package/android/src/newarch/com/streamchatexpo/StreamMultipartUploaderModule.kt @@ -4,10 +4,14 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.modules.core.DeviceEventManagerModule import com.streamchatreactnative.shared.upload.StreamMultipartUploadRequestParser import com.streamchatreactnative.shared.upload.StreamMultipartUploader -import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit class StreamMultipartUploaderModule( reactContext: ReactApplicationContext, @@ -29,65 +33,90 @@ class StreamMultipartUploaderModule( method: String, headers: ReadableArray, parts: ReadableArray, - progress: com.facebook.react.bridge.ReadableMap?, + progress: ReadableMap?, + timeoutMs: Double?, promise: Promise, ) { - executor.execute { + val request = try { - val request = StreamMultipartUploadRequestParser.parse( + StreamMultipartUploadRequestParser.parse( uploadId = uploadId, url = url, method = method, headers = headers, parts = parts, progress = progress, + timeoutMs = timeoutMs, ) - val response = - StreamMultipartUploader.upload(reactApplicationContext, request) { loaded, total -> - emitProgress(uploadId, loaded, total) - } + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) + return + } - val payload = Arguments.createMap().apply { - putString("body", response.body) - putArray("headers", Arguments.createArray().apply { - response.headers.forEach { (name, value) -> - pushMap( - Arguments.createMap().apply { - putString("name", name) - putString("value", value) - }, - ) + try { + executor.execute { + try { + val response = + StreamMultipartUploader.upload(reactApplicationContext, request) { loaded, total -> + emitProgress(uploadId, loaded, total) } - }) - putDouble("status", response.status.toDouble()) - putString("statusText", response.statusText) + + val payload = Arguments.createMap().apply { + putString("body", response.body) + putArray("headers", Arguments.createArray().apply { + response.headers.forEach { (name, value) -> + pushMap( + Arguments.createMap().apply { + putString("name", name) + putString("value", value) + }, + ) + } + }) + putDouble("status", response.status.toDouble()) + putString("statusText", response.statusText) + } + promise.resolve(payload) + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) } - promise.resolve(payload) - } catch (error: Throwable) { - promise.reject("stream_multipart_upload_error", error.message, error) } + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) } } private fun emitProgress(uploadId: String, loaded: Long, total: Long?) { - val payload = Arguments.createMap().apply { - putDouble("loaded", loaded.toDouble()) - if (total != null) { - putDouble("total", total.toDouble()) - } else { - putNull("total") + UiThreadUtil.runOnUiThread { + val payload = Arguments.createMap().apply { + putDouble("loaded", loaded.toDouble()) + if (total != null) { + putDouble("total", total.toDouble()) + } else { + putNull("total") + } + putString("uploadId", uploadId) } - putString("uploadId", uploadId) - } - reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit(PROGRESS_EVENT_NAME, payload) + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(PROGRESS_EVENT_NAME, payload) + } } companion object { const val NAME = "StreamMultipartUploader" private const val PROGRESS_EVENT_NAME = "streamMultipartUploadProgress" - private val executor = Executors.newCachedThreadPool() + private val maxConcurrentUploads = Runtime.getRuntime().availableProcessors().coerceIn(2, 4) + private val executor = + ThreadPoolExecutor( + maxConcurrentUploads, + maxConcurrentUploads, + 30L, + TimeUnit.SECONDS, + LinkedBlockingQueue(64), + ).apply { + allowCoreThreadTimeOut(true) + } } } diff --git a/package/expo-package/src/native/NativeStreamMultipartUploader.ts b/package/expo-package/src/native/NativeStreamMultipartUploader.ts index d8f6aa01ca..154d12235a 100644 --- a/package/expo-package/src/native/NativeStreamMultipartUploader.ts +++ b/package/expo-package/src/native/NativeStreamMultipartUploader.ts @@ -45,6 +45,7 @@ export interface Spec extends TurboModule { headers: ReadonlyArray, parts: ReadonlyArray, progress?: UploadProgressConfig | null, + timeoutMs?: number | null, ): Promise; } diff --git a/package/expo-package/src/native/multipartUploader.ts b/package/expo-package/src/native/multipartUploader.ts index bc58b7eee4..718f37d517 100644 --- a/package/expo-package/src/native/multipartUploader.ts +++ b/package/expo-package/src/native/multipartUploader.ts @@ -43,6 +43,7 @@ type MultipartUploadRequest = { parts: UploadPart[]; progress?: UploadProgressConfig; signal?: NativeMultipartAbortSignal; + timeoutMs?: number; uploadId: string; url: string; }; @@ -58,6 +59,7 @@ export const uploadMultipart = async ({ parts, progress, signal, + timeoutMs, uploadId, url, }: MultipartUploadRequest): Promise => { @@ -118,6 +120,7 @@ export const uploadMultipart = async ({ toUploadHeaders(headers), parts, progress ?? {}, + timeoutMs, ); if (signal?.aborted) { diff --git a/package/expo-package/src/optionalDependencies/multipartUpload.ts b/package/expo-package/src/optionalDependencies/multipartUpload.ts index 0a6e6a1954..3298bf067d 100644 --- a/package/expo-package/src/optionalDependencies/multipartUpload.ts +++ b/package/expo-package/src/optionalDependencies/multipartUpload.ts @@ -41,6 +41,7 @@ export const multipartUpload = async ({ parts, progress, signal, + timeoutMs, url, }: NativeMultipartUploadRequest) => { const resolvedParts = await Promise.all(parts.map(resolvePartUri)); @@ -52,6 +53,7 @@ export const multipartUpload = async ({ parts: resolvedParts, progress, signal, + timeoutMs, uploadId: `stream-upload-${Date.now()}-${Math.random().toString(16).slice(2)}`, url, }); diff --git a/package/native-package/android/src/newarch/com/streamchatreactnative/StreamMultipartUploaderModule.kt b/package/native-package/android/src/newarch/com/streamchatreactnative/StreamMultipartUploaderModule.kt index 7d3ab1d7f0..006fb4282d 100644 --- a/package/native-package/android/src/newarch/com/streamchatreactnative/StreamMultipartUploaderModule.kt +++ b/package/native-package/android/src/newarch/com/streamchatreactnative/StreamMultipartUploaderModule.kt @@ -4,10 +4,14 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.modules.core.DeviceEventManagerModule import com.streamchatreactnative.shared.upload.StreamMultipartUploadRequestParser import com.streamchatreactnative.shared.upload.StreamMultipartUploader -import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit class StreamMultipartUploaderModule( reactContext: ReactApplicationContext, @@ -29,65 +33,90 @@ class StreamMultipartUploaderModule( method: String, headers: ReadableArray, parts: ReadableArray, - progress: com.facebook.react.bridge.ReadableMap?, + progress: ReadableMap?, + timeoutMs: Double?, promise: Promise, ) { - executor.execute { + val request = try { - val request = StreamMultipartUploadRequestParser.parse( + StreamMultipartUploadRequestParser.parse( uploadId = uploadId, url = url, method = method, headers = headers, parts = parts, progress = progress, + timeoutMs = timeoutMs, ) - val response = - StreamMultipartUploader.upload(reactApplicationContext, request) { loaded, total -> - emitProgress(uploadId, loaded, total) - } + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) + return + } - val payload = Arguments.createMap().apply { - putString("body", response.body) - putArray("headers", Arguments.createArray().apply { - response.headers.forEach { (name, value) -> - pushMap( - Arguments.createMap().apply { - putString("name", name) - putString("value", value) - }, - ) + try { + executor.execute { + try { + val response = + StreamMultipartUploader.upload(reactApplicationContext, request) { loaded, total -> + emitProgress(uploadId, loaded, total) } - }) - putDouble("status", response.status.toDouble()) - putString("statusText", response.statusText) + + val payload = Arguments.createMap().apply { + putString("body", response.body) + putArray("headers", Arguments.createArray().apply { + response.headers.forEach { (name, value) -> + pushMap( + Arguments.createMap().apply { + putString("name", name) + putString("value", value) + }, + ) + } + }) + putDouble("status", response.status.toDouble()) + putString("statusText", response.statusText) + } + promise.resolve(payload) + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) } - promise.resolve(payload) - } catch (error: Throwable) { - promise.reject("stream_multipart_upload_error", error.message, error) } + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) } } private fun emitProgress(uploadId: String, loaded: Long, total: Long?) { - val payload = Arguments.createMap().apply { - putDouble("loaded", loaded.toDouble()) - if (total != null) { - putDouble("total", total.toDouble()) - } else { - putNull("total") + UiThreadUtil.runOnUiThread { + val payload = Arguments.createMap().apply { + putDouble("loaded", loaded.toDouble()) + if (total != null) { + putDouble("total", total.toDouble()) + } else { + putNull("total") + } + putString("uploadId", uploadId) } - putString("uploadId", uploadId) - } - reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit(PROGRESS_EVENT_NAME, payload) + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(PROGRESS_EVENT_NAME, payload) + } } companion object { const val NAME = "StreamMultipartUploader" private const val PROGRESS_EVENT_NAME = "streamMultipartUploadProgress" - private val executor = Executors.newCachedThreadPool() + private val maxConcurrentUploads = Runtime.getRuntime().availableProcessors().coerceIn(2, 4) + private val executor = + ThreadPoolExecutor( + maxConcurrentUploads, + maxConcurrentUploads, + 30L, + TimeUnit.SECONDS, + LinkedBlockingQueue(64), + ).apply { + allowCoreThreadTimeOut(true) + } } } diff --git a/package/native-package/src/native/NativeStreamMultipartUploader.ts b/package/native-package/src/native/NativeStreamMultipartUploader.ts index d8f6aa01ca..154d12235a 100644 --- a/package/native-package/src/native/NativeStreamMultipartUploader.ts +++ b/package/native-package/src/native/NativeStreamMultipartUploader.ts @@ -45,6 +45,7 @@ export interface Spec extends TurboModule { headers: ReadonlyArray, parts: ReadonlyArray, progress?: UploadProgressConfig | null, + timeoutMs?: number | null, ): Promise; } diff --git a/package/native-package/src/native/multipartUploader.ts b/package/native-package/src/native/multipartUploader.ts index bc58b7eee4..718f37d517 100644 --- a/package/native-package/src/native/multipartUploader.ts +++ b/package/native-package/src/native/multipartUploader.ts @@ -43,6 +43,7 @@ type MultipartUploadRequest = { parts: UploadPart[]; progress?: UploadProgressConfig; signal?: NativeMultipartAbortSignal; + timeoutMs?: number; uploadId: string; url: string; }; @@ -58,6 +59,7 @@ export const uploadMultipart = async ({ parts, progress, signal, + timeoutMs, uploadId, url, }: MultipartUploadRequest): Promise => { @@ -118,6 +120,7 @@ export const uploadMultipart = async ({ toUploadHeaders(headers), parts, progress ?? {}, + timeoutMs, ); if (signal?.aborted) { diff --git a/package/native-package/src/optionalDependencies/multipartUpload.ts b/package/native-package/src/optionalDependencies/multipartUpload.ts index 0a6e6a1954..3298bf067d 100644 --- a/package/native-package/src/optionalDependencies/multipartUpload.ts +++ b/package/native-package/src/optionalDependencies/multipartUpload.ts @@ -41,6 +41,7 @@ export const multipartUpload = async ({ parts, progress, signal, + timeoutMs, url, }: NativeMultipartUploadRequest) => { const resolvedParts = await Promise.all(parts.map(resolvePartUri)); @@ -52,6 +53,7 @@ export const multipartUpload = async ({ parts: resolvedParts, progress, signal, + timeoutMs, uploadId: `stream-upload-${Date.now()}-${Math.random().toString(16).slice(2)}`, url, }); diff --git a/package/shared-native/android/upload/StreamMultipartUploadModels.kt b/package/shared-native/android/upload/StreamMultipartUploadModels.kt index a9123c376d..35d77a73dd 100644 --- a/package/shared-native/android/upload/StreamMultipartUploadModels.kt +++ b/package/shared-native/android/upload/StreamMultipartUploadModels.kt @@ -5,6 +5,7 @@ data class StreamMultipartUploadRequest( val method: String, val parts: List, val progress: StreamMultipartUploadProgressOptions?, + val timeoutMs: Long?, val uploadId: String, val url: String, ) diff --git a/package/shared-native/android/upload/StreamMultipartUploadProgress.kt b/package/shared-native/android/upload/StreamMultipartUploadProgress.kt index bba001e852..a9c99c1252 100644 --- a/package/shared-native/android/upload/StreamMultipartUploadProgress.kt +++ b/package/shared-native/android/upload/StreamMultipartUploadProgress.kt @@ -13,8 +13,8 @@ class StreamMultipartUploadProgressThrottler( options: StreamMultipartUploadProgressOptions?, private val onProgress: (loaded: Long, total: Long?) -> Unit, ) { - private val intervalMs = options?.intervalMs ?: 16L - private val count = options?.count ?: 20 + private val intervalMs = (options?.intervalMs ?: 16L).coerceIn(16L, 1_000L) + private val count = (options?.count ?: 20).coerceIn(1, 100) private var emittedBuckets = -1 private var lastEventAtMs = 0L @@ -54,6 +54,8 @@ class StreamMultipartUploadProgressRequestBody( private val requestBody: RequestBody, private val throttler: StreamMultipartUploadProgressThrottler, ) : RequestBody() { + private val resolvedContentLength by lazy { requestBody.contentLength().takeIf { it >= 0L } } + override fun contentLength(): Long = requestBody.contentLength() override fun contentType() = requestBody.contentType() @@ -67,8 +69,7 @@ class StreamMultipartUploadProgressRequestBody( super.write(source, byteCount) bytesWritten += byteCount - val total = requestBody.contentLength().takeIf { it >= 0L } - throttler.dispatch(bytesWritten, total) + throttler.dispatch(bytesWritten, resolvedContentLength) } } diff --git a/package/shared-native/android/upload/StreamMultipartUploadRequestParser.kt b/package/shared-native/android/upload/StreamMultipartUploadRequestParser.kt index 8470d4cd05..5f3fc01707 100644 --- a/package/shared-native/android/upload/StreamMultipartUploadRequestParser.kt +++ b/package/shared-native/android/upload/StreamMultipartUploadRequestParser.kt @@ -12,12 +12,14 @@ object StreamMultipartUploadRequestParser { headers: ReadableArray, parts: ReadableArray, progress: ReadableMap?, + timeoutMs: Double?, ): StreamMultipartUploadRequest { return StreamMultipartUploadRequest( headers = headers.toStringMap(), method = method, parts = parts.toUploadParts(), progress = progress?.toProgressOptions(), + timeoutMs = timeoutMs?.toLong()?.takeIf { it > 0L }, uploadId = uploadId, url = url, ) @@ -89,13 +91,13 @@ object StreamMultipartUploadRequestParser { private fun ReadableMap.toProgressOptions(): StreamMultipartUploadProgressOptions { val count = if (hasKey("count") && !isNull("count")) { - getDouble("count").toInt() + getDouble("count").toInt().coerceIn(1, 100) } else { null } val intervalMs = if (hasKey("intervalMs") && !isNull("intervalMs")) { - getDouble("intervalMs").toLong() + getDouble("intervalMs").toLong().coerceIn(16L, 1_000L) } else { null } diff --git a/package/shared-native/android/upload/StreamMultipartUploader.kt b/package/shared-native/android/upload/StreamMultipartUploader.kt index f92979d2b4..3e53373734 100644 --- a/package/shared-native/android/upload/StreamMultipartUploader.kt +++ b/package/shared-native/android/upload/StreamMultipartUploader.kt @@ -6,11 +6,15 @@ import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import okhttp3.ResponseBody import java.io.InterruptedIOException +import java.io.IOException import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit object StreamMultipartUploader { private val client: OkHttpClient = OkHttpClient.Builder().retryOnConnectionFailure(true).build() + private const val MAX_RESPONSE_BODY_BYTES = 1_048_576L private val cancelledUploadIds = ConcurrentHashMap.newKeySet() private val inFlightCalls = ConcurrentHashMap() @@ -30,7 +34,7 @@ object StreamMultipartUploader { } val httpRequest = createRequest(context, request, onProgress) - val call = client.newCall(httpRequest) + val call = clientFor(request).newCall(httpRequest) inFlightCalls[request.uploadId] = call try { @@ -40,7 +44,7 @@ object StreamMultipartUploader { call.execute().use { response -> return StreamMultipartUploadResponse( - body = response.body?.string().orEmpty(), + body = readResponseBody(response.body), headers = response.headers.names().associateWith { name -> response.headers(name).joinToString(", ") @@ -55,6 +59,29 @@ object StreamMultipartUploader { } } + private fun clientFor(request: StreamMultipartUploadRequest): OkHttpClient { + val timeoutMs = request.timeoutMs ?: return client + return client.newBuilder() + .callTimeout(timeoutMs, TimeUnit.MILLISECONDS) + .build() + } + + private fun readResponseBody(body: ResponseBody?): String { + if (body == null) { + return "" + } + + val source = body.source() + source.request(MAX_RESPONSE_BODY_BYTES + 1L) + val buffer = source.buffer + + if (buffer.size > MAX_RESPONSE_BODY_BYTES) { + throw IOException("Upload response body exceeded $MAX_RESPONSE_BODY_BYTES bytes") + } + + return buffer.clone().readString(Charsets.UTF_8) + } + private fun createMultipartBody( context: Context, request: StreamMultipartUploadRequest, diff --git a/package/shared-native/ios/StreamMultipartUploadBodyStream.swift b/package/shared-native/ios/StreamMultipartUploadBodyStream.swift index faffb0726e..e9a8eb59d6 100644 --- a/package/shared-native/ios/StreamMultipartUploadBodyStream.swift +++ b/package/shared-native/ios/StreamMultipartUploadBodyStream.swift @@ -183,6 +183,10 @@ private final class StreamMultipartSequentialInputStream: InputStream { while true { guard let currentStream else { + if internalStatus == .error { + return -1 + } + internalStatus = .atEnd return 0 } @@ -226,6 +230,11 @@ private final class StreamMultipartSequentialInputStream: InputStream { nextStream = InputStream(data: data) case .file(let url): nextStream = InputStream(url: url) + if nextStream == nil { + internalError = StreamMultipartUploadError.unreadableFile(url.path) + internalStatus = .error + return + } } if let nextStream { diff --git a/package/shared-native/ios/StreamMultipartUploadManager.swift b/package/shared-native/ios/StreamMultipartUploadManager.swift index ff015e8606..c759219c4b 100644 --- a/package/shared-native/ios/StreamMultipartUploadManager.swift +++ b/package/shared-native/ios/StreamMultipartUploadManager.swift @@ -9,6 +9,7 @@ private final class StreamMultipartUploadTaskState { ((Result) -> Void)? var response: HTTPURLResponse? var responseData = Data() + var responseDataError: Error? init( bodyFactory: StreamMultipartUploadBodyStreamFactory, @@ -27,9 +28,11 @@ private final class StreamMultipartUploadTaskState { final class StreamMultipartUploadManager: NSObject { static let shared = StreamMultipartUploadManager() + private let maxResponseBodyBytes = 1_048_576 private lazy var session: URLSession = { let delegateQueue = OperationQueue() + delegateQueue.maxConcurrentOperationCount = 1 delegateQueue.qualityOfService = .userInitiated let configuration = URLSessionConfiguration.ephemeral configuration.waitsForConnectivity = false @@ -63,6 +66,7 @@ final class StreamMultipartUploadManager: NSObject { headers: [String: String], parts: [[String: Any]], progress: [String: Any]?, + timeoutMs: TimeInterval?, onProgress: @escaping (Int64, Int64?) -> Void ) async throws -> StreamMultipartUploadResponse { let request = try parseRequest( @@ -71,7 +75,8 @@ final class StreamMultipartUploadManager: NSObject { method: method, headers: headers, parts: parts, - progress: progress + progress: progress, + timeoutMs: timeoutMs ) try throwIfCancelled(uploadId: uploadId) @@ -79,6 +84,9 @@ final class StreamMultipartUploadManager: NSObject { try throwIfCancelled(uploadId: uploadId) var urlRequest = URLRequest(url: request.url) urlRequest.httpMethod = request.method + if let timeoutMs = request.timeoutMs, timeoutMs > 0 { + urlRequest.timeoutInterval = timeoutMs / 1_000 + } request.headers.forEach { key, value in if @@ -129,7 +137,8 @@ final class StreamMultipartUploadManager: NSObject { method: String, headers: [String: String], parts: [[String: Any]], - progress: [String: Any]? + progress: [String: Any]?, + timeoutMs: TimeInterval? ) throws -> StreamMultipartUploadRequest { guard let parsedURL = URL(string: url) else { throw StreamMultipartUploadError.invalidURL(url) @@ -196,11 +205,14 @@ final class StreamMultipartUploadManager: NSObject { intervalMs: progress?["intervalMs"] as? Double ?? (progress?["intervalMs"] as? NSNumber)?.doubleValue ) + let parsedTimeoutMs = timeoutMs.flatMap { $0 > 0 ? $0 : nil } + return StreamMultipartUploadRequest( headers: headers, method: method, parts: uploadParts, progress: progress == nil ? nil : progressOptions, + timeoutMs: parsedTimeoutMs, uploadId: uploadId, url: parsedURL ) @@ -258,6 +270,12 @@ extension StreamMultipartUploadManager: URLSessionDataDelegate, URLSessionTaskDe return } + if state.responseData.count + data.count > maxResponseBodyBytes { + state.responseDataError = StreamMultipartUploadError.responseBodyTooLarge(maxResponseBodyBytes) + dataTask.cancel() + return + } + state.responseData.append(data) } @@ -281,6 +299,12 @@ extension StreamMultipartUploadManager: URLSessionDataDelegate, URLSessionTaskDe } if let error { + if let responseDataError = state.responseDataError { + state.completion?(.failure(responseDataError)) + state.completion = nil + return + } + let nsError = error as NSError if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { diff --git a/package/shared-native/ios/StreamMultipartUploadModels.swift b/package/shared-native/ios/StreamMultipartUploadModels.swift index de513037c9..ab5ba841ca 100644 --- a/package/shared-native/ios/StreamMultipartUploadModels.swift +++ b/package/shared-native/ios/StreamMultipartUploadModels.swift @@ -5,6 +5,7 @@ struct StreamMultipartUploadRequest { let method: String let parts: [StreamMultipartUploadPart] let progress: StreamMultipartUploadProgressOptions? + let timeoutMs: TimeInterval? let uploadId: String let url: URL } @@ -43,6 +44,8 @@ enum StreamMultipartUploadError: LocalizedError { case invalidRequest(String) case invalidURL(String) case missingHTTPResponse + case responseBodyTooLarge(Int) + case unreadableFile(String) case unsupportedSource(String) var errorDescription: String? { @@ -55,6 +58,10 @@ enum StreamMultipartUploadError: LocalizedError { return "Invalid upload URL: \(value)" case .missingHTTPResponse: return "Upload completed without an HTTP response" + case .responseBodyTooLarge(let maxBytes): + return "Upload response body exceeded \(maxBytes) bytes" + case .unreadableFile(let path): + return "Unable to read upload file: \(path)" case .unsupportedSource(let uri): return "Unsupported upload URI: \(uri)" } diff --git a/package/shared-native/ios/StreamMultipartUploadProgress.swift b/package/shared-native/ios/StreamMultipartUploadProgress.swift index e14a47e0a8..d6a943a233 100644 --- a/package/shared-native/ios/StreamMultipartUploadProgress.swift +++ b/package/shared-native/ios/StreamMultipartUploadProgress.swift @@ -11,8 +11,8 @@ final class StreamMultipartUploadProgressThrottler { options: StreamMultipartUploadProgressOptions?, onProgress: @escaping (Int64, Int64?) -> Void ) { - self.count = options?.count ?? 20 - self.intervalMs = options?.intervalMs ?? 16 + self.count = min(max(options?.count ?? 20, 1), 100) + self.intervalMs = min(max(options?.intervalMs ?? 16, 16), 1_000) self.onProgress = onProgress } diff --git a/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift b/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift index b33180b199..024dce1000 100644 --- a/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift +++ b/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift @@ -4,6 +4,54 @@ import MobileCoreServices import Photos import UniformTypeIdentifiers +private final class StreamPhotoRequestBox { + private let lock = NSLock() + private var requestId: PHImageRequestID = PHInvalidImageRequestID + + func set(_ requestId: PHImageRequestID) { + lock.lock() + self.requestId = requestId + lock.unlock() + } + + func cancel() { + lock.lock() + let requestId = self.requestId + lock.unlock() + + if requestId != PHInvalidImageRequestID { + PHImageManager.default().cancelImageRequest(requestId) + } + } +} + +private final class StreamContentEditingInputRequestBox { + private let lock = NSLock() + private weak var asset: PHAsset? + private var requestId: PHContentEditingInputRequestID = 0 + + init(asset: PHAsset) { + self.asset = asset + } + + func set(_ requestId: PHContentEditingInputRequestID) { + lock.lock() + self.requestId = requestId + lock.unlock() + } + + func cancel() { + lock.lock() + let requestId = self.requestId + let asset = self.asset + lock.unlock() + + if requestId != 0 { + asset?.cancelContentEditingInputRequest(requestId) + } + } +} + struct StreamMultipartResolvedFilePart { let fieldName: String let fileName: String @@ -14,7 +62,9 @@ struct StreamMultipartResolvedFilePart { enum StreamMultipartUploadSourceResolver { static func resolve(_ part: StreamMultipartFilePart) async throws -> StreamMultipartResolvedFilePart { + try Task.checkCancellation() let fileURL = sanitizeFileURL(try await resolveFileURL(from: part.uri)) + try Task.checkCancellation() let mimeType = part.mimeType ?? guessMimeType(fileURL: fileURL, fallbackFileName: part.fileName) let size = fileSize(url: fileURL) @@ -108,18 +158,29 @@ enum StreamMultipartUploadSourceResolver { private static func requestImageAssetURL(_ asset: PHAsset) async throws -> URL { let options = PHContentEditingInputRequestOptions() options.isNetworkAccessAllowed = true - - return try await withCheckedThrowingContinuation { continuation in - asset.requestContentEditingInput(with: options) { input, _ in - if let url = input?.fullSizeImageURL { - continuation.resume(returning: url) - return + let requestBox = StreamContentEditingInputRequestBox(asset: asset) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let requestId = asset.requestContentEditingInput(with: options) { input, _ in + if Task.isCancelled { + continuation.resume(throwing: StreamMultipartUploadError.cancelled) + return + } + + if let url = input?.fullSizeImageURL { + continuation.resume(returning: url) + return + } + + continuation.resume( + throwing: StreamMultipartUploadError.unsupportedSource(asset.localIdentifier) + ) } - - continuation.resume( - throwing: StreamMultipartUploadError.unsupportedSource(asset.localIdentifier) - ) + requestBox.set(requestId) } + } onCancel: { + requestBox.cancel() } } @@ -128,28 +189,39 @@ enum StreamMultipartUploadSourceResolver { options.deliveryMode = .highQualityFormat options.isNetworkAccessAllowed = true options.version = .current - - return try await withCheckedThrowingContinuation { continuation in - PHImageManager.default().requestAVAsset(forVideo: asset, options: options) { avAsset, _, info in - if let isCancelled = (info?[PHImageCancelledKey] as? NSNumber)?.boolValue, isCancelled { - continuation.resume(throwing: StreamMultipartUploadError.cancelled) - return - } - - if let error = info?[PHImageErrorKey] as? Error { - continuation.resume(throwing: error) - return + let requestBox = StreamPhotoRequestBox() + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let requestId = PHImageManager.default().requestAVAsset(forVideo: asset, options: options) { avAsset, _, info in + if Task.isCancelled { + continuation.resume(throwing: StreamMultipartUploadError.cancelled) + return + } + + if let isCancelled = (info?[PHImageCancelledKey] as? NSNumber)?.boolValue, isCancelled { + continuation.resume(throwing: StreamMultipartUploadError.cancelled) + return + } + + if let error = info?[PHImageErrorKey] as? Error { + continuation.resume(throwing: error) + return + } + + if let url = (avAsset as? AVURLAsset)?.url { + continuation.resume(returning: url) + return + } + + continuation.resume( + throwing: StreamMultipartUploadError.unsupportedSource(asset.localIdentifier) + ) } - - if let url = (avAsset as? AVURLAsset)?.url { - continuation.resume(returning: url) - return - } - - continuation.resume( - throwing: StreamMultipartUploadError.unsupportedSource(asset.localIdentifier) - ) + requestBox.set(requestId) } + } onCancel: { + requestBox.cancel() } } diff --git a/package/shared-native/ios/StreamMultipartUploader.mm b/package/shared-native/ios/StreamMultipartUploader.mm index 11535c4923..43a2a6d1a6 100644 --- a/package/shared-native/ios/StreamMultipartUploader.mm +++ b/package/shared-native/ios/StreamMultipartUploader.mm @@ -49,9 +49,10 @@ + (BOOL)requiresMainQueueSetup - (void)uploadMultipart:(NSString *)uploadId url:(NSString *)url method:(NSString *)method - headers:(NSArray *> *)headers - parts:(NSArray *> *)parts + headers:(NSArray *> *)headers + parts:(NSArray *> *)parts progress:(JS::NativeStreamMultipartUploader::UploadProgressConfig &)progress + timeoutMs:(NSNumber * _Nullable)timeoutMs resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { @@ -64,6 +65,7 @@ - (void)uploadMultipart:(NSString *)uploadId headers:headers parts:parts progress:progressOptions + timeoutMs:timeoutMs onProgress:^(NSNumber *loaded, NSNumber * _Nullable total) { __strong __typeof__(weakSelf) strongSelf = weakSelf; if (strongSelf == nil) { diff --git a/package/shared-native/ios/StreamMultipartUploaderBridge.swift b/package/shared-native/ios/StreamMultipartUploaderBridge.swift index 6f3cbec050..b277a0ea18 100644 --- a/package/shared-native/ios/StreamMultipartUploaderBridge.swift +++ b/package/shared-native/ios/StreamMultipartUploaderBridge.swift @@ -1,8 +1,38 @@ import Foundation +private final class StreamMultipartUploadBridgeTaskBox { + private let lock = NSLock() + private var isCancelled = false + private var task: Task? + + func setTask(_ task: Task) { + lock.lock() + if isCancelled { + lock.unlock() + task.cancel() + return + } + + self.task = task + lock.unlock() + } + + func cancel() { + lock.lock() + isCancelled = true + let task = self.task + lock.unlock() + + task?.cancel() + } +} + @objcMembers public final class StreamMultipartUploaderBridge: NSObject { - @objc(uploadMultipartWithUploadId:url:method:headers:parts:progress:onProgress:completion:) + private static let taskLock = NSLock() + private static var tasksByUploadId = [String: StreamMultipartUploadBridgeTaskBox]() + + @objc(uploadMultipartWithUploadId:url:method:headers:parts:progress:timeoutMs:onProgress:completion:) public static func uploadMultipart( uploadId: String, url: String, @@ -10,10 +40,26 @@ public final class StreamMultipartUploaderBridge: NSObject { headers: [[String: String]], parts: [[String: Any]], progress: [String: Any]?, + timeoutMs: NSNumber?, onProgress: @escaping (NSNumber, NSNumber?) -> Void, completion: @escaping (NSDictionary?, NSError?) -> Void ) { - Task(priority: .userInitiated) { + let taskBox = StreamMultipartUploadBridgeTaskBox() + + taskLock.lock() + tasksByUploadId[uploadId]?.cancel() + tasksByUploadId[uploadId] = taskBox + taskLock.unlock() + + let task = Task(priority: .userInitiated) { + defer { + taskLock.lock() + if tasksByUploadId[uploadId] === taskBox { + tasksByUploadId.removeValue(forKey: uploadId) + } + taskLock.unlock() + } + do { let response = try await StreamMultipartUploadManager.shared.uploadMultipart( uploadId: uploadId, @@ -22,6 +68,7 @@ public final class StreamMultipartUploaderBridge: NSObject { headers: dictionary(from: headers), parts: parts, progress: progress, + timeoutMs: timeoutMs?.doubleValue, onProgress: { loaded, total in onProgress(NSNumber(value: loaded), total.map { NSNumber(value: $0) }) } @@ -38,10 +85,17 @@ public final class StreamMultipartUploaderBridge: NSObject { completion(nil, error.asStreamMultipartNSError()) } } + + taskBox.setTask(task) } @objc(cancelUploadWithUploadId:) public static func cancelUpload(uploadId: String) { + taskLock.lock() + let taskBox = tasksByUploadId.removeValue(forKey: uploadId) + taskLock.unlock() + + taskBox?.cancel() StreamMultipartUploadManager.shared.cancel(uploadId: uploadId) } @@ -63,6 +117,14 @@ public final class StreamMultipartUploaderBridge: NSObject { private extension Error { func asStreamMultipartNSError() -> NSError { + if self is CancellationError { + return NSError( + domain: "StreamMultipartUploader", + code: 2, + userInfo: [NSLocalizedDescriptionKey: StreamMultipartUploadError.cancelled.localizedDescription] + ) + } + let nsError = self as NSError if nsError.domain != NSCocoaErrorDomain || nsError.code != 0 { diff --git a/package/src/native.ts b/package/src/native.ts index 8487467305..de39d17975 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -44,6 +44,7 @@ export type NativeMultipartUploadRequest = { parts: NativeMultipartUploadPart[]; progress?: NativeMultipartUploadProgressConfig; signal?: NativeMultipartAbortSignal; + timeoutMs?: number; url: string; }; diff --git a/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts index 5e9e54bc83..26d75ce411 100644 --- a/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts +++ b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts @@ -63,6 +63,7 @@ describe('installNativeMultipartAdapter', () => { params: { api_key: 'test-key', }, + timeout: 1234, }); expect(defaultAdapter).not.toHaveBeenCalled(); @@ -73,6 +74,7 @@ describe('installNativeMultipartAdapter', () => { 'Content-Type': 'multipart/form-data', 'X-Stream-Client': 'stream-test', }), + timeoutMs: 1234, parts: [ { fieldName: 'file', diff --git a/package/src/utils/installNativeMultipartAdapter.ts b/package/src/utils/installNativeMultipartAdapter.ts index 2987e6b774..38a5913c36 100644 --- a/package/src/utils/installNativeMultipartAdapter.ts +++ b/package/src/utils/installNativeMultipartAdapter.ts @@ -126,6 +126,7 @@ const createNativeMultipartRequest = ( parts, progress: getNativeProgressOptions(config.uploadProgressOptions), signal: config.signal, + timeoutMs: config.timeout, url: client.axiosInstance.getUri(config), }; }; From 7f284ea29920426e68e4f2a0dbfcb4367dadb605 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 23 Apr 2026 11:04:47 +0200 Subject: [PATCH 10/26] fix: concurrency issues on ios --- examples/SampleApp/ios/Podfile | 41 +++++++++++ examples/SampleApp/ios/Podfile.lock | 18 ++--- .../native/NativeStreamMultipartUploader.ts | 20 ++--- .../native/NativeStreamMultipartUploader.ts | 20 ++--- .../android/upload/StreamMultipartUploader.kt | 7 +- .../ios/StreamMultipartUploadManager.swift | 73 ++++++++++++++++++- .../ios/StreamMultipartUploader.mm | 2 +- .../ios/StreamMultipartUploaderBridge.swift | 7 +- 8 files changed, 154 insertions(+), 34 deletions(-) diff --git a/examples/SampleApp/ios/Podfile b/examples/SampleApp/ios/Podfile index 6726f8772f..26da171601 100644 --- a/examples/SampleApp/ios/Podfile +++ b/examples/SampleApp/ios/Podfile @@ -5,6 +5,34 @@ require Pod::Executable.execute_command('node', ['-p', {paths: [process.argv[1]]}, )', __dir__]).strip +react_native_path = File.dirname( + Pod::Executable.execute_command('node', ['-p', + 'require.resolve( + "react-native/package.json", + {paths: [process.argv[1]]}, + )', __dir__]).strip, +) + +fmt_podspec_path = File.join(react_native_path, 'third-party-podspecs', 'fmt.podspec') +rct_folly_podspec_path = File.join(react_native_path, 'third-party-podspecs', 'RCT-Folly.podspec') + +fmt_podspec = File.read(fmt_podspec_path) +fmt_podspec = fmt_podspec.gsub('spec.version = "11.0.2"', 'spec.version = "12.1.0"') +fmt_podspec = fmt_podspec.gsub(':tag => "11.0.2"', ':tag => "12.1.0"') +fmt_podspec = fmt_podspec.gsub( + '"GCC_WARN_INHIBIT_ALL_WARNINGS" => "YES" # Disable warnings because we don\'t control this library', + "\"GCC_WARN_INHIBIT_ALL_WARNINGS\" => \"YES\", \"OTHER_CPLUSPLUSFLAGS\" => \"$(inherited) -DFMT_USE_CONSTEVAL=0\" # Disable warnings because we don't control this library", +) +File.write(fmt_podspec_path, fmt_podspec) + +rct_folly_podspec = File.read(rct_folly_podspec_path) +rct_folly_podspec = rct_folly_podspec.gsub('spec.dependency "fmt", "11.0.2"', 'spec.dependency "fmt", "12.1.0"') +rct_folly_podspec = rct_folly_podspec.gsub( + '"GCC_WARN_INHIBIT_ALL_WARNINGS" => "YES" # Disable warnings because we don\'t control this library', + "\"GCC_WARN_INHIBIT_ALL_WARNINGS\" => \"YES\", \"OTHER_CPLUSPLUSFLAGS\" => \"$(inherited) -DFMT_USE_CONSTEVAL=0\" # Disable warnings because we don't control this library", +) +File.write(rct_folly_podspec_path, rct_folly_podspec) + platform :ios, min_ios_version_supported prepare_react_native_project! @@ -55,5 +83,18 @@ target 'SampleApp' do :mac_catalyst_enabled => false, # :ccache_enabled => true ) + + installer.pods_project.targets.each do |target| + next unless ['fmt', 'RCT-Folly'].include?(target.name) + + target.build_configurations.each do |config| + flags = Array(config.build_settings['OTHER_CPLUSPLUSFLAGS'] || '$(inherited)') + unless flags.include?('-DFMT_USE_CONSTEVAL=0') + flags << '-DFMT_USE_CONSTEVAL=0' + end + config.build_settings['OTHER_CPLUSPLUSFLAGS'] = flags + end + end + end end diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 9deda9f191..33279d1d8f 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -73,7 +73,7 @@ PODS: - GoogleUtilities/Reachability (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - - FirebaseRemoteConfigInterop (11.14.0) + - FirebaseRemoteConfigInterop (11.15.0) - FirebaseSessions (11.13.0): - FirebaseCore (~> 11.13.0) - FirebaseCoreExtension (~> 11.13.0) @@ -83,7 +83,7 @@ PODS: - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - PromisesSwift (~> 2.1) - - fmt (11.0.2) + - fmt (12.1.0) - glog (0.3.5) - GoogleAppMeasurement (11.13.0): - GoogleAppMeasurement/AdIdSupport (= 11.13.0) @@ -251,20 +251,20 @@ PODS: - boost - DoubleConversion - fast_float (= 8.0.0) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - RCT-Folly/Default (= 2024.11.18.00) - RCT-Folly/Default (2024.11.18.00): - boost - DoubleConversion - fast_float (= 8.0.0) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - RCT-Folly/Fabric (2024.11.18.00): - boost - DoubleConversion - fast_float (= 8.0.0) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - RCTDeprecation (0.81.6) - RCTRequired (0.81.6) @@ -3731,9 +3731,9 @@ SPEC CHECKSUMS: FirebaseCrashlytics: 8281e577b6f85a08ea7aeb8b66f95e1ae430c943 FirebaseInstallations: 0ee9074f2c1e86561ace168ee1470dc67aabaf02 FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 - FirebaseRemoteConfigInterop: 7b74ceaa54e28863ed17fa39da8951692725eced + FirebaseRemoteConfigInterop: 1c6135e8a094cc6368949f5faeeca7ee8948b8aa FirebaseSessions: eaa8ec037e7793769defe4201c20bd4d976f9677 - fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd + fmt: 12a698626610c2fef5e7d8de472b100baf225f93 glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 GoogleAppMeasurement: 0dfca1a4b534d123de3945e28f77869d10d0d600 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 @@ -3746,7 +3746,7 @@ SPEC CHECKSUMS: op-sqlite: 2e58f87227360fa6251d1fe103d189f11ae8c95f PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f + RCT-Folly: 5a8bea092f38495b327c6eff2dc52ee25c10f637 RCTDeprecation: d4ef510f229cea15314176aee5e3ba10064a8496 RCTRequired: 1e41b794629558f6626e2bc39c166ac0ec1c5878 RCTTypeSafety: 62c8105cf08af634c93d38ea1e8ec8a57b7abc2c @@ -3839,6 +3839,6 @@ SPEC CHECKSUMS: Teleport: ed828b19e62ca8b9ec101d991bf0594b1c1c8812 Yoga: ff16d80456ce825ffc9400eeccc645a0dfcccdf5 -PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d +PODFILE CHECKSUM: 84efea5f3e8c9c79671ee6e525f700f244c17388 COCOAPODS: 1.15.2 diff --git a/package/expo-package/src/native/NativeStreamMultipartUploader.ts b/package/expo-package/src/native/NativeStreamMultipartUploader.ts index 154d12235a..56c9cc6661 100644 --- a/package/expo-package/src/native/NativeStreamMultipartUploader.ts +++ b/package/expo-package/src/native/NativeStreamMultipartUploader.ts @@ -9,29 +9,29 @@ export type UploadHeader = { export type UploadPart = { fieldName: string; - fileName?: string | null; + fileName?: string; kind: string; - mimeType?: string | null; - uri?: string | null; - value?: string | null; + mimeType?: string; + uri?: string; + value?: string; }; export type UploadProgressConfig = { - count?: number | null; - intervalMs?: number | null; + count?: number; + intervalMs?: number; }; export type UploadProgressEvent = { loaded: number; - total?: number | null; + total?: number; uploadId: string; }; export type UploadResponse = { body: string; - headers?: ReadonlyArray | null; + headers?: ReadonlyArray; status: number; - statusText?: string | null; + statusText?: string; }; export interface Spec extends TurboModule { @@ -44,7 +44,7 @@ export interface Spec extends TurboModule { method: string, headers: ReadonlyArray, parts: ReadonlyArray, - progress?: UploadProgressConfig | null, + progress?: UploadProgressConfig, timeoutMs?: number | null, ): Promise; } diff --git a/package/native-package/src/native/NativeStreamMultipartUploader.ts b/package/native-package/src/native/NativeStreamMultipartUploader.ts index 154d12235a..56c9cc6661 100644 --- a/package/native-package/src/native/NativeStreamMultipartUploader.ts +++ b/package/native-package/src/native/NativeStreamMultipartUploader.ts @@ -9,29 +9,29 @@ export type UploadHeader = { export type UploadPart = { fieldName: string; - fileName?: string | null; + fileName?: string; kind: string; - mimeType?: string | null; - uri?: string | null; - value?: string | null; + mimeType?: string; + uri?: string; + value?: string; }; export type UploadProgressConfig = { - count?: number | null; - intervalMs?: number | null; + count?: number; + intervalMs?: number; }; export type UploadProgressEvent = { loaded: number; - total?: number | null; + total?: number; uploadId: string; }; export type UploadResponse = { body: string; - headers?: ReadonlyArray | null; + headers?: ReadonlyArray; status: number; - statusText?: string | null; + statusText?: string; }; export interface Spec extends TurboModule { @@ -44,7 +44,7 @@ export interface Spec extends TurboModule { method: string, headers: ReadonlyArray, parts: ReadonlyArray, - progress?: UploadProgressConfig | null, + progress?: UploadProgressConfig, timeoutMs?: number | null, ): Promise; } diff --git a/package/shared-native/android/upload/StreamMultipartUploader.kt b/package/shared-native/android/upload/StreamMultipartUploader.kt index 3e53373734..ff3b282c64 100644 --- a/package/shared-native/android/upload/StreamMultipartUploader.kt +++ b/package/shared-native/android/upload/StreamMultipartUploader.kt @@ -35,7 +35,10 @@ object StreamMultipartUploader { val httpRequest = createRequest(context, request, onProgress) val call = clientFor(request).newCall(httpRequest) - inFlightCalls[request.uploadId] = call + val existingCall = inFlightCalls.putIfAbsent(request.uploadId, call) + if (existingCall != null) { + throw IllegalStateException("Upload already in flight for id: ${request.uploadId}") + } try { if (cancelledUploadIds.remove(request.uploadId)) { @@ -54,7 +57,7 @@ object StreamMultipartUploader { ) } } finally { - inFlightCalls.remove(request.uploadId) + inFlightCalls.remove(request.uploadId, call) cancelledUploadIds.remove(request.uploadId) } } diff --git a/package/shared-native/ios/StreamMultipartUploadManager.swift b/package/shared-native/ios/StreamMultipartUploadManager.swift index c759219c4b..ebd98f3903 100644 --- a/package/shared-native/ios/StreamMultipartUploadManager.swift +++ b/package/shared-native/ios/StreamMultipartUploadManager.swift @@ -1,5 +1,62 @@ import Foundation +private actor StreamMultipartUploadConcurrencyLimiter { + private var activeUploads = 0 + private let maxConcurrentUploads: Int + private var waiterOrder = [UUID]() + private var waiters = [UUID: CheckedContinuation]() + + init(maxConcurrentUploads: Int) { + self.maxConcurrentUploads = max(1, maxConcurrentUploads) + } + + func acquire() async throws { + if activeUploads < maxConcurrentUploads { + activeUploads += 1 + return + } + + let waiterId = UUID() + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if activeUploads < maxConcurrentUploads { + activeUploads += 1 + continuation.resume() + return + } + + waiterOrder.append(waiterId) + waiters[waiterId] = continuation + } + } onCancel: { + Task { + await self.cancelWaiter(id: waiterId) + } + } + } + + func release() { + while !waiterOrder.isEmpty { + let waiterId = waiterOrder.removeFirst() + + guard let continuation = waiters.removeValue(forKey: waiterId) else { + continue + } + + continuation.resume() + return + } + + activeUploads = max(0, activeUploads - 1) + } + + private func cancelWaiter(id: UUID) { + waiterOrder.removeAll { $0 == id } + waiters.removeValue(forKey: id)?.resume(throwing: StreamMultipartUploadError.cancelled) + } +} + private final class StreamMultipartUploadTaskState { let bodyFactory: StreamMultipartUploadBodyStreamFactory let progressThrottler: StreamMultipartUploadProgressThrottler @@ -29,15 +86,20 @@ private final class StreamMultipartUploadTaskState { final class StreamMultipartUploadManager: NSObject { static let shared = StreamMultipartUploadManager() private let maxResponseBodyBytes = 1_048_576 + private let maxConcurrentUploads = min(max(ProcessInfo.processInfo.activeProcessorCount, 2), 4) private lazy var session: URLSession = { let delegateQueue = OperationQueue() delegateQueue.maxConcurrentOperationCount = 1 delegateQueue.qualityOfService = .userInitiated let configuration = URLSessionConfiguration.ephemeral + configuration.httpMaximumConnectionsPerHost = maxConcurrentUploads configuration.waitsForConnectivity = false return URLSession(configuration: configuration, delegate: self, delegateQueue: delegateQueue) }() + private lazy var uploadLimiter = StreamMultipartUploadConcurrencyLimiter( + maxConcurrentUploads: maxConcurrentUploads + ) private let lock = NSLock() private var cancelledUploadIds = Set() @@ -109,6 +171,7 @@ final class StreamMultipartUploadManager: NSObject { let progressThrottler = StreamMultipartUploadProgressThrottler(options: request.progress, onProgress: onProgress) + try await uploadLimiter.acquire() return try await withCheckedThrowingContinuation { continuation in let task = session.uploadTask(withStreamedRequest: urlRequest) @@ -118,11 +181,17 @@ final class StreamMultipartUploadManager: NSObject { task: task, uploadId: uploadId ) { result in + Task { + await self.uploadLimiter.release() + } continuation.resume(with: result) } guard register(state) else { task.cancel() + Task { + await self.uploadLimiter.release() + } continuation.resume(throwing: StreamMultipartUploadError.cancelled) return } @@ -245,7 +314,9 @@ final class StreamMultipartUploadManager: NSObject { lock.lock() let state = statesByTaskIdentifier.removeValue(forKey: taskIdentifier) if let uploadId = state?.uploadId { - taskIdentifiersByUploadId.removeValue(forKey: uploadId) + if taskIdentifiersByUploadId[uploadId] == taskIdentifier { + taskIdentifiersByUploadId.removeValue(forKey: uploadId) + } cancelledUploadIds.remove(uploadId) } lock.unlock() diff --git a/package/shared-native/ios/StreamMultipartUploader.mm b/package/shared-native/ios/StreamMultipartUploader.mm index 43a2a6d1a6..058c5988d2 100644 --- a/package/shared-native/ios/StreamMultipartUploader.mm +++ b/package/shared-native/ios/StreamMultipartUploader.mm @@ -52,7 +52,7 @@ - (void)uploadMultipart:(NSString *)uploadId headers:(NSArray *> *)headers parts:(NSArray *> *)parts progress:(JS::NativeStreamMultipartUploader::UploadProgressConfig &)progress - timeoutMs:(NSNumber * _Nullable)timeoutMs + timeoutMs:(NSNumber *)timeoutMs resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { diff --git a/package/shared-native/ios/StreamMultipartUploaderBridge.swift b/package/shared-native/ios/StreamMultipartUploaderBridge.swift index b277a0ea18..d1922ebca1 100644 --- a/package/shared-native/ios/StreamMultipartUploaderBridge.swift +++ b/package/shared-native/ios/StreamMultipartUploaderBridge.swift @@ -45,11 +45,16 @@ public final class StreamMultipartUploaderBridge: NSObject { completion: @escaping (NSDictionary?, NSError?) -> Void ) { let taskBox = StreamMultipartUploadBridgeTaskBox() + var replacedTaskBox: StreamMultipartUploadBridgeTaskBox? taskLock.lock() - tasksByUploadId[uploadId]?.cancel() + replacedTaskBox = tasksByUploadId[uploadId] tasksByUploadId[uploadId] = taskBox taskLock.unlock() + if replacedTaskBox != nil { + replacedTaskBox?.cancel() + StreamMultipartUploadManager.shared.cancel(uploadId: uploadId) + } let task = Task(priority: .userInitiated) { defer { From 8168a19f07d5b32959fe2cf80b3331a7236d542e Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 23 Apr 2026 11:04:56 +0200 Subject: [PATCH 11/26] refactor: video gallery --- .../src/components/Attachment/Attachment.tsx | 10 +-- .../AttachmentFileUploadProgressIndicator.tsx | 38 ++++++++-- .../Attachment/AttachmentUploadIndicator.tsx | 74 +++++++++++++------ .../components/Attachment/FileAttachment.tsx | 13 ++-- package/src/components/Attachment/Gallery.tsx | 12 ++- .../components/Attachment/VideoThumbnail.tsx | 30 ++++---- package/src/components/Channel/Channel.tsx | 16 +++- 7 files changed, 130 insertions(+), 63 deletions(-) diff --git a/package/src/components/Attachment/Attachment.tsx b/package/src/components/Attachment/Attachment.tsx index 7831e1db51..23c84f41dd 100644 --- a/package/src/components/Attachment/Attachment.tsx +++ b/package/src/components/Attachment/Attachment.tsx @@ -26,7 +26,6 @@ import { MessagesContextValue, useMessagesContext, } from '../../contexts/messagesContext/MessagesContext'; -import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload'; import { isSoundPackageAvailable, isVideoPlayerAvailable } from '../../native'; import { primitives } from '../../theme'; @@ -189,14 +188,13 @@ const MessageAudioAttachment = ({ message, }: MessageAudioAttachmentProps) => { const localId = (attachment as DefaultAttachmentData).localId; - const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId); - - const indicator = isUploading ? ( + const indicator = ( - ) : undefined; + ); const audioItemType = isVoiceRecordingAttachment(attachment) ? 'voiceRecording' : 'audio'; diff --git a/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx b/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx index 6dae9297cc..e63be2ef05 100644 --- a/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx +++ b/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx @@ -1,14 +1,19 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; import { AttachmentUploadIndicator } from './AttachmentUploadIndicator'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload'; import { primitives } from '../../theme'; +import { isLocalUrl } from '../../utils/utils'; export type AttachmentFileUploadProgressIndicatorProps = { + containerStyle?: StyleProp; + localId?: string; + sourceUrl?: string; totalBytes?: number | string | null; - uploadProgress: number | undefined; }; const parseTotalBytes = (value: number | string | null | undefined): number | null => { @@ -35,13 +40,19 @@ const formatMegabytesOneDecimal = (bytes: number) => { /** * Circular progress plus `uploaded / total` for file and audio attachments during upload. */ -export const AttachmentFileUploadProgressIndicator = ({ +export const AttachmentFileUploadProgressIndicatorUI = ({ + containerStyle, + localId, + sourceUrl, totalBytes, - uploadProgress, }: AttachmentFileUploadProgressIndicatorProps) => { const { theme: { semantics }, } = useTheme(); + const shouldTrackPendingUpload = !!localId && !!sourceUrl && isLocalUrl(sourceUrl); + const pendingUpload = usePendingAttachmentUpload(shouldTrackPendingUpload ? localId : undefined); + const uploadProgress = pendingUpload.uploadProgress; + const shouldRender = pendingUpload.isUploading; const progressLabel = useMemo(() => { const bytes = parseTotalBytes(totalBytes); @@ -52,9 +63,13 @@ export const AttachmentFileUploadProgressIndicator = ({ return `${formatMegabytesOneDecimal(uploaded)} / ${formatMegabytesOneDecimal(bytes)}`; }, [totalBytes, uploadProgress]); + if (!shouldRender) { + return null; + } + return ( - - + + {progressLabel ? ( {progressLabel} @@ -64,6 +79,19 @@ export const AttachmentFileUploadProgressIndicator = ({ ); }; +export const AttachmentFileUploadProgressIndicator = ( + props: AttachmentFileUploadProgressIndicatorProps, +) => { + const { localId, sourceUrl } = props; + const shouldTrackPendingUpload = !!localId && !!sourceUrl && isLocalUrl(sourceUrl); + + if (!shouldTrackPendingUpload) { + return null; + } + + return ; +}; + const styles = StyleSheet.create({ label: { flex: 1, diff --git a/package/src/components/Attachment/AttachmentUploadIndicator.tsx b/package/src/components/Attachment/AttachmentUploadIndicator.tsx index 4f2041c375..516bdcbab9 100644 --- a/package/src/components/Attachment/AttachmentUploadIndicator.tsx +++ b/package/src/components/Attachment/AttachmentUploadIndicator.tsx @@ -5,50 +5,82 @@ import type { StyleProp, ViewStyle } from 'react-native'; import { CircularProgressIndicator } from './CircularProgressIndicator'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload'; +import { isLocalUrl } from '../../utils/utils'; export type AttachmentUploadIndicatorProps = { + containerStyle?: StyleProp; + localId?: string; size?: number; + sourceUrl?: string; 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 = ({ +export const AttachmentUploadIndicatorUI = ({ + containerStyle, + localId, size = 16, strokeWidth = 2, style, testID, - uploadProgress, }: AttachmentUploadIndicatorProps) => { const { theme: { semantics }, } = useTheme(); + const pendingUpload = usePendingAttachmentUpload(localId); + const uploadProgress = pendingUpload.uploadProgress; + const shouldRender = pendingUpload.isUploading; + + if (!shouldRender) { + return null; + } + + return ( + + {uploadProgress === undefined ? ( + + + + ) : ( + + )} + + ); +}; + +export const AttachmentUploadIndicator = ({ + containerStyle, + localId, + sourceUrl, + ...props +}: AttachmentUploadIndicatorProps) => { + const shouldTrackPendingUpload = !!localId && !!sourceUrl && isLocalUrl(sourceUrl); - if (uploadProgress === undefined) { - return ( - - - - ); + if (!shouldTrackPendingUpload) { + return null; } return ( - ); }; diff --git a/package/src/components/Attachment/FileAttachment.tsx b/package/src/components/Attachment/FileAttachment.tsx index bc4c230801..de194f0429 100644 --- a/package/src/components/Attachment/FileAttachment.tsx +++ b/package/src/components/Attachment/FileAttachment.tsx @@ -18,7 +18,6 @@ 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< @@ -54,7 +53,6 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { const { FilePreview } = useComponentsContext(); const localId = (attachment as DefaultAttachmentData).localId; - const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId); const defaultOnPress = () => openUrlSafely(attachment.asset_url); @@ -98,12 +96,11 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { attachment={attachment} attachmentIconSize={attachmentIconSize} indicator={ - isUploading ? ( - - ) : undefined + } styles={stylesProp} /> diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index d6b5dd0982..1b9963d79e 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -37,7 +37,6 @@ 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'; @@ -347,7 +346,6 @@ const GalleryImageThumbnail = ({ }, } = useTheme(); const styles = useStyles(); - const { isUploading, uploadProgress } = usePendingAttachmentUpload(thumbnail.localId); const onLoadStart = useStableCallback(() => { setLoadingImageError(false); @@ -379,11 +377,11 @@ const GalleryImageThumbnail = ({ uri={thumbnail.url} /> {isLoadingImage ? : null} - {isUploading ? ( - - - - ) : null} + )} diff --git a/package/src/components/Attachment/VideoThumbnail.tsx b/package/src/components/Attachment/VideoThumbnail.tsx index 1037b3fdb5..a6124c5b89 100644 --- a/package/src/components/Attachment/VideoThumbnail.tsx +++ b/package/src/components/Attachment/VideoThumbnail.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { ImageBackground, ImageStyle, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +import { ImageStyle, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import { AttachmentUploadIndicator } from './AttachmentUploadIndicator'; +import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload'; import { primitives } from '../../theme'; import { VideoPlayIndicator } from '../ui/VideoPlayIndicator'; @@ -42,22 +42,22 @@ export const VideoThumbnail = (props: VideoThumbnailProps) => { }, }, } = useTheme(); + const { ImageComponent } = useComponentsContext(); const { imageStyle, localId, style, thumb_url } = props; - const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId); return ( - + + - {isUploading ? ( - - - - ) : null} - + + ); }; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index e3b2e592a2..23f27fc2fb 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -1082,7 +1082,21 @@ const ChannelWithContext = (props: PropsWithChildren) = fileForUpload = { ...originalFile, name: filename, uri: compressedUri }; } - const response = await client.uploadManager.upload({ + const response = await ( + client as typeof client & { + uploadManager: { + upload(args: { + channelCid: string; + file: { + name?: string; + type?: string; + uri: string; + }; + id: string; + }): Promise<{ file: string; thumb_url?: string }>; + }; + } + ).uploadManager.upload({ channelCid: channel.cid, file: fileForUpload, id: localId, From a1eec028075312e373ceea57bb087a9aa9cd6107 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 23 Apr 2026 11:05:00 +0200 Subject: [PATCH 12/26] chore: move adapter to shared code --- .../native/NativeStreamMultipartUploader.ts | 2 +- .../src/native/multipartUploader.ts | 145 +------ .../optionalDependencies/multipartUpload.ts | 60 +-- .../native/NativeStreamMultipartUploader.ts | 2 +- .../src/native/multipartUploader.ts | 145 +------ .../optionalDependencies/multipartUpload.ts | 60 +-- .../ios/StreamMultipartUploadBodyStream.swift | 3 + .../ios/StreamMultipartUploadManager.swift | 12 +- .../StreamMultipartUploadSourceResolver.swift | 133 +++++- .../ios/StreamMultipartUploaderBridge.swift | 2 +- .../__tests__/nativeMultipartUpload.test.ts | 262 ++++++++++++ package/src/index.ts | 1 + package/src/native.ts | 79 +--- package/src/nativeMultipartUpload.ts | 384 ++++++++++++++++++ 14 files changed, 824 insertions(+), 466 deletions(-) create mode 100644 package/src/__tests__/nativeMultipartUpload.test.ts create mode 100644 package/src/nativeMultipartUpload.ts diff --git a/package/expo-package/src/native/NativeStreamMultipartUploader.ts b/package/expo-package/src/native/NativeStreamMultipartUploader.ts index 56c9cc6661..4caeacaeee 100644 --- a/package/expo-package/src/native/NativeStreamMultipartUploader.ts +++ b/package/expo-package/src/native/NativeStreamMultipartUploader.ts @@ -49,4 +49,4 @@ export interface Spec extends TurboModule { ): Promise; } -export default TurboModuleRegistry.getEnforcing('StreamMultipartUploader'); +export default TurboModuleRegistry.get('StreamMultipartUploader'); diff --git a/package/expo-package/src/native/multipartUploader.ts b/package/expo-package/src/native/multipartUploader.ts index 718f37d517..e3010a88fa 100644 --- a/package/expo-package/src/native/multipartUploader.ts +++ b/package/expo-package/src/native/multipartUploader.ts @@ -1,144 +1,5 @@ -import { NativeEventEmitter } from 'react-native'; +import { createNativeMultipartUploader } from 'stream-chat-react-native-core'; -import NativeStreamMultipartUploader, { - type UploadHeader, - type UploadPart, - type UploadProgressConfig, - type UploadProgressEvent, - type UploadResponse as NativeUploadResponse, -} from './NativeStreamMultipartUploader'; +import NativeStreamMultipartUploader from './NativeStreamMultipartUploader'; -import type { NativeMultipartAbortSignal } from '../../../src/native'; - -const STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT = 'streamMultipartUploadProgress'; - -const multipartUploadEventEmitter = new NativeEventEmitter(NativeStreamMultipartUploader); - -const toUploadHeaders = (headers: Record): UploadHeader[] => - Object.entries(headers).map(([name, value]) => ({ name, value })); - -const fromUploadHeaders = ( - headers?: ReadonlyArray | null, -): Record | undefined => { - if (!headers?.length) { - return undefined; - } - - return headers.reduce>((acc, header) => { - acc[header.name] = header.value; - return acc; - }, {}); -}; - -const createAbortError = () => { - const error = new Error('Request aborted'); - error.name = 'CanceledError'; - return error; -}; - -type MultipartUploadRequest = { - headers: Record; - method: string; - onProgress?: (event: { loaded: number; total?: number }) => void; - parts: UploadPart[]; - progress?: UploadProgressConfig; - signal?: NativeMultipartAbortSignal; - timeoutMs?: number; - uploadId: string; - url: string; -}; - -type MultipartUploadResponse = Omit & { - headers?: Record; -}; - -export const uploadMultipart = async ({ - headers, - method, - onProgress, - parts, - progress, - signal, - timeoutMs, - uploadId, - url, -}: MultipartUploadRequest): Promise => { - let progressSubscription: - | { - remove: () => void; - } - | undefined; - let removedAbortListener = false; - - const abortUpload = async () => { - try { - await NativeStreamMultipartUploader.cancelUpload(uploadId); - } catch { - // Ignore cancellation races for already-finished uploads. - } - }; - - const removeAbortListener = () => { - if (!removedAbortListener) { - signal?.removeEventListener('abort', handleAbort); - removedAbortListener = true; - } - }; - - const handleAbort = () => { - abortUpload().catch(() => undefined); - }; - - if (signal?.aborted) { - await abortUpload(); - throw createAbortError(); - } - - if (onProgress) { - progressSubscription = multipartUploadEventEmitter.addListener( - STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT, - (event: UploadProgressEvent) => { - if (event.uploadId !== uploadId) { - return; - } - - onProgress({ - loaded: event.loaded, - total: typeof event.total === 'number' ? event.total : undefined, - }); - }, - ); - } - - signal?.addEventListener('abort', handleAbort, { once: true }); - - try { - const response = await NativeStreamMultipartUploader.uploadMultipart( - uploadId, - url, - method, - toUploadHeaders(headers), - parts, - progress ?? {}, - timeoutMs, - ); - - if (signal?.aborted) { - throw createAbortError(); - } - - return { - ...response, - headers: fromUploadHeaders(response.headers), - }; - } catch (error) { - if (signal?.aborted) { - throw createAbortError(); - } - - throw error; - } finally { - progressSubscription?.remove(); - removeAbortListener(); - } -}; +export const uploadMultipart = createNativeMultipartUploader(NativeStreamMultipartUploader); diff --git a/package/expo-package/src/optionalDependencies/multipartUpload.ts b/package/expo-package/src/optionalDependencies/multipartUpload.ts index 3298bf067d..37f5b7f4f3 100644 --- a/package/expo-package/src/optionalDependencies/multipartUpload.ts +++ b/package/expo-package/src/optionalDependencies/multipartUpload.ts @@ -1,60 +1,10 @@ -import type { - NativeMultipartUploadPart, - NativeMultipartUploadRequest, -} from 'stream-chat-react-native-core'; +import { createNativeMultipartUpload } from 'stream-chat-react-native-core'; import { getLocalAssetUri } from './getLocalAssetUri'; import { uploadMultipart } from '../native/multipartUploader'; -const sanitizeResolvedFileUri = (uri: string) => { - const normalizedUri = uri.startsWith('/') ? `file://${uri}` : uri; - - if (!normalizedUri.startsWith('file://')) { - return normalizedUri; - } - - return normalizedUri.split('#')[0].split('?')[0]; -}; - -const resolvePartUri = async (part: NativeMultipartUploadPart) => { - if ( - part.kind !== 'file' || - typeof getLocalAssetUri !== 'function' || - !(part.uri.startsWith('ph://') || part.uri.startsWith('assets-library://')) - ) { - return part; - } - - const resolvedUri = await getLocalAssetUri(part.uri); - - return { - ...part, - uri: resolvedUri ? sanitizeResolvedFileUri(resolvedUri) : part.uri, - }; -}; - -export const multipartUpload = async ({ - headers, - method, - onProgress, - parts, - progress, - signal, - timeoutMs, - url, -}: NativeMultipartUploadRequest) => { - const resolvedParts = await Promise.all(parts.map(resolvePartUri)); - - return uploadMultipart({ - headers, - method, - onProgress, - parts: resolvedParts, - progress, - signal, - timeoutMs, - uploadId: `stream-upload-${Date.now()}-${Math.random().toString(16).slice(2)}`, - url, - }); -}; +export const multipartUpload = createNativeMultipartUpload({ + getLocalAssetUri, + uploadMultipart, +}); diff --git a/package/native-package/src/native/NativeStreamMultipartUploader.ts b/package/native-package/src/native/NativeStreamMultipartUploader.ts index 56c9cc6661..4caeacaeee 100644 --- a/package/native-package/src/native/NativeStreamMultipartUploader.ts +++ b/package/native-package/src/native/NativeStreamMultipartUploader.ts @@ -49,4 +49,4 @@ export interface Spec extends TurboModule { ): Promise; } -export default TurboModuleRegistry.getEnforcing('StreamMultipartUploader'); +export default TurboModuleRegistry.get('StreamMultipartUploader'); diff --git a/package/native-package/src/native/multipartUploader.ts b/package/native-package/src/native/multipartUploader.ts index 718f37d517..e3010a88fa 100644 --- a/package/native-package/src/native/multipartUploader.ts +++ b/package/native-package/src/native/multipartUploader.ts @@ -1,144 +1,5 @@ -import { NativeEventEmitter } from 'react-native'; +import { createNativeMultipartUploader } from 'stream-chat-react-native-core'; -import NativeStreamMultipartUploader, { - type UploadHeader, - type UploadPart, - type UploadProgressConfig, - type UploadProgressEvent, - type UploadResponse as NativeUploadResponse, -} from './NativeStreamMultipartUploader'; +import NativeStreamMultipartUploader from './NativeStreamMultipartUploader'; -import type { NativeMultipartAbortSignal } from '../../../src/native'; - -const STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT = 'streamMultipartUploadProgress'; - -const multipartUploadEventEmitter = new NativeEventEmitter(NativeStreamMultipartUploader); - -const toUploadHeaders = (headers: Record): UploadHeader[] => - Object.entries(headers).map(([name, value]) => ({ name, value })); - -const fromUploadHeaders = ( - headers?: ReadonlyArray | null, -): Record | undefined => { - if (!headers?.length) { - return undefined; - } - - return headers.reduce>((acc, header) => { - acc[header.name] = header.value; - return acc; - }, {}); -}; - -const createAbortError = () => { - const error = new Error('Request aborted'); - error.name = 'CanceledError'; - return error; -}; - -type MultipartUploadRequest = { - headers: Record; - method: string; - onProgress?: (event: { loaded: number; total?: number }) => void; - parts: UploadPart[]; - progress?: UploadProgressConfig; - signal?: NativeMultipartAbortSignal; - timeoutMs?: number; - uploadId: string; - url: string; -}; - -type MultipartUploadResponse = Omit & { - headers?: Record; -}; - -export const uploadMultipart = async ({ - headers, - method, - onProgress, - parts, - progress, - signal, - timeoutMs, - uploadId, - url, -}: MultipartUploadRequest): Promise => { - let progressSubscription: - | { - remove: () => void; - } - | undefined; - let removedAbortListener = false; - - const abortUpload = async () => { - try { - await NativeStreamMultipartUploader.cancelUpload(uploadId); - } catch { - // Ignore cancellation races for already-finished uploads. - } - }; - - const removeAbortListener = () => { - if (!removedAbortListener) { - signal?.removeEventListener('abort', handleAbort); - removedAbortListener = true; - } - }; - - const handleAbort = () => { - abortUpload().catch(() => undefined); - }; - - if (signal?.aborted) { - await abortUpload(); - throw createAbortError(); - } - - if (onProgress) { - progressSubscription = multipartUploadEventEmitter.addListener( - STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT, - (event: UploadProgressEvent) => { - if (event.uploadId !== uploadId) { - return; - } - - onProgress({ - loaded: event.loaded, - total: typeof event.total === 'number' ? event.total : undefined, - }); - }, - ); - } - - signal?.addEventListener('abort', handleAbort, { once: true }); - - try { - const response = await NativeStreamMultipartUploader.uploadMultipart( - uploadId, - url, - method, - toUploadHeaders(headers), - parts, - progress ?? {}, - timeoutMs, - ); - - if (signal?.aborted) { - throw createAbortError(); - } - - return { - ...response, - headers: fromUploadHeaders(response.headers), - }; - } catch (error) { - if (signal?.aborted) { - throw createAbortError(); - } - - throw error; - } finally { - progressSubscription?.remove(); - removeAbortListener(); - } -}; +export const uploadMultipart = createNativeMultipartUploader(NativeStreamMultipartUploader); diff --git a/package/native-package/src/optionalDependencies/multipartUpload.ts b/package/native-package/src/optionalDependencies/multipartUpload.ts index 3298bf067d..37f5b7f4f3 100644 --- a/package/native-package/src/optionalDependencies/multipartUpload.ts +++ b/package/native-package/src/optionalDependencies/multipartUpload.ts @@ -1,60 +1,10 @@ -import type { - NativeMultipartUploadPart, - NativeMultipartUploadRequest, -} from 'stream-chat-react-native-core'; +import { createNativeMultipartUpload } from 'stream-chat-react-native-core'; import { getLocalAssetUri } from './getLocalAssetUri'; import { uploadMultipart } from '../native/multipartUploader'; -const sanitizeResolvedFileUri = (uri: string) => { - const normalizedUri = uri.startsWith('/') ? `file://${uri}` : uri; - - if (!normalizedUri.startsWith('file://')) { - return normalizedUri; - } - - return normalizedUri.split('#')[0].split('?')[0]; -}; - -const resolvePartUri = async (part: NativeMultipartUploadPart) => { - if ( - part.kind !== 'file' || - typeof getLocalAssetUri !== 'function' || - !(part.uri.startsWith('ph://') || part.uri.startsWith('assets-library://')) - ) { - return part; - } - - const resolvedUri = await getLocalAssetUri(part.uri); - - return { - ...part, - uri: resolvedUri ? sanitizeResolvedFileUri(resolvedUri) : part.uri, - }; -}; - -export const multipartUpload = async ({ - headers, - method, - onProgress, - parts, - progress, - signal, - timeoutMs, - url, -}: NativeMultipartUploadRequest) => { - const resolvedParts = await Promise.all(parts.map(resolvePartUri)); - - return uploadMultipart({ - headers, - method, - onProgress, - parts: resolvedParts, - progress, - signal, - timeoutMs, - uploadId: `stream-upload-${Date.now()}-${Math.random().toString(16).slice(2)}`, - url, - }); -}; +export const multipartUpload = createNativeMultipartUpload({ + getLocalAssetUri, + uploadMultipart, +}); diff --git a/package/shared-native/ios/StreamMultipartUploadBodyStream.swift b/package/shared-native/ios/StreamMultipartUploadBodyStream.swift index e9a8eb59d6..f3b1376cd0 100644 --- a/package/shared-native/ios/StreamMultipartUploadBodyStream.swift +++ b/package/shared-native/ios/StreamMultipartUploadBodyStream.swift @@ -153,6 +153,9 @@ private final class StreamMultipartSequentialInputStream: InputStream { internalStatus = .opening advanceStreamIfNeeded() + if internalStatus == .error { + return + } internalStatus = currentStream == nil ? .atEnd : .open } diff --git a/package/shared-native/ios/StreamMultipartUploadManager.swift b/package/shared-native/ios/StreamMultipartUploadManager.swift index ebd98f3903..951c988ebb 100644 --- a/package/shared-native/ios/StreamMultipartUploadManager.swift +++ b/package/shared-native/ios/StreamMultipartUploadManager.swift @@ -107,8 +107,18 @@ final class StreamMultipartUploadManager: NSObject { private var taskIdentifiersByUploadId = [String: Int]() func cancel(uploadId: String) { + cancel(uploadId: uploadId, recordCancellation: true) + } + + func cancelInFlight(uploadId: String) { + cancel(uploadId: uploadId, recordCancellation: false) + } + + private func cancel(uploadId: String, recordCancellation: Bool) { lock.lock() - cancelledUploadIds.insert(uploadId) + if recordCancellation { + cancelledUploadIds.insert(uploadId) + } let taskIdentifier = taskIdentifiersByUploadId[uploadId] let task: URLSessionUploadTask? if let taskIdentifier { diff --git a/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift b/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift index 024dce1000..112156a8b8 100644 --- a/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift +++ b/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift @@ -6,16 +6,29 @@ import UniformTypeIdentifiers private final class StreamPhotoRequestBox { private let lock = NSLock() + private var isCancelled = false private var requestId: PHImageRequestID = PHInvalidImageRequestID func set(_ requestId: PHImageRequestID) { + let shouldCancel: Bool + lock.lock() - self.requestId = requestId + if isCancelled { + shouldCancel = true + } else { + self.requestId = requestId + shouldCancel = false + } lock.unlock() + + if shouldCancel, requestId != PHInvalidImageRequestID { + PHImageManager.default().cancelImageRequest(requestId) + } } func cancel() { lock.lock() + isCancelled = true let requestId = self.requestId lock.unlock() @@ -28,6 +41,7 @@ private final class StreamPhotoRequestBox { private final class StreamContentEditingInputRequestBox { private let lock = NSLock() private weak var asset: PHAsset? + private var isCancelled = false private var requestId: PHContentEditingInputRequestID = 0 init(asset: PHAsset) { @@ -35,13 +49,27 @@ private final class StreamContentEditingInputRequestBox { } func set(_ requestId: PHContentEditingInputRequestID) { + let asset: PHAsset? + let shouldCancel: Bool + lock.lock() - self.requestId = requestId + asset = self.asset + if isCancelled { + shouldCancel = true + } else { + self.requestId = requestId + shouldCancel = false + } lock.unlock() + + if shouldCancel, requestId != 0 { + asset?.cancelContentEditingInputRequest(requestId) + } } func cancel() { lock.lock() + isCancelled = true let requestId = self.requestId let asset = self.asset lock.unlock() @@ -52,6 +80,75 @@ private final class StreamContentEditingInputRequestBox { } } +private final class StreamMultipartContinuationBox { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var pendingResult: Result? + private var hasResumed = false + + func set(_ continuation: CheckedContinuation) { + let result: Result? + + lock.lock() + if let pendingResult { + self.pendingResult = nil + result = pendingResult + } else if hasResumed { + result = nil + } else { + self.continuation = continuation + result = nil + } + lock.unlock() + + if let result { + resume(continuation, with: result) + } + } + + func resume(returning value: Value) { + resume(with: .success(value)) + } + + func resume(throwing error: Error) { + resume(with: .failure(error)) + } + + private func resume(with result: Result) { + let continuationToResume: CheckedContinuation? + + lock.lock() + if hasResumed { + continuationToResume = nil + } else if let continuation { + self.continuation = nil + hasResumed = true + continuationToResume = continuation + } else { + pendingResult = result + hasResumed = true + continuationToResume = nil + } + lock.unlock() + + if let continuationToResume { + resume(continuationToResume, with: result) + } + } + + private func resume( + _ continuation: CheckedContinuation, + with result: Result + ) { + switch result { + case .success(let value): + continuation.resume(returning: value) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} + struct StreamMultipartResolvedFilePart { let fieldName: String let fileName: String @@ -159,21 +256,28 @@ enum StreamMultipartUploadSourceResolver { let options = PHContentEditingInputRequestOptions() options.isNetworkAccessAllowed = true let requestBox = StreamContentEditingInputRequestBox(asset: asset) + let continuationBox = StreamMultipartContinuationBox() return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in + continuationBox.set(continuation) + if Task.isCancelled { + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) + return + } + let requestId = asset.requestContentEditingInput(with: options) { input, _ in if Task.isCancelled { - continuation.resume(throwing: StreamMultipartUploadError.cancelled) + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) return } if let url = input?.fullSizeImageURL { - continuation.resume(returning: url) + continuationBox.resume(returning: url) return } - continuation.resume( + continuationBox.resume( throwing: StreamMultipartUploadError.unsupportedSource(asset.localIdentifier) ) } @@ -181,6 +285,7 @@ enum StreamMultipartUploadSourceResolver { } } onCancel: { requestBox.cancel() + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) } } @@ -190,31 +295,38 @@ enum StreamMultipartUploadSourceResolver { options.isNetworkAccessAllowed = true options.version = .current let requestBox = StreamPhotoRequestBox() + let continuationBox = StreamMultipartContinuationBox() return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in + continuationBox.set(continuation) + if Task.isCancelled { + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) + return + } + let requestId = PHImageManager.default().requestAVAsset(forVideo: asset, options: options) { avAsset, _, info in if Task.isCancelled { - continuation.resume(throwing: StreamMultipartUploadError.cancelled) + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) return } if let isCancelled = (info?[PHImageCancelledKey] as? NSNumber)?.boolValue, isCancelled { - continuation.resume(throwing: StreamMultipartUploadError.cancelled) + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) return } if let error = info?[PHImageErrorKey] as? Error { - continuation.resume(throwing: error) + continuationBox.resume(throwing: error) return } if let url = (avAsset as? AVURLAsset)?.url { - continuation.resume(returning: url) + continuationBox.resume(returning: url) return } - continuation.resume( + continuationBox.resume( throwing: StreamMultipartUploadError.unsupportedSource(asset.localIdentifier) ) } @@ -222,6 +334,7 @@ enum StreamMultipartUploadSourceResolver { } } onCancel: { requestBox.cancel() + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) } } diff --git a/package/shared-native/ios/StreamMultipartUploaderBridge.swift b/package/shared-native/ios/StreamMultipartUploaderBridge.swift index d1922ebca1..0dc41f84e1 100644 --- a/package/shared-native/ios/StreamMultipartUploaderBridge.swift +++ b/package/shared-native/ios/StreamMultipartUploaderBridge.swift @@ -53,7 +53,7 @@ public final class StreamMultipartUploaderBridge: NSObject { taskLock.unlock() if replacedTaskBox != nil { replacedTaskBox?.cancel() - StreamMultipartUploadManager.shared.cancel(uploadId: uploadId) + StreamMultipartUploadManager.shared.cancelInFlight(uploadId: uploadId) } let task = Task(priority: .userInitiated) { diff --git a/package/src/__tests__/nativeMultipartUpload.test.ts b/package/src/__tests__/nativeMultipartUpload.test.ts new file mode 100644 index 0000000000..61c5df8c1b --- /dev/null +++ b/package/src/__tests__/nativeMultipartUpload.test.ts @@ -0,0 +1,262 @@ +import { + createNativeMultipartUpload, + createNativeMultipartUploader, + NativeMultipartAbortSignal, + NativeMultipartUploadEventEmitter, + NativeMultipartUploadProgressEvent, +} from '../nativeMultipartUpload'; + +const progressEventName = 'streamMultipartUploadProgress'; + +const filePart = { + fieldName: 'file', + fileName: 'test.jpg', + kind: 'file' as const, + mimeType: 'image/jpeg', + uri: 'file:///tmp/test.jpg', +}; + +const createNativeModule = () => ({ + addListener: jest.fn(), + cancelUpload: jest.fn(() => Promise.resolve()), + removeListeners: jest.fn(), + uploadMultipart: jest.fn(() => + Promise.resolve({ + body: 'ok', + headers: [{ name: 'x-test', value: 'yes' }], + status: 201, + statusText: 'Created', + }), + ), +}); + +const createEventEmitter = () => { + const listeners = new Map void>>(); + const subscriptions: Array<{ remove: jest.Mock }> = []; + + const eventEmitter: NativeMultipartUploadEventEmitter & { + emit: (eventType: string, event: NativeMultipartUploadProgressEvent) => void; + subscriptions: Array<{ remove: jest.Mock }>; + } = { + addListener: jest.fn((eventType, listener) => { + const eventListeners = listeners.get(eventType) ?? new Set(); + eventListeners.add(listener); + listeners.set(eventType, eventListeners); + + const subscription = { + remove: jest.fn(() => { + eventListeners.delete(listener); + }), + }; + subscriptions.push(subscription); + return subscription; + }), + emit: (eventType, event) => { + listeners.get(eventType)?.forEach((listener) => listener(event)); + }, + subscriptions, + }; + + return eventEmitter; +}; + +describe('nativeMultipartUpload', () => { + it('does not create a native uploader when the native module is missing', () => { + expect(createNativeMultipartUploader(null)).toBeUndefined(); + }); + + it('passes requests to the native module and forwards matching progress events', async () => { + const nativeModule = createNativeModule(); + const eventEmitter = createEventEmitter(); + let resolveUpload: (response: Awaited>) => void; + nativeModule.uploadMultipart.mockImplementation( + () => + new Promise((resolve) => { + resolveUpload = resolve; + }), + ); + const uploadMultipart = createNativeMultipartUploader(nativeModule, { eventEmitter }); + const onProgress = jest.fn(); + + const responsePromise = uploadMultipart?.({ + headers: { Authorization: 'token' }, + method: 'POST', + onProgress, + parts: [filePart], + progress: { count: 10 }, + timeoutMs: 1234, + uploadId: 'upload-id', + url: 'https://example.com/upload', + }); + + eventEmitter.emit(progressEventName, { + loaded: 5, + total: 10, + uploadId: 'other-upload-id', + }); + eventEmitter.emit(progressEventName, { + loaded: 10, + total: null, + uploadId: 'upload-id', + }); + resolveUpload!({ + body: 'ok', + headers: [{ name: 'x-test', value: 'yes' }], + status: 201, + statusText: null, + }); + + await expect(responsePromise).resolves.toEqual({ + body: 'ok', + headers: { 'x-test': 'yes' }, + status: 201, + statusText: undefined, + }); + expect(onProgress).toHaveBeenCalledTimes(1); + expect(onProgress).toHaveBeenCalledWith({ loaded: 10, total: undefined }); + expect(nativeModule.uploadMultipart).toHaveBeenCalledWith( + 'upload-id', + 'https://example.com/upload', + 'POST', + [{ name: 'Authorization', value: 'token' }], + [filePart], + { count: 10 }, + 1234, + ); + expect(eventEmitter.subscriptions[0].remove).toHaveBeenCalledTimes(1); + }); + + it('throws an Axios-compatible cancellation error without pre-canceling native uploads', async () => { + const nativeModule = createNativeModule(); + const uploadMultipart = createNativeMultipartUploader(nativeModule, { + eventEmitter: createEventEmitter(), + }); + + await expect( + uploadMultipart?.({ + headers: {}, + method: 'POST', + parts: [filePart], + signal: { aborted: true }, + uploadId: 'upload-id', + url: 'https://example.com/upload', + }), + ).rejects.toMatchObject({ + __CANCEL__: true, + code: 'ERR_CANCELED', + name: 'CanceledError', + }); + expect(nativeModule.cancelUpload).not.toHaveBeenCalled(); + expect(nativeModule.uploadMultipart).not.toHaveBeenCalled(); + }); + + it('supports onabort-only signals and restores the previous handler', async () => { + const nativeModule = createNativeModule(); + const eventEmitter = createEventEmitter(); + let rejectUpload: (error: Error) => void; + nativeModule.uploadMultipart.mockImplementation( + () => + new Promise((_, reject) => { + rejectUpload = reject; + }), + ); + const uploadMultipart = createNativeMultipartUploader(nativeModule, { eventEmitter }); + const previousOnAbort = jest.fn(); + const signal: NativeMultipartAbortSignal = { + aborted: false, + onabort: previousOnAbort, + }; + + const responsePromise = uploadMultipart?.({ + headers: {}, + method: 'POST', + parts: [filePart], + signal, + uploadId: 'upload-id', + url: 'https://example.com/upload', + }); + + signal.aborted = true; + signal.onabort?.('abort-event'); + rejectUpload!(new Error('native aborted')); + + await expect(responsePromise).rejects.toMatchObject({ + __CANCEL__: true, + code: 'ERR_CANCELED', + name: 'CanceledError', + }); + expect(previousOnAbort).toHaveBeenCalledWith('abort-event'); + expect(nativeModule.cancelUpload).toHaveBeenCalledWith('upload-id'); + expect(signal.onabort).toBe(previousOnAbort); + }); + + it('does not create a multipart upload handler without an uploader', () => { + expect(createNativeMultipartUpload({ uploadMultipart: undefined })).toBeUndefined(); + }); + + it('resolves photo library URIs and strips non-native progress options', async () => { + const uploadMultipart = jest.fn(() => + Promise.resolve({ + body: 'ok', + status: 200, + }), + ); + const getLocalAssetUri = jest.fn(() => Promise.resolve('/tmp/image.jpg?token=1#fragment')); + const multipartUpload = createNativeMultipartUpload({ + getLocalAssetUri, + uploadIdFactory: () => 'generated-upload-id', + uploadMultipart, + }); + + await multipartUpload?.({ + headers: {}, + method: 'POST', + parts: [{ ...filePart, uri: 'ph://asset-id' }], + progress: { + completionProgressCap: 75, + count: 10, + intervalMs: 50, + }, + url: 'https://example.com/upload', + }); + + expect(getLocalAssetUri).toHaveBeenCalledWith('ph://asset-id'); + expect(uploadMultipart).toHaveBeenCalledWith( + expect.objectContaining({ + parts: [{ ...filePart, uri: 'file:///tmp/image.jpg' }], + progress: { + count: 10, + intervalMs: 50, + }, + uploadId: 'generated-upload-id', + }), + ); + }); + + it('falls back to the original photo library URI when JS resolution fails', async () => { + const uploadMultipart = jest.fn(() => + Promise.resolve({ + body: 'ok', + status: 200, + }), + ); + const multipartUpload = createNativeMultipartUpload({ + getLocalAssetUri: jest.fn(() => Promise.reject(new Error('resolution failed'))), + uploadIdFactory: () => 'generated-upload-id', + uploadMultipart, + }); + + await multipartUpload?.({ + headers: {}, + method: 'POST', + parts: [{ ...filePart, uri: 'assets-library://asset-id' }], + url: 'https://example.com/upload', + }); + + expect(uploadMultipart).toHaveBeenCalledWith( + expect.objectContaining({ + parts: [{ ...filePart, uri: 'assets-library://asset-id' }], + }), + ); + }); +}); diff --git a/package/src/index.ts b/package/src/index.ts index 2d53b2f005..8a8eacfb28 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -18,6 +18,7 @@ export * from './utils/i18n/Streami18n'; export * from './utils/setupCommandUIMiddlewares'; export * from './utils/createGenerateVideoThumbnails'; export * from './utils/utils'; +export * from './nativeMultipartUpload'; export { default as enTranslations } from './i18n/en.json'; export { default as esTranslations } from './i18n/es.json'; diff --git a/package/src/native.ts b/package/src/native.ts index de39d17975..783eee3e90 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -7,7 +7,27 @@ import { ViewStyle, } from 'react-native'; +import type { NativeMultipartUpload } from './nativeMultipartUpload'; import type { File } from './types/types'; + +export type { + NativeMultipartAbortSignal, + NativeMultipartCanceledError, + NativeMultipartUpload, + NativeMultipartUploadEventEmitter, + NativeMultipartUploadHeader, + NativeMultipartUploadNativeResponse, + NativeMultipartUploadPart, + NativeMultipartUploadProgressConfig, + NativeMultipartUploadProgressEvent, + NativeMultipartUploadRequest, + NativeMultipartUploadResult, + NativeMultipartUploader, + NativeMultipartUploaderModule, + NativeMultipartUploaderProgressConfig, + NativeMultipartUploaderRequest, +} from './nativeMultipartUpload'; + const fail = () => { throw Error( 'Native handler was not registered, you should import stream-chat-expo or stream-chat-react-native', @@ -28,63 +48,6 @@ type CompressImage = ({ type DeleteFile = ({ uri }: { uri: string }) => Promise | never; -// Axios uses a looser GenericAbortSignal type than the DOM AbortSignal and -// the native multipart path only needs this shared subset for cancellation -export type NativeMultipartAbortSignal = { - aborted: boolean; - addEventListener?: (...args: unknown[]) => unknown; - onabort?: ((...args: unknown[]) => unknown) | null; - removeEventListener?: (...args: unknown[]) => unknown; -}; - -export type NativeMultipartUploadRequest = { - headers: Record; - method: string; - onProgress?: (progress: { loaded: number; total?: number }) => void; - parts: NativeMultipartUploadPart[]; - progress?: NativeMultipartUploadProgressConfig; - signal?: NativeMultipartAbortSignal; - timeoutMs?: number; - url: string; -}; - -export type NativeMultipartUploadPart = - | { - fieldName: string; - kind: 'file'; - fileName: string; - mimeType?: string; - uri: string; - } - | { - fieldName: string; - kind: 'text'; - value: string; - }; - -export type NativeMultipartUploadProgressConfig = { - /** - * Maximum progress percentage reported while the native request body is still being sent. - * Completion is represented by the upload request resolving and the upload indicator being removed. - * - * @default 90 - */ - completionProgressCap?: number; - count?: number; - intervalMs?: number; -}; - -export type NativeMultipartUploadResult = { - body: string; - headers?: Record; - status: number; - statusText?: string; -}; - -type MultipartUpload = ( - request: NativeMultipartUploadRequest, -) => Promise | never; - type GetLocalAssetUri = (uriOrAssetId: string) => Promise | never; type OniOS14LibrarySelectionChange = (callback: () => void) => { unsubscribe: () => void }; @@ -365,7 +328,7 @@ type Handlers = { getLocalAssetUri?: GetLocalAssetUri; getPhotos?: GetPhotos; iOS14RefreshGallerySelection?: iOS14RefreshGallerySelection; - multipartUpload?: MultipartUpload; + multipartUpload?: NativeMultipartUpload; oniOS14GalleryLibrarySelectionChange?: OniOS14LibrarySelectionChange; overrideAudioRecordingConfiguration?: ( audioRecordingConfiguration: AudioRecordingConfiguration, diff --git a/package/src/nativeMultipartUpload.ts b/package/src/nativeMultipartUpload.ts new file mode 100644 index 0000000000..81164eaf20 --- /dev/null +++ b/package/src/nativeMultipartUpload.ts @@ -0,0 +1,384 @@ +import { NativeEventEmitter } from 'react-native'; + +const STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT = 'streamMultipartUploadProgress'; +const CANCELED_ERROR_CODE = 'ERR_CANCELED'; + +export type NativeMultipartAbortSignal = { + aborted: boolean; + addEventListener?: (...args: unknown[]) => unknown; + onabort?: ((...args: unknown[]) => unknown) | null; + removeEventListener?: (...args: unknown[]) => unknown; +}; + +export type NativeMultipartUploadHeader = { + name: string; + value: string; +}; + +export type NativeMultipartUploadPart = + | { + fieldName: string; + kind: 'file'; + fileName: string; + mimeType?: string; + uri: string; + } + | { + fieldName: string; + kind: 'text'; + value: string; + }; + +export type NativeMultipartUploaderProgressConfig = { + count?: number; + intervalMs?: number; +}; + +export type NativeMultipartUploadProgressConfig = NativeMultipartUploaderProgressConfig & { + /** + * Maximum progress percentage reported while the native request body is still being sent. + * Completion is represented by the upload request resolving and the upload indicator being removed. + * + * @default 90 + */ + completionProgressCap?: number; +}; + +export type NativeMultipartUploadProgressEvent = { + loaded: number; + total?: number | null; + uploadId: string; +}; + +export type NativeMultipartUploadNativeResponse = { + body: string; + headers?: ReadonlyArray | null; + status: number; + statusText?: string | null; +}; + +export type NativeMultipartUploadResult = { + body: string; + headers?: Record; + status: number; + statusText?: string; +}; + +export type NativeMultipartUploadRequest = { + headers: Record; + method: string; + onProgress?: (progress: { loaded: number; total?: number }) => void; + parts: NativeMultipartUploadPart[]; + progress?: NativeMultipartUploadProgressConfig; + signal?: NativeMultipartAbortSignal; + timeoutMs?: number; + url: string; +}; + +export type NativeMultipartUploaderRequest = Omit & { + progress?: NativeMultipartUploaderProgressConfig; + uploadId: string; +}; + +export type NativeMultipartUpload = ( + request: NativeMultipartUploadRequest, +) => Promise | never; + +export type NativeMultipartUploader = ( + request: NativeMultipartUploaderRequest, +) => Promise; + +export type NativeMultipartUploaderModule = { + addListener(eventType: string): void; + cancelUpload(uploadId: string): Promise; + removeListeners(count: number): void; + uploadMultipart( + uploadId: string, + url: string, + method: string, + headers: ReadonlyArray, + parts: ReadonlyArray, + progress?: NativeMultipartUploaderProgressConfig, + timeoutMs?: number | null, + ): Promise; +}; + +export type NativeMultipartUploadEventEmitter = { + addListener( + eventType: string, + listener: (event: NativeMultipartUploadProgressEvent) => void, + ): { remove: () => void }; +}; + +export type NativeMultipartCanceledError = Error & { + __CANCEL__: true; + code: typeof CANCELED_ERROR_CODE; +}; + +type CreateNativeMultipartUploaderOptions = { + eventEmitter?: NativeMultipartUploadEventEmitter; +}; + +type CreateNativeMultipartUploadOptions = { + getLocalAssetUri?: ((uri: string) => Promise) | null; + uploadIdFactory?: () => string; + uploadMultipart?: NativeMultipartUploader; +}; + +const createDefaultUploadId = () => + `stream-upload-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +const createCanceledError = (): NativeMultipartCanceledError => { + const error = new Error('Request aborted') as NativeMultipartCanceledError; + error.name = 'CanceledError'; + error.code = CANCELED_ERROR_CODE; + // eslint-disable-next-line no-underscore-dangle -- Axios marks cancellation with this legacy field, and callers still use axios.isCancel. + error.__CANCEL__ = true; + return error; +}; + +const toUploadHeaders = (headers: Record): NativeMultipartUploadHeader[] => + Object.entries(headers).map(([name, value]) => ({ name, value })); + +const fromUploadHeaders = ( + headers?: ReadonlyArray | null, +): Record | undefined => { + if (!headers?.length) { + return undefined; + } + + return headers.reduce>((acc, header) => { + acc[header.name] = header.value; + return acc; + }, {}); +}; + +const addAbortHandler = (signal: NativeMultipartAbortSignal | undefined, onAbort: () => void) => { + if (!signal) { + return () => undefined; + } + + let handled = false; + const handleAbort = () => { + if (handled) { + return; + } + + handled = true; + onAbort(); + }; + + if (typeof signal.addEventListener === 'function') { + signal.addEventListener('abort', handleAbort, { once: true }); + return () => { + signal.removeEventListener?.('abort', handleAbort); + }; + } + + const previousOnAbort = signal.onabort; + const chainedOnAbort = (...args: unknown[]) => { + previousOnAbort?.(...args); + handleAbort(); + }; + + signal.onabort = chainedOnAbort; + + return () => { + if (signal.onabort === chainedOnAbort) { + signal.onabort = previousOnAbort ?? null; + } + }; +}; + +const getNativeProgressConfig = ( + progress?: NativeMultipartUploadProgressConfig, +): NativeMultipartUploaderProgressConfig | undefined => { + if (!progress) { + return undefined; + } + + const nativeProgressConfig = { ...progress }; + delete nativeProgressConfig.completionProgressCap; + + return Object.keys(nativeProgressConfig).length ? nativeProgressConfig : undefined; +}; + +const isPhotoLibraryUri = (uri: string) => { + const normalizedUri = uri.toLowerCase(); + return normalizedUri.startsWith('ph://') || normalizedUri.startsWith('assets-library://'); +}; + +const sanitizeResolvedFileUri = (uri: string) => { + const normalizedUri = uri.startsWith('/') ? `file://${uri}` : uri; + + if (!normalizedUri.startsWith('file://')) { + return normalizedUri; + } + + return normalizedUri.split('#')[0].split('?')[0]; +}; + +const resolvePartUri = async ( + part: NativeMultipartUploadPart, + getLocalAssetUri: CreateNativeMultipartUploadOptions['getLocalAssetUri'], +): Promise => { + if ( + part.kind !== 'file' || + typeof getLocalAssetUri !== 'function' || + !isPhotoLibraryUri(part.uri) + ) { + return part; + } + + try { + const resolvedUri = await getLocalAssetUri(part.uri); + + return { + ...part, + uri: resolvedUri ? sanitizeResolvedFileUri(resolvedUri) : part.uri, + }; + } catch { + return part; + } +}; + +export const createNativeMultipartUploader = ( + nativeModule: NativeMultipartUploaderModule | null | undefined, + options: CreateNativeMultipartUploaderOptions = {}, +): NativeMultipartUploader | undefined => { + if (!nativeModule) { + return undefined; + } + + const multipartUploadEventEmitter = options.eventEmitter ?? new NativeEventEmitter(nativeModule); + + return async ({ + headers, + method, + onProgress, + parts, + progress, + signal, + timeoutMs, + uploadId, + url, + }: NativeMultipartUploaderRequest): Promise => { + let progressSubscription: + | { + remove: () => void; + } + | undefined; + let uploadStarted = false; + + const abortUpload = async () => { + try { + await nativeModule.cancelUpload(uploadId); + } catch { + // Ignore cancellation races for already-finished uploads. + } + }; + + const handleAbort = () => { + if (uploadStarted) { + abortUpload().catch(() => undefined); + } + }; + + if (signal?.aborted) { + throw createCanceledError(); + } + + const removeAbortListener = addAbortHandler(signal, handleAbort); + + if (signal?.aborted) { + removeAbortListener(); + throw createCanceledError(); + } + + if (onProgress) { + progressSubscription = multipartUploadEventEmitter.addListener( + STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT, + (event: NativeMultipartUploadProgressEvent) => { + if (event.uploadId !== uploadId) { + return; + } + + onProgress({ + loaded: event.loaded, + total: typeof event.total === 'number' ? event.total : undefined, + }); + }, + ); + } + + try { + uploadStarted = true; + const response = await nativeModule.uploadMultipart( + uploadId, + url, + method, + toUploadHeaders(headers), + parts, + progress ?? {}, + timeoutMs, + ); + + if (signal?.aborted) { + throw createCanceledError(); + } + + return { + body: response.body, + headers: fromUploadHeaders(response.headers), + status: response.status, + statusText: typeof response.statusText === 'string' ? response.statusText : undefined, + }; + } catch (error) { + if (signal?.aborted) { + throw createCanceledError(); + } + + throw error; + } finally { + progressSubscription?.remove(); + removeAbortListener(); + } + }; +}; + +export const createNativeMultipartUpload = ({ + getLocalAssetUri, + uploadIdFactory = createDefaultUploadId, + uploadMultipart, +}: CreateNativeMultipartUploadOptions): NativeMultipartUpload | undefined => { + if (!uploadMultipart) { + return undefined; + } + + return async ({ + headers, + method, + onProgress, + parts, + progress, + signal, + timeoutMs, + url, + }: NativeMultipartUploadRequest) => { + const resolvedParts = await Promise.all( + parts.map((part) => resolvePartUri(part, getLocalAssetUri)), + ); + + return uploadMultipart({ + headers, + method, + onProgress, + parts: resolvedParts, + progress: getNativeProgressConfig(progress), + signal, + timeoutMs, + uploadId: uploadIdFactory(), + url, + }); + }; +}; From 02557e1a60e4e7dde4ced730b825efb9b81a1d84 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 23 Apr 2026 11:05:05 +0200 Subject: [PATCH 13/26] fix: audio attachments --- .../src/components/Attachment/Attachment.tsx | 11 ++- .../Attachment/__tests__/Attachment.test.js | 67 ++++++++++++++----- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/package/src/components/Attachment/Attachment.tsx b/package/src/components/Attachment/Attachment.tsx index 23c84f41dd..8232065edc 100644 --- a/package/src/components/Attachment/Attachment.tsx +++ b/package/src/components/Attachment/Attachment.tsx @@ -26,11 +26,13 @@ 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'; +import { isLocalUrl } from '../../utils/utils'; export type ActionHandler = (name: string, value: string) => void; @@ -188,13 +190,16 @@ const MessageAudioAttachment = ({ message, }: MessageAudioAttachmentProps) => { const localId = (attachment as DefaultAttachmentData).localId; - const indicator = ( + const sourceUrl = attachment.asset_url ?? attachment.originalFile?.uri; + const shouldTrackPendingUpload = !!localId && !!sourceUrl && isLocalUrl(sourceUrl); + const pendingUpload = usePendingAttachmentUpload(shouldTrackPendingUpload ? localId : undefined); + const indicator = pendingUpload.isUploading ? ( - ); + ) : undefined; const audioItemType = isVoiceRecordingAttachment(attachment) ? 'voiceRecording' : 'audio'; diff --git a/package/src/components/Attachment/__tests__/Attachment.test.js b/package/src/components/Attachment/__tests__/Attachment.test.js index c15205ebdd..fc3184ad93 100644 --- a/package/src/components/Attachment/__tests__/Attachment.test.js +++ b/package/src/components/Attachment/__tests__/Attachment.test.js @@ -1,8 +1,10 @@ import React from 'react'; +import { StyleSheet, View } from 'react-native'; import { render, waitFor } from '@testing-library/react-native'; import { v4 as uuidv4 } from 'uuid'; +import { AudioPlayerProvider } from '../../../contexts/audioPlayerContext/AudioPlayerContext'; import { MessageProvider } from '../../../contexts/messageContext/MessageContext'; import { MessagesProvider } from '../../../contexts/messagesContext/MessagesContext'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; @@ -19,10 +21,21 @@ import { ImageLoadingIndicator } from '../../Attachment/ImageLoadingIndicator'; import { Attachment } from '../Attachment'; import { FilePreview as FilePreviewDefault } from '../FilePreview'; -jest.mock('../../../native.ts', () => ({ - isVideoPlayerAvailable: jest.fn(() => false), - isSoundPackageAvailable: jest.fn(() => false), -})); +jest.mock('../../../native.ts', () => { + const { View } = require('react-native'); + + return { + NativeHandlers: { + SDK: 'stream-chat-react-native', + Sound: { + initializeSound: jest.fn(() => null), + Player: View, + }, + }, + isVideoPlayerAvailable: jest.fn(() => false), + isSoundPackageAvailable: jest.fn(() => false), + }; +}); jest.mock('../../../hooks/usePendingAttachmentUpload', () => ({ usePendingAttachmentUpload: jest.fn(() => ({ @@ -35,21 +48,29 @@ const getAttachmentComponent = (props) => { const message = generateMessage(); return ( - - - - - + + + + + + + ); }; +const getWaveformBarCount = (root) => + root.findAllByType(View).filter((node) => { + const flattenedStyle = StyleSheet.flatten(node.props.style); + return flattenedStyle?.width === 2 && typeof flattenedStyle?.height === 'number'; + }).length; + describe('Attachment', () => { it('should render File component for "audio" type attachment', async () => { const attachment = generateAudioAttachment(); @@ -78,6 +99,22 @@ describe('Attachment', () => { }); }); + it('should render waveform for playable audio attachments without an active upload', async () => { + const { isSoundPackageAvailable } = require('../../../native'); + isSoundPackageAvailable.mockReturnValue(true); + const attachment = generateAudioAttachment({ + duration: 10, + waveform_data: [0.2, 0.6, 0.4], + }); + const { getByLabelText, root } = render(getAttachmentComponent({ attachment })); + + await waitFor(() => { + expect(getByLabelText('audio-attachment-preview')).toBeTruthy(); + expect(getWaveformBarCount(root)).toBeGreaterThan(0); + }); + isSoundPackageAvailable.mockReturnValue(false); + }); + it('should render UrlPreview component if attachment has title_link or og_scrape_url', async () => { const attachment = generateImageAttachment({ og_scrape_url: uuidv4(), From 4d1cd3c0400ea77ecea1a5f8429b8b3da1a528ac Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 23 Apr 2026 10:56:16 +0200 Subject: [PATCH 14/26] perf: shimmer view and fastimage --- examples/SampleApp/ios/Podfile.lock | 74 ++++- examples/SampleApp/package.json | 2 +- .../SampleAppComponentOverrides.tsx | 2 +- examples/SampleApp/yarn.lock | 10 +- .../shared-native/ios/StreamShimmerView.swift | 257 ++++++++++++------ 5 files changed, 248 insertions(+), 97 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 33279d1d8f..0b806c8b1f 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -138,6 +138,11 @@ PODS: - hermes-engine (0.81.6): - hermes-engine/Pre-built (= 0.81.6) - hermes-engine/Pre-built (0.81.6) + - libavif/core (0.11.1) + - libavif/libdav1d (0.11.1): + - libavif/core + - libdav1d (>= 0.6.0) + - libdav1d (1.2.0) - libwebp (1.5.0): - libwebp/demux (= 1.5.0) - libwebp/mux (= 1.5.0) @@ -2893,10 +2898,40 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNFastImage (8.6.3): + - RNFastImage (8.13.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - libavif/core (~> 0.11.1) + - libavif/libdav1d (~> 0.11.1) + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety - React-Core - - SDWebImage (~> 5.11.1) - - SDWebImageWebPCoder (~> 0.8.4) + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SDWebImage (>= 5.19.1) + - SDWebImageAVIFCoder (~> 0.11.0) + - SDWebImageSVGCoder (~> 1.7.0) + - SDWebImageWebPCoder (~> 0.14) + - SocketRocket + - Yoga - RNFBApp (22.2.1): - Firebase/CoreOnly (= 11.13.0) - React-Core @@ -3292,12 +3327,17 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - SDWebImage (5.11.1): - - SDWebImage/Core (= 5.11.1) - - SDWebImage/Core (5.11.1) - - SDWebImageWebPCoder (0.8.5): + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - SDWebImageAVIFCoder (0.11.1): + - libavif/core (>= 0.11.0) + - SDWebImage (~> 5.10) + - SDWebImageSVGCoder (1.7.0): + - SDWebImage/Core (~> 5.6) + - SDWebImageWebPCoder (0.15.0): - libwebp (~> 1.0) - - SDWebImage/Core (~> 5.10) + - SDWebImage/Core (~> 5.17) - SocketRocket (0.7.1) - stream-chat-react-native (8.1.0): - boost @@ -3476,7 +3516,7 @@ DEPENDENCIES: - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - - RNFastImage (from `../node_modules/react-native-fast-image`) + - "RNFastImage (from `../node_modules/@d11/react-native-fast-image`)" - "RNFBApp (from `../node_modules/@react-native-firebase/app`)" - "RNFBMessaging (from `../node_modules/@react-native-firebase/messaging`)" - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) @@ -3508,11 +3548,15 @@ SPEC REPOS: - GoogleAppMeasurement - GoogleDataTransport - GoogleUtilities + - libavif + - libdav1d - libwebp - nanopb - PromisesObjC - PromisesSwift - SDWebImage + - SDWebImageAVIFCoder + - SDWebImageSVGCoder - SDWebImageWebPCoder - SocketRocket @@ -3689,7 +3733,7 @@ EXTERNAL SOURCES: RNCClipboard: :path: "../node_modules/@react-native-clipboard/clipboard" RNFastImage: - :path: "../node_modules/react-native-fast-image" + :path: "../node_modules/@d11/react-native-fast-image" RNFBApp: :path: "../node_modules/@react-native-firebase/app" RNFBMessaging: @@ -3739,6 +3783,8 @@ SPEC CHECKSUMS: GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 hermes-engine: 7219f6e751ad6ec7f3d7ec121830ee34dae40749 + libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 + libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NitroModules: 62786c3090e21b6e28baf91ea69257b1b75fdcfd @@ -3821,7 +3867,7 @@ SPEC CHECKSUMS: ReactCommon: 66eb46e6696f1f4816b250ab2807389018bacd78 RNCAsyncStorage: fd44f4b03e007e642e98df6726737bc66e9ba609 RNCClipboard: e560338bf6cc4656a09ff90610b62ddc0dbdad65 - RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87 + RNFastImage: 674d5912e174468a60971d2ba9efc7bb43d116fa RNFBApp: db9c2e6d36fe579ab19b82c0a4a417ff7569db7e RNFBMessaging: de62448d205095171915d622ed5fb45c2be5e075 RNGestureHandler: 6bc8f2f56c8a68f3380cd159f3a1ae06defcfabb @@ -3832,8 +3878,10 @@ SPEC CHECKSUMS: RNShare: c0f25f3d0ec275239c35cadbc98c94053118bee7 RNSVG: b1cb00d54dbc3066a3e98732e5418c8361335124 RNWorklets: 68ab13976d7eba39fb2f0844994a51380e76046d - SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d - SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 + SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c + SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 stream-chat-react-native: d15df89b47c1a08bc7db90c316d34b8ac4e13900 Teleport: ed828b19e62ca8b9ec101d991bf0594b1c1c8812 diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 228b0e2334..c0353722d5 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -32,6 +32,7 @@ "fastlane:ios-deploy": "bundle exec fastlane ios deploy_to_testflight_qa deploy:true" }, "dependencies": { + "@d11/react-native-fast-image": "^8.13.0", "@emoji-mart/data": "^1.2.1", "@notifee/react-native": "^9.1.8", "@op-engineering/op-sqlite": "^14.0.4", @@ -54,7 +55,6 @@ "react": "19.1.4", "react-native": "0.81.6", "react-native-blob-util": "^0.22.2", - "react-native-fast-image": "^8.6.3", "react-native-gesture-handler": "^2.31.0", "react-native-haptic-feedback": "^2.3.3", "react-native-image-picker": "^8.2.1", diff --git a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx index d5ec67d778..5bba6e1624 100644 --- a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx +++ b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { Platform, StyleSheet, useColorScheme, View } from 'react-native'; import type { ComponentOverrides } from 'stream-chat-react-native'; import { BlurView } from '@react-native-community/blur'; -import FastImage from 'react-native-fast-image'; +import FastImage from '@d11/react-native-fast-image'; import { useTheme, } from 'stream-chat-react-native'; diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 8dac41d7b4..3dc3ab4a6a 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -1124,6 +1124,11 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@d11/react-native-fast-image@^8.13.0": + version "8.13.0" + resolved "https://registry.yarnpkg.com/@d11/react-native-fast-image/-/react-native-fast-image-8.13.0.tgz#ae73d61fdc54b6c0b97cb97860773fb9f8db2b7f" + integrity sha512-zfsBtYNttiZVV/NwEnN/PzgW3PGlGYqn0/6DUOQ/tCv1lO0gO7+S0GiANmNDl35oVmh8o0DK81lF8xAhYz/aNA== + "@egjs/hammerjs@^2.0.17": version "2.0.17" resolved "https://registry.yarnpkg.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz#5dc02af75a6a06e4c2db0202cae38c9263895124" @@ -7574,11 +7579,6 @@ react-native-drawer-layout@^4.1.10: dependencies: use-latest-callback "^0.2.3" -react-native-fast-image@^8.6.3: - version "8.6.3" - resolved "https://registry.yarnpkg.com/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz#6edc3f9190092a909d636d93eecbcc54a8822255" - integrity sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg== - react-native-gesture-handler@^2.31.0: version "2.31.0" resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.31.0.tgz#7963b37b5566134bb6006024ec6a20d215a5b1a0" diff --git a/package/shared-native/ios/StreamShimmerView.swift b/package/shared-native/ios/StreamShimmerView.swift index cea996385c..d126cdd5b2 100644 --- a/package/shared-native/ios/StreamShimmerView.swift +++ b/package/shared-native/ios/StreamShimmerView.swift @@ -1,6 +1,74 @@ import QuartzCore import UIKit +private protocol StreamShimmerAppLifecycleObserving: AnyObject { + func shimmerAppLifecycleDidChange(isActive: Bool) +} + +private final class StreamShimmerAppLifecycleCoordinator: NSObject { + static let shared = StreamShimmerAppLifecycleCoordinator() + + private let observers = NSHashTable.weakObjects() + + private(set) var isAppActive: Bool + + private init(notificationCenter: NotificationCenter = .default) { + isAppActive = Self.currentAppActiveState() + super.init() + + notificationCenter.addObserver( + self, + selector: #selector(handleWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + notificationCenter.addObserver( + self, + selector: #selector(handleDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + func addObserver(_ observer: StreamShimmerAppLifecycleObserving) { + observers.add(observer as AnyObject) + observer.shimmerAppLifecycleDidChange(isActive: isAppActive) + } + + func removeObserver(_ observer: StreamShimmerAppLifecycleObserving) { + observers.remove(observer as AnyObject) + } + + @objc + private func handleWillEnterForeground() { + broadcastAppState(isActive: true) + } + + @objc + private func handleDidEnterBackground() { + broadcastAppState(isActive: false) + } + + private func broadcastAppState(isActive: Bool) { + self.isAppActive = isActive + + for case let observer as StreamShimmerAppLifecycleObserving in observers.allObjects { + observer.shimmerAppLifecycleDidChange(isActive: isActive) + } + } + + private static func currentAppActiveState() -> Bool { + switch UIApplication.shared.applicationState { + case .active, .inactive: + return true + case .background: + return false + @unknown default: + return true + } + } +} + /// Native shimmer view used by the Fabric component view. /// /// It renders a base layer and a moving gradient highlight entirely in native code, so shimmer @@ -8,14 +76,16 @@ import UIKit /// stops animation when it is not drawable (backgrounded, detached, hidden, or zero sized). @objcMembers public final class StreamShimmerView: UIView { - private static let edgeHighlightAlpha: CGFloat = 0.1 private static let softHighlightAlpha: CGFloat = 0.24 - private static let midHighlightAlpha: CGFloat = 0.48 - private static let innerHighlightAlpha: CGFloat = 0.72 private static let defaultHighlightAlpha: CGFloat = 0.35 private static let defaultShimmerDuration: CFTimeInterval = 1.2 private static let shimmerStripWidthRatio: CGFloat = 1.25 private static let shimmerAnimationKey = "stream_shimmer_translate_x" + private static let gradientLocations: [NSNumber] = [0.0, 0.35, 0.5, 0.65, 1.0] + private static let gradientAlphaFactors: [CGFloat] = [0, softHighlightAlpha, 1, softHighlightAlpha, 0] + private static var animationDistanceTolerance: CGFloat { + 1 / max(UIScreen.main.scale, 1) + } private let baseLayer = CALayer() private let shimmerLayer = CAGradientLayer() @@ -25,23 +95,37 @@ public final class StreamShimmerView: UIView { private var enabled = false private var shimmerDuration: CFTimeInterval = defaultShimmerDuration private var lastAnimatedDuration: CFTimeInterval = 0 - private var lastAnimatedSize: CGSize = .zero - private var isAppActive = true + private var lastAnimatedTravelDistance: CGFloat = 0 + private var isAppActive = StreamShimmerAppLifecycleCoordinator.shared.isAppActive + private var needsBaseColorUpdate = true + private var needsGradientColorUpdate = true + + public override var isHidden: Bool { + didSet { + updateLayersForCurrentState() + } + } + + public override var alpha: CGFloat { + didSet { + updateLayersForCurrentState() + } + } public override init(frame: CGRect) { super.init(frame: frame) setupLayers() - setupLifecycleObservers() + StreamShimmerAppLifecycleCoordinator.shared.addObserver(self) } public required init?(coder: NSCoder) { super.init(coder: coder) setupLayers() - setupLifecycleObservers() + StreamShimmerAppLifecycleCoordinator.shared.addObserver(self) } deinit { - NotificationCenter.default.removeObserver(self) + StreamShimmerAppLifecycleCoordinator.shared.removeObserver(self) } public override func layoutSubviews() { @@ -69,6 +153,7 @@ public final class StreamShimmerView: UIView { { // In current usage, colors are typically driven by JS props. We still refresh on trait // changes so dynamically resolved native colors remain correct if that path is used later. + invalidateResolvedColors() updateLayersForCurrentState() } } @@ -79,17 +164,34 @@ public final class StreamShimmerView: UIView { durationMilliseconds: Double, enabled: Bool ) { - self.baseColor = baseColor - self.gradientColor = gradientColor - shimmerDuration = Self.normalizedDuration(milliseconds: durationMilliseconds) + let normalizedDuration = Self.normalizedDuration(milliseconds: durationMilliseconds) + let baseColorChanged = !self.baseColor.isEqual(baseColor) + let gradientColorChanged = !self.gradientColor.isEqual(gradientColor) + let durationChanged = shimmerDuration != normalizedDuration + let enabledChanged = self.enabled != enabled + + if baseColorChanged { + self.baseColor = baseColor + needsBaseColorUpdate = true + } + + if gradientColorChanged { + self.gradientColor = gradientColor + needsGradientColorUpdate = true + } + + shimmerDuration = normalizedDuration self.enabled = enabled - updateLayersForCurrentState() + + if baseColorChanged || gradientColorChanged || durationChanged || enabledChanged { + updateLayersForCurrentState() + } } public func stopAnimation() { shimmerLayer.removeAnimation(forKey: Self.shimmerAnimationKey) lastAnimatedDuration = 0 - lastAnimatedSize = .zero + lastAnimatedTravelDistance = 0 } private func setupLayers() { @@ -99,86 +201,73 @@ public final class StreamShimmerView: UIView { shimmerLayer.allowsEdgeAntialiasing = true shimmerLayer.startPoint = CGPoint(x: 0, y: 0.5) shimmerLayer.endPoint = CGPoint(x: 1, y: 0.5) - shimmerLayer.locations = [0.0, 0.08, 0.2, 0.32, 0.4, 0.5, 0.6, 0.68, 0.8, 0.92, 1.0] + shimmerLayer.locations = Self.gradientLocations layer.addSublayer(baseLayer) layer.addSublayer(shimmerLayer) } - private func setupLifecycleObservers() { - NotificationCenter.default.addObserver( - self, - selector: #selector(handleWillEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - } - - @objc - private func handleWillEnterForeground() { - // iOS can drop active layer animations while the app is backgrounded. We explicitly rerun - // a state update on foreground so shimmer reliably restarts when returning to the app. - isAppActive = true - updateLayersForCurrentState() - } - - @objc - private func handleDidEnterBackground() { - isAppActive = false - stopAnimation() - } - private func updateLayersForCurrentState() { let bounds = self.bounds + let shouldHideShimmer = !enabled || bounds.isEmpty || isHidden || alpha <= 0.01 + + shimmerLayer.isHidden = shouldHideShimmer + guard !bounds.isEmpty else { stopAnimation() return } baseLayer.frame = bounds - baseLayer.backgroundColor = baseColor.cgColor - - updateShimmerLayer(for: bounds) + updateBaseLayerColorIfNeeded() + updateShimmerGeometry(for: bounds) + updateShimmerColorsIfNeeded() updateShimmerAnimation(for: bounds) } - private func updateShimmerLayer(for bounds: CGRect) { - // Rebuild the shimmer gradient for current width/colors. Keep this tied to real state changes - // such as layout/prop updates, not continuous per frame calls. + private func updateBaseLayerColorIfNeeded() { + guard needsBaseColorUpdate else { return } + baseLayer.backgroundColor = baseColor.resolvedColor(with: traitCollection).cgColor + needsBaseColorUpdate = false + } + + private func updateShimmerGeometry(for bounds: CGRect) { let shimmerWidth = max(bounds.width * Self.shimmerStripWidthRatio, 1) - let transparentHighlight = color(gradientColor, alphaFactor: 0) shimmerLayer.frame = CGRect(x: -shimmerWidth, y: 0, width: shimmerWidth, height: bounds.height) - shimmerLayer.colors = [ - transparentHighlight.cgColor, - color(gradientColor, alphaFactor: Self.edgeHighlightAlpha).cgColor, - color(gradientColor, alphaFactor: Self.softHighlightAlpha).cgColor, - color(gradientColor, alphaFactor: Self.midHighlightAlpha).cgColor, - color(gradientColor, alphaFactor: Self.innerHighlightAlpha).cgColor, - gradientColor.cgColor, - color(gradientColor, alphaFactor: Self.innerHighlightAlpha).cgColor, - color(gradientColor, alphaFactor: Self.midHighlightAlpha).cgColor, - color(gradientColor, alphaFactor: Self.softHighlightAlpha).cgColor, - color(gradientColor, alphaFactor: Self.edgeHighlightAlpha).cgColor, - transparentHighlight.cgColor, - ] - shimmerLayer.isHidden = !enabled + } + + private func updateShimmerColorsIfNeeded() { + guard needsGradientColorUpdate else { return } + + let resolvedGradientColor = gradientColor.resolvedColor(with: traitCollection) + shimmerLayer.colors = Self.gradientAlphaFactors.map { + color(resolvedGradientColor, alphaFactor: $0).cgColor + } + needsGradientColorUpdate = false } private func updateShimmerAnimation(for bounds: CGRect) { - guard enabled, isAppActive, window != nil, bounds.width > 0, bounds.height > 0 else { + guard + enabled, + isAppActive, + window != nil, + !isHidden, + alpha > 0.01, + bounds.width > 0, + bounds.height > 0 + else { stopAnimation() return } - // If an animation already exists for the same size, keep it running instead of restarting. + let shimmerWidth = max(bounds.width * Self.shimmerStripWidthRatio, 1) + let animationTravelDistance = bounds.width + shimmerWidth + + // If an animation already exists for the same travel distance, keep it running instead of + // restarting. Fabric can relayout the view for height-only or subpixel changes that do not + // require a new horizontal sweep. if shimmerLayer.animation(forKey: Self.shimmerAnimationKey) != nil, - lastAnimatedSize == bounds.size, + abs(lastAnimatedTravelDistance - animationTravelDistance) <= Self.animationDistanceTolerance, lastAnimatedDuration == shimmerDuration { return @@ -187,17 +276,16 @@ public final class StreamShimmerView: UIView { stopAnimation() // Start just outside the left edge and sweep fully past the right edge for a clean pass. - let shimmerWidth = max(bounds.width * Self.shimmerStripWidthRatio, 1) let animation = CABasicAnimation(keyPath: "transform.translation.x") animation.fromValue = 0 - animation.toValue = bounds.width + shimmerWidth + animation.toValue = animationTravelDistance animation.duration = shimmerDuration animation.repeatCount = .infinity animation.timingFunction = CAMediaTimingFunction(name: .linear) animation.isRemovedOnCompletion = true shimmerLayer.add(animation, forKey: Self.shimmerAnimationKey) lastAnimatedDuration = shimmerDuration - lastAnimatedSize = bounds.size + lastAnimatedTravelDistance = animationTravelDistance } private static func normalizedDuration(milliseconds: Double) -> CFTimeInterval { @@ -205,28 +293,30 @@ public final class StreamShimmerView: UIView { return milliseconds / 1000 } - private func color(_ color: UIColor, alphaFactor: CGFloat) -> UIColor { - // Preserve the resolved color channels and shape only alpha for smooth highlight falloff. - let resolvedColor = color.resolvedColor(with: traitCollection) + private func invalidateResolvedColors() { + needsBaseColorUpdate = true + needsGradientColorUpdate = true + } + private func color(_ color: UIColor, alphaFactor: CGFloat) -> UIColor { var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 - if resolvedColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) { + if color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) { return UIColor(red: red, green: green, blue: blue, alpha: alpha * alphaFactor) } guard - let converted = resolvedColor.cgColor.converted( + let converted = color.cgColor.converted( to: CGColorSpace(name: CGColorSpace.extendedSRGB)!, intent: .defaultIntent, options: nil ), let components = converted.components else { - return resolvedColor.withAlphaComponent(resolvedColor.cgColor.alpha * alphaFactor) + return color.withAlphaComponent(color.cgColor.alpha * alphaFactor) } switch components.count { @@ -243,7 +333,20 @@ public final class StreamShimmerView: UIView { alpha: components[3] * alphaFactor ) default: - return resolvedColor.withAlphaComponent(resolvedColor.cgColor.alpha * alphaFactor) + return color.withAlphaComponent(color.cgColor.alpha * alphaFactor) + } + } +} + +extension StreamShimmerView: StreamShimmerAppLifecycleObserving { + func shimmerAppLifecycleDidChange(isActive: Bool) { + // iOS can drop active layer animations while the app is backgrounded. We explicitly rerun + // a state update on foreground so shimmer reliably restarts when returning to the app. + self.isAppActive = isActive + if isActive { + updateLayersForCurrentState() + } else { + stopAnimation() } } } From 5b68170426c11f8a09a062d8205647d39cec3918 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 23 Apr 2026 16:16:02 +0200 Subject: [PATCH 15/26] fix: upload progress styles --- .../Attachment/AttachmentUploadIndicator.tsx | 26 ++++++- .../Attachment/CircularProgressIndicator.tsx | 28 ++++++- package/src/components/Attachment/Gallery.tsx | 7 +- .../Attachment/MediaUploadProgressOverlay.tsx | 77 +++++++++++++++++++ .../components/Attachment/VideoThumbnail.tsx | 14 +--- .../src/contexts/themeContext/utils/theme.ts | 10 +++ 6 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 package/src/components/Attachment/MediaUploadProgressOverlay.tsx diff --git a/package/src/components/Attachment/AttachmentUploadIndicator.tsx b/package/src/components/Attachment/AttachmentUploadIndicator.tsx index 516bdcbab9..3e625765b3 100644 --- a/package/src/components/Attachment/AttachmentUploadIndicator.tsx +++ b/package/src/components/Attachment/AttachmentUploadIndicator.tsx @@ -3,6 +3,7 @@ import { ActivityIndicator, StyleSheet, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; import { CircularProgressIndicator } from './CircularProgressIndicator'; +import { MediaUploadProgressOverlay } from './MediaUploadProgressOverlay'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload'; @@ -16,6 +17,7 @@ export type AttachmentUploadIndicatorProps = { strokeWidth?: number; style?: StyleProp; testID?: string; + variant?: 'compact' | 'overlay'; }; /** @@ -28,6 +30,7 @@ export const AttachmentUploadIndicatorUI = ({ strokeWidth = 2, style, testID, + variant = 'compact', }: AttachmentUploadIndicatorProps) => { const { theme: { semantics }, @@ -35,11 +38,24 @@ export const AttachmentUploadIndicatorUI = ({ const pendingUpload = usePendingAttachmentUpload(localId); const uploadProgress = pendingUpload.uploadProgress; const shouldRender = pendingUpload.isUploading; + const resolvedSize = variant === 'overlay' && size === 16 ? 28 : size; + const resolvedStrokeWidth = variant === 'overlay' && strokeWidth === 2 ? 3 : strokeWidth; if (!shouldRender) { return null; } + if (variant === 'overlay') { + return ( + + ); + } + return ( {uploadProgress === undefined ? ( @@ -52,12 +68,14 @@ export const AttachmentUploadIndicatorUI = ({ ) : ( )} @@ -68,6 +86,7 @@ export const AttachmentUploadIndicator = ({ containerStyle, localId, sourceUrl, + variant, ...props }: AttachmentUploadIndicatorProps) => { const shouldTrackPendingUpload = !!localId && !!sourceUrl && isLocalUrl(sourceUrl); @@ -81,6 +100,7 @@ export const AttachmentUploadIndicator = ({ {...props} containerStyle={containerStyle} localId={localId} + variant={variant} /> ); }; diff --git a/package/src/components/Attachment/CircularProgressIndicator.tsx b/package/src/components/Attachment/CircularProgressIndicator.tsx index 26d650d7bb..0a9f0caaa2 100644 --- a/package/src/components/Attachment/CircularProgressIndicator.tsx +++ b/package/src/components/Attachment/CircularProgressIndicator.tsx @@ -17,8 +17,10 @@ const PROGRESS_ANIMATION_DURATION_MS = 1200; export type CircularProgressIndicatorProps = { /** Upload percent **0–100**. */ + backgroundColor: ColorValue; + filledColor: ColorValue; progress: number; - color: ColorValue; + unfilledColor: ColorValue; size?: number; strokeWidth?: number; style?: StyleProp; @@ -29,12 +31,14 @@ export type CircularProgressIndicatorProps = { * Circular upload progress ring (determinate) or rotating arc (indeterminate). */ export const CircularProgressIndicator = ({ - color, + backgroundColor, + filledColor, progress, size = 16, strokeWidth = 2, style, testID, + unfilledColor, }: CircularProgressIndicatorProps) => { const animatedProgress = useSharedValue(0); const rotation = useSharedValue(0); @@ -99,13 +103,21 @@ export const CircularProgressIndicator = ({ if (fraction !== undefined) { return ( + + {isLoadingImage ? : null} )} @@ -604,11 +604,6 @@ const useStyles = () => { top: 0, overflow: 'hidden', }, - uploadProgressOnImage: { - bottom: primitives.spacingXxs, - left: primitives.spacingXxs, - position: 'absolute', - }, }); }, [semantics, isMyMessage]); }; diff --git a/package/src/components/Attachment/MediaUploadProgressOverlay.tsx b/package/src/components/Attachment/MediaUploadProgressOverlay.tsx new file mode 100644 index 0000000000..a38728737c --- /dev/null +++ b/package/src/components/Attachment/MediaUploadProgressOverlay.tsx @@ -0,0 +1,77 @@ +import React, { useMemo } from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +import { CircularProgressIndicator } from './CircularProgressIndicator'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; + +export type MediaUploadProgressOverlayProps = { + progress?: number; + size?: number; + strokeWidth?: number; + testID?: string; +}; + +/** + * Full-cover upload overlay for image and video thumbnails. + */ +export const MediaUploadProgressOverlay = ({ + progress, + size = 18, + strokeWidth = 3, + testID, +}: MediaUploadProgressOverlayProps) => { + const styles = useStyles(); + const { + theme: { + messageItemView: { attachmentUploadIndicator }, + semantics, + }, + } = useTheme(); + + return ( + + {typeof progress === 'number' ? ( + + ) : ( + + )} + + ); +}; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + indicatorContainer: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: semantics.backgroundCoreOverlayLight, + }, + }), + [semantics], + ); +}; diff --git a/package/src/components/Attachment/VideoThumbnail.tsx b/package/src/components/Attachment/VideoThumbnail.tsx index a6124c5b89..760e4feff6 100644 --- a/package/src/components/Attachment/VideoThumbnail.tsx +++ b/package/src/components/Attachment/VideoThumbnail.tsx @@ -5,17 +5,9 @@ import { AttachmentUploadIndicator } from './AttachmentUploadIndicator'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -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', @@ -53,11 +45,7 @@ export const VideoThumbnail = (props: VideoThumbnailProps) => { style={[StyleSheet.absoluteFill, imageStyle]} /> - + ); }; diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index d722ea2a81..8eee45fdf3 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -677,6 +677,11 @@ export type Theme = { attachmentContainer: ViewStyle; container: ViewStyle; }; + attachmentUploadIndicator: { + indicator: ViewStyle; + overlay: ViewStyle; + overlayContent: ViewStyle; + }; gallery: { galleryContainer: ViewStyle; galleryItemColumn: ViewStyle; @@ -1602,6 +1607,11 @@ export const defaultTheme: Theme = { attachmentContainer: {}, container: {}, }, + attachmentUploadIndicator: { + indicator: {}, + overlay: {}, + overlayContent: {}, + }, gallery: { galleryContainer: {}, galleryItemColumn: {}, From 69cd37f40f990c3c08b90926944c9790652e3d49 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 23 Apr 2026 17:22:13 +0200 Subject: [PATCH 16/26] feat: component overrides --- .../Attachment/AttachmentFileUploadProgressIndicator.tsx | 4 ++-- .../src/components/Attachment/AttachmentUploadIndicator.tsx | 5 ++--- package/src/components/Attachment/Gallery.tsx | 4 ++-- .../components/Attachment/MediaUploadProgressOverlay.tsx | 4 ++-- package/src/components/Attachment/VideoThumbnail.tsx | 4 +--- package/src/components/index.ts | 1 + package/src/contexts/componentsContext/defaultComponents.ts | 6 ++++++ 7 files changed, 16 insertions(+), 12 deletions(-) diff --git a/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx b/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx index e63be2ef05..208ac9c319 100644 --- a/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx +++ b/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx @@ -2,8 +2,7 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; -import { AttachmentUploadIndicator } from './AttachmentUploadIndicator'; - +import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload'; import { primitives } from '../../theme'; @@ -49,6 +48,7 @@ export const AttachmentFileUploadProgressIndicatorUI = ({ const { theme: { semantics }, } = useTheme(); + const { AttachmentUploadIndicator } = useComponentsContext(); const shouldTrackPendingUpload = !!localId && !!sourceUrl && isLocalUrl(sourceUrl); const pendingUpload = usePendingAttachmentUpload(shouldTrackPendingUpload ? localId : undefined); const uploadProgress = pendingUpload.uploadProgress; diff --git a/package/src/components/Attachment/AttachmentUploadIndicator.tsx b/package/src/components/Attachment/AttachmentUploadIndicator.tsx index 3e625765b3..093ec3566b 100644 --- a/package/src/components/Attachment/AttachmentUploadIndicator.tsx +++ b/package/src/components/Attachment/AttachmentUploadIndicator.tsx @@ -2,9 +2,7 @@ import React from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; -import { CircularProgressIndicator } from './CircularProgressIndicator'; -import { MediaUploadProgressOverlay } from './MediaUploadProgressOverlay'; - +import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload'; import { isLocalUrl } from '../../utils/utils'; @@ -35,6 +33,7 @@ export const AttachmentUploadIndicatorUI = ({ const { theme: { semantics }, } = useTheme(); + const { CircularProgressIndicator, MediaUploadProgressOverlay } = useComponentsContext(); const pendingUpload = usePendingAttachmentUpload(localId); const uploadProgress = pendingUpload.uploadProgress; const shouldRender = pendingUpload.isUploading; diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 6691a766c6..716d2e325c 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -3,7 +3,6 @@ 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'; @@ -332,7 +331,8 @@ const GalleryImageThumbnail = ({ borderRadius, thumbnail, }: Pick) => { - const { ImageLoadingFailedIndicator, ImageLoadingIndicator } = useComponentsContext(); + const { AttachmentUploadIndicator, ImageLoadingFailedIndicator, ImageLoadingIndicator } = + useComponentsContext(); const { isLoadingImage, isLoadingImageError, diff --git a/package/src/components/Attachment/MediaUploadProgressOverlay.tsx b/package/src/components/Attachment/MediaUploadProgressOverlay.tsx index a38728737c..f36a89b57f 100644 --- a/package/src/components/Attachment/MediaUploadProgressOverlay.tsx +++ b/package/src/components/Attachment/MediaUploadProgressOverlay.tsx @@ -1,8 +1,7 @@ import React, { useMemo } from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import { CircularProgressIndicator } from './CircularProgressIndicator'; - +import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; export type MediaUploadProgressOverlayProps = { @@ -22,6 +21,7 @@ export const MediaUploadProgressOverlay = ({ testID, }: MediaUploadProgressOverlayProps) => { const styles = useStyles(); + const { CircularProgressIndicator } = useComponentsContext(); const { theme: { messageItemView: { attachmentUploadIndicator }, diff --git a/package/src/components/Attachment/VideoThumbnail.tsx b/package/src/components/Attachment/VideoThumbnail.tsx index 760e4feff6..8e30036bbb 100644 --- a/package/src/components/Attachment/VideoThumbnail.tsx +++ b/package/src/components/Attachment/VideoThumbnail.tsx @@ -1,8 +1,6 @@ import React from 'react'; import { ImageStyle, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; -import { AttachmentUploadIndicator } from './AttachmentUploadIndicator'; - import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { VideoPlayIndicator } from '../ui/VideoPlayIndicator'; @@ -34,7 +32,7 @@ export const VideoThumbnail = (props: VideoThumbnailProps) => { }, }, } = useTheme(); - const { ImageComponent } = useComponentsContext(); + const { AttachmentUploadIndicator, ImageComponent } = useComponentsContext(); const { imageStyle, localId, style, thumb_url } = props; return ( diff --git a/package/src/components/index.ts b/package/src/components/index.ts index a898402140..9a22b3dde0 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -8,6 +8,7 @@ export * from './Attachment/Gallery'; export * from './Attachment/Giphy'; export * from './Attachment/CircularProgressIndicator'; export * from './Attachment/AttachmentUploadIndicator'; +export * from './Attachment/MediaUploadProgressOverlay'; export * from './Attachment/VideoThumbnail'; export * from './Attachment/UrlPreview'; export * from './Attachment/utils/buildGallery/buildGallery'; diff --git a/package/src/contexts/componentsContext/defaultComponents.ts b/package/src/contexts/componentsContext/defaultComponents.ts index 544a415853..ca2460a841 100644 --- a/package/src/contexts/componentsContext/defaultComponents.ts +++ b/package/src/contexts/componentsContext/defaultComponents.ts @@ -4,7 +4,9 @@ import { Image, ImageProps, TextInputProps } from 'react-native'; import type { LocalMessage, UserResponse } from 'stream-chat'; import { Attachment } from '../../components/Attachment/Attachment'; +import { AttachmentUploadIndicator } from '../../components/Attachment/AttachmentUploadIndicator'; import { AudioAttachment } from '../../components/Attachment/Audio'; +import { CircularProgressIndicator } from '../../components/Attachment/CircularProgressIndicator'; import { FileAttachment } from '../../components/Attachment/FileAttachment'; import { FileAttachmentGroup } from '../../components/Attachment/FileAttachmentGroup'; import { FileIcon } from '../../components/Attachment/FileIcon'; @@ -13,6 +15,7 @@ import { Gallery } from '../../components/Attachment/Gallery'; import { Giphy } from '../../components/Attachment/Giphy'; import { ImageLoadingFailedIndicator } from '../../components/Attachment/ImageLoadingFailedIndicator'; import { ImageLoadingIndicator } from '../../components/Attachment/ImageLoadingIndicator'; +import { MediaUploadProgressOverlay } from '../../components/Attachment/MediaUploadProgressOverlay'; import { UnsupportedAttachment } from '../../components/Attachment/UnsupportedAttachment'; import { URLPreview } from '../../components/Attachment/UrlPreview'; import { URLPreviewCompact } from '../../components/Attachment/UrlPreview/URLPreviewCompact'; @@ -160,6 +163,7 @@ type NormalizeComponents = { const components = { Attachment, + AttachmentUploadIndicator, AttachButton, AttachmentPickerContent, AttachmentPickerSelectionBar, @@ -176,6 +180,7 @@ const components = { AutoCompleteSuggestionList, ChannelDetailsBottomSheet, CooldownTimer, + CircularProgressIndicator, DateHeader, EmptyStateIndicator, FileAttachment, @@ -206,6 +211,7 @@ const components = { LoadingErrorIndicator, ChannelListLoadingIndicator, MessageListLoadingIndicator: LoadingIndicator, + MediaUploadProgressOverlay, Message, MessageActionList, MessageActionListItem, From 9267be7b9fda30dcb0e7689be9bd6c80ed9dc2b7 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 23 Apr 2026 17:33:30 +0200 Subject: [PATCH 17/26] chore: make native uploads configurable --- package/src/components/Chat/Chat.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 5086162587..89df8a737e 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -44,6 +44,16 @@ export type ChatProps = Pick & * Enables offline storage and loading for chat data. */ enableOfflineSupport?: boolean; + /** + * When true, multipart uploads use the SDK's native upload adapter when available. + * When false, uploads stay on the default axios adapter. + * + * This only controls whether the native adapter gets installed by this Chat instance. + * It does not uninstall an adapter that was already installed on the client. + * + * @default true + */ + useNativeMultipartUpload?: boolean; /** * Instance of Streami18n class should be provided to Chat component to enable internationalization. * @@ -142,6 +152,7 @@ const ChatWithContext = (props: PropsWithChildren) => { i18nInstance, isMessageAIGenerated, style, + useNativeMultipartUpload = false, } = props; const { ChatLoadingIndicator } = useComponentsContext(); @@ -243,8 +254,12 @@ const ChatWithContext = (props: PropsWithChildren) => { }, [client]); useEffect(() => { + if (!useNativeMultipartUpload) { + return; + } + installNativeMultipartAdapter(client); - }, [client]); + }, [client, useNativeMultipartUpload]); const initialisedDatabase = !!offlineDbInitialized && userID === offlineDbUserId; From 1cea748480442c19b52a02742c77524bec959cec Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 23 Apr 2026 17:34:52 +0200 Subject: [PATCH 18/26] chore: bump stream-chat-js --- package/package.json | 2 +- package/yarn.lock | 51 +++++++++++++++++++++++++++----------------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/package/package.json b/package/package.json index 3cf1d43ef8..047c815a2b 100644 --- a/package/package.json +++ b/package/package.json @@ -82,7 +82,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.41.1", + "stream-chat": "^9.42.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/yarn.lock b/package/yarn.lock index 6a6ea61ebc..f7c50bdaff 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -3089,14 +3089,14 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" -axios@^1.12.2: - version "1.12.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.2.tgz#6c307390136cf7a2278d09cec63b136dfc6e6da7" - integrity sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw== +axios@^1.15.1: + version "1.15.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.2.tgz#eb8fb6d30349abace6ade5b4cb4d9e8a0dc23e5b" + integrity sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A== dependencies: - follow-redirects "^1.15.6" - form-data "^4.0.4" - proxy-from-env "^1.1.0" + follow-redirects "^1.15.11" + form-data "^4.0.5" + proxy-from-env "^2.1.0" babel-eslint@10.1.0: version "10.1.0" @@ -4678,10 +4678,10 @@ flow-enums-runtime@^0.0.6: resolved "https://registry.yarnpkg.com/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz#5bb0cd1b0a3e471330f4d109039b7eba5cb3e787" integrity sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw== -follow-redirects@^1.15.6: - version "1.15.9" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" - integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== +follow-redirects@^1.15.11: + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== for-each@^0.3.3: version "0.3.5" @@ -4709,6 +4709,17 @@ form-data@^4.0.4: hasown "^2.0.2" mime-types "^2.1.12" +form-data@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -7585,10 +7596,10 @@ prop-types@^15.5.10, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== pump@^3.0.0: version "3.0.2" @@ -8352,14 +8363,14 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -stream-chat@^9.41.1: - version "9.41.1" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.41.1.tgz#a877c8aa800d78b497eec2fad636345d4422309c" - integrity sha512-W8zjfINYol2UtdRMz2t/NN2GyjDrvb4pJgKmhtuRYzCY1u0Cjezcsu5OCNgyAM0QsenlY6tRqnvAU8Qam5R49Q== +stream-chat@^9.42.0: + version "9.42.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.42.0.tgz#ee0dafa01e9a306d5328edcd6c3e42c52f4c1a68" + integrity sha512-EwqCwo2VtZpX+6gx5vKqzKZ2L5VKovj8SSPdZXPejuR+gPhSnbzrgZCK5mChDvDEBqzPILInqlzyoevcs7JLYw== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" - axios "^1.12.2" + axios "^1.15.1" base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0" From e91b0287512fdb2f6f706fb808a95952fa3401f3 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 23 Apr 2026 22:48:53 +0200 Subject: [PATCH 19/26] feat: message composer progress --- .../AttachmentUploadProgressIndicator.tsx | 72 +++++++++---------- .../AudioAttachmentUploadPreview.tsx | 6 +- .../FileAttachmentUploadPreview.tsx | 17 ++++- .../ImageAttachmentUploadPreview.tsx | 25 +++++-- .../VideoAttachmentUploadPreview.tsx | 5 +- 5 files changed, 75 insertions(+), 50 deletions(-) diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx index ad6772860f..8980384cb1 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx @@ -1,32 +1,39 @@ import React, { useMemo } from 'react'; -import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import { LocalAttachmentUploadMetadata } from 'stream-chat'; +import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { ExclamationCircle } from '../../../../icons/exclamation-circle-fill'; import { Warning } from '../../../../icons/exclamation-triangle-fill'; import { primitives } from '../../../../theme'; import { RetryBadge } from '../../../ui/Badge/RetryBadge'; -export const FileUploadInProgressIndicator = () => { +export type UploadInProgressIndicatorProps = { + localId?: string; + sourceUrl?: string; +}; + +export const FileUploadInProgressIndicator = ({ + localId, + sourceUrl, +}: UploadInProgressIndicatorProps = {}) => { const { theme: { - semantics, messageComposer: { fileUploadInProgressIndicator }, }, } = useTheme(); + const { AttachmentUploadIndicator } = useComponentsContext(); return ( - - - + /> ); }; @@ -103,23 +110,19 @@ export const FileUploadNotSupportedIndicator = ({ ); }; -export const ImageUploadInProgressIndicator = () => { - const { - theme: { - semantics, - messageComposer: { imageUploadInProgressIndicator }, - }, - } = useTheme(); - const styles = useImageUploadInProgressIndicatorStyles(); +export const ImageUploadInProgressIndicator = ({ + localId, + sourceUrl, +}: UploadInProgressIndicatorProps = {}) => { + const { AttachmentUploadIndicator } = useComponentsContext(); + return ( - - - + ); }; @@ -153,16 +156,6 @@ export const ImageUploadNotSupportedIndicator = () => { ); }; -const useImageUploadInProgressIndicatorStyles = () => { - return StyleSheet.create({ - container: { - position: 'absolute', - left: primitives.spacingXxs, - bottom: primitives.spacingXxs, - }, - }); -}; - const useImageUploadNotSupportedIndicatorStyles = () => { const { theme: { semantics }, @@ -230,7 +223,10 @@ const useFileUploadNotSupportedStyles = () => { }; const styles = StyleSheet.create({ - activityIndicatorContainer: {}, + activityIndicatorContainer: { + alignItems: 'center', + justifyContent: 'center', + }, activityIndicator: { alignItems: 'flex-start', justifyContent: 'flex-start', diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx index fd81d1eaa3..4efc9578cb 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx @@ -63,7 +63,9 @@ export const AudioAttachmentUploadPreview = ({ const renderIndicator = useMemo(() => { if (indicatorType === ProgressIndicatorTypes.IN_PROGRESS) { - return ; + return ( + + ); } if (indicatorType === ProgressIndicatorTypes.RETRY) { return ; @@ -72,7 +74,7 @@ export const AudioAttachmentUploadPreview = ({ return ; } return null; - }, [attachment.localMetadata, indicatorType, onRetryHandler]); + }, [assetUrl, attachment.localMetadata, indicatorType, onRetryHandler]); return ( diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 86f3a0442c..3be4dd5d61 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -2,7 +2,12 @@ import React, { useCallback, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; -import { LocalAudioAttachment, LocalFileAttachment, LocalVideoAttachment } from 'stream-chat'; +import { + FileReference, + LocalAudioAttachment, + LocalFileAttachment, + LocalVideoAttachment, +} from 'stream-chat'; import { AttachmentRemoveControl } from './AttachmentRemoveControl'; @@ -27,6 +32,8 @@ export const FileAttachmentUploadPreview = ({ removeAttachments, }: FileAttachmentUploadPreviewProps) => { const styles = useStyles(); + const sourceUrl = + attachment.asset_url ?? (attachment.localMetadata.file as FileReference | undefined)?.uri; const { FileUploadInProgressIndicator, FileUploadRetryIndicator, @@ -56,7 +63,12 @@ export const FileAttachmentUploadPreview = ({ const renderIndicator = useMemo(() => { if (indicatorType === ProgressIndicatorTypes.IN_PROGRESS) { - return ; + return ( + + ); } if (indicatorType === ProgressIndicatorTypes.RETRY) { return ; @@ -72,6 +84,7 @@ export const FileAttachmentUploadPreview = ({ attachment.localMetadata, indicatorType, onRetryHandler, + sourceUrl, ]); return ( diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx index ff47d481fe..31b1fe7466 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx @@ -26,13 +26,18 @@ export const ImageAttachmentUploadPreview = ({ const [loading, setLoading] = useState(true); const { enableOfflineSupport } = useChatContext(); const { + ImageLoadingIndicator, ImageUploadInProgressIndicator, ImageUploadRetryIndicator, ImageUploadNotSupportedIndicator, } = useComponentsContext(); - const indicatorType = loading - ? ProgressIndicatorTypes.IN_PROGRESS - : getIndicatorTypeForFileState(attachment.localMetadata.uploadState, enableOfflineSupport); + const indicatorType = getIndicatorTypeForFileState( + attachment.localMetadata.uploadState, + enableOfflineSupport, + ); + const previewUri = attachment.localMetadata.previewUri ?? attachment.image_url; + const shouldShowImageLoadingIndicator = + loading && indicatorType !== ProgressIndicatorTypes.IN_PROGRESS; const { theme: { @@ -65,15 +70,21 @@ export const ImageAttachmentUploadPreview = ({ - {indicatorType === ProgressIndicatorTypes.IN_PROGRESS && } - {indicatorType === ProgressIndicatorTypes.RETRY && ( + {shouldShowImageLoadingIndicator ? : null} + {indicatorType === ProgressIndicatorTypes.IN_PROGRESS && ( + + )} + {!loading && indicatorType === ProgressIndicatorTypes.RETRY && ( )} - {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED && ( + {!loading && indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED && ( )} diff --git a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx index ebad74359f..4c2a2f4b1f 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx @@ -22,6 +22,7 @@ export const VideoAttachmentUploadPreview = ({ removeAttachments, }: VideoAttachmentUploadPreviewProps) => { const previewUri = attachment.thumb_url ?? attachment.localMetadata.previewUri; + const shouldShowMetadataPill = attachment.localMetadata.uploadState !== 'uploading'; return previewUri ? ( <> @@ -38,7 +39,9 @@ export const VideoAttachmentUploadPreview = ({ handleRetry={handleRetry} removeAttachments={removeAttachments} /> - + {shouldShowMetadataPill ? ( + + ) : null} ) : ( Date: Thu, 23 Apr 2026 23:26:04 +0200 Subject: [PATCH 20/26] fix: file upload indicators in composer --- examples/SampleApp/App.tsx | 1 + .../AttachmentUploadProgressIndicator.tsx | 22 +++++-------------- .../AudioAttachmentUploadPreview.tsx | 8 +++++-- .../FileAttachmentUploadPreview.tsx | 2 ++ 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 046b44170c..907ec41ac7 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -341,6 +341,7 @@ const DrawerNavigatorWrapper: React.FC<{ enableOfflineSupport isMessageAIGenerated={isMessageAIGenerated} i18nInstance={i18nInstance} + useNativeMultipartUpload > diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx index 8980384cb1..df8808ce9e 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx @@ -3,6 +3,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; import { LocalAttachmentUploadMetadata } from 'stream-chat'; +import { AttachmentFileUploadProgressIndicator } from '../../../../components/Attachment/AttachmentFileUploadProgressIndicator'; import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { ExclamationCircle } from '../../../../icons/exclamation-circle-fill'; @@ -13,26 +14,26 @@ import { RetryBadge } from '../../../ui/Badge/RetryBadge'; export type UploadInProgressIndicatorProps = { localId?: string; sourceUrl?: string; + totalBytes?: number | string | null; }; export const FileUploadInProgressIndicator = ({ localId, sourceUrl, + totalBytes, }: UploadInProgressIndicatorProps = {}) => { const { theme: { messageComposer: { fileUploadInProgressIndicator }, }, } = useTheme(); - const { AttachmentUploadIndicator } = useComponentsContext(); return ( - ); }; @@ -116,14 +117,7 @@ export const ImageUploadInProgressIndicator = ({ }: UploadInProgressIndicatorProps = {}) => { const { AttachmentUploadIndicator } = useComponentsContext(); - return ( - - ); + return ; }; export type ImageUploadRetryIndicatorProps = { @@ -227,8 +221,4 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - activityIndicator: { - alignItems: 'flex-start', - justifyContent: 'flex-start', - }, }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx index 4efc9578cb..447db14ca3 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx @@ -64,7 +64,11 @@ export const AudioAttachmentUploadPreview = ({ const renderIndicator = useMemo(() => { if (indicatorType === ProgressIndicatorTypes.IN_PROGRESS) { return ( - + ); } if (indicatorType === ProgressIndicatorTypes.RETRY) { @@ -74,7 +78,7 @@ export const AudioAttachmentUploadPreview = ({ return ; } return null; - }, [assetUrl, attachment.localMetadata, indicatorType, onRetryHandler]); + }, [assetUrl, attachment.file_size, attachment.localMetadata, indicatorType, onRetryHandler]); return ( diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 3be4dd5d61..29cc741aff 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -67,6 +67,7 @@ export const FileAttachmentUploadPreview = ({ ); } @@ -82,6 +83,7 @@ export const FileAttachmentUploadPreview = ({ FileUploadNotSupportedIndicator, FileUploadRetryIndicator, attachment.localMetadata, + attachment.file_size, indicatorType, onRetryHandler, sourceUrl, From 603ca1d053aaeb52f4c617525153235537622ae4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 23 Apr 2026 23:42:18 +0200 Subject: [PATCH 21/26] fix: lint and tests --- .../AttachmentUploadPreviewList.test.js | 45 ++++++++++++++++--- .../AudioAttachmentUploadPreview.test.js | 20 ++++++++- .../usePendingAttachmentUpload.test.tsx | 27 ++++++----- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js index 77a46ea529..cffac4e92e 100644 --- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js @@ -1,5 +1,7 @@ import React from 'react'; +import { ActivityIndicator } from 'react-native'; + import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../contexts'; @@ -46,6 +48,19 @@ const renderComponent = ({ client, channel, props }) => { ); }; +const setPendingUploads = (client, uploads) => { + act(() => { + client.uploadManager.state.partialNext({ + uploads: Object.fromEntries( + uploads.map(({ id, uploadProgress }) => [id, { id, uploadProgress }]), + ), + }); + }); +}; + +const countActivityIndicators = (nodes) => + nodes.reduce((count, node) => count + node.findAllByType(ActivityIndicator).length, 0); + describe('AttachmentUploadPreviewList', () => { let client; let channel; @@ -60,6 +75,7 @@ describe('AttachmentUploadPreviewList', () => { jest.clearAllMocks(); cleanup(); act(() => { + client?.uploadManager?.reset(); channel.messageComposer.attachmentManager.initState(); }); }); @@ -103,7 +119,11 @@ describe('AttachmentUploadPreviewList', () => { it('should render FileAttachmentUploadPreview when the sound package is unavailable', async () => { const attachments = [ generateAudioAttachment({ + asset_url: undefined, localMetadata: { + file: { + uri: 'file://audio-attachment.mp3', + }, id: 'audio-attachment', uploadState: FileState.UPLOADING, }, @@ -115,14 +135,15 @@ describe('AttachmentUploadPreviewList', () => { act(() => { channel.messageComposer.attachmentManager.upsertAttachments(attachments); }); + setPendingUploads(client, [{ id: 'audio-attachment' }]); renderComponent({ channel, client, props }); - const { queryAllByTestId } = screen; + const { getAllByTestId, queryAllByTestId } = screen; await waitFor(() => { expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(1); - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); + expect(countActivityIndicators(getAllByTestId('file-attachment-upload-preview'))).toBe(1); }); }); @@ -130,13 +151,20 @@ describe('AttachmentUploadPreviewList', () => { it('should render FileAttachmentUploadPreview with all uploading files', async () => { const attachments = [ generateFileAttachment({ + asset_url: undefined, localMetadata: { + file: { + uri: 'file://file-attachment.xls', + }, id: 'file-attachment', uploadState: FileState.UPLOADING, }, }), generateVideoAttachment({ localMetadata: { + file: { + uri: 'file://video-attachment.mp4', + }, id: 'video-attachment', uploadState: FileState.UPLOADING, }, @@ -147,6 +175,7 @@ describe('AttachmentUploadPreviewList', () => { act(() => { channel.messageComposer.attachmentManager.upsertAttachments(attachments); }); + setPendingUploads(client, [{ id: 'file-attachment' }, { id: 'video-attachment' }]); renderComponent({ channel, client, props }); @@ -154,7 +183,7 @@ describe('AttachmentUploadPreviewList', () => { await waitFor(() => { expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(2); - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(2); + expect(countActivityIndicators(getAllByTestId('file-attachment-upload-preview'))).toBe(2); }); act(() => { @@ -285,6 +314,7 @@ describe('AttachmentUploadPreviewList', () => { generateImageAttachment({ localMetadata: { id: 'image-attachment', + previewUri: 'file://image-attachment.png', uploadState: FileState.UPLOADING, }, }), @@ -294,6 +324,7 @@ describe('AttachmentUploadPreviewList', () => { await act(() => { channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); }); + setPendingUploads(client, [{ id: 'image-attachment' }]); renderComponent({ channel, client, props }); @@ -301,7 +332,7 @@ describe('AttachmentUploadPreviewList', () => { await waitFor(() => { expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); + expect(countActivityIndicators(getAllByTestId('image-attachment-upload-preview'))).toBe(1); }); await act(() => { @@ -437,6 +468,7 @@ describe('AttachmentUploadPreviewList', () => { generateImageAttachment({ localMetadata: { id: 'image-attachment-1', + previewUri: 'file://image-attachment-1.png', uploadState: FileState.UPLOADING, }, }), @@ -464,10 +496,11 @@ describe('AttachmentUploadPreviewList', () => { await act(() => { channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); }); + setPendingUploads(client, [{ id: 'image-attachment-1' }]); renderComponent({ channel, client, props }); - const { queryAllByTestId } = screen; + const { getAllByTestId, queryAllByTestId } = screen; await waitFor(() => { const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); @@ -478,7 +511,7 @@ describe('AttachmentUploadPreviewList', () => { await waitFor(() => { expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(4); - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); + expect(countActivityIndicators(getAllByTestId('image-attachment-upload-preview'))).toBe(1); expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(1); expect(queryAllByTestId('inline-not-supported-indicator')).toHaveLength(1); }); diff --git a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js index 1c6bbb8d2a..ae59297498 100644 --- a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js +++ b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js @@ -1,5 +1,7 @@ import React from 'react'; +import { ActivityIndicator } from 'react-native'; + import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../contexts'; @@ -41,6 +43,19 @@ const renderComponent = ({ client, channel, props }) => { ); }; +const setPendingUploads = (client, uploads) => { + act(() => { + client.uploadManager.state.partialNext({ + uploads: Object.fromEntries( + uploads.map(({ id, uploadProgress }) => [id, { id, uploadProgress }]), + ), + }); + }); +}; + +const countActivityIndicators = (nodes) => + nodes.reduce((count, node) => count + node.findAllByType(ActivityIndicator).length, 0); + describe('AudioAttachmentUploadPreview render', () => { let client; let channel; @@ -55,6 +70,7 @@ describe('AudioAttachmentUploadPreview render', () => { jest.clearAllMocks(); cleanup(); act(() => { + client?.uploadManager?.reset(); channel.messageComposer.attachmentManager.initState(); }); }); @@ -62,6 +78,7 @@ describe('AudioAttachmentUploadPreview render', () => { it('should render AudioAttachmentUploadPreview with all uploading files', async () => { const attachments = [ generateAudioAttachment({ + asset_url: undefined, localMetadata: { file: { uri: 'file://audio-attachment.mp3', @@ -76,6 +93,7 @@ describe('AudioAttachmentUploadPreview render', () => { act(() => { channel.messageComposer.attachmentManager.upsertAttachments(attachments); }); + setPendingUploads(client, [{ id: 'audio-attachment' }]); renderComponent({ channel, client, props }); @@ -83,7 +101,7 @@ describe('AudioAttachmentUploadPreview render', () => { await waitFor(() => { expect(queryAllByTestId('audio-attachment-upload-preview')).toHaveLength(1); - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); + expect(countActivityIndicators(getAllByTestId('audio-attachment-upload-preview'))).toBe(1); }); act(() => { diff --git a/package/src/hooks/__tests__/usePendingAttachmentUpload.test.tsx b/package/src/hooks/__tests__/usePendingAttachmentUpload.test.tsx index 29c782c680..6af644decc 100644 --- a/package/src/hooks/__tests__/usePendingAttachmentUpload.test.tsx +++ b/package/src/hooks/__tests__/usePendingAttachmentUpload.test.tsx @@ -7,10 +7,13 @@ import { ChatProvider } from '../../contexts/chatContext/ChatContext'; import { usePendingAttachmentUpload } from '../usePendingAttachmentUpload'; type UploadManagerState = { - uploads: Array<{ - id: string; - uploadProgress?: number; - }>; + uploads: Record< + string, + { + id: string; + uploadProgress?: number; + } + >; }; const createWrapper = (state: StateStore) => { @@ -35,7 +38,7 @@ describe('usePendingAttachmentUpload', () => { }); it('briefly holds completed upload progress after a ready upload record disappears', () => { - const state = new StateStore({ uploads: [] }); + const state = new StateStore({ uploads: {} }); const { result } = renderHook(() => usePendingAttachmentUpload('upload-id'), { wrapper: createWrapper(state), }); @@ -47,7 +50,9 @@ describe('usePendingAttachmentUpload', () => { act(() => { state.partialNext({ - uploads: [{ id: 'upload-id', uploadProgress: 90 }], + uploads: { + 'upload-id': { id: 'upload-id', uploadProgress: 90 }, + }, }); }); @@ -57,7 +62,7 @@ describe('usePendingAttachmentUpload', () => { }); act(() => { - state.partialNext({ uploads: [] }); + state.partialNext({ uploads: {} }); }); expect(result.current).toEqual({ @@ -76,19 +81,21 @@ describe('usePendingAttachmentUpload', () => { }); it('does not hold completed progress when an upload record disappears before reaching the ready threshold', () => { - const state = new StateStore({ uploads: [] }); + const state = new StateStore({ uploads: {} }); const { result } = renderHook(() => usePendingAttachmentUpload('upload-id'), { wrapper: createWrapper(state), }); act(() => { state.partialNext({ - uploads: [{ id: 'upload-id', uploadProgress: 50 }], + uploads: { + 'upload-id': { id: 'upload-id', uploadProgress: 50 }, + }, }); }); act(() => { - state.partialNext({ uploads: [] }); + state.partialNext({ uploads: {} }); }); expect(result.current).toEqual({ From e0c07324e8d39c118fb470bc4f84a7f8eab9df34 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 24 Apr 2026 13:16:01 +0200 Subject: [PATCH 22/26] fix: make uploads depend solely on allowSendBeforeAttachmentsUpload --- .../AttachmentPreview/AudioAttachmentUploadPreview.tsx | 6 +++--- .../AttachmentPreview/FileAttachmentUploadPreview.tsx | 6 +++--- .../AttachmentPreview/ImageAttachmentUploadPreview.tsx | 6 +++--- .../AttachmentPreview/VideoAttachmentUploadPreview.tsx | 5 ++++- .../hooks/useCreateMessageInputContext.ts | 4 +++- .../hooks/useMessageComposerHasSendableData.ts | 6 +++++- package/src/utils/utils.ts | 6 +++--- 7 files changed, 24 insertions(+), 15 deletions(-) diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx index 447db14ca3..1db5d7a83a 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx @@ -13,8 +13,8 @@ import { import { AudioAttachment } from '../../../../components/Attachment/Audio'; import { useTheme } from '../../../../contexts'; -import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useMessageInputContext } from '../../../../contexts/messageInputContext/MessageInputContext'; import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils'; @@ -30,10 +30,10 @@ export const AudioAttachmentUploadPreview = ({ removeAttachments, }: AudioAttachmentUploadPreviewProps) => { const styles = useStyles(); - const { enableOfflineSupport } = useChatContext(); + const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); const indicatorType = getIndicatorTypeForFileState( attachment.localMetadata.uploadState, - enableOfflineSupport, + !!allowSendBeforeAttachmentsUpload, ); const messageComposer = useMessageComposer(); const isDraft = messageComposer.draftId; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 29cc741aff..11c4137d6e 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -12,8 +12,8 @@ import { import { AttachmentRemoveControl } from './AttachmentRemoveControl'; import { FilePreview } from '../../../../components/Attachment/FilePreview'; -import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; +import { useMessageInputContext } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; @@ -39,10 +39,10 @@ export const FileAttachmentUploadPreview = ({ FileUploadRetryIndicator, FileUploadNotSupportedIndicator, } = useComponentsContext(); - const { enableOfflineSupport } = useChatContext(); + const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); const indicatorType = getIndicatorTypeForFileState( attachment.localMetadata.uploadState, - enableOfflineSupport, + !!allowSendBeforeAttachmentsUpload, ); const { diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx index 31b1fe7466..4fd9148217 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx @@ -6,8 +6,8 @@ import { LocalImageAttachment } from 'stream-chat'; import { AttachmentRemoveControl } from './AttachmentRemoveControl'; -import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; +import { useMessageInputContext } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; @@ -24,7 +24,7 @@ export const ImageAttachmentUploadPreview = ({ removeAttachments, }: ImageAttachmentUploadPreviewProps) => { const [loading, setLoading] = useState(true); - const { enableOfflineSupport } = useChatContext(); + const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); const { ImageLoadingIndicator, ImageUploadInProgressIndicator, @@ -33,7 +33,7 @@ export const ImageAttachmentUploadPreview = ({ } = useComponentsContext(); const indicatorType = getIndicatorTypeForFileState( attachment.localMetadata.uploadState, - enableOfflineSupport, + !!allowSendBeforeAttachmentsUpload, ); const previewUri = attachment.localMetadata.previewUri ?? attachment.image_url; const shouldShowImageLoadingIndicator = diff --git a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx index 4c2a2f4b1f..380e1821db 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx @@ -12,6 +12,7 @@ import { Recorder } from '../../../../icons'; import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; import { formatMsToMinSec, getDurationLabelFromDuration } from '../../../../utils/utils'; +import { useMessageInputContext } from '../../../../contexts'; export type VideoAttachmentUploadPreviewProps> = UploadAttachmentPreviewProps>; @@ -22,7 +23,9 @@ export const VideoAttachmentUploadPreview = ({ removeAttachments, }: VideoAttachmentUploadPreviewProps) => { const previewUri = attachment.thumb_url ?? attachment.localMetadata.previewUri; - const shouldShowMetadataPill = attachment.localMetadata.uploadState !== 'uploading'; + const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); + const shouldShowMetadataPill = + allowSendBeforeAttachmentsUpload || attachment.localMetadata.uploadState !== 'uploading'; return previewUri ? ( <> diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 5343dad8e0..6b0b681ad7 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -5,6 +5,7 @@ import type { MessageInputContextValue } from '../MessageInputContext'; export const useCreateMessageInputContext = ({ additionalTextInputProps, + allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesSlideToCancelDistance, @@ -47,6 +48,7 @@ export const useCreateMessageInputContext = ({ const messageInputContext: MessageInputContextValue = useMemo( () => ({ additionalTextInputProps, + allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesSlideToCancelDistance, @@ -84,7 +86,7 @@ export const useCreateMessageInputContext = ({ stopVoiceRecording, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [threadId, showPollCreationDialog], + [threadId, showPollCreationDialog, allowSendBeforeAttachmentsUpload], ); return messageInputContext; diff --git a/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts b/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts index e3017cc62b..e4de4689d7 100644 --- a/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts +++ b/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts @@ -3,11 +3,15 @@ import type { EditingAuditState } from 'stream-chat'; import { useMessageComposer } from './useMessageComposer'; import { useStateStore } from '../../../hooks/useStateStore'; +import { useMessageInputContext } from '../MessageInputContext'; const editingAuditStateStateSelector = (state: EditingAuditState) => state; export const useMessageComposerHasSendableData = () => { + const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); const messageComposer = useMessageComposer(); useStateStore(messageComposer.editingAuditState, editingAuditStateStateSelector); - return messageComposer.hasSendableData; + return allowSendBeforeAttachmentsUpload + ? !messageComposer.contentIsEmpty + : messageComposer.hasSendableData; }; diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index f40c9bde89..2c6533634f 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -57,14 +57,14 @@ type IndicatorStatesMap = Record; export const getIndicatorTypeForFileState = ( fileState: AttachmentLoadingState, - enableOfflineSupport: boolean, + allowSendBeforeAttachmentsUpload: boolean, ): Progress | undefined => { const indicatorMap: IndicatorStatesMap = { - [FileState.UPLOADING]: enableOfflineSupport + [FileState.UPLOADING]: allowSendBeforeAttachmentsUpload ? ProgressIndicatorTypes.INACTIVE : ProgressIndicatorTypes.IN_PROGRESS, [FileState.BLOCKED]: ProgressIndicatorTypes.NOT_SUPPORTED, - [FileState.FAILED]: enableOfflineSupport + [FileState.FAILED]: allowSendBeforeAttachmentsUpload ? ProgressIndicatorTypes.INACTIVE : ProgressIndicatorTypes.RETRY, [FileState.PENDING]: ProgressIndicatorTypes.PENDING, From 06c4ae071dafffe24a1f121c6d61a0bf9e4e7cd1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 24 Apr 2026 13:49:25 +0200 Subject: [PATCH 23/26] fix: bump stream-chat-js --- package/package.json | 2 +- package/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package/package.json b/package/package.json index 047c815a2b..73d07bdb14 100644 --- a/package/package.json +++ b/package/package.json @@ -82,7 +82,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.42.0", + "stream-chat": "^9.42.1", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/yarn.lock b/package/yarn.lock index f7c50bdaff..a8786502d2 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -8363,10 +8363,10 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -stream-chat@^9.42.0: - version "9.42.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.42.0.tgz#ee0dafa01e9a306d5328edcd6c3e42c52f4c1a68" - integrity sha512-EwqCwo2VtZpX+6gx5vKqzKZ2L5VKovj8SSPdZXPejuR+gPhSnbzrgZCK5mChDvDEBqzPILInqlzyoevcs7JLYw== +stream-chat@^9.42.1: + version "9.42.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.42.1.tgz#8b6aa4e3e73a39ed07bb2a4f2a6829ba9354567a" + integrity sha512-o+9wQO4Ruu1A48T0IrX9ZH8+9F5xPgGLPvflaswaPeLyIZXcy8bsQdcT/HSrPmT7gs0WGD3qcbXaAJU5lMQezQ== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" From 340c2e01b701e5c1cf1e2c04e9546084b8b9ce98 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 24 Apr 2026 14:13:31 +0200 Subject: [PATCH 24/26] chore: move multipart upload to native handlers --- package/expo-package/src/handlers/index.ts | 1 + .../src/{optionalDependencies => handlers}/multipartUpload.ts | 3 +-- package/expo-package/src/index.js | 3 +-- package/expo-package/src/optionalDependencies/index.ts | 1 - package/native-package/src/handlers/index.ts | 1 + .../src/{optionalDependencies => handlers}/multipartUpload.ts | 3 +-- package/native-package/src/index.js | 3 +-- package/native-package/src/optionalDependencies/index.ts | 1 - 8 files changed, 6 insertions(+), 10 deletions(-) rename package/expo-package/src/{optionalDependencies => handlers}/multipartUpload.ts (76%) rename package/native-package/src/{optionalDependencies => handlers}/multipartUpload.ts (76%) diff --git a/package/expo-package/src/handlers/index.ts b/package/expo-package/src/handlers/index.ts index 8d6c44780b..83b0ed3ce3 100644 --- a/package/expo-package/src/handlers/index.ts +++ b/package/expo-package/src/handlers/index.ts @@ -1 +1,2 @@ export * from './compressImage'; +export * from './multipartUpload'; diff --git a/package/expo-package/src/optionalDependencies/multipartUpload.ts b/package/expo-package/src/handlers/multipartUpload.ts similarity index 76% rename from package/expo-package/src/optionalDependencies/multipartUpload.ts rename to package/expo-package/src/handlers/multipartUpload.ts index 37f5b7f4f3..5a2be4e4f4 100644 --- a/package/expo-package/src/optionalDependencies/multipartUpload.ts +++ b/package/expo-package/src/handlers/multipartUpload.ts @@ -1,8 +1,7 @@ import { createNativeMultipartUpload } from 'stream-chat-react-native-core'; -import { getLocalAssetUri } from './getLocalAssetUri'; - import { uploadMultipart } from '../native/multipartUploader'; +import { getLocalAssetUri } from '../optionalDependencies/getLocalAssetUri'; export const multipartUpload = createNativeMultipartUpload({ getLocalAssetUri, diff --git a/package/expo-package/src/index.js b/package/expo-package/src/index.js index 471bea7ac4..4bb4a5b005 100644 --- a/package/expo-package/src/index.js +++ b/package/expo-package/src/index.js @@ -2,7 +2,7 @@ import { FlatList } from 'react-native'; import { registerNativeHandlers } from 'stream-chat-react-native-core'; -import { compressImage } from './handlers'; +import { compressImage, multipartUpload } from './handlers'; import { Audio, @@ -10,7 +10,6 @@ import { getLocalAssetUri, getPhotos, iOS14RefreshGallerySelection, - multipartUpload, NativeShimmerView, oniOS14GalleryLibrarySelectionChange, overrideAudioRecordingConfiguration, diff --git a/package/expo-package/src/optionalDependencies/index.ts b/package/expo-package/src/optionalDependencies/index.ts index 270bbbd6bf..9f2cc0d87f 100644 --- a/package/expo-package/src/optionalDependencies/index.ts +++ b/package/expo-package/src/optionalDependencies/index.ts @@ -4,7 +4,6 @@ export * from './generateThumbnail'; export * from './getLocalAssetUri'; export * from './getPhotos'; export * from './iOS14RefreshGallerySelection'; -export * from './multipartUpload'; export * from './NativeShimmerView'; export * from './oniOS14GalleryLibrarySelectionChange'; export * from './pickDocument'; diff --git a/package/native-package/src/handlers/index.ts b/package/native-package/src/handlers/index.ts index 8d6c44780b..83b0ed3ce3 100644 --- a/package/native-package/src/handlers/index.ts +++ b/package/native-package/src/handlers/index.ts @@ -1 +1,2 @@ export * from './compressImage'; +export * from './multipartUpload'; diff --git a/package/native-package/src/optionalDependencies/multipartUpload.ts b/package/native-package/src/handlers/multipartUpload.ts similarity index 76% rename from package/native-package/src/optionalDependencies/multipartUpload.ts rename to package/native-package/src/handlers/multipartUpload.ts index 37f5b7f4f3..5a2be4e4f4 100644 --- a/package/native-package/src/optionalDependencies/multipartUpload.ts +++ b/package/native-package/src/handlers/multipartUpload.ts @@ -1,8 +1,7 @@ import { createNativeMultipartUpload } from 'stream-chat-react-native-core'; -import { getLocalAssetUri } from './getLocalAssetUri'; - import { uploadMultipart } from '../native/multipartUploader'; +import { getLocalAssetUri } from '../optionalDependencies/getLocalAssetUri'; export const multipartUpload = createNativeMultipartUpload({ getLocalAssetUri, diff --git a/package/native-package/src/index.js b/package/native-package/src/index.js index 09b663e42e..3afaa684cf 100644 --- a/package/native-package/src/index.js +++ b/package/native-package/src/index.js @@ -2,7 +2,7 @@ import { Platform } from 'react-native'; import { registerNativeHandlers } from 'stream-chat-react-native-core'; -import { compressImage } from './handlers'; +import { compressImage, multipartUpload } from './handlers'; import { Audio, @@ -11,7 +11,6 @@ import { getLocalAssetUri, getPhotos, iOS14RefreshGallerySelection, - multipartUpload, NativeShimmerView, oniOS14GalleryLibrarySelectionChange, overrideAudioRecordingConfiguration, diff --git a/package/native-package/src/optionalDependencies/index.ts b/package/native-package/src/optionalDependencies/index.ts index 419f2c03b0..1b1ddee508 100644 --- a/package/native-package/src/optionalDependencies/index.ts +++ b/package/native-package/src/optionalDependencies/index.ts @@ -4,7 +4,6 @@ export * from './FlatList'; export * from './getLocalAssetUri'; export * from './getPhotos'; export * from './iOS14RefreshGallerySelection'; -export * from './multipartUpload'; export * from './NativeShimmerView'; export * from './oniOS14GalleryLibrarySelectionChange'; export * from './pickDocument'; From fbf3197c372579ac9956cc4240818997fdf71e04 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 24 Apr 2026 14:21:48 +0200 Subject: [PATCH 25/26] fix: tests from merge --- .../Attachment/__tests__/Attachment.test.tsx | 17 ++++++++++------- .../Attachment/__tests__/Giphy.test.tsx | 14 +++++++++----- .../VideoAttachmentUploadPreview.tsx | 2 +- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/package/src/components/Attachment/__tests__/Attachment.test.tsx b/package/src/components/Attachment/__tests__/Attachment.test.tsx index eb23eddba2..44d9c5ded7 100644 --- a/package/src/components/Attachment/__tests__/Attachment.test.tsx +++ b/package/src/components/Attachment/__tests__/Attachment.test.tsx @@ -4,8 +4,8 @@ import { StyleSheet, View } from 'react-native'; import { render, waitFor } from '@testing-library/react-native'; import { v4 as uuidv4 } from 'uuid'; -import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; import { AudioPlayerProvider } from '../../../contexts/audioPlayerContext/AudioPlayerContext'; +import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; import { MessageProvider } from '../../../contexts/messageContext/MessageContext'; import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import { MessagesProvider } from '../../../contexts/messagesContext/MessagesContext'; @@ -52,13 +52,16 @@ const getAttachmentComponent = (props: ComponentProps) => { - + diff --git a/package/src/components/Attachment/__tests__/Giphy.test.tsx b/package/src/components/Attachment/__tests__/Giphy.test.tsx index 5f6f5ee833..2e2f0a8088 100644 --- a/package/src/components/Attachment/__tests__/Giphy.test.tsx +++ b/package/src/components/Attachment/__tests__/Giphy.test.tsx @@ -50,12 +50,16 @@ describe('Giphy', () => { return ( - + diff --git a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx index 380e1821db..1fb06388a4 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx @@ -7,12 +7,12 @@ import { LocalImageAttachment, LocalVideoAttachment } from 'stream-chat'; import { FileAttachmentUploadPreview } from './FileAttachmentUploadPreview'; import { ImageAttachmentUploadPreview } from './ImageAttachmentUploadPreview'; +import { useMessageInputContext } from '../../../../contexts'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { Recorder } from '../../../../icons'; import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; import { formatMsToMinSec, getDurationLabelFromDuration } from '../../../../utils/utils'; -import { useMessageInputContext } from '../../../../contexts'; export type VideoAttachmentUploadPreviewProps> = UploadAttachmentPreviewProps>; From 0824549750f8c8686478d1dc535abfeeb992b313 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 24 Apr 2026 14:39:47 +0200 Subject: [PATCH 26/26] fix: typescript errors --- .../__tests__/nativeMultipartUpload.test.ts | 13 ++-- .../Attachment/__tests__/Attachment.test.tsx | 6 +- .../Attachment/__tests__/Giphy.test.tsx | 2 +- .../AttachmentUploadPreviewList.test.tsx | 17 ++++- .../AudioAttachmentUploadPreview.test.tsx | 17 ++++- .../installNativeMultipartAdapter.test.ts | 64 +++++++++++-------- 6 files changed, 81 insertions(+), 38 deletions(-) diff --git a/package/src/__tests__/nativeMultipartUpload.test.ts b/package/src/__tests__/nativeMultipartUpload.test.ts index 61c5df8c1b..1591e5996e 100644 --- a/package/src/__tests__/nativeMultipartUpload.test.ts +++ b/package/src/__tests__/nativeMultipartUpload.test.ts @@ -3,7 +3,9 @@ import { createNativeMultipartUploader, NativeMultipartAbortSignal, NativeMultipartUploadEventEmitter, + NativeMultipartUploadNativeResponse, NativeMultipartUploadProgressEvent, + NativeMultipartUploaderModule, } from '../nativeMultipartUpload'; const progressEventName = 'streamMultipartUploadProgress'; @@ -20,7 +22,10 @@ const createNativeModule = () => ({ addListener: jest.fn(), cancelUpload: jest.fn(() => Promise.resolve()), removeListeners: jest.fn(), - uploadMultipart: jest.fn(() => + uploadMultipart: jest.fn< + ReturnType, + Parameters + >(() => Promise.resolve({ body: 'ok', headers: [{ name: 'x-test', value: 'yes' }], @@ -68,11 +73,11 @@ describe('nativeMultipartUpload', () => { it('passes requests to the native module and forwards matching progress events', async () => { const nativeModule = createNativeModule(); const eventEmitter = createEventEmitter(); - let resolveUpload: (response: Awaited>) => void; + let resolveUpload: (response: NativeMultipartUploadNativeResponse) => void; nativeModule.uploadMultipart.mockImplementation( () => - new Promise((resolve) => { - resolveUpload = resolve; + new Promise((resolve) => { + resolveUpload = (response) => resolve(response); }), ); const uploadMultipart = createNativeMultipartUploader(nativeModule, { eventEmitter }); diff --git a/package/src/components/Attachment/__tests__/Attachment.test.tsx b/package/src/components/Attachment/__tests__/Attachment.test.tsx index 44d9c5ded7..ba8869acac 100644 --- a/package/src/components/Attachment/__tests__/Attachment.test.tsx +++ b/package/src/components/Attachment/__tests__/Attachment.test.tsx @@ -1,6 +1,8 @@ import React, { ComponentProps } from 'react'; import { StyleSheet, View } from 'react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; + import { render, waitFor } from '@testing-library/react-native'; import { v4 as uuidv4 } from 'uuid'; @@ -70,8 +72,8 @@ const getAttachmentComponent = (props: ComponentProps) => { ); }; -const getWaveformBarCount = (root) => - root.findAllByType(View).filter((node) => { +const getWaveformBarCount = (root: ReactTestInstance) => + root.findAllByType(View).filter((node: ReactTestInstance) => { const flattenedStyle = StyleSheet.flatten(node.props.style); return flattenedStyle?.width === 2 && typeof flattenedStyle?.height === 'number'; }).length; diff --git a/package/src/components/Attachment/__tests__/Giphy.test.tsx b/package/src/components/Attachment/__tests__/Giphy.test.tsx index 2e2f0a8088..fc4b14736b 100644 --- a/package/src/components/Attachment/__tests__/Giphy.test.tsx +++ b/package/src/components/Attachment/__tests__/Giphy.test.tsx @@ -58,7 +58,7 @@ describe('Giphy', () => { } > diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx index 9266308371..20d9216c28 100644 --- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx @@ -2,6 +2,8 @@ import React, { ComponentProps } from 'react'; import { ActivityIndicator } from 'react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; + import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import type { Attachment, Channel as ChannelType, LocalAttachment, StreamChat } from 'stream-chat'; @@ -67,7 +69,12 @@ const renderComponent = ({ ); }; -const setPendingUploads = (client, uploads) => { +type PendingUploadRecord = { + id: string; + uploadProgress?: number; +}; + +const setPendingUploads = (client: StreamChat, uploads: PendingUploadRecord[]) => { act(() => { client.uploadManager.state.partialNext({ uploads: Object.fromEntries( @@ -77,8 +84,12 @@ const setPendingUploads = (client, uploads) => { }); }; -const countActivityIndicators = (nodes) => - nodes.reduce((count, node) => count + node.findAllByType(ActivityIndicator).length, 0); +const countActivityIndicators = (nodes: ReactTestInstance[]) => + nodes.reduce( + (count: number, node: ReactTestInstance) => + count + node.findAllByType(ActivityIndicator).length, + 0, + ); describe('AttachmentUploadPreviewList', () => { let client: StreamChat; diff --git a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.tsx b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.tsx index 2b12f8e903..59fc47dc79 100644 --- a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.tsx +++ b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.tsx @@ -2,6 +2,8 @@ import React, { ComponentProps } from 'react'; import { ActivityIndicator } from 'react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; + import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import type { Attachment, Channel as ChannelType, LocalAttachment, StreamChat } from 'stream-chat'; @@ -56,7 +58,12 @@ const renderComponent = ({ ); }; -const setPendingUploads = (client, uploads) => { +type PendingUploadRecord = { + id: string; + uploadProgress?: number; +}; + +const setPendingUploads = (client: StreamChat, uploads: PendingUploadRecord[]) => { act(() => { client.uploadManager.state.partialNext({ uploads: Object.fromEntries( @@ -66,8 +73,12 @@ const setPendingUploads = (client, uploads) => { }); }; -const countActivityIndicators = (nodes) => - nodes.reduce((count, node) => count + node.findAllByType(ActivityIndicator).length, 0); +const countActivityIndicators = (nodes: ReactTestInstance[]) => + nodes.reduce( + (count: number, node: ReactTestInstance) => + count + node.findAllByType(ActivityIndicator).length, + 0, + ); describe('AudioAttachmentUploadPreview render', () => { let client: StreamChat; diff --git a/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts index 26d75ce411..cbb14c3109 100644 --- a/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts +++ b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts @@ -1,10 +1,19 @@ +import type { AxiosProgressEvent, AxiosRequestConfig } from 'axios'; + import { getTestClient } from '../../mock-builders/mock'; -import { NativeHandlers } from '../../native'; +import { NativeHandlers, NativeMultipartUploadProgressConfig } from '../../native'; import { installNativeMultipartAdapter, wrapAxiosAdapterWithNativeMultipart, } from '../installNativeMultipartAdapter'; +type NativeMultipartTestAxiosConfig = AxiosRequestConfig & { + uploadProgress?: (event: AxiosProgressEvent) => void; + uploadProgressOptions?: NativeMultipartUploadProgressConfig; +}; + +const nativeMultipartConfig = (config: NativeMultipartTestAxiosConfig) => config; + describe('installNativeMultipartAdapter', () => { const originalMultipartUpload = NativeHandlers.multipartUpload; @@ -148,14 +157,18 @@ describe('installNativeMultipartAdapter', () => { ], }; - await client.axiosInstance.post('/uploads/image', formData, { - onUploadProgress, - uploadProgressOptions: { - count: 10, - intervalMs: 25, - }, - uploadProgress, - }); + await client.axiosInstance.post( + '/uploads/image', + formData, + nativeMultipartConfig({ + onUploadProgress, + uploadProgressOptions: { + count: 10, + intervalMs: 25, + }, + uploadProgress, + }), + ); expect(onUploadProgress).toHaveBeenCalledWith( expect.objectContaining({ @@ -261,14 +274,18 @@ describe('installNativeMultipartAdapter', () => { ], }; - await client.axiosInstance.post('/uploads/image', formData, { - onUploadProgress, - uploadProgressOptions: { - completionProgressCap: 75, - count: 10, - intervalMs: 25, - }, - }); + await client.axiosInstance.post( + '/uploads/image', + formData, + nativeMultipartConfig({ + onUploadProgress, + uploadProgressOptions: { + completionProgressCap: 75, + count: 10, + intervalMs: 25, + }, + }), + ); expect(onUploadProgress).toHaveBeenCalledTimes(1); expect(onUploadProgress).toHaveBeenCalledWith( @@ -302,14 +319,11 @@ describe('installNativeMultipartAdapter', () => { client.axiosInstance.defaults.adapter = defaultAdapter; - const interceptorId = client.axiosInstance.interceptors.request.use((config) => ({ - ...config, - headers: { - ...config.headers, - 'X-CDN-Route': 'custom-cdn', - }, - url: '/uploads/file', - })); + const interceptorId = client.axiosInstance.interceptors.request.use((config) => { + config.headers.set('X-CDN-Route', 'custom-cdn'); + config.url = '/uploads/file'; + return config; + }); installNativeMultipartAdapter(client); const formData = {