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/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..0b806c8b1f 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) @@ -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) @@ -251,20 +256,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) @@ -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: @@ -3731,14 +3775,16 @@ SPEC CHECKSUMS: FirebaseCrashlytics: 8281e577b6f85a08ea7aeb8b66f95e1ae430c943 FirebaseInstallations: 0ee9074f2c1e86561ace168ee1470dc67aabaf02 FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 - FirebaseRemoteConfigInterop: 7b74ceaa54e28863ed17fa39da8951692725eced + FirebaseRemoteConfigInterop: 1c6135e8a094cc6368949f5faeeca7ee8948b8aa FirebaseSessions: eaa8ec037e7793769defe4201c20bd4d976f9677 - fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd + fmt: 12a698626610c2fef5e7d8de472b100baf225f93 glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 GoogleAppMeasurement: 0dfca1a4b534d123de3945e28f77869d10d0d600 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 hermes-engine: 7219f6e751ad6ec7f3d7ec121830ee34dae40749 + libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 + libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NitroModules: 62786c3090e21b6e28baf91ea69257b1b75fdcfd @@ -3746,7 +3792,7 @@ SPEC CHECKSUMS: op-sqlite: 2e58f87227360fa6251d1fe103d189f11ae8c95f PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f + RCT-Folly: 5a8bea092f38495b327c6eff2dc52ee25c10f637 RCTDeprecation: d4ef510f229cea15314176aee5e3ba10064a8496 RCTRequired: 1e41b794629558f6626e2bc39c166ac0ec1c5878 RCTTypeSafety: 62c8105cf08af634c93d38ea1e8ec8a57b7abc2c @@ -3821,7 +3867,7 @@ SPEC CHECKSUMS: ReactCommon: 66eb46e6696f1f4816b250ab2807389018bacd78 RNCAsyncStorage: fd44f4b03e007e642e98df6726737bc66e9ba609 RNCClipboard: e560338bf6cc4656a09ff90610b62ddc0dbdad65 - RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87 + RNFastImage: 674d5912e174468a60971d2ba9efc7bb43d116fa RNFBApp: db9c2e6d36fe579ab19b82c0a4a417ff7569db7e RNFBMessaging: de62448d205095171915d622ed5fb45c2be5e075 RNGestureHandler: 6bc8f2f56c8a68f3380cd159f3a1ae06defcfabb @@ -3832,13 +3878,15 @@ 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 Yoga: ff16d80456ce825ffc9400eeccc645a0dfcccdf5 -PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d +PODFILE CHECKSUM: 84efea5f3e8c9c79671ee6e525f700f244c17388 COCOAPODS: 1.15.2 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/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..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 @@ -14,12 +14,17 @@ 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_VIDEO_THUMBNAIL_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)) { return createNewArchModule("com.streamchatexpo.StreamVideoThumbnailModule", reactContext); } @@ -30,7 +35,17 @@ 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( + STREAM_MULTIPART_UPLOADER_MODULE, + STREAM_MULTIPART_UPLOADER_MODULE, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + true // isTurboModule + )); moduleInfos.put( STREAM_VIDEO_THUMBNAIL_MODULE, new ReactModuleInfo( @@ -40,7 +55,7 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // needsEagerInit false, // hasConstants false, // isCxxModule - isTurboModule // isTurboModule + true // isTurboModule )); return moduleInfos; }; 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..11ec5fc4af --- /dev/null +++ b/package/expo-package/android/src/newarch/com/streamchatexpo/StreamMultipartUploaderModule.kt @@ -0,0 +1,122 @@ +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.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.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit + +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: ReadableMap?, + timeoutMs: Double?, + promise: Promise, + ) { + val request = + try { + StreamMultipartUploadRequestParser.parse( + uploadId = uploadId, + url = url, + method = method, + headers = headers, + parts = parts, + progress = progress, + timeoutMs = timeoutMs, + ) + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) + return + } + + try { + executor.execute { + try { + 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) + } + } + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) + } + } + + private fun emitProgress(uploadId: String, loaded: Long, total: Long?) { + UiThreadUtil.runOnUiThread { + 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 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/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/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/handlers/multipartUpload.ts b/package/expo-package/src/handlers/multipartUpload.ts new file mode 100644 index 0000000000..5a2be4e4f4 --- /dev/null +++ b/package/expo-package/src/handlers/multipartUpload.ts @@ -0,0 +1,9 @@ +import { createNativeMultipartUpload } from 'stream-chat-react-native-core'; + +import { uploadMultipart } from '../native/multipartUploader'; +import { getLocalAssetUri } from '../optionalDependencies/getLocalAssetUri'; + +export const multipartUpload = createNativeMultipartUpload({ + getLocalAssetUri, + uploadMultipart, +}); diff --git a/package/expo-package/src/index.js b/package/expo-package/src/index.js index 53960194f9..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, @@ -32,6 +32,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..4caeacaeee --- /dev/null +++ b/package/expo-package/src/native/NativeStreamMultipartUploader.ts @@ -0,0 +1,52 @@ +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; + kind: string; + mimeType?: string; + uri?: string; + value?: string; +}; + +export type UploadProgressConfig = { + count?: number; + intervalMs?: number; +}; + +export type UploadProgressEvent = { + loaded: number; + total?: number; + uploadId: string; +}; + +export type UploadResponse = { + body: string; + headers?: ReadonlyArray; + status: number; + statusText?: string; +}; + +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, + timeoutMs?: number | null, + ): Promise; +} + +export default TurboModuleRegistry.get('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..e3010a88fa --- /dev/null +++ b/package/expo-package/src/native/multipartUploader.ts @@ -0,0 +1,5 @@ +import { createNativeMultipartUploader } from 'stream-chat-react-native-core'; + +import NativeStreamMultipartUploader from './NativeStreamMultipartUploader'; + +export const uploadMultipart = createNativeMultipartUploader(NativeStreamMultipartUploader); 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/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..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 @@ -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,7 +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_VIDEO_THUMBNAIL_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)) { return createNewArchModule( "com.streamchatreactnative.StreamVideoThumbnailModule", reactContext @@ -35,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( @@ -45,7 +50,18 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // needsEagerInit true, // hasConstants false, // isCxxModule - isTurboModule // isTurboModule + true // 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 + true // isTurboModule )); moduleInfos.put( STREAM_VIDEO_THUMBNAIL_MODULE, @@ -56,7 +72,7 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // needsEagerInit false, // hasConstants false, // isCxxModule - isTurboModule // isTurboModule + true // isTurboModule )); return moduleInfos; }; 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..006fb4282d --- /dev/null +++ b/package/native-package/android/src/newarch/com/streamchatreactnative/StreamMultipartUploaderModule.kt @@ -0,0 +1,122 @@ +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.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.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit + +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: ReadableMap?, + timeoutMs: Double?, + promise: Promise, + ) { + val request = + try { + StreamMultipartUploadRequestParser.parse( + uploadId = uploadId, + url = url, + method = method, + headers = headers, + parts = parts, + progress = progress, + timeoutMs = timeoutMs, + ) + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) + return + } + + try { + executor.execute { + try { + 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) + } + } + } catch (error: Throwable) { + promise.reject("stream_multipart_upload_error", error.message, error) + } + } + + private fun emitProgress(uploadId: String, loaded: Long, total: Long?) { + UiThreadUtil.runOnUiThread { + 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 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/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/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/handlers/multipartUpload.ts b/package/native-package/src/handlers/multipartUpload.ts new file mode 100644 index 0000000000..5a2be4e4f4 --- /dev/null +++ b/package/native-package/src/handlers/multipartUpload.ts @@ -0,0 +1,9 @@ +import { createNativeMultipartUpload } from 'stream-chat-react-native-core'; + +import { uploadMultipart } from '../native/multipartUploader'; +import { getLocalAssetUri } from '../optionalDependencies/getLocalAssetUri'; + +export const multipartUpload = createNativeMultipartUpload({ + getLocalAssetUri, + uploadMultipart, +}); diff --git a/package/native-package/src/index.js b/package/native-package/src/index.js index ee090a1cc6..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, @@ -33,6 +33,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..4caeacaeee --- /dev/null +++ b/package/native-package/src/native/NativeStreamMultipartUploader.ts @@ -0,0 +1,52 @@ +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; + kind: string; + mimeType?: string; + uri?: string; + value?: string; +}; + +export type UploadProgressConfig = { + count?: number; + intervalMs?: number; +}; + +export type UploadProgressEvent = { + loaded: number; + total?: number; + uploadId: string; +}; + +export type UploadResponse = { + body: string; + headers?: ReadonlyArray; + status: number; + statusText?: string; +}; + +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, + timeoutMs?: number | null, + ): Promise; +} + +export default TurboModuleRegistry.get('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..e3010a88fa --- /dev/null +++ b/package/native-package/src/native/multipartUploader.ts @@ -0,0 +1,5 @@ +import { createNativeMultipartUploader } from 'stream-chat-react-native-core'; + +import NativeStreamMultipartUploader from './NativeStreamMultipartUploader'; + +export const uploadMultipart = createNativeMultipartUploader(NativeStreamMultipartUploader); 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/package.json b/package/package.json index 426a9a8583..05f837483a 100644 --- a/package/package.json +++ b/package/package.json @@ -83,7 +83,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.1", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { 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..35d77a73dd --- /dev/null +++ b/package/shared-native/android/upload/StreamMultipartUploadModels.kt @@ -0,0 +1,39 @@ +package com.streamchatreactnative.shared.upload + +data class StreamMultipartUploadRequest( + val headers: Map, + val method: String, + val parts: List, + val progress: StreamMultipartUploadProgressOptions?, + val timeoutMs: Long?, + 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..a9c99c1252 --- /dev/null +++ b/package/shared-native/android/upload/StreamMultipartUploadProgress.kt @@ -0,0 +1,80 @@ +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).coerceIn(16L, 1_000L) + private val count = (options?.count ?: 20).coerceIn(1, 100) + 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() { + private val resolvedContentLength by lazy { requestBody.contentLength().takeIf { it >= 0L } } + + 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 + throttler.dispatch(bytesWritten, resolvedContentLength) + } + } + + 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..5f3fc01707 --- /dev/null +++ b/package/shared-native/android/upload/StreamMultipartUploadRequestParser.kt @@ -0,0 +1,110 @@ +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?, + 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, + ) + } + + 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().coerceIn(1, 100) + } else { + null + } + val intervalMs = + if (hasKey("intervalMs") && !isNull("intervalMs")) { + getDouble("intervalMs").toLong().coerceIn(16L, 1_000L) + } 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..ff3b282c64 --- /dev/null +++ b/package/shared-native/android/upload/StreamMultipartUploader.kt @@ -0,0 +1,138 @@ +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 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() + + fun cancel(uploadId: String) { + cancelledUploadIds.add(uploadId) + inFlightCalls.remove(uploadId)?.cancel() + } + + fun upload( + context: Context, + 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 = clientFor(request).newCall(httpRequest) + 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)) { + call.cancel() + } + + call.execute().use { response -> + return StreamMultipartUploadResponse( + body = readResponseBody(response.body), + headers = + response.headers.names().associateWith { name -> + response.headers(name).joinToString(", ") + }, + status = response.code, + statusText = response.message, + ) + } + } finally { + inFlightCalls.remove(request.uploadId, call) + cancelledUploadIds.remove(request.uploadId) + } + } + + 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, + 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..f3b1376cd0 --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploadBodyStream.swift @@ -0,0 +1,254 @@ +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=\(multipartQuotedParameter(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=\(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 { + 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() + if internalStatus == .error { + return + } + 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 { + if internalStatus == .error { + return -1 + } + + 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 nextStream == nil { + internalError = StreamMultipartUploadError.unreadableFile(url.path) + internalStatus = .error + return + } + } + + 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..951c988ebb --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploadManager.swift @@ -0,0 +1,462 @@ +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 + let task: URLSessionUploadTask + let uploadId: String + var completion: + ((Result) -> Void)? + var response: HTTPURLResponse? + var responseData = Data() + var responseDataError: Error? + + 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 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() + private var statesByTaskIdentifier = [Int: StreamMultipartUploadTaskState]() + 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() + if recordCancellation { + cancelledUploadIds.insert(uploadId) + } + 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]?, + timeoutMs: TimeInterval?, + onProgress: @escaping (Int64, Int64?) -> Void + ) async throws -> StreamMultipartUploadResponse { + let request = try parseRequest( + uploadId: uploadId, + url: url, + method: method, + headers: headers, + parts: parts, + progress: progress, + timeoutMs: timeoutMs + ) + + 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 + if let timeoutMs = request.timeoutMs, timeoutMs > 0 { + urlRequest.timeoutInterval = timeoutMs / 1_000 + } + + 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) + try await uploadLimiter.acquire() + + 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 + 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 + } + + task.resume() + } + } + + private func parseRequest( + uploadId: String, + url: String, + method: String, + headers: [String: String], + parts: [[String: Any]], + progress: [String: Any]?, + timeoutMs: TimeInterval? + ) 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 + ) + + 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 + ) + } + + 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? { + lock.lock() + let state = statesByTaskIdentifier.removeValue(forKey: taskIdentifier) + if let uploadId = state?.uploadId { + if taskIdentifiersByUploadId[uploadId] == taskIdentifier { + taskIdentifiersByUploadId.removeValue(forKey: uploadId) + } + cancelledUploadIds.remove(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 + } + + if state.responseData.count + data.count > maxResponseBodyBytes { + state.responseDataError = StreamMultipartUploadError.responseBodyTooLarge(maxResponseBodyBytes) + dataTask.cancel() + 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 { + 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 { + 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..ab5ba841ca --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploadModels.swift @@ -0,0 +1,69 @@ +import Foundation + +struct StreamMultipartUploadRequest { + let headers: [String: String] + let method: String + let parts: [StreamMultipartUploadPart] + let progress: StreamMultipartUploadProgressOptions? + let timeoutMs: TimeInterval? + 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 responseBodyTooLarge(Int) + case unreadableFile(String) + 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 .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 new file mode 100644 index 0000000000..d6a943a233 --- /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 = min(max(options?.count ?? 20, 1), 100) + self.intervalMs = min(max(options?.intervalMs ?? 16, 16), 1_000) + 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..112156a8b8 --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploadSourceResolver.swift @@ -0,0 +1,391 @@ +import AVFoundation +import Foundation +import MobileCoreServices +import Photos +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() + 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() + + if requestId != PHInvalidImageRequestID { + PHImageManager.default().cancelImageRequest(requestId) + } + } +} + +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) { + self.asset = asset + } + + func set(_ requestId: PHContentEditingInputRequestID) { + let asset: PHAsset? + let shouldCancel: Bool + + lock.lock() + 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() + + if requestId != 0 { + asset?.cancelContentEditingInputRequest(requestId) + } + } +} + +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 + let fileURL: URL + let mimeType: String + let size: Int64? +} + +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) + + 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 + 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 { + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) + return + } + + if let url = input?.fullSizeImageURL { + continuationBox.resume(returning: url) + return + } + + continuationBox.resume( + throwing: StreamMultipartUploadError.unsupportedSource(asset.localIdentifier) + ) + } + requestBox.set(requestId) + } + } onCancel: { + requestBox.cancel() + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) + } + } + + private static func requestVideoAssetURL(_ asset: PHAsset) async throws -> URL { + let options = PHVideoRequestOptions() + options.deliveryMode = .highQualityFormat + 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 { + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) + return + } + + if let isCancelled = (info?[PHImageCancelledKey] as? NSNumber)?.boolValue, isCancelled { + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) + return + } + + if let error = info?[PHImageErrorKey] as? Error { + continuationBox.resume(throwing: error) + return + } + + if let url = (avAsset as? AVURLAsset)?.url { + continuationBox.resume(returning: url) + return + } + + continuationBox.resume( + throwing: StreamMultipartUploadError.unsupportedSource(asset.localIdentifier) + ) + } + requestBox.set(requestId) + } + } onCancel: { + requestBox.cancel() + continuationBox.resume(throwing: StreamMultipartUploadError.cancelled) + } + } + + 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..058c5988d2 --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploader.mm @@ -0,0 +1,109 @@ +#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 + timeoutMs:(NSNumber *)timeoutMs + 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 + timeoutMs:timeoutMs + 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..0dc41f84e1 --- /dev/null +++ b/package/shared-native/ios/StreamMultipartUploaderBridge.swift @@ -0,0 +1,145 @@ +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 { + 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, + method: String, + headers: [[String: String]], + parts: [[String: Any]], + progress: [String: Any]?, + timeoutMs: NSNumber?, + onProgress: @escaping (NSNumber, NSNumber?) -> Void, + completion: @escaping (NSDictionary?, NSError?) -> Void + ) { + let taskBox = StreamMultipartUploadBridgeTaskBox() + var replacedTaskBox: StreamMultipartUploadBridgeTaskBox? + + taskLock.lock() + replacedTaskBox = tasksByUploadId[uploadId] + tasksByUploadId[uploadId] = taskBox + taskLock.unlock() + if replacedTaskBox != nil { + replacedTaskBox?.cancel() + StreamMultipartUploadManager.shared.cancelInFlight(uploadId: uploadId) + } + + 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, + url: url, + method: method, + headers: dictionary(from: headers), + parts: parts, + progress: progress, + timeoutMs: timeoutMs?.doubleValue, + 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()) + } + } + + 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) + } + + 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 { + 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 { + return nsError + } + + return NSError( + domain: "StreamMultipartUploader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: localizedDescription] + ) + } +} 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() } } } 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( diff --git a/package/src/__tests__/nativeMultipartUpload.test.ts b/package/src/__tests__/nativeMultipartUpload.test.ts new file mode 100644 index 0000000000..1591e5996e --- /dev/null +++ b/package/src/__tests__/nativeMultipartUpload.test.ts @@ -0,0 +1,267 @@ +import { + createNativeMultipartUpload, + createNativeMultipartUploader, + NativeMultipartAbortSignal, + NativeMultipartUploadEventEmitter, + NativeMultipartUploadNativeResponse, + NativeMultipartUploadProgressEvent, + NativeMultipartUploaderModule, +} 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< + ReturnType, + Parameters + >(() => + 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: NativeMultipartUploadNativeResponse) => void; + nativeModule.uploadMultipart.mockImplementation( + () => + new Promise((resolve) => { + resolveUpload = (response) => resolve(response); + }), + ); + 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/components/Attachment/Attachment.tsx b/package/src/components/Attachment/Attachment.tsx index 75c8c8e745..8232065edc 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'; @@ -31,6 +32,7 @@ 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,12 +190,14 @@ const MessageAudioAttachment = ({ message, }: MessageAudioAttachmentProps) => { const localId = (attachment as DefaultAttachmentData).localId; - const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId); - - const indicator = isUploading ? ( + 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; diff --git a/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx b/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx index 6dae9297cc..208ac9c319 100644 --- a/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx +++ b/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx @@ -1,14 +1,18 @@ 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'; +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 +39,20 @@ 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 { AttachmentUploadIndicator } = useComponentsContext(); + 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..093ec3566b 100644 --- a/package/src/components/Attachment/AttachmentUploadIndicator.tsx +++ b/package/src/components/Attachment/AttachmentUploadIndicator.tsx @@ -2,53 +2,104 @@ import React from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; -import { CircularProgressIndicator } from './CircularProgressIndicator'; - +import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; 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; + variant?: 'compact' | 'overlay'; }; /** * 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, + variant = 'compact', }: AttachmentUploadIndicatorProps) => { const { theme: { semantics }, } = useTheme(); + const { CircularProgressIndicator, MediaUploadProgressOverlay } = useComponentsContext(); + 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 (uploadProgress === undefined) { + if (variant === 'overlay') { return ( - - - + /> ); } return ( - + {uploadProgress === undefined ? ( + + + + ) : ( + + )} + + ); +}; + +export const AttachmentUploadIndicator = ({ + containerStyle, + localId, + sourceUrl, + variant, + ...props +}: AttachmentUploadIndicatorProps) => { + const shouldTrackPendingUpload = !!localId && !!sourceUrl && isLocalUrl(sourceUrl); + + if (!shouldTrackPendingUpload) { + return null; + } + + return ( + ); }; diff --git a/package/src/components/Attachment/CircularProgressIndicator.tsx b/package/src/components/Attachment/CircularProgressIndicator.tsx index 18d9f4b6a3..0a9f0caaa2 100644 --- a/package/src/components/Attachment/CircularProgressIndicator.tsx +++ b/package/src/components/Attachment/CircularProgressIndicator.tsx @@ -1,12 +1,26 @@ -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**. */ + backgroundColor: ColorValue; + filledColor: ColorValue; progress: number; - color: ColorValue; + unfilledColor: ColorValue; size?: number; strokeWidth?: number; style?: StyleProp; @@ -17,39 +31,17 @@ 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 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 +59,66 @@ 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 ( + + { 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..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'; @@ -37,7 +36,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'; @@ -333,7 +331,8 @@ const GalleryImageThumbnail = ({ borderRadius, thumbnail, }: Pick) => { - const { ImageLoadingFailedIndicator, ImageLoadingIndicator } = useComponentsContext(); + const { AttachmentUploadIndicator, ImageLoadingFailedIndicator, ImageLoadingIndicator } = + useComponentsContext(); const { isLoadingImage, isLoadingImageError, @@ -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} + )} @@ -606,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..f36a89b57f --- /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 { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; +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 { CircularProgressIndicator } = useComponentsContext(); + 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 1037b3fdb5..8e30036bbb 100644 --- a/package/src/components/Attachment/VideoThumbnail.tsx +++ b/package/src/components/Attachment/VideoThumbnail.tsx @@ -1,21 +1,11 @@ import React from 'react'; -import { ImageBackground, ImageStyle, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; - -import { AttachmentUploadIndicator } from './AttachmentUploadIndicator'; +import { ImageStyle, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +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'; const styles = StyleSheet.create({ - uploadProgressContainer: { - alignItems: 'flex-start', - bottom: primitives.spacingXxs, - justifyContent: 'flex-start', - left: primitives.spacingXxs, - position: 'absolute', - }, container: { alignItems: 'center', justifyContent: 'center', @@ -42,22 +32,18 @@ export const VideoThumbnail = (props: VideoThumbnailProps) => { }, }, } = useTheme(); + const { AttachmentUploadIndicator, ImageComponent } = useComponentsContext(); const { imageStyle, localId, style, thumb_url } = props; - const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId); return ( - + + - {isUploading ? ( - - - - ) : null} - + + ); }; diff --git a/package/src/components/Attachment/__tests__/Attachment.test.tsx b/package/src/components/Attachment/__tests__/Attachment.test.tsx index 9af88ccced..ba8869acac 100644 --- a/package/src/components/Attachment/__tests__/Attachment.test.tsx +++ b/package/src/components/Attachment/__tests__/Attachment.test.tsx @@ -1,8 +1,12 @@ 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'; +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'; @@ -21,10 +25,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(() => ({ @@ -37,21 +52,32 @@ const getAttachmentComponent = (props: ComponentProps) => { const message = generateMessage(); return ( - - - - - + + + + + + + ); }; +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; + describe('Attachment', () => { it('should render File component for "audio" type attachment', async () => { const attachment = generateAudioAttachment(); @@ -80,6 +106,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(), diff --git a/package/src/components/Attachment/__tests__/Giphy.test.tsx b/package/src/components/Attachment/__tests__/Giphy.test.tsx index 24682b04a5..fc4b14736b 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/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, diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 1648abbbd2..89df8a737e 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 { installNativeMultipartAdapter } from '../../utils/installNativeMultipartAdapter'; import { version } from '../../version.json'; init(); @@ -43,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. * @@ -141,6 +152,7 @@ const ChatWithContext = (props: PropsWithChildren) => { i18nInstance, isMessageAIGenerated, style, + useNativeMultipartUpload = false, } = props; const { ChatLoadingIndicator } = useComponentsContext(); @@ -241,6 +253,14 @@ const ChatWithContext = (props: PropsWithChildren) => { }; }, [client]); + useEffect(() => { + if (!useNativeMultipartUpload) { + return; + } + + installNativeMultipartAdapter(client); + }, [client, useNativeMultipartUpload]); + const initialisedDatabase = !!offlineDbInitialized && userID === offlineDbUserId; const appSettings = useAppSettings(client, isOnline, enableOfflineSupport, initialisedDatabase); diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx index df959fc386..20d9216c28 100644 --- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx @@ -1,5 +1,9 @@ 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'; @@ -35,6 +39,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: { @@ -64,6 +69,28 @@ const renderComponent = ({ ); }; +type PendingUploadRecord = { + id: string; + uploadProgress?: number; +}; + +const setPendingUploads = (client: StreamChat, uploads: PendingUploadRecord[]) => { + act(() => { + client.uploadManager.state.partialNext({ + uploads: Object.fromEntries( + uploads.map(({ id, uploadProgress }) => [id, { id, uploadProgress }]), + ), + }); + }); +}; + +const countActivityIndicators = (nodes: ReactTestInstance[]) => + nodes.reduce( + (count: number, node: ReactTestInstance) => + count + node.findAllByType(ActivityIndicator).length, + 0, + ); + describe('AttachmentUploadPreviewList', () => { let client: StreamChat; let channel: ChannelType; @@ -78,6 +105,7 @@ describe('AttachmentUploadPreviewList', () => { jest.clearAllMocks(); cleanup(); act(() => { + client?.uploadManager?.reset(); channel.messageComposer.attachmentManager.initState(); }); }); @@ -121,7 +149,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, }, @@ -133,14 +165,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); }); }); @@ -148,13 +181,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, }, @@ -165,6 +205,7 @@ describe('AttachmentUploadPreviewList', () => { act(() => { channel.messageComposer.attachmentManager.upsertAttachments(attachments); }); + setPendingUploads(client, [{ id: 'file-attachment' }, { id: 'video-attachment' }]); renderComponent({ channel, client, props }); @@ -172,7 +213,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(() => { @@ -303,6 +344,7 @@ describe('AttachmentUploadPreviewList', () => { generateImageAttachment({ localMetadata: { id: 'image-attachment', + previewUri: 'file://image-attachment.png', uploadState: FileState.UPLOADING, }, }), @@ -312,6 +354,7 @@ describe('AttachmentUploadPreviewList', () => { await act(() => { channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); }); + setPendingUploads(client, [{ id: 'image-attachment' }]); renderComponent({ channel, client, props }); @@ -319,7 +362,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(() => { @@ -455,6 +498,7 @@ describe('AttachmentUploadPreviewList', () => { generateImageAttachment({ localMetadata: { id: 'image-attachment-1', + previewUri: 'file://image-attachment-1.png', uploadState: FileState.UPLOADING, }, }), @@ -482,10 +526,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'); @@ -496,7 +541,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.tsx b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.tsx index 45569459a2..59fc47dc79 100644 --- a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.tsx +++ b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.tsx @@ -1,5 +1,9 @@ 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'; @@ -24,6 +28,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: { @@ -53,6 +58,28 @@ const renderComponent = ({ ); }; +type PendingUploadRecord = { + id: string; + uploadProgress?: number; +}; + +const setPendingUploads = (client: StreamChat, uploads: PendingUploadRecord[]) => { + act(() => { + client.uploadManager.state.partialNext({ + uploads: Object.fromEntries( + uploads.map(({ id, uploadProgress }) => [id, { id, uploadProgress }]), + ), + }); + }); +}; + +const countActivityIndicators = (nodes: ReactTestInstance[]) => + nodes.reduce( + (count: number, node: ReactTestInstance) => + count + node.findAllByType(ActivityIndicator).length, + 0, + ); + describe('AudioAttachmentUploadPreview render', () => { let client: StreamChat; let channel: ChannelType; @@ -67,6 +94,7 @@ describe('AudioAttachmentUploadPreview render', () => { jest.clearAllMocks(); cleanup(); act(() => { + client?.uploadManager?.reset(); channel.messageComposer.attachmentManager.initState(); }); }); @@ -74,6 +102,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', @@ -88,6 +117,7 @@ describe('AudioAttachmentUploadPreview render', () => { act(() => { channel.messageComposer.attachmentManager.upsertAttachments(attachments); }); + setPendingUploads(client, [{ id: 'audio-attachment' }]); renderComponent({ channel, client, props }); @@ -95,7 +125,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/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx index 9f54a3a5ca..c42939150a 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx @@ -1,8 +1,10 @@ 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 { AttachmentFileUploadProgressIndicator } from '../../../../components/Attachment/AttachmentFileUploadProgressIndicator'; +import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; import { ExclamationCircle } from '../../../../icons/exclamation-circle-fill'; @@ -10,24 +12,30 @@ 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; + totalBytes?: number | string | null; +}; + +export const FileUploadInProgressIndicator = ({ + localId, + sourceUrl, + totalBytes, +}: UploadInProgressIndicatorProps = {}) => { const { theme: { - semantics, messageComposer: { fileUploadInProgressIndicator }, }, } = useTheme(); return ( - - - + ); }; @@ -108,24 +116,13 @@ export const FileUploadNotSupportedIndicator = ({ ); }; -export const ImageUploadInProgressIndicator = () => { - const { - theme: { - semantics, - messageComposer: { imageUploadInProgressIndicator }, - }, - } = useTheme(); - const styles = useImageUploadInProgressIndicatorStyles(); - return ( - - - - ); +export const ImageUploadInProgressIndicator = ({ + localId, + sourceUrl, +}: UploadInProgressIndicatorProps = {}) => { + const { AttachmentUploadIndicator } = useComponentsContext(); + + return ; }; export type ImageUploadRetryIndicatorProps = { @@ -158,16 +155,6 @@ export const ImageUploadNotSupportedIndicator = () => { ); }; -const useImageUploadInProgressIndicatorStyles = () => { - return StyleSheet.create({ - container: { - position: 'absolute', - left: primitives.spacingXxs, - bottom: primitives.spacingXxs, - }, - }); -}; - const useImageUploadNotSupportedIndicatorStyles = () => { const { theme: { semantics }, @@ -235,9 +222,8 @@ const useFileUploadNotSupportedStyles = () => { }; const styles = StyleSheet.create({ - activityIndicatorContainer: {}, - activityIndicator: { - alignItems: 'flex-start', - justifyContent: 'flex-start', + activityIndicatorContainer: { + alignItems: 'center', + justifyContent: 'center', }, }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx index fd81d1eaa3..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; @@ -63,7 +63,13 @@ export const AudioAttachmentUploadPreview = ({ const renderIndicator = useMemo(() => { if (indicatorType === ProgressIndicatorTypes.IN_PROGRESS) { - return ; + return ( + + ); } if (indicatorType === ProgressIndicatorTypes.RETRY) { return ; @@ -72,7 +78,7 @@ export const AudioAttachmentUploadPreview = ({ return ; } return null; - }, [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 86f3a0442c..11c4137d6e 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -2,13 +2,18 @@ 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'; 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'; @@ -27,15 +32,17 @@ export const FileAttachmentUploadPreview = ({ removeAttachments, }: FileAttachmentUploadPreviewProps) => { const styles = useStyles(); + const sourceUrl = + attachment.asset_url ?? (attachment.localMetadata.file as FileReference | undefined)?.uri; const { FileUploadInProgressIndicator, FileUploadRetryIndicator, FileUploadNotSupportedIndicator, } = useComponentsContext(); - const { enableOfflineSupport } = useChatContext(); + const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); const indicatorType = getIndicatorTypeForFileState( attachment.localMetadata.uploadState, - enableOfflineSupport, + !!allowSendBeforeAttachmentsUpload, ); const { @@ -56,7 +63,13 @@ export const FileAttachmentUploadPreview = ({ const renderIndicator = useMemo(() => { if (indicatorType === ProgressIndicatorTypes.IN_PROGRESS) { - return ; + return ( + + ); } if (indicatorType === ProgressIndicatorTypes.RETRY) { return ; @@ -70,8 +83,10 @@ export const FileAttachmentUploadPreview = ({ FileUploadNotSupportedIndicator, FileUploadRetryIndicator, attachment.localMetadata, + attachment.file_size, 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..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,15 +24,20 @@ export const ImageAttachmentUploadPreview = ({ removeAttachments, }: ImageAttachmentUploadPreviewProps) => { const [loading, setLoading] = useState(true); - const { enableOfflineSupport } = useChatContext(); + const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); const { + ImageLoadingIndicator, ImageUploadInProgressIndicator, ImageUploadRetryIndicator, ImageUploadNotSupportedIndicator, } = useComponentsContext(); - const indicatorType = loading - ? ProgressIndicatorTypes.IN_PROGRESS - : getIndicatorTypeForFileState(attachment.localMetadata.uploadState, enableOfflineSupport); + const indicatorType = getIndicatorTypeForFileState( + attachment.localMetadata.uploadState, + !!allowSendBeforeAttachmentsUpload, + ); + 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..1fb06388a4 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx @@ -7,6 +7,7 @@ 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'; @@ -22,6 +23,9 @@ export const VideoAttachmentUploadPreview = ({ removeAttachments, }: VideoAttachmentUploadPreviewProps) => { const previewUri = attachment.thumb_url ?? attachment.localMetadata.previewUri; + const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); + const shouldShowMetadataPill = + allowSendBeforeAttachmentsUpload || attachment.localMetadata.uploadState !== 'uploading'; return previewUri ? ( <> @@ -38,7 +42,9 @@ export const VideoAttachmentUploadPreview = ({ handleRetry={handleRetry} removeAttachments={removeAttachments} /> - + {shouldShowMetadataPill ? ( + + ) : null} ) : ( = { 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, 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/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: {}, diff --git a/package/src/hooks/__tests__/usePendingAttachmentUpload.test.tsx b/package/src/hooks/__tests__/usePendingAttachmentUpload.test.tsx new file mode 100644 index 0000000000..6af644decc --- /dev/null +++ b/package/src/hooks/__tests__/usePendingAttachmentUpload.test.tsx @@ -0,0 +1,106 @@ +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: Record< + string, + { + 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: { + 'upload-id': { 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: { + 'upload-id': { 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/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 f151487703..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', @@ -308,6 +328,7 @@ type Handlers = { getLocalAssetUri?: GetLocalAssetUri; getPhotos?: GetPhotos; iOS14RefreshGallerySelection?: iOS14RefreshGallerySelection; + multipartUpload?: NativeMultipartUpload; oniOS14GalleryLibrarySelectionChange?: OniOS14LibrarySelectionChange; overrideAudioRecordingConfiguration?: ( audioRecordingConfiguration: AudioRecordingConfiguration, @@ -338,6 +359,7 @@ export const NativeHandlers: Pick< | 'getLocalAssetUri' | 'getPhotos' | 'iOS14RefreshGallerySelection' + | 'multipartUpload' | 'oniOS14GalleryLibrarySelectionChange' | 'pickDocument' | 'pickImage' @@ -355,6 +377,7 @@ export const NativeHandlers: Pick< getLocalAssetUri: fail, getPhotos: fail, iOS14RefreshGallerySelection: fail, + multipartUpload: fail, oniOS14GalleryLibrarySelectionChange: fail, pickDocument: fail, pickImage: fail, @@ -404,6 +427,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 +496,4 @@ export const isImageMediaLibraryAvailable = () => !!NativeHandlers.iOS14RefreshGallerySelection && !!NativeHandlers.oniOS14GalleryLibrarySelectionChange && !!NativeHandlers.getLocalAssetUri; +export const isNativeMultipartUploadAvailable = () => NativeHandlers.multipartUpload !== fail; 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, + }); + }; +}; diff --git a/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts new file mode 100644 index 0000000000..cbb14c3109 --- /dev/null +++ b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts @@ -0,0 +1,437 @@ +import type { AxiosProgressEvent, AxiosRequestConfig } from 'axios'; + +import { getTestClient } from '../../mock-builders/mock'; +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; + + beforeEach(() => { + NativeHandlers.multipartUpload = jest.fn().mockResolvedValue({ + body: JSON.stringify({ file: 'https://example.com/file.jpg' }), + headers: { 'content-type': 'application/json' }, + status: 200, + statusText: 'OK', + }); + }); + + const preserveRequestData = (client: ReturnType) => { + client.axiosInstance.defaults.transformRequest = [(data) => data]; + }; + + afterEach(() => { + NativeHandlers.multipartUpload = originalMultipartUpload; + jest.clearAllMocks(); + }); + + it('routes multipart requests through the native handler', 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 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', + }, + timeout: 1234, + }); + + 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', + }), + timeoutMs: 1234, + 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); + }); + + it('leaves non-multipart requests on the fallback adapter', 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); + + await client.axiosInstance.post('/messages', { text: 'hello' }); + + expect(defaultAdapter).toHaveBeenCalled(); + expect(NativeHandlers.multipartUpload).not.toHaveBeenCalled(); + }); + + 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(); + preserveRequestData(client); + installNativeMultipartAdapter(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, + nativeMultipartConfig({ + 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, + }, + }), + ); + }); + + 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, + nativeMultipartConfig({ + 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); + const defaultAdapter = jest.fn().mockResolvedValue({ + config: {}, + data: 'default', + headers: {}, + status: 200, + statusText: 'OK', + }); + + client.axiosInstance.defaults.adapter = defaultAdapter; + + 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 = { + _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: [ + [ + 'file', + { + name: 'test.jpg', + type: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }, + ], + ], + }; + + await client.axiosInstance.post('/uploads/image', formData); + + 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/installNativeMultipartAdapter.ts b/package/src/utils/installNativeMultipartAdapter.ts new file mode 100644 index 0000000000..38a5913c36 --- /dev/null +++ b/package/src/utils/installNativeMultipartAdapter.ts @@ -0,0 +1,302 @@ +import axios from 'axios'; +import type { AxiosAdapter, AxiosProgressEvent, InternalAxiosRequestConfig } from 'axios'; +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 NativeMultipartAxiosRequestConfig = InternalAxiosRequestConfig & { + onUploadProgress?: (event: AxiosProgressEvent) => void; + uploadProgressOptions?: NativeMultipartUploadProgressConfig; + uploadProgress?: (event: AxiosProgressEvent) => void; +}; + +type ResolvableAxiosAdapter = Parameters[0]; + +const DEFAULT_COMPLETION_PROGRESS_CAP = 90; + +const installedAdapters = new WeakSet(); + +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: NativeMultipartAxiosRequestConfig['headers'], +): Record => { + const rawHeaders = headers?.toJSON() ?? {}; + 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 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, +): 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: getNativeProgressOptions(config.uploadProgressOptions), + signal: config.signal, + timeoutMs: config.timeout, + 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 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 => + typeof callback === 'function', + ); + + return Array.from(new Set(callbacks)); +}; + +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: + typeof normalizedTotal === 'number' && normalizedTotal > 0 + ? normalizedLoaded / normalizedTotal + : undefined, + total: normalizedTotal, + upload: true, + }; +}; + +const nativeMultipartAxiosAdapter = async ( + request: NativeMultipartUploadRequest, + config: NativeMultipartAxiosRequestConfig, +) => { + const uploadProgressCallbacks = getUploadProgressCallbacks(config); + const completionProgressCap = getCompletionProgressCap(config); + let lastLoaded = 0; + + const response = await NativeHandlers.multipartUpload({ + ...request, + onProgress: uploadProgressCallbacks.length + ? ({ loaded, total }) => { + const normalizedLoaded = toFiniteNumber(loaded) ?? 0; + const normalizedTotal = toFiniteNumber(total); + const displayLoaded = getDisplayLoaded({ + completionProgressCap, + loaded: normalizedLoaded, + total: normalizedTotal, + }); + const event = createUploadProgressEvent({ + bytes: Math.max(0, displayLoaded - lastLoaded), + loaded: displayLoaded, + total: normalizedTotal, + }); + lastLoaded = displayLoaded; + 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 ?? '', + }; +}; + +const resolveAxiosAdapter = (adapter: ResolvableAxiosAdapter): AxiosAdapter => + axios.getAdapter(adapter); + +const createNativeMultipartAwareAdapter = ( + client: StreamChat, + fallbackAdapter: ResolvableAxiosAdapter, +): AxiosAdapter => { + const resolvedFallbackAdapter = resolveAxiosAdapter(fallbackAdapter); + + return (config) => { + const nativeMultipartRequest = createNativeMultipartRequest( + client, + config as NativeMultipartAxiosRequestConfig, + ); + + if (!nativeMultipartRequest) { + return resolvedFallbackAdapter(config); + } + + return nativeMultipartAxiosAdapter(nativeMultipartRequest, config); + }; +}; + +export const wrapAxiosAdapterWithNativeMultipart = ( + client: StreamChat, + fallbackAdapter: ResolvableAxiosAdapter, +): AxiosAdapter => { + if (!isNativeMultipartUploadAvailable()) { + return resolveAxiosAdapter(fallbackAdapter); + } + + return createNativeMultipartAwareAdapter(client, fallbackAdapter); +}; + +export const installNativeMultipartAdapter = (client: StreamChat) => { + if (!isNativeMultipartUploadAvailable()) { + return; + } + + if (installedAdapters.has(client)) { + return; + } + + const previousAdapter = client.axiosInstance.defaults.adapter; + client.axiosInstance.defaults.adapter = wrapAxiosAdapterWithNativeMultipart( + client, + previousAdapter, + ); + installedAdapters.add(client); +}; 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, diff --git a/package/yarn.lock b/package/yarn.lock index d47d87ee9e..b7dc20eabe 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -3134,14 +3134,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" @@ -4772,10 +4772,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" @@ -4803,6 +4803,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" @@ -7729,10 +7740,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" @@ -8496,14 +8507,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.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" - axios "^1.12.2" + axios "^1.15.1" base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0"