diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index d9784bfe96..89d27b2b65 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -17,6 +17,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + - name: Install Linux build tools + run: sudo apt-get update && sudo apt-get install -y build-essential - uses: actions/checkout@v2 with: ref: develop diff --git a/.github/workflows/check-pr.yml b/.github/workflows/check-pr.yml index 98113381d5..18abfa41c8 100644 --- a/.github/workflows/check-pr.yml +++ b/.github/workflows/check-pr.yml @@ -23,6 +23,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + - name: Install Linux build tools + run: sudo apt-get update && sudo apt-get install -y build-essential - name: Install && Build - SDK and Sample App uses: ./.github/actions/install-and-build-sdk - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a21f87ff0..d37ff1aa11 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,8 @@ jobs: with: node-version: ${{ matrix.node-version }} registry-url: 'https://registry.npmjs.org' + - name: Install Linux build tools + run: sudo apt-get update && sudo apt-get install -y build-essential - name: Prepare git run: | diff --git a/.github/workflows/sample-distribution.yml b/.github/workflows/sample-distribution.yml index 5280534365..18b09a9080 100644 --- a/.github/workflows/sample-distribution.yml +++ b/.github/workflows/sample-distribution.yml @@ -69,6 +69,8 @@ jobs: with: node-version: ${{ matrix.node-version }} - uses: actions/checkout@v2 + - name: Install Linux build tools + run: sudo apt-get update && sudo apt-get install -y build-essential - uses: actions/setup-java@v3 with: distribution: 'zulu' diff --git a/examples/SampleApp/android/app/build.gradle b/examples/SampleApp/android/app/build.gradle index 39b1680d7a..2da29e8bfc 100644 --- a/examples/SampleApp/android/app/build.gradle +++ b/examples/SampleApp/android/app/build.gradle @@ -4,6 +4,18 @@ apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.firebase.crashlytics' apply plugin: "com.facebook.react" +// Some libraries look up the React Native location on the application project +// instead of the root project. Mirror the root setting here so they never need +// to shell out to a bare `node` binary just to resolve react-native. +ext.REACT_NATIVE_NODE_MODULES_DIR = + rootProject.ext.has("REACT_NATIVE_NODE_MODULES_DIR") + ? rootProject.ext.get("REACT_NATIVE_NODE_MODULES_DIR") + : file("$rootDir/../node_modules/react-native").absolutePath +ext.REACT_NATIVE_WORKLETS_NODE_MODULES_DIR = + rootProject.ext.has("REACT_NATIVE_WORKLETS_NODE_MODULES_DIR") + ? rootProject.ext.get("REACT_NATIVE_WORKLETS_NODE_MODULES_DIR") + : file("$rootDir/../node_modules/react-native-worklets").absolutePath + /** * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. diff --git a/examples/SampleApp/android/build.gradle b/examples/SampleApp/android/build.gradle index 3122469674..2c015768a0 100644 --- a/examples/SampleApp/android/build.gradle +++ b/examples/SampleApp/android/build.gradle @@ -26,6 +26,21 @@ buildscript { } } +ext.REACT_NATIVE_NODE_MODULES_DIR = file("$rootDir/../node_modules/react-native").absolutePath +ext.REACT_NATIVE_WORKLETS_NODE_MODULES_DIR = + file("$rootDir/../node_modules/react-native-worklets").absolutePath + +subprojects { subproject -> + if (subproject.path != ":app") { + evaluationDependsOn(":app") + project(":app").tasks.matching { task -> + task.name.startsWith("configureCMake") + }.configureEach { + dependsOn(subproject.tasks.matching { it.name == "preBuild" }) + } + } +} + allprojects { repositories { maven { @@ -39,15 +54,8 @@ allprojects { } project(':app') { - afterEvaluate { - if (tasks.findByName("preBuild")) { - tasks.preBuild.doFirst { - exec { - workingDir rootDir - commandLine './gradlew', 'generateCodegenArtifactsFromSchema' - } - } - } + tasks.matching { it.name == "preBuild" || it.name.startsWith("configureCMake") }.configureEach { + dependsOn("generateCodegenArtifactsFromSchema") } } diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 0a3b243ecf..e65de54792 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -37,9 +37,9 @@ "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-camera-roll/camera-roll": "^7.10.0", "@react-native-clipboard/clipboard": "^1.16.3", + "@react-native-community/blur": "^4.4.1", "@react-native-community/geolocation": "^3.4.0", "@react-native-community/netinfo": "^11.4.1", - "@react-native-community/blur": "^4.4.1", "@react-native-documents/picker": "^10.1.3", "@react-native-firebase/app": "22.2.1", "@react-native-firebase/messaging": "22.2.1", diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index fd640d7fc8..bbeba18123 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -2190,9 +2190,9 @@ merge-options "^3.0.4" "@react-native-camera-roll/camera-roll@^7.10.0": - version "7.10.0" - resolved "https://registry.yarnpkg.com/@react-native-camera-roll/camera-roll/-/camera-roll-7.10.0.tgz#5e9518d78a9cd87ddc8e68d03e31a608df5033ab" - integrity sha512-Zm1yHxxTQS2APsnnxUFoLnK+DMMTPqmIQ2z2pGtNyHRXAG40Nt4MLVB3tDJTWnuJLAG87BpTCEvpz49+u0YkUw== + version "7.10.2" + resolved "https://registry.yarnpkg.com/@react-native-camera-roll/camera-roll/-/camera-roll-7.10.2.tgz#af2234f60f0b55aff9afb60888ce7f0669d52593" + integrity sha512-XgJQJDFUycmqSX+MH7vTcRigQwEIQNLIu1GvOngCZRwlSV2mF61UzeruSmmHwkBcGnHZFXkKg9fil0FQVfyglw== "@react-native-clipboard/clipboard@^1.16.3": version "1.16.3" 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 cd42f18297..20fa4cab28 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,9 +14,15 @@ import java.util.Map; public class StreamChatExpoPackage extends TurboReactPackage { + 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) { + return createNewArchModule("com.streamchatexpo.StreamVideoThumbnailModule", reactContext); + } + return null; } @@ -24,6 +30,18 @@ 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_VIDEO_THUMBNAIL_MODULE, + new ReactModuleInfo( + STREAM_VIDEO_THUMBNAIL_MODULE, + STREAM_VIDEO_THUMBNAIL_MODULE, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + isTurboModule // isTurboModule + )); return moduleInfos; }; } @@ -32,4 +50,19 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { public List createViewManagers(ReactApplicationContext reactContext) { return Collections.singletonList(new StreamShimmerViewManager()); } + + @Nullable + private NativeModule createNewArchModule( + String className, + ReactApplicationContext reactContext + ) { + try { + Class moduleClass = Class.forName(className); + return (NativeModule) moduleClass + .getConstructor(ReactApplicationContext.class) + .newInstance(reactContext); + } catch (Throwable ignored) { + return null; + } + } } diff --git a/package/expo-package/android/src/newarch/com/streamchatexpo/StreamVideoThumbnailModule.kt b/package/expo-package/android/src/newarch/com/streamchatexpo/StreamVideoThumbnailModule.kt new file mode 100644 index 0000000000..500ab7e9f3 --- /dev/null +++ b/package/expo-package/android/src/newarch/com/streamchatexpo/StreamVideoThumbnailModule.kt @@ -0,0 +1,50 @@ +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.streamchatreactnative.shared.StreamVideoThumbnailGenerator +import java.util.concurrent.Executors + +class StreamVideoThumbnailModule( + reactContext: ReactApplicationContext, +) : NativeStreamVideoThumbnailSpec(reactContext) { + override fun getName(): String = NAME + + override fun createVideoThumbnails(urls: ReadableArray, promise: Promise) { + val urlList = mutableListOf() + for (index in 0 until urls.size()) { + urlList.add(urls.getString(index) ?: "") + } + + executor.execute { + try { + val thumbnails = StreamVideoThumbnailGenerator.generateThumbnails(reactApplicationContext, urlList) + val result = Arguments.createArray() + thumbnails.forEach { thumbnail -> + val thumbnailMap = Arguments.createMap() + if (thumbnail.uri != null) { + thumbnailMap.putString("uri", thumbnail.uri) + } else { + thumbnailMap.putNull("uri") + } + if (thumbnail.error != null) { + thumbnailMap.putString("error", thumbnail.error) + } else { + thumbnailMap.putNull("error") + } + result.pushMap(thumbnailMap) + } + promise.resolve(result) + } catch (error: Throwable) { + promise.reject("stream_video_thumbnail_error", error.message, error) + } + } + } + + companion object { + const val NAME = "StreamVideoThumbnail" + private val executor = Executors.newCachedThreadPool() + } +} diff --git a/package/expo-package/package.json b/package/expo-package/package.json index 4108730345..301c6baa66 100644 --- a/package/expo-package/package.json +++ b/package/expo-package/package.json @@ -69,6 +69,9 @@ "expo-image-picker": { "optional": true }, + "expo-image-manipulator": { + "optional": true + }, "expo-sharing": { "optional": true }, @@ -91,6 +94,9 @@ "type": "all", "jsSrcsDir": "src/native", "ios": { + "modulesProvider": { + "StreamVideoThumbnail": "StreamVideoThumbnail" + }, "componentProvider": { "StreamShimmerView": "StreamShimmerViewComponentView" } diff --git a/package/expo-package/src/native/NativeStreamVideoThumbnail.ts b/package/expo-package/src/native/NativeStreamVideoThumbnail.ts new file mode 100644 index 0000000000..bd8d8981ab --- /dev/null +++ b/package/expo-package/src/native/NativeStreamVideoThumbnail.ts @@ -0,0 +1,14 @@ +import type { TurboModule } from 'react-native'; + +import { TurboModuleRegistry } from 'react-native'; + +export type VideoThumbnailResult = { + error?: string | null; + uri?: string | null; +}; + +export interface Spec extends TurboModule { + createVideoThumbnails(urls: ReadonlyArray): Promise>; +} + +export default TurboModuleRegistry.getEnforcing('StreamVideoThumbnail'); diff --git a/package/expo-package/src/native/videoThumbnail.ts b/package/expo-package/src/native/videoThumbnail.ts new file mode 100644 index 0000000000..1182acb04a --- /dev/null +++ b/package/expo-package/src/native/videoThumbnail.ts @@ -0,0 +1,8 @@ +import NativeStreamVideoThumbnail, { type VideoThumbnailResult } from './NativeStreamVideoThumbnail'; + +export type { VideoThumbnailResult } from './NativeStreamVideoThumbnail'; + +export const createVideoThumbnails = async (urls: string[]): Promise => { + const results = await NativeStreamVideoThumbnail.createVideoThumbnails(urls); + return Array.from(results); +}; diff --git a/package/expo-package/src/optionalDependencies/AudioVideo.ts b/package/expo-package/src/optionalDependencies/AudioVideo.ts index b023130bf8..538e341683 100644 --- a/package/expo-package/src/optionalDependencies/AudioVideo.ts +++ b/package/expo-package/src/optionalDependencies/AudioVideo.ts @@ -1,20 +1,18 @@ let AudioComponent; -let VideoComponent; let RecordingObject; try { const audioVideoPackage = require('expo-av'); AudioComponent = audioVideoPackage.Audio; - VideoComponent = audioVideoPackage.Video; RecordingObject = audioVideoPackage.RecordingObject; } catch (e) { // do nothing } -if (!AudioComponent || !VideoComponent) { +if (!AudioComponent) { console.log( - 'Audio Video library is currently not installed. To allow in-app audio playback, install the "expo-av" package.', + 'The audio library is currently not installed. To allow in-app audio playback, install the "expo-av" package.', ); } -export { AudioComponent, RecordingObject, VideoComponent }; +export { AudioComponent, RecordingObject }; diff --git a/package/expo-package/src/optionalDependencies/Video.tsx b/package/expo-package/src/optionalDependencies/Video.tsx index 70feffba66..fc705be101 100644 --- a/package/expo-package/src/optionalDependencies/Video.tsx +++ b/package/expo-package/src/optionalDependencies/Video.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { useEventListener } from 'expo'; -import { AudioComponent, VideoComponent as ExpoAVVideoComponent } from './AudioVideo'; +import { AudioComponent } from './AudioVideo'; let videoPackage; @@ -18,8 +18,8 @@ if (!videoPackage) { ); } -const VideoComponent = videoPackage ? videoPackage.VideoView : ExpoAVVideoComponent; -const useVideoPlayer = videoPackage ? videoPackage.useVideoPlayer : null; +const VideoComponent = videoPackage?.VideoView; +const useVideoPlayer = videoPackage?.useVideoPlayer; let Video = null; @@ -84,33 +84,5 @@ if (videoPackage) { ); }; } -// expo-av -else if (ExpoAVVideoComponent) { - Video = ({ onPlaybackStatusUpdate, paused, resizeMode, style, uri, videoRef, rate }) => { - // This is done so that the audio of the video is not muted when the phone is in silent mode for iOS. - useEffect(() => { - const initializeSound = async () => { - await AudioComponent.setAudioModeAsync({ - playsInSilentModeIOS: true, - }); - }; - initializeSound(); - }, []); - - return ( - - ); - }; -} export { Video }; diff --git a/package/expo-package/src/optionalDependencies/generateThumbnail.ts b/package/expo-package/src/optionalDependencies/generateThumbnail.ts new file mode 100644 index 0000000000..c3e8e007f4 --- /dev/null +++ b/package/expo-package/src/optionalDependencies/generateThumbnail.ts @@ -0,0 +1,9 @@ +import { createGenerateVideoThumbnails } from 'stream-chat-react-native-core/src/utils/createGenerateVideoThumbnails'; + +import { createVideoThumbnails, type VideoThumbnailResult } from '../native/videoThumbnail'; + +export const generateThumbnails: ( + uris: string[], +) => Promise> = createGenerateVideoThumbnails({ + createVideoThumbnails, +}); diff --git a/package/expo-package/src/optionalDependencies/getPhotos.ts b/package/expo-package/src/optionalDependencies/getPhotos.ts index c0bbe84696..50a742e77e 100644 --- a/package/expo-package/src/optionalDependencies/getPhotos.ts +++ b/package/expo-package/src/optionalDependencies/getPhotos.ts @@ -4,6 +4,9 @@ import mime from 'mime'; import type { File } from 'stream-chat-react-native-core'; +import { generateThumbnails } from './generateThumbnail'; +import { getLocalAssetUri } from './getLocalAssetUri'; + let MediaLibrary; try { @@ -18,8 +21,6 @@ if (!MediaLibrary) { ); } -import { getLocalAssetUri } from './getLocalAssetUri'; - type ReturnType = { assets: File[]; endCursor: string | undefined; @@ -52,24 +53,41 @@ export const getPhotos = MediaLibrary mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video], sortBy: [MediaLibrary.SortBy.modificationTime], }); - const assets = await Promise.all( + const assetEntries = await Promise.all( results.assets.map(async (asset) => { const localUri = await getLocalAssetUri(asset.id); const mimeType = mime.getType(asset.filename || asset.uri) || (asset.mediaType === MediaLibrary.MediaType.video ? 'video/*' : 'image/*'); + const uri = localUri || asset.uri; + return { - duration: asset.duration * 1000, - height: asset.height, - name: asset.filename, - size: 0, - thumb_url: asset.mediaType === 'photo' ? undefined : asset.uri, - type: mimeType, - uri: localUri || asset.uri, - width: asset.width, + asset, + isVideo: asset.mediaType === MediaLibrary.MediaType.video, + mimeType, + uri, }; }), ); + const videoUris = assetEntries + .filter(({ isVideo, uri }) => isVideo && !!uri) + .map(({ uri }) => uri); + const videoThumbnailResults = await generateThumbnails(videoUris); + + const assets = assetEntries.map(({ asset, isVideo, mimeType, uri }) => { + const thumbnailResult = isVideo && uri ? videoThumbnailResults[uri] : undefined; + + return { + duration: asset.duration * 1000, + height: asset.height, + name: asset.filename, + size: 0, + thumb_url: thumbnailResult?.uri || undefined, + type: mimeType, + uri, + width: asset.width, + }; + }); const hasNextPage = results.hasNextPage; const endCursor = results.endCursor; diff --git a/package/expo-package/src/optionalDependencies/index.ts b/package/expo-package/src/optionalDependencies/index.ts index 5cc6f86346..9f2cc0d87f 100644 --- a/package/expo-package/src/optionalDependencies/index.ts +++ b/package/expo-package/src/optionalDependencies/index.ts @@ -1,5 +1,6 @@ export * from './Audio'; export * from './deleteFile'; +export * from './generateThumbnail'; export * from './getLocalAssetUri'; export * from './getPhotos'; export * from './iOS14RefreshGallerySelection'; diff --git a/package/expo-package/src/optionalDependencies/pickImage.ts b/package/expo-package/src/optionalDependencies/pickImage.ts index 7f441b6a74..452d9eb637 100644 --- a/package/expo-package/src/optionalDependencies/pickImage.ts +++ b/package/expo-package/src/optionalDependencies/pickImage.ts @@ -1,6 +1,11 @@ import { Platform } from 'react-native'; + import mime from 'mime'; + import { PickImageOptions } from 'stream-chat-react-native-core'; + +import { generateThumbnails } from './generateThumbnail'; + let ImagePicker; try { @@ -43,17 +48,37 @@ export const pickImage = ImagePicker const canceled = result.canceled; if (!canceled) { - const assets = result.assets.map((asset) => ({ - ...asset, - duration: asset.duration, - name: asset.fileName, - size: asset.fileSize, - type: + const assetsWithType = result.assets.map((asset) => { + const type = asset.mimeType || mime.getType(asset.fileName || asset.uri) || - (asset.duration ? 'video/*' : 'image/*'), - uri: asset.uri, - })); + (asset.duration ? 'video/*' : 'image/*'); + + return { + asset, + isVideo: type.includes('video'), + type, + }; + }); + const videoUris = assetsWithType + .filter(({ asset, isVideo }) => isVideo && !!asset.uri) + .map(({ asset }) => asset.uri); + const videoThumbnailResults = await generateThumbnails(videoUris); + + const assets = assetsWithType.map(({ asset, isVideo, type }) => { + const thumbnailResult = + isVideo && asset.uri ? videoThumbnailResults[asset.uri] : undefined; + + return { + ...asset, + duration: asset.duration, + name: asset.fileName, + size: asset.fileSize, + thumb_url: thumbnailResult?.uri || undefined, + type, + uri: asset.uri, + }; + }); return { assets, cancelled: false }; } else { return { cancelled: true }; diff --git a/package/expo-package/src/optionalDependencies/takePhoto.ts b/package/expo-package/src/optionalDependencies/takePhoto.ts index 47c8e36ece..c544fff8db 100644 --- a/package/expo-package/src/optionalDependencies/takePhoto.ts +++ b/package/expo-package/src/optionalDependencies/takePhoto.ts @@ -2,6 +2,8 @@ import { Image, Platform } from 'react-native'; import mime from 'mime'; +import { generateThumbnails } from './generateThumbnail'; + let ImagePicker; try { @@ -61,12 +63,16 @@ export const takePhoto = ImagePicker if (mimeType.includes('video')) { const clearFilter = new RegExp('[.:]', 'g'); const date = new Date().toISOString().replace(clearFilter, '_'); + const thumbnailResults = await generateThumbnails([photo.uri]); + const thumbnailResult = thumbnailResults[photo.uri]; + return { ...photo, cancelled: false, duration: photo.duration, // in milliseconds name: 'video_recording_' + date + '.' + photo.uri.split('.').pop(), size: photo.fileSize, + thumb_url: thumbnailResult?.uri || undefined, type: mimeType, uri: photo.uri, }; 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 9f2decab6d..ec32749c90 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,12 +14,18 @@ import java.util.Map; public class StreamChatReactNativePackage extends TurboReactPackage { + private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail"; @Nullable @Override 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) { + return createNewArchModule( + "com.streamchatreactnative.StreamVideoThumbnailModule", + reactContext + ); } else { return null; } @@ -41,6 +47,17 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // isCxxModule isTurboModule // isTurboModule )); + moduleInfos.put( + STREAM_VIDEO_THUMBNAIL_MODULE, + new ReactModuleInfo( + STREAM_VIDEO_THUMBNAIL_MODULE, + STREAM_VIDEO_THUMBNAIL_MODULE, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + isTurboModule // isTurboModule + )); return moduleInfos; }; } @@ -49,4 +66,19 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { public List createViewManagers(ReactApplicationContext reactContext) { return Collections.singletonList(new StreamShimmerViewManager()); } + + @Nullable + private NativeModule createNewArchModule( + String className, + ReactApplicationContext reactContext + ) { + try { + Class moduleClass = Class.forName(className); + return (NativeModule) moduleClass + .getConstructor(ReactApplicationContext.class) + .newInstance(reactContext); + } catch (Throwable ignored) { + return null; + } + } } diff --git a/package/native-package/android/src/newarch/com/streamchatreactnative/StreamVideoThumbnailModule.kt b/package/native-package/android/src/newarch/com/streamchatreactnative/StreamVideoThumbnailModule.kt new file mode 100644 index 0000000000..5e9c3fa94e --- /dev/null +++ b/package/native-package/android/src/newarch/com/streamchatreactnative/StreamVideoThumbnailModule.kt @@ -0,0 +1,50 @@ +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.streamchatreactnative.shared.StreamVideoThumbnailGenerator +import java.util.concurrent.Executors + +class StreamVideoThumbnailModule( + reactContext: ReactApplicationContext, +) : NativeStreamVideoThumbnailSpec(reactContext) { + override fun getName(): String = NAME + + override fun createVideoThumbnails(urls: ReadableArray, promise: Promise) { + val urlList = mutableListOf() + for (index in 0 until urls.size()) { + urlList.add(urls.getString(index) ?: "") + } + + executor.execute { + try { + val thumbnails = StreamVideoThumbnailGenerator.generateThumbnails(reactApplicationContext, urlList) + val result = Arguments.createArray() + thumbnails.forEach { thumbnail -> + val thumbnailMap = Arguments.createMap() + if (thumbnail.uri != null) { + thumbnailMap.putString("uri", thumbnail.uri) + } else { + thumbnailMap.putNull("uri") + } + if (thumbnail.error != null) { + thumbnailMap.putString("error", thumbnail.error) + } else { + thumbnailMap.putNull("error") + } + result.pushMap(thumbnailMap) + } + promise.resolve(result) + } catch (error: Throwable) { + promise.reject("stream_video_thumbnail_error", error.message, error) + } + } + } + + companion object { + const val NAME = "StreamVideoThumbnail" + private val executor = Executors.newCachedThreadPool() + } +} diff --git a/package/native-package/package.json b/package/native-package/package.json index 1dcd41ad08..96d5f3ae9f 100644 --- a/package/native-package/package.json +++ b/package/native-package/package.json @@ -93,6 +93,10 @@ "type": "all", "jsSrcsDir": "src/native", "ios": { + "modulesProvider": { + "StreamChatReactNative": "StreamChatReactNative", + "StreamVideoThumbnail": "StreamVideoThumbnail" + }, "componentProvider": { "StreamShimmerView": "StreamShimmerViewComponentView" } diff --git a/package/native-package/src/native/NativeStreamVideoThumbnail.ts b/package/native-package/src/native/NativeStreamVideoThumbnail.ts new file mode 100644 index 0000000000..bd8d8981ab --- /dev/null +++ b/package/native-package/src/native/NativeStreamVideoThumbnail.ts @@ -0,0 +1,14 @@ +import type { TurboModule } from 'react-native'; + +import { TurboModuleRegistry } from 'react-native'; + +export type VideoThumbnailResult = { + error?: string | null; + uri?: string | null; +}; + +export interface Spec extends TurboModule { + createVideoThumbnails(urls: ReadonlyArray): Promise>; +} + +export default TurboModuleRegistry.getEnforcing('StreamVideoThumbnail'); diff --git a/package/native-package/src/native/types.ts b/package/native-package/src/native/types.ts index ec74640b14..715041f110 100644 --- a/package/native-package/src/native/types.ts +++ b/package/native-package/src/native/types.ts @@ -7,6 +7,8 @@ export interface Response { width: number; } +export interface VideoThumbnailResponse extends Response {} + export type ResizeFormat = 'PNG' | 'JPEG' | 'WEBP'; export type ResizeMode = 'contain' | 'cover' | 'stretch'; diff --git a/package/native-package/src/native/videoThumbnail.ts b/package/native-package/src/native/videoThumbnail.ts new file mode 100644 index 0000000000..1182acb04a --- /dev/null +++ b/package/native-package/src/native/videoThumbnail.ts @@ -0,0 +1,8 @@ +import NativeStreamVideoThumbnail, { type VideoThumbnailResult } from './NativeStreamVideoThumbnail'; + +export type { VideoThumbnailResult } from './NativeStreamVideoThumbnail'; + +export const createVideoThumbnails = async (urls: string[]): Promise => { + const results = await NativeStreamVideoThumbnail.createVideoThumbnails(urls); + return Array.from(results); +}; diff --git a/package/native-package/src/optionalDependencies/generateThumbnail.ts b/package/native-package/src/optionalDependencies/generateThumbnail.ts new file mode 100644 index 0000000000..2edfd5f869 --- /dev/null +++ b/package/native-package/src/optionalDependencies/generateThumbnail.ts @@ -0,0 +1,8 @@ +import { createGenerateVideoThumbnails } from 'stream-chat-react-native-core'; + +import { createVideoThumbnails, type VideoThumbnailResult } from '../native/videoThumbnail'; + +export const generateThumbnails: (uris: string[]) => Promise> = + createGenerateVideoThumbnails({ + createVideoThumbnails, + }); diff --git a/package/native-package/src/optionalDependencies/getPhotos.ts b/package/native-package/src/optionalDependencies/getPhotos.ts index f83db0b257..684b701d57 100644 --- a/package/native-package/src/optionalDependencies/getPhotos.ts +++ b/package/native-package/src/optionalDependencies/getPhotos.ts @@ -1,8 +1,12 @@ import { PermissionsAndroid, Platform } from 'react-native'; + import mime from 'mime'; import type { File } from 'stream-chat-react-native-core'; +import { generateThumbnails } from './generateThumbnail'; +import { getLocalAssetUri } from './getLocalAssetUri'; + let CameraRollDependency; try { @@ -14,8 +18,6 @@ try { ); } -import { getLocalAssetUri } from './getLocalAssetUri'; - type ReturnType = { assets: File[]; endCursor: string | undefined; @@ -88,7 +90,7 @@ export const getPhotos = CameraRollDependency first, include: ['fileSize', 'filename', 'imageSize', 'playableDuration'], }); - const assets = await Promise.all( + const assetEntries = await Promise.all( results.edges.map(async (edge) => { const originalUri = edge.node?.image?.uri; const type = @@ -100,20 +102,34 @@ export const getPhotos = CameraRollDependency (edge.node.image.playableDuration ? 'video/*' : 'image/*'); const isImage = type.includes('image'); - const uri = - isImage && getLocalAssetUri ? await getLocalAssetUri(originalUri) : originalUri; - return { - ...edge.node.image, - name: edge.node.image.filename as string, - duration: edge.node.image.playableDuration * 1000, - thumb_url: isImage ? undefined : originalUri, - size: edge.node.image.fileSize as number, + edge, + isImage, + originalUri, type, - uri, + uri: isImage && getLocalAssetUri ? await getLocalAssetUri(originalUri) : originalUri, }; }), ); + const videoUris = assetEntries + .filter(({ isImage, originalUri }) => !isImage && !!originalUri) + .map(({ originalUri }) => originalUri); + const videoThumbnailResults = await generateThumbnails(videoUris); + + const assets = assetEntries.map(({ edge, isImage, originalUri, type, uri }) => { + const thumbnailResult = + !isImage && originalUri ? videoThumbnailResults[originalUri] : undefined; + + return { + ...edge.node.image, + name: edge.node.image.filename as string, + duration: edge.node.image.playableDuration * 1000, + thumb_url: thumbnailResult?.uri || undefined, + size: edge.node.image.fileSize as number, + type, + uri, + }; + }); const hasNextPage = results.page_info.has_next_page; const endCursor = results.page_info.end_cursor; return { assets, endCursor, hasNextPage, iOSLimited: !!results.limited }; diff --git a/package/native-package/src/optionalDependencies/pickImage.ts b/package/native-package/src/optionalDependencies/pickImage.ts index 5bbec26a31..d17ecf265b 100644 --- a/package/native-package/src/optionalDependencies/pickImage.ts +++ b/package/native-package/src/optionalDependencies/pickImage.ts @@ -1,6 +1,11 @@ import { Platform } from 'react-native'; + import mime from 'mime'; + import { PickImageOptions } from 'stream-chat-react-native-core'; + +import { generateThumbnails } from './generateThumbnail'; + let ImagePicker; try { @@ -24,17 +29,37 @@ export const pickImage = ImagePicker return { askToOpenSettings: true, cancelled: true }; } if (!canceled) { - const assets = result.assets.map((asset) => ({ - ...asset, - duration: asset.duration ? asset.duration * 1000 : undefined, // in milliseconds - name: asset.fileName, - size: asset.fileSize, - type: + const assetsWithType = result.assets.map((asset) => { + const type = asset.type || mime.getType(asset.fileName || asset.uri) || - (asset.duration ? 'video/*' : 'image/*'), - uri: asset.uri, - })); + (asset.duration ? 'video/*' : 'image/*'); + + return { + asset, + isVideo: type.includes('video'), + type, + }; + }); + const videoUris = assetsWithType + .filter(({ asset, isVideo }) => isVideo && !!asset.uri) + .map(({ asset }) => asset.uri); + const videoThumbnailResults = await generateThumbnails(videoUris); + + const assets = assetsWithType.map(({ asset, isVideo, type }) => { + const thumbnailResult = + isVideo && asset.uri ? videoThumbnailResults[asset.uri] : undefined; + + return { + ...asset, + duration: asset.duration ? asset.duration * 1000 : undefined, // in milliseconds + name: asset.fileName, + size: asset.fileSize, + thumb_url: thumbnailResult?.uri || undefined, + type, + uri: asset.uri, + }; + }); return { assets, cancelled: false }; } else { return { cancelled: true }; diff --git a/package/native-package/src/optionalDependencies/takePhoto.ts b/package/native-package/src/optionalDependencies/takePhoto.ts index 5d3c07837b..a168bdbb19 100644 --- a/package/native-package/src/optionalDependencies/takePhoto.ts +++ b/package/native-package/src/optionalDependencies/takePhoto.ts @@ -1,6 +1,9 @@ import { AppState, Image, PermissionsAndroid, Platform } from 'react-native'; + import mime from 'mime'; +import { generateThumbnails } from './generateThumbnail'; + let ImagePicker; try { @@ -54,12 +57,16 @@ export const takePhoto = ImagePicker if (assetType.includes('video')) { const clearFilter = new RegExp('[.:]', 'g'); const date = new Date().toISOString().replace(clearFilter, '_'); + const thumbnailResults = await generateThumbnails([asset.uri]); + const thumbnailResult = thumbnailResults[asset.uri]; + return { ...asset, cancelled: false, duration: asset.duration * 1000, name: 'video_recording_' + date + '.' + asset.fileName.split('.').pop(), size: asset.fileSize, + thumb_url: thumbnailResult?.uri || undefined, type: assetType, uri: asset.uri, }; diff --git a/package/scripts/clean-shared-native-copies.sh b/package/scripts/clean-shared-native-copies.sh index f4eb986d4e..4b183454ce 100644 --- a/package/scripts/clean-shared-native-copies.sh +++ b/package/scripts/clean-shared-native-copies.sh @@ -5,6 +5,7 @@ set -euo pipefail TARGET="${1:-all}" ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +STATE_DIR="$ROOT_DIR/shared-native/.sync-state" clean_package() { local package_name="$1" @@ -12,6 +13,9 @@ clean_package() { local ios_dir="$ROOT_DIR/$package_name/ios/shared" rm -rf "$android_dir" "$ios_dir" + rm -f \ + "$STATE_DIR/${package_name}_android.manifest" \ + "$STATE_DIR/${package_name}_ios.manifest" } case "$TARGET" in diff --git a/package/shared-native/android/StreamVideoThumbnailGenerator.kt b/package/shared-native/android/StreamVideoThumbnailGenerator.kt new file mode 100644 index 0000000000..2d9841cf9b --- /dev/null +++ b/package/shared-native/android/StreamVideoThumbnailGenerator.kt @@ -0,0 +1,159 @@ +package com.streamchatreactnative.shared + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.Executors + +data class StreamVideoThumbnailResult( + val error: String? = null, + val uri: String? = null, +) + +object StreamVideoThumbnailGenerator { + private const val DEFAULT_COMPRESSION_QUALITY = 80 + private const val DEFAULT_MAX_DIMENSION = 512 + private const val CACHE_VERSION = "v1" + private const val CACHE_DIRECTORY_NAME = "@stream-io-stream-video-thumbnails" + private const val MAX_CONCURRENT_GENERATIONS = 5 + + fun generateThumbnails(context: Context, urls: List): List { + if (urls.size <= 1) { + return urls.map { url -> generateThumbnailResult(context, url) } + } + + val parallelism = minOf(urls.size, MAX_CONCURRENT_GENERATIONS) + val executor = Executors.newFixedThreadPool(parallelism) + + return try { + val tasks = urls.map { url -> + executor.submit { + generateThumbnailResult(context, url) + } + } + tasks.map { task -> task.get() } + } finally { + executor.shutdown() + } + } + + private fun generateThumbnailResult(context: Context, url: String): StreamVideoThumbnailResult { + return try { + StreamVideoThumbnailResult(uri = generateThumbnail(context, url)) + } catch (error: Throwable) { + StreamVideoThumbnailResult( + error = error.message ?: "Thumbnail generation failed for $url", + uri = null, + ) + } + } + + private fun generateThumbnail(context: Context, url: String): String { + val outputDirectory = File(context.cacheDir, CACHE_DIRECTORY_NAME).apply { mkdirs() } + val outputFile = File(outputDirectory, buildCacheFileName(url)) + + if (outputFile.isFile() && outputFile.length() > 0L) { + return Uri.fromFile(outputFile).toString() + } + + val retriever = MediaMetadataRetriever() + + return try { + setDataSource(retriever, context, url) + val thumbnail = extractThumbnailFrame(retriever, url) + + try { + FileOutputStream(outputFile).use { stream -> + thumbnail.compress(Bitmap.CompressFormat.JPEG, DEFAULT_COMPRESSION_QUALITY, stream) + } + } finally { + if (!thumbnail.isRecycled) { + thumbnail.recycle() + } + } + + Uri.fromFile(outputFile).toString() + } catch (error: Throwable) { + throw IllegalStateException("Thumbnail generation failed for $url", error) + } finally { + try { + retriever.release() + } catch (_: Throwable) { + // Ignore cleanup failures. + } + } + } + + private fun extractThumbnailFrame(retriever: MediaMetadataRetriever, url: String): Bitmap { + if (Build.VERSION.SDK_INT >= 27) { + return retriever.getScaledFrameAtTime( + 100000, + MediaMetadataRetriever.OPTION_CLOSEST_SYNC, + DEFAULT_MAX_DIMENSION, + DEFAULT_MAX_DIMENSION, + ) ?: throw IllegalStateException("Failed to extract video frame for $url") + } + + val frame = + retriever.getFrameAtTime(100000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + ?: throw IllegalStateException("Failed to extract video frame for $url") + val scaledFrame = scaleBitmap(frame) + + if (scaledFrame != frame) { + frame.recycle() + } + + return scaledFrame + } + + private fun buildCacheFileName(url: String): String { + val cacheKey = + fnv1a64("$CACHE_VERSION|$DEFAULT_MAX_DIMENSION|$DEFAULT_COMPRESSION_QUALITY|$url") + return "stream-video-thumbnail-$cacheKey.jpg" + } + + private fun fnv1a64(value: String): String { + var hash = -0x340d631b8c46751fL + + value.toByteArray(Charsets.UTF_8).forEach { byte -> + hash = hash xor (byte.toLong() and 0xff) + hash *= 0x100000001b3L + } + + return java.lang.Long.toUnsignedString(hash, 16) + } + + private fun setDataSource(retriever: MediaMetadataRetriever, context: Context, url: String) { + val uri = Uri.parse(url) + val scheme = uri.scheme?.lowercase() + + when { + scheme.isNullOrEmpty() -> retriever.setDataSource(url) + scheme == "content" || scheme == "file" -> retriever.setDataSource(context, uri) + else -> + throw IllegalArgumentException( + "Unsupported video URI scheme for thumbnail generation: $scheme. Local assets only.", + ) + } + } + + private fun scaleBitmap(bitmap: Bitmap): Bitmap { + val width = bitmap.width + val height = bitmap.height + val largestDimension = maxOf(width, height) + + if (largestDimension <= DEFAULT_MAX_DIMENSION) { + return bitmap + } + + val scale = DEFAULT_MAX_DIMENSION.toFloat() / largestDimension.toFloat() + val targetWidth = (width * scale).toInt().coerceAtLeast(1) + val targetHeight = (height * scale).toInt().coerceAtLeast(1) + + return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true) + } +} diff --git a/package/shared-native/ios/StreamVideoThumbnail.h b/package/shared-native/ios/StreamVideoThumbnail.h new file mode 100644 index 0000000000..dcc2456c64 --- /dev/null +++ b/package/shared-native/ios/StreamVideoThumbnail.h @@ -0,0 +1,14 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#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 StreamVideoThumbnail." +#endif + +@interface StreamVideoThumbnail : NSObject +@end + +#endif diff --git a/package/shared-native/ios/StreamVideoThumbnail.mm b/package/shared-native/ios/StreamVideoThumbnail.mm new file mode 100644 index 0000000000..9a60b2dbc1 --- /dev/null +++ b/package/shared-native/ios/StreamVideoThumbnail.mm @@ -0,0 +1,56 @@ +#import "StreamVideoThumbnail.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 StreamVideoThumbnail." +#endif + +@implementation StreamVideoThumbnail + +RCT_EXPORT_MODULE(StreamVideoThumbnail) + +RCT_REMAP_METHOD(createVideoThumbnails, urls:(NSArray *)urls resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +{ + [self createVideoThumbnails:urls resolve:resolve reject:reject]; +} + +- (void)createVideoThumbnails:(NSArray *)urls + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [StreamVideoThumbnailGenerator generateThumbnailsWithUrls:urls completion:^(NSArray *thumbnails) { + NSMutableArray *> *payload = [NSMutableArray arrayWithCapacity:thumbnails.count]; + + for (StreamVideoThumbnailResult *thumbnail in thumbnails) { + NSMutableDictionary *entry = [NSMutableDictionary dictionaryWithCapacity:2]; + entry[@"uri"] = thumbnail.uri ?: [NSNull null]; + entry[@"error"] = thumbnail.error ?: [NSNull null]; + [payload addObject:entry]; + } + + @try { + resolve(payload); + } @catch (NSException *exception) { + reject(@"stream_video_thumbnail_error", exception.reason, 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/StreamVideoThumbnailGenerator.swift b/package/shared-native/ios/StreamVideoThumbnailGenerator.swift new file mode 100644 index 0000000000..71336dbe41 --- /dev/null +++ b/package/shared-native/ios/StreamVideoThumbnailGenerator.swift @@ -0,0 +1,338 @@ +import AVFoundation +import Photos +import UIKit + +private final class StreamPhotoLibraryAssetRequestState: @unchecked Sendable { + let lock = NSLock() + var didResume = false + var requestID: PHImageRequestID = PHInvalidImageRequestID +} + +@objcMembers +public final class StreamVideoThumbnailResult: NSObject { + public let error: String? + public let uri: String? + + public init(error: String? = nil, uri: String? = nil) { + self.error = error + self.uri = uri + } +} + +@objcMembers +public final class StreamVideoThumbnailGenerator: NSObject { + private static let compressionQuality: CGFloat = 0.8 + private static let maxDimension: CGFloat = 512 + private static let cacheVersion = "v1" + private static let cacheDirectoryName = "@stream-io-stream-video-thumbnails" + private static let maxConcurrentGenerations = 5 + private static let photoLibraryAssetResolutionTimeout: TimeInterval = 3 + + @objc(generateThumbnailsWithUrls:completion:) + public static func generateThumbnails( + urls: [String], + completion: @escaping ([StreamVideoThumbnailResult]) -> Void + ) { + Task(priority: .userInitiated) { + completion(await generateThumbnailsAsync(urls: urls)) + } + } + + private static func generateThumbnailsAsync(urls: [String]) async -> [StreamVideoThumbnailResult] { + guard !urls.isEmpty else { + return [] + } + + if urls.count == 1 { + return [await generateThumbnailResult(url: urls[0])] + } + + let parallelism = min(maxConcurrentGenerations, urls.count) + + return await withTaskGroup( + of: (Int, StreamVideoThumbnailResult).self, + returning: [StreamVideoThumbnailResult].self + ) { group in + var thumbnails = Array(repeating: nil, count: urls.count) + var nextIndexToSchedule = 0 + + while nextIndexToSchedule < parallelism { + let index = nextIndexToSchedule + let url = urls[index] + group.addTask { + (index, await generateThumbnailResult(url: url)) + } + nextIndexToSchedule += 1 + } + + while let (index, thumbnail) = await group.next() { + thumbnails[index] = thumbnail + + if nextIndexToSchedule < urls.count { + let nextIndex = nextIndexToSchedule + let nextURL = urls[nextIndex] + group.addTask { + (nextIndex, await generateThumbnailResult(url: nextURL)) + } + nextIndexToSchedule += 1 + } + } + + return thumbnails.enumerated().map { index, thumbnail in + thumbnail ?? StreamVideoThumbnailResult( + error: "Thumbnail generation produced no output for index \(index)", + uri: nil + ) + } + } + } + + private static func generateThumbnailResult(url: String) async -> StreamVideoThumbnailResult { + do { + return StreamVideoThumbnailResult(uri: try await generateThumbnail(url: url)) + } catch { + return StreamVideoThumbnailResult( + error: error.localizedDescription, + uri: nil + ) + } + } + + private static func generateThumbnail(url: String) async throws -> String { + let outputDirectory = try thumbnailCacheDirectory() + let outputURL = outputDirectory + .appendingPathComponent(buildCacheFileName(url: url)) + .appendingPathExtension("jpg") + + if + FileManager.default.fileExists(atPath: outputURL.path), + let attributes = try? FileManager.default.attributesOfItem(atPath: outputURL.path), + let fileSize = attributes[.size] as? NSNumber, + fileSize.intValue > 0 + { + return outputURL.absoluteString + } + + let asset = try await resolveAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: maxDimension, height: maxDimension) + + let requestedTime = thumbnailTime(for: asset) + + do { + let cgImage = try generator.copyCGImage(at: requestedTime, actualTime: nil) + let image = UIImage(cgImage: cgImage) + guard let data = image.jpegData(compressionQuality: compressionQuality) else { + throw thumbnailError(code: 2, message: "Failed to encode JPEG thumbnail for \(url)") + } + + try data.write(to: outputURL, options: .atomic) + return outputURL.absoluteString + } catch { + throw thumbnailError(error, code: 3, message: "Thumbnail generation failed for \(url)") + } + } + + private static func thumbnailCacheDirectory() throws -> URL { + let outputDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent(cacheDirectoryName, isDirectory: true) + try FileManager.default.createDirectory( + at: outputDirectory, + withIntermediateDirectories: true + ) + return outputDirectory + } + + private static func buildCacheFileName(url: String) -> String { + let cacheKey = fnv1a64("\(cacheVersion)|\(Int(maxDimension))|\(compressionQuality)|\(url)") + return "stream-video-thumbnail-\(cacheKey)" + } + + private static func fnv1a64(_ value: String) -> String { + var hash: UInt64 = 0xcbf29ce484222325 + let prime: UInt64 = 0x100000001b3 + + for byte in value.utf8 { + hash ^= UInt64(byte) + hash &*= prime + } + + return String(hash, radix: 16) + } + + private static func thumbnailTime(for asset: AVAsset) -> CMTime { + let durationSeconds = asset.duration.seconds + if durationSeconds.isFinite, durationSeconds > 0.1 { + return CMTime(seconds: 0.1, preferredTimescale: 600) + } + + return .zero + } + + private static func resolveAsset(url: String) async throws -> AVAsset { + if isPhotoLibraryURL(url) { + return try await resolvePhotoLibraryAsset(url: url) + } + + if let normalizedURL = normalizeLocalURL(url) { + return AVURLAsset(url: normalizedURL) + } + + throw thumbnailError(code: 5, message: "Unsupported video URL for thumbnail generation: \(url)") + } + + private static func isPhotoLibraryURL(_ url: String) -> Bool { + url.lowercased().hasPrefix("ph://") + } + + private static func resolvePhotoLibraryAsset(url: String) async throws -> AVAsset { + let identifier = photoLibraryIdentifier(from: url) + + guard !identifier.isEmpty else { + throw thumbnailError(code: 6, message: "Missing photo library identifier for \(url)") + } + + let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil) + guard let asset = fetchResult.firstObject else { + throw thumbnailError(code: 7, message: "Failed to find photo library asset for \(url)") + } + + let options = PHVideoRequestOptions() + options.deliveryMode = .highQualityFormat + options.isNetworkAccessAllowed = true + options.version = .current + + return try await withThrowingTaskGroup(of: AVAsset.self) { group in + group.addTask { + try await requestPhotoLibraryAsset(url: url, asset: asset, options: options) + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(photoLibraryAssetResolutionTimeout * 1_000_000_000)) + throw thumbnailError( + code: 11, + message: "Timed out resolving photo library asset for \(url)" + ) + } + + guard let resolvedAsset = try await group.next() else { + throw thumbnailError( + code: 10, + message: "Failed to resolve photo library asset for \(url)" + ) + } + + group.cancelAll() + return resolvedAsset + } + } + + private static func requestPhotoLibraryAsset( + url: String, + asset: PHAsset, + options: PHVideoRequestOptions + ) async throws -> AVAsset { + let imageManager = PHImageManager.default() + let state = StreamPhotoLibraryAssetRequestState() + + return try await withTaskCancellationHandler(operation: { + try await withCheckedThrowingContinuation { continuation in + let requestID = imageManager.requestAVAsset(forVideo: asset, options: options) { + avAsset, _, info in + state.lock.lock() + if state.didResume { + state.lock.unlock() + return + } + state.didResume = true + state.lock.unlock() + + if let isCancelled = (info?[PHImageCancelledKey] as? NSNumber)?.boolValue, isCancelled { + continuation.resume( + throwing: thumbnailError( + code: 8, + message: "Photo library asset request was cancelled for \(url)" + ) + ) + return + } + + if let error = info?[PHImageErrorKey] as? Error { + continuation.resume( + throwing: thumbnailError( + error, + code: 9, + message: "Photo library asset request failed for \(url)" + ) + ) + return + } + + guard let avAsset else { + continuation.resume( + throwing: thumbnailError( + code: 10, + message: "Failed to resolve photo library asset for \(url)" + ) + ) + return + } + + continuation.resume(returning: avAsset) + } + + state.lock.lock() + state.requestID = requestID + state.lock.unlock() + } + }, onCancel: { + state.lock.lock() + let requestID = state.requestID + state.lock.unlock() + + if requestID != PHInvalidImageRequestID { + imageManager.cancelImageRequest(requestID) + } + }) + } + + 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: "/")) ?? "" + } + + private static func normalizeLocalURL(_ url: String) -> URL? { + if let parsedURL = URL(string: url), let scheme = parsedURL.scheme?.lowercased() { + if scheme == "file" { + return parsedURL + } + + return nil + } + + return URL(fileURLWithPath: url) + } + + private static func thumbnailError( + _ error: Error? = nil, + code: Int, + message: String + ) -> Error { + let description = error.map { "\(message): \($0.localizedDescription)" } ?? message + return NSError( + domain: "StreamVideoThumbnail", + code: code, + userInfo: [NSLocalizedDescriptionKey: description] + ) + } +} diff --git a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx index bd4971bd5f..f7e0169a2d 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Alert, ImageBackground, StyleSheet, Text, View } from 'react-native'; +import { Alert, Image, StyleSheet, Text, View } from 'react-native'; import { FileReference, isLocalImageAttachment, isLocalVideoAttachment } from 'stream-chat'; @@ -68,23 +68,22 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => { }; return ( - - - - - - - + + + + + + ); }; @@ -128,22 +127,21 @@ const AttachmentImage = (props: AttachmentPickerItemType) => { }; return ( - - - - - - + + + + + ); }; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx index 5149b86cee..cbe8d11055 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useState } from 'react'; -import { ImageBackground, StyleSheet, View } from 'react-native'; +import { Image, StyleSheet, View } from 'react-native'; import { LocalImageAttachment } from 'stream-chat'; @@ -61,13 +61,14 @@ export const ImageAttachmentUploadPreview = ({ return ( - + + {indicatorType === ProgressIndicatorTypes.IN_PROGRESS && } {indicatorType === ProgressIndicatorTypes.RETRY && ( @@ -75,7 +76,7 @@ export const ImageAttachmentUploadPreview = ({ {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 184c484672..0059314681 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx @@ -21,10 +21,20 @@ export const VideoAttachmentUploadPreview = ({ handleRetry, removeAttachments, }: VideoAttachmentUploadPreviewProps) => { - return attachment.localMetadata.previewUri ? ( + const previewUri = attachment.thumb_url ?? attachment.localMetadata.previewUri; + + return previewUri ? ( <> diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index b729b5d862..109c8a1f92 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -56,6 +56,7 @@ import { useStableCallback } from '../../hooks/useStableCallback'; import { createAttachmentsCompositionMiddleware, createDraftAttachmentsCompositionMiddleware, + setupVideoAttachmentPreviewMiddleware, } from '../../middlewares/attachments'; import { isDocumentPickerAvailable, MediaTypes, NativeHandlers } from '../../native'; @@ -424,6 +425,8 @@ export const MessageInputProvider = ({ attachmentManager.setCustomUploadFn(value.doFileUploadRequest); } + setupVideoAttachmentPreviewMiddleware(messageComposer); + if (allowSendBeforeAttachmentsUpload) { messageComposer.compositionMiddlewareExecutor.replace([ createAttachmentsCompositionMiddleware(messageComposer), diff --git a/package/src/index.ts b/package/src/index.ts index 2c4d7c5d04..2d53b2f005 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -16,6 +16,7 @@ export * from './types/types'; export * from './utils/patchMessageTextCommand'; export * from './utils/i18n/Streami18n'; export * from './utils/setupCommandUIMiddlewares'; +export * from './utils/createGenerateVideoThumbnails'; export * from './utils/utils'; export { default as enTranslations } from './i18n/en.json'; diff --git a/package/src/middlewares/attachments.ts b/package/src/middlewares/attachments.ts index 33c5531727..8996af684e 100644 --- a/package/src/middlewares/attachments.ts +++ b/package/src/middlewares/attachments.ts @@ -1,7 +1,9 @@ import { Attachment, + AttachmentPreUploadMiddleware, FileReference, isLocalImageAttachment, + isLocalVideoAttachment, LocalAttachment, MessageComposer, MessageComposerMiddlewareState, @@ -100,3 +102,35 @@ export const createDraftAttachmentsCompositionMiddleware = ( }, id: 'stream-io/message-composer-middleware/draft-attachments', }); + +const createVideoAttachmentPreviewMiddleware = (): AttachmentPreUploadMiddleware => ({ + id: 'stream-io/message-composer-ui-middleware/video-attachment-preview', + handlers: { + prepare: ({ next, forward, state }) => { + const { attachment } = state; + + if (!attachment || !isLocalVideoAttachment(attachment)) { + return forward(); + } + + return next({ + ...state, + attachment: { + ...attachment, + localMetadata: { + ...attachment.localMetadata, + previewUri: attachment.thumb_url, + }, + }, + }); + }, + }, +}); + +export const setupVideoAttachmentPreviewMiddleware = (messageComposer: MessageComposer) => { + messageComposer.attachmentManager.preUploadMiddlewareExecutor.insert({ + middleware: [createVideoAttachmentPreviewMiddleware()], + position: { after: 'stream-io/attachment-manager-middleware/file-upload-config-check' }, + unique: true, + }); +}; diff --git a/package/src/utils/createGenerateVideoThumbnails.ts b/package/src/utils/createGenerateVideoThumbnails.ts new file mode 100644 index 0000000000..63bda7373a --- /dev/null +++ b/package/src/utils/createGenerateVideoThumbnails.ts @@ -0,0 +1,45 @@ +export type GenerateVideoThumbnailResult = { + error?: string | null; + uri?: string | null; +}; + +export const createGenerateVideoThumbnails = ({ + createVideoThumbnails, +}: { + createVideoThumbnails: (uris: string[]) => Promise; +}) => { + return async (uris: string[]): Promise> => { + if (!uris.length) { + return {}; + } + + const uniqueUris: string[] = []; + const seenUris = new Set(); + + uris.forEach((uri) => { + if (!seenUris.has(uri)) { + seenUris.add(uri); + uniqueUris.push(uri); + } + }); + + const uniqueResults = await createVideoThumbnails(uniqueUris); + + return uniqueUris.reduce>( + (resultMap, uri, index) => { + const result = uniqueResults[index] ?? { + error: 'Thumbnail generation returned no result', + uri: null, + }; + + if (result.error) { + console.warn(`Failed to generate thumbnail for ${uri}: ${result.error}`); + } + + resultMap[uri] = result; + return resultMap; + }, + {}, + ); + }; +};