stream-chat-expo 9.0.0-beta.9 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -137,3 +137,33 @@ if (isNewArchitectureEnabled()) {
137
137
  codegenJavaPackageName = "com.streamchatexpo"
138
138
  }
139
139
  }
140
+
141
+ if (isNewArchitectureEnabled()) {
142
+ gradle.projectsEvaluated {
143
+ if (rootProject.ext.has("streamChatReactNativeCodegenHookInstalled")) {
144
+ return
145
+ }
146
+
147
+ def androidAppProject = rootProject.subprojects.find { it.plugins.hasPlugin("com.android.application") }
148
+ if (androidAppProject == null) {
149
+ return
150
+ }
151
+
152
+ def dependencyCodegenTasks = rootProject.subprojects
153
+ .findAll { it != androidAppProject }
154
+ .collect { it.tasks.findByName("generateCodegenArtifactsFromSchema") }
155
+ .findAll { it != null }
156
+
157
+ if (dependencyCodegenTasks.isEmpty()) {
158
+ return
159
+ }
160
+
161
+ rootProject.ext.set("streamChatReactNativeCodegenHookInstalled", true)
162
+
163
+ androidAppProject.tasks.matching { task ->
164
+ task.name.startsWith("configureCMake")
165
+ }.configureEach {
166
+ dependsOn(dependencyCodegenTasks)
167
+ }
168
+ }
169
+ }
@@ -14,9 +14,15 @@ import java.util.List;
14
14
  import java.util.Map;
15
15
 
16
16
  public class StreamChatExpoPackage extends TurboReactPackage {
17
+ private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail";
18
+
17
19
  @Nullable
18
20
  @Override
19
21
  public NativeModule getModule(String name, ReactApplicationContext reactContext) {
22
+ if (name.equals(STREAM_VIDEO_THUMBNAIL_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
23
+ return createNewArchModule("com.streamchatexpo.StreamVideoThumbnailModule", reactContext);
24
+ }
25
+
20
26
  return null;
21
27
  }
22
28
 
@@ -24,6 +30,18 @@ public class StreamChatExpoPackage extends TurboReactPackage {
24
30
  public ReactModuleInfoProvider getReactModuleInfoProvider() {
25
31
  return () -> {
26
32
  final Map<String, ReactModuleInfo> moduleInfos = new HashMap<>();
33
+ boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
34
+ moduleInfos.put(
35
+ STREAM_VIDEO_THUMBNAIL_MODULE,
36
+ new ReactModuleInfo(
37
+ STREAM_VIDEO_THUMBNAIL_MODULE,
38
+ STREAM_VIDEO_THUMBNAIL_MODULE,
39
+ false, // canOverrideExistingModule
40
+ false, // needsEagerInit
41
+ false, // hasConstants
42
+ false, // isCxxModule
43
+ isTurboModule // isTurboModule
44
+ ));
27
45
  return moduleInfos;
28
46
  };
29
47
  }
@@ -32,4 +50,19 @@ public class StreamChatExpoPackage extends TurboReactPackage {
32
50
  public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
33
51
  return Collections.<ViewManager>singletonList(new StreamShimmerViewManager());
34
52
  }
53
+
54
+ @Nullable
55
+ private NativeModule createNewArchModule(
56
+ String className,
57
+ ReactApplicationContext reactContext
58
+ ) {
59
+ try {
60
+ Class<?> moduleClass = Class.forName(className);
61
+ return (NativeModule) moduleClass
62
+ .getConstructor(ReactApplicationContext.class)
63
+ .newInstance(reactContext);
64
+ } catch (Throwable ignored) {
65
+ return null;
66
+ }
67
+ }
35
68
  }
@@ -0,0 +1,159 @@
1
+ package com.streamchatreactnative.shared
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.media.MediaMetadataRetriever
6
+ import android.net.Uri
7
+ import android.os.Build
8
+ import java.io.File
9
+ import java.io.FileOutputStream
10
+ import java.util.concurrent.Executors
11
+
12
+ data class StreamVideoThumbnailResult(
13
+ val error: String? = null,
14
+ val uri: String? = null,
15
+ )
16
+
17
+ object StreamVideoThumbnailGenerator {
18
+ private const val DEFAULT_COMPRESSION_QUALITY = 80
19
+ private const val DEFAULT_MAX_DIMENSION = 512
20
+ private const val CACHE_VERSION = "v1"
21
+ private const val CACHE_DIRECTORY_NAME = "@stream-io-stream-video-thumbnails"
22
+ private const val MAX_CONCURRENT_GENERATIONS = 5
23
+
24
+ fun generateThumbnails(context: Context, urls: List<String>): List<StreamVideoThumbnailResult> {
25
+ if (urls.size <= 1) {
26
+ return urls.map { url -> generateThumbnailResult(context, url) }
27
+ }
28
+
29
+ val parallelism = minOf(urls.size, MAX_CONCURRENT_GENERATIONS)
30
+ val executor = Executors.newFixedThreadPool(parallelism)
31
+
32
+ return try {
33
+ val tasks = urls.map { url ->
34
+ executor.submit<StreamVideoThumbnailResult> {
35
+ generateThumbnailResult(context, url)
36
+ }
37
+ }
38
+ tasks.map { task -> task.get() }
39
+ } finally {
40
+ executor.shutdown()
41
+ }
42
+ }
43
+
44
+ private fun generateThumbnailResult(context: Context, url: String): StreamVideoThumbnailResult {
45
+ return try {
46
+ StreamVideoThumbnailResult(uri = generateThumbnail(context, url))
47
+ } catch (error: Throwable) {
48
+ StreamVideoThumbnailResult(
49
+ error = error.message ?: "Thumbnail generation failed for $url",
50
+ uri = null,
51
+ )
52
+ }
53
+ }
54
+
55
+ private fun generateThumbnail(context: Context, url: String): String {
56
+ val outputDirectory = File(context.cacheDir, CACHE_DIRECTORY_NAME).apply { mkdirs() }
57
+ val outputFile = File(outputDirectory, buildCacheFileName(url))
58
+
59
+ if (outputFile.isFile() && outputFile.length() > 0L) {
60
+ return Uri.fromFile(outputFile).toString()
61
+ }
62
+
63
+ val retriever = MediaMetadataRetriever()
64
+
65
+ return try {
66
+ setDataSource(retriever, context, url)
67
+ val thumbnail = extractThumbnailFrame(retriever, url)
68
+
69
+ try {
70
+ FileOutputStream(outputFile).use { stream ->
71
+ thumbnail.compress(Bitmap.CompressFormat.JPEG, DEFAULT_COMPRESSION_QUALITY, stream)
72
+ }
73
+ } finally {
74
+ if (!thumbnail.isRecycled) {
75
+ thumbnail.recycle()
76
+ }
77
+ }
78
+
79
+ Uri.fromFile(outputFile).toString()
80
+ } catch (error: Throwable) {
81
+ throw IllegalStateException("Thumbnail generation failed for $url", error)
82
+ } finally {
83
+ try {
84
+ retriever.release()
85
+ } catch (_: Throwable) {
86
+ // Ignore cleanup failures.
87
+ }
88
+ }
89
+ }
90
+
91
+ private fun extractThumbnailFrame(retriever: MediaMetadataRetriever, url: String): Bitmap {
92
+ if (Build.VERSION.SDK_INT >= 27) {
93
+ return retriever.getScaledFrameAtTime(
94
+ 100000,
95
+ MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
96
+ DEFAULT_MAX_DIMENSION,
97
+ DEFAULT_MAX_DIMENSION,
98
+ ) ?: throw IllegalStateException("Failed to extract video frame for $url")
99
+ }
100
+
101
+ val frame =
102
+ retriever.getFrameAtTime(100000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
103
+ ?: throw IllegalStateException("Failed to extract video frame for $url")
104
+ val scaledFrame = scaleBitmap(frame)
105
+
106
+ if (scaledFrame != frame) {
107
+ frame.recycle()
108
+ }
109
+
110
+ return scaledFrame
111
+ }
112
+
113
+ private fun buildCacheFileName(url: String): String {
114
+ val cacheKey =
115
+ fnv1a64("$CACHE_VERSION|$DEFAULT_MAX_DIMENSION|$DEFAULT_COMPRESSION_QUALITY|$url")
116
+ return "stream-video-thumbnail-$cacheKey.jpg"
117
+ }
118
+
119
+ private fun fnv1a64(value: String): String {
120
+ var hash = -0x340d631b8c46751fL
121
+
122
+ value.toByteArray(Charsets.UTF_8).forEach { byte ->
123
+ hash = hash xor (byte.toLong() and 0xff)
124
+ hash *= 0x100000001b3L
125
+ }
126
+
127
+ return java.lang.Long.toUnsignedString(hash, 16)
128
+ }
129
+
130
+ private fun setDataSource(retriever: MediaMetadataRetriever, context: Context, url: String) {
131
+ val uri = Uri.parse(url)
132
+ val scheme = uri.scheme?.lowercase()
133
+
134
+ when {
135
+ scheme.isNullOrEmpty() -> retriever.setDataSource(url)
136
+ scheme == "content" || scheme == "file" -> retriever.setDataSource(context, uri)
137
+ else ->
138
+ throw IllegalArgumentException(
139
+ "Unsupported video URI scheme for thumbnail generation: $scheme. Local assets only.",
140
+ )
141
+ }
142
+ }
143
+
144
+ private fun scaleBitmap(bitmap: Bitmap): Bitmap {
145
+ val width = bitmap.width
146
+ val height = bitmap.height
147
+ val largestDimension = maxOf(width, height)
148
+
149
+ if (largestDimension <= DEFAULT_MAX_DIMENSION) {
150
+ return bitmap
151
+ }
152
+
153
+ val scale = DEFAULT_MAX_DIMENSION.toFloat() / largestDimension.toFloat()
154
+ val targetWidth = (width * scale).toInt().coerceAtLeast(1)
155
+ val targetHeight = (height * scale).toInt().coerceAtLeast(1)
156
+
157
+ return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
158
+ }
159
+ }
@@ -0,0 +1,50 @@
1
+ package com.streamchatexpo
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.Promise
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.bridge.ReadableArray
7
+ import com.streamchatreactnative.shared.StreamVideoThumbnailGenerator
8
+ import java.util.concurrent.Executors
9
+
10
+ class StreamVideoThumbnailModule(
11
+ reactContext: ReactApplicationContext,
12
+ ) : NativeStreamVideoThumbnailSpec(reactContext) {
13
+ override fun getName(): String = NAME
14
+
15
+ override fun createVideoThumbnails(urls: ReadableArray, promise: Promise) {
16
+ val urlList = mutableListOf<String>()
17
+ for (index in 0 until urls.size()) {
18
+ urlList.add(urls.getString(index) ?: "")
19
+ }
20
+
21
+ executor.execute {
22
+ try {
23
+ val thumbnails = StreamVideoThumbnailGenerator.generateThumbnails(reactApplicationContext, urlList)
24
+ val result = Arguments.createArray()
25
+ thumbnails.forEach { thumbnail ->
26
+ val thumbnailMap = Arguments.createMap()
27
+ if (thumbnail.uri != null) {
28
+ thumbnailMap.putString("uri", thumbnail.uri)
29
+ } else {
30
+ thumbnailMap.putNull("uri")
31
+ }
32
+ if (thumbnail.error != null) {
33
+ thumbnailMap.putString("error", thumbnail.error)
34
+ } else {
35
+ thumbnailMap.putNull("error")
36
+ }
37
+ result.pushMap(thumbnailMap)
38
+ }
39
+ promise.resolve(result)
40
+ } catch (error: Throwable) {
41
+ promise.reject("stream_video_thumbnail_error", error.message, error)
42
+ }
43
+ }
44
+ }
45
+
46
+ companion object {
47
+ const val NAME = "StreamVideoThumbnail"
48
+ private val executor = Executors.newCachedThreadPool()
49
+ }
50
+ }
@@ -0,0 +1,14 @@
1
+ #ifdef RCT_NEW_ARCH_ENABLED
2
+
3
+ #if __has_include("StreamChatReactNativeSpec.h")
4
+ #import "StreamChatReactNativeSpec.h"
5
+ #elif __has_include("StreamChatExpoSpec.h")
6
+ #import "StreamChatExpoSpec.h"
7
+ #else
8
+ #error "Unable to find generated codegen spec header for StreamVideoThumbnail."
9
+ #endif
10
+
11
+ @interface StreamVideoThumbnail : NSObject <NativeStreamVideoThumbnailSpec>
12
+ @end
13
+
14
+ #endif
@@ -0,0 +1,56 @@
1
+ #import "StreamVideoThumbnail.h"
2
+
3
+ #ifdef RCT_NEW_ARCH_ENABLED
4
+
5
+ #if __has_include(<stream_chat_react_native/stream_chat_react_native-Swift.h>)
6
+ #import <stream_chat_react_native/stream_chat_react_native-Swift.h>
7
+ #elif __has_include(<stream_chat_expo/stream_chat_expo-Swift.h>)
8
+ #import <stream_chat_expo/stream_chat_expo-Swift.h>
9
+ #elif __has_include("stream_chat_react_native-Swift.h")
10
+ #import "stream_chat_react_native-Swift.h"
11
+ #elif __has_include("stream_chat_expo-Swift.h")
12
+ #import "stream_chat_expo-Swift.h"
13
+ #else
14
+ #error "Unable to import generated Swift header for StreamVideoThumbnail."
15
+ #endif
16
+
17
+ @implementation StreamVideoThumbnail
18
+
19
+ RCT_EXPORT_MODULE(StreamVideoThumbnail)
20
+
21
+ RCT_REMAP_METHOD(createVideoThumbnails, urls:(NSArray<NSString *> *)urls resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
22
+ {
23
+ [self createVideoThumbnails:urls resolve:resolve reject:reject];
24
+ }
25
+
26
+ - (void)createVideoThumbnails:(NSArray<NSString *> *)urls
27
+ resolve:(RCTPromiseResolveBlock)resolve
28
+ reject:(RCTPromiseRejectBlock)reject
29
+ {
30
+ [StreamVideoThumbnailGenerator generateThumbnailsWithUrls:urls completion:^(NSArray<StreamVideoThumbnailResult *> *thumbnails) {
31
+ NSMutableArray<NSDictionary<NSString *, id> *> *payload = [NSMutableArray arrayWithCapacity:thumbnails.count];
32
+
33
+ for (StreamVideoThumbnailResult *thumbnail in thumbnails) {
34
+ NSMutableDictionary<NSString *, id> *entry = [NSMutableDictionary dictionaryWithCapacity:2];
35
+ entry[@"uri"] = thumbnail.uri ?: [NSNull null];
36
+ entry[@"error"] = thumbnail.error ?: [NSNull null];
37
+ [payload addObject:entry];
38
+ }
39
+
40
+ @try {
41
+ resolve(payload);
42
+ } @catch (NSException *exception) {
43
+ reject(@"stream_video_thumbnail_error", exception.reason, nil);
44
+ }
45
+ }];
46
+ }
47
+
48
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
49
+ (const facebook::react::ObjCTurboModule::InitParams &)params
50
+ {
51
+ return std::make_shared<facebook::react::NativeStreamVideoThumbnailSpecJSI>(params);
52
+ }
53
+
54
+ @end
55
+
56
+ #endif
@@ -0,0 +1,338 @@
1
+ import AVFoundation
2
+ import Photos
3
+ import UIKit
4
+
5
+ private final class StreamPhotoLibraryAssetRequestState: @unchecked Sendable {
6
+ let lock = NSLock()
7
+ var didResume = false
8
+ var requestID: PHImageRequestID = PHInvalidImageRequestID
9
+ }
10
+
11
+ @objcMembers
12
+ public final class StreamVideoThumbnailResult: NSObject {
13
+ public let error: String?
14
+ public let uri: String?
15
+
16
+ public init(error: String? = nil, uri: String? = nil) {
17
+ self.error = error
18
+ self.uri = uri
19
+ }
20
+ }
21
+
22
+ @objcMembers
23
+ public final class StreamVideoThumbnailGenerator: NSObject {
24
+ private static let compressionQuality: CGFloat = 0.8
25
+ private static let maxDimension: CGFloat = 512
26
+ private static let cacheVersion = "v1"
27
+ private static let cacheDirectoryName = "@stream-io-stream-video-thumbnails"
28
+ private static let maxConcurrentGenerations = 5
29
+ private static let photoLibraryAssetResolutionTimeout: TimeInterval = 3
30
+
31
+ @objc(generateThumbnailsWithUrls:completion:)
32
+ public static func generateThumbnails(
33
+ urls: [String],
34
+ completion: @escaping ([StreamVideoThumbnailResult]) -> Void
35
+ ) {
36
+ Task(priority: .userInitiated) {
37
+ completion(await generateThumbnailsAsync(urls: urls))
38
+ }
39
+ }
40
+
41
+ private static func generateThumbnailsAsync(urls: [String]) async -> [StreamVideoThumbnailResult] {
42
+ guard !urls.isEmpty else {
43
+ return []
44
+ }
45
+
46
+ if urls.count == 1 {
47
+ return [await generateThumbnailResult(url: urls[0])]
48
+ }
49
+
50
+ let parallelism = min(maxConcurrentGenerations, urls.count)
51
+
52
+ return await withTaskGroup(
53
+ of: (Int, StreamVideoThumbnailResult).self,
54
+ returning: [StreamVideoThumbnailResult].self
55
+ ) { group in
56
+ var thumbnails = Array<StreamVideoThumbnailResult?>(repeating: nil, count: urls.count)
57
+ var nextIndexToSchedule = 0
58
+
59
+ while nextIndexToSchedule < parallelism {
60
+ let index = nextIndexToSchedule
61
+ let url = urls[index]
62
+ group.addTask {
63
+ (index, await generateThumbnailResult(url: url))
64
+ }
65
+ nextIndexToSchedule += 1
66
+ }
67
+
68
+ while let (index, thumbnail) = await group.next() {
69
+ thumbnails[index] = thumbnail
70
+
71
+ if nextIndexToSchedule < urls.count {
72
+ let nextIndex = nextIndexToSchedule
73
+ let nextURL = urls[nextIndex]
74
+ group.addTask {
75
+ (nextIndex, await generateThumbnailResult(url: nextURL))
76
+ }
77
+ nextIndexToSchedule += 1
78
+ }
79
+ }
80
+
81
+ return thumbnails.enumerated().map { index, thumbnail in
82
+ thumbnail ?? StreamVideoThumbnailResult(
83
+ error: "Thumbnail generation produced no output for index \(index)",
84
+ uri: nil
85
+ )
86
+ }
87
+ }
88
+ }
89
+
90
+ private static func generateThumbnailResult(url: String) async -> StreamVideoThumbnailResult {
91
+ do {
92
+ return StreamVideoThumbnailResult(uri: try await generateThumbnail(url: url))
93
+ } catch {
94
+ return StreamVideoThumbnailResult(
95
+ error: error.localizedDescription,
96
+ uri: nil
97
+ )
98
+ }
99
+ }
100
+
101
+ private static func generateThumbnail(url: String) async throws -> String {
102
+ let outputDirectory = try thumbnailCacheDirectory()
103
+ let outputURL = outputDirectory
104
+ .appendingPathComponent(buildCacheFileName(url: url))
105
+ .appendingPathExtension("jpg")
106
+
107
+ if
108
+ FileManager.default.fileExists(atPath: outputURL.path),
109
+ let attributes = try? FileManager.default.attributesOfItem(atPath: outputURL.path),
110
+ let fileSize = attributes[.size] as? NSNumber,
111
+ fileSize.intValue > 0
112
+ {
113
+ return outputURL.absoluteString
114
+ }
115
+
116
+ let asset = try await resolveAsset(url: url)
117
+ let generator = AVAssetImageGenerator(asset: asset)
118
+ generator.appliesPreferredTrackTransform = true
119
+ generator.maximumSize = CGSize(width: maxDimension, height: maxDimension)
120
+
121
+ let requestedTime = thumbnailTime(for: asset)
122
+
123
+ do {
124
+ let cgImage = try generator.copyCGImage(at: requestedTime, actualTime: nil)
125
+ let image = UIImage(cgImage: cgImage)
126
+ guard let data = image.jpegData(compressionQuality: compressionQuality) else {
127
+ throw thumbnailError(code: 2, message: "Failed to encode JPEG thumbnail for \(url)")
128
+ }
129
+
130
+ try data.write(to: outputURL, options: .atomic)
131
+ return outputURL.absoluteString
132
+ } catch {
133
+ throw thumbnailError(error, code: 3, message: "Thumbnail generation failed for \(url)")
134
+ }
135
+ }
136
+
137
+ private static func thumbnailCacheDirectory() throws -> URL {
138
+ let outputDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
139
+ .appendingPathComponent(cacheDirectoryName, isDirectory: true)
140
+ try FileManager.default.createDirectory(
141
+ at: outputDirectory,
142
+ withIntermediateDirectories: true
143
+ )
144
+ return outputDirectory
145
+ }
146
+
147
+ private static func buildCacheFileName(url: String) -> String {
148
+ let cacheKey = fnv1a64("\(cacheVersion)|\(Int(maxDimension))|\(compressionQuality)|\(url)")
149
+ return "stream-video-thumbnail-\(cacheKey)"
150
+ }
151
+
152
+ private static func fnv1a64(_ value: String) -> String {
153
+ var hash: UInt64 = 0xcbf29ce484222325
154
+ let prime: UInt64 = 0x100000001b3
155
+
156
+ for byte in value.utf8 {
157
+ hash ^= UInt64(byte)
158
+ hash &*= prime
159
+ }
160
+
161
+ return String(hash, radix: 16)
162
+ }
163
+
164
+ private static func thumbnailTime(for asset: AVAsset) -> CMTime {
165
+ let durationSeconds = asset.duration.seconds
166
+ if durationSeconds.isFinite, durationSeconds > 0.1 {
167
+ return CMTime(seconds: 0.1, preferredTimescale: 600)
168
+ }
169
+
170
+ return .zero
171
+ }
172
+
173
+ private static func resolveAsset(url: String) async throws -> AVAsset {
174
+ if isPhotoLibraryURL(url) {
175
+ return try await resolvePhotoLibraryAsset(url: url)
176
+ }
177
+
178
+ if let normalizedURL = normalizeLocalURL(url) {
179
+ return AVURLAsset(url: normalizedURL)
180
+ }
181
+
182
+ throw thumbnailError(code: 5, message: "Unsupported video URL for thumbnail generation: \(url)")
183
+ }
184
+
185
+ private static func isPhotoLibraryURL(_ url: String) -> Bool {
186
+ url.lowercased().hasPrefix("ph://")
187
+ }
188
+
189
+ private static func resolvePhotoLibraryAsset(url: String) async throws -> AVAsset {
190
+ let identifier = photoLibraryIdentifier(from: url)
191
+
192
+ guard !identifier.isEmpty else {
193
+ throw thumbnailError(code: 6, message: "Missing photo library identifier for \(url)")
194
+ }
195
+
196
+ let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil)
197
+ guard let asset = fetchResult.firstObject else {
198
+ throw thumbnailError(code: 7, message: "Failed to find photo library asset for \(url)")
199
+ }
200
+
201
+ let options = PHVideoRequestOptions()
202
+ options.deliveryMode = .highQualityFormat
203
+ options.isNetworkAccessAllowed = true
204
+ options.version = .current
205
+
206
+ return try await withThrowingTaskGroup(of: AVAsset.self) { group in
207
+ group.addTask {
208
+ try await requestPhotoLibraryAsset(url: url, asset: asset, options: options)
209
+ }
210
+ group.addTask {
211
+ try await Task.sleep(nanoseconds: UInt64(photoLibraryAssetResolutionTimeout * 1_000_000_000))
212
+ throw thumbnailError(
213
+ code: 11,
214
+ message: "Timed out resolving photo library asset for \(url)"
215
+ )
216
+ }
217
+
218
+ guard let resolvedAsset = try await group.next() else {
219
+ throw thumbnailError(
220
+ code: 10,
221
+ message: "Failed to resolve photo library asset for \(url)"
222
+ )
223
+ }
224
+
225
+ group.cancelAll()
226
+ return resolvedAsset
227
+ }
228
+ }
229
+
230
+ private static func requestPhotoLibraryAsset(
231
+ url: String,
232
+ asset: PHAsset,
233
+ options: PHVideoRequestOptions
234
+ ) async throws -> AVAsset {
235
+ let imageManager = PHImageManager.default()
236
+ let state = StreamPhotoLibraryAssetRequestState()
237
+
238
+ return try await withTaskCancellationHandler(operation: {
239
+ try await withCheckedThrowingContinuation { continuation in
240
+ let requestID = imageManager.requestAVAsset(forVideo: asset, options: options) {
241
+ avAsset, _, info in
242
+ state.lock.lock()
243
+ if state.didResume {
244
+ state.lock.unlock()
245
+ return
246
+ }
247
+ state.didResume = true
248
+ state.lock.unlock()
249
+
250
+ if let isCancelled = (info?[PHImageCancelledKey] as? NSNumber)?.boolValue, isCancelled {
251
+ continuation.resume(
252
+ throwing: thumbnailError(
253
+ code: 8,
254
+ message: "Photo library asset request was cancelled for \(url)"
255
+ )
256
+ )
257
+ return
258
+ }
259
+
260
+ if let error = info?[PHImageErrorKey] as? Error {
261
+ continuation.resume(
262
+ throwing: thumbnailError(
263
+ error,
264
+ code: 9,
265
+ message: "Photo library asset request failed for \(url)"
266
+ )
267
+ )
268
+ return
269
+ }
270
+
271
+ guard let avAsset else {
272
+ continuation.resume(
273
+ throwing: thumbnailError(
274
+ code: 10,
275
+ message: "Failed to resolve photo library asset for \(url)"
276
+ )
277
+ )
278
+ return
279
+ }
280
+
281
+ continuation.resume(returning: avAsset)
282
+ }
283
+
284
+ state.lock.lock()
285
+ state.requestID = requestID
286
+ state.lock.unlock()
287
+ }
288
+ }, onCancel: {
289
+ state.lock.lock()
290
+ let requestID = state.requestID
291
+ state.lock.unlock()
292
+
293
+ if requestID != PHInvalidImageRequestID {
294
+ imageManager.cancelImageRequest(requestID)
295
+ }
296
+ })
297
+ }
298
+
299
+ private static func photoLibraryIdentifier(from url: String) -> String {
300
+ guard let parsedURL = URL(string: url), parsedURL.scheme?.lowercased() == "ph" else {
301
+ return url
302
+ .replacingOccurrences(of: "ph://", with: "", options: [.caseInsensitive])
303
+ .removingPercentEncoding?
304
+ .trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
305
+ }
306
+
307
+ let host = parsedURL.host ?? ""
308
+ let path = parsedURL.path
309
+ let combined = host.isEmpty ? path : "\(host)\(path)"
310
+ return combined.removingPercentEncoding?
311
+ .trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
312
+ }
313
+
314
+ private static func normalizeLocalURL(_ url: String) -> URL? {
315
+ if let parsedURL = URL(string: url), let scheme = parsedURL.scheme?.lowercased() {
316
+ if scheme == "file" {
317
+ return parsedURL
318
+ }
319
+
320
+ return nil
321
+ }
322
+
323
+ return URL(fileURLWithPath: url)
324
+ }
325
+
326
+ private static func thumbnailError(
327
+ _ error: Error? = nil,
328
+ code: Int,
329
+ message: String
330
+ ) -> Error {
331
+ let description = error.map { "\(message): \($0.localizedDescription)" } ?? message
332
+ return NSError(
333
+ domain: "StreamVideoThumbnail",
334
+ code: code,
335
+ userInfo: [NSLocalizedDescriptionKey: description]
336
+ )
337
+ }
338
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stream-chat-expo",
3
3
  "description": "The official Expo SDK for Stream Chat, a service for building chat applications",
4
- "version": "9.0.0-beta.9",
4
+ "version": "9.0.0",
5
5
  "author": {
6
6
  "company": "Stream.io Inc",
7
7
  "name": "Stream.io Inc"
@@ -27,10 +27,10 @@
27
27
  "types": "types/index.d.ts",
28
28
  "dependencies": {
29
29
  "mime": "^4.0.7",
30
- "stream-chat-react-native-core": "9.0.0-beta.9"
30
+ "stream-chat-react-native-core": "9.0.0"
31
31
  },
32
32
  "peerDependencies": {
33
- "expo": ">=51.0.0",
33
+ "expo": ">=52.0.0",
34
34
  "react-native": ">=0.76.0",
35
35
  "expo-av": "*",
36
36
  "expo-clipboard": "*",
@@ -69,6 +69,9 @@
69
69
  "expo-image-picker": {
70
70
  "optional": true
71
71
  },
72
+ "expo-image-manipulator": {
73
+ "optional": true
74
+ },
72
75
  "expo-sharing": {
73
76
  "optional": true
74
77
  },
@@ -82,7 +85,6 @@
82
85
  "expo-audio": "~0.4.6"
83
86
  },
84
87
  "scripts": {
85
- "postinstall": "if [ -f ../scripts/sync-shared-native.sh ] && [ -d ../shared-native/ios ]; then bash ../scripts/sync-shared-native.sh expo-package; fi",
86
88
  "prepack": "bash ../scripts/sync-shared-native.sh expo-package && cp ../../README.md .",
87
89
  "postpack": "rm README.md && bash ../scripts/clean-shared-native-copies.sh expo-package"
88
90
  },
@@ -90,7 +92,13 @@
90
92
  "name": "StreamChatExpoSpec",
91
93
  "type": "all",
92
94
  "jsSrcsDir": "src/native",
95
+ "android": {
96
+ "javaPackageName": "com.streamchatexpo"
97
+ },
93
98
  "ios": {
99
+ "modulesProvider": {
100
+ "StreamVideoThumbnail": "StreamVideoThumbnail"
101
+ },
94
102
  "componentProvider": {
95
103
  "StreamShimmerView": "StreamShimmerViewComponentView"
96
104
  }
@@ -0,0 +1,14 @@
1
+ import type { TurboModule } from 'react-native';
2
+
3
+ import { TurboModuleRegistry } from 'react-native';
4
+
5
+ export type VideoThumbnailResult = {
6
+ error?: string | null;
7
+ uri?: string | null;
8
+ };
9
+
10
+ export interface Spec extends TurboModule {
11
+ createVideoThumbnails(urls: ReadonlyArray<string>): Promise<ReadonlyArray<VideoThumbnailResult>>;
12
+ }
13
+
14
+ export default TurboModuleRegistry.getEnforcing<Spec>('StreamVideoThumbnail');
@@ -0,0 +1,8 @@
1
+ import NativeStreamVideoThumbnail, { type VideoThumbnailResult } from './NativeStreamVideoThumbnail';
2
+
3
+ export type { VideoThumbnailResult } from './NativeStreamVideoThumbnail';
4
+
5
+ export const createVideoThumbnails = async (urls: string[]): Promise<VideoThumbnailResult[]> => {
6
+ const results = await NativeStreamVideoThumbnail.createVideoThumbnails(urls);
7
+ return Array.from(results);
8
+ };
@@ -1,20 +1,18 @@
1
1
  let AudioComponent;
2
- let VideoComponent;
3
2
  let RecordingObject;
4
3
 
5
4
  try {
6
5
  const audioVideoPackage = require('expo-av');
7
6
  AudioComponent = audioVideoPackage.Audio;
8
- VideoComponent = audioVideoPackage.Video;
9
7
  RecordingObject = audioVideoPackage.RecordingObject;
10
8
  } catch (e) {
11
9
  // do nothing
12
10
  }
13
11
 
14
- if (!AudioComponent || !VideoComponent) {
12
+ if (!AudioComponent) {
15
13
  console.log(
16
- 'Audio Video library is currently not installed. To allow in-app audio playback, install the "expo-av" package.',
14
+ 'The audio library is currently not installed. To allow in-app audio playback, install the "expo-av" package.',
17
15
  );
18
16
  }
19
17
 
20
- export { AudioComponent, RecordingObject, VideoComponent };
18
+ export { AudioComponent, RecordingObject };
@@ -138,8 +138,8 @@ class ExpoAudioSoundAdapter {
138
138
  };
139
139
 
140
140
  // eslint-disable-next-line require-await
141
- setPositionAsync: SoundReturnType['setPositionAsync'] = async (seconds) => {
142
- this.player.seekTo(seconds);
141
+ setPositionAsync: SoundReturnType['setPositionAsync'] = async (millis) => {
142
+ await this.player.seekTo(millis / 1000);
143
143
  };
144
144
 
145
145
  // eslint-disable-next-line require-await
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
2
2
 
3
3
  import { useEventListener } from 'expo';
4
4
 
5
- import { AudioComponent, VideoComponent as ExpoAVVideoComponent } from './AudioVideo';
5
+ import { AudioComponent } from './AudioVideo';
6
6
 
7
7
  let videoPackage;
8
8
 
@@ -18,8 +18,8 @@ if (!videoPackage) {
18
18
  );
19
19
  }
20
20
 
21
- const VideoComponent = videoPackage ? videoPackage.VideoView : ExpoAVVideoComponent;
22
- const useVideoPlayer = videoPackage ? videoPackage.useVideoPlayer : null;
21
+ const VideoComponent = videoPackage?.VideoView;
22
+ const useVideoPlayer = videoPackage?.useVideoPlayer;
23
23
 
24
24
  let Video = null;
25
25
 
@@ -84,33 +84,5 @@ if (videoPackage) {
84
84
  );
85
85
  };
86
86
  }
87
- // expo-av
88
- else if (ExpoAVVideoComponent) {
89
- Video = ({ onPlaybackStatusUpdate, paused, resizeMode, style, uri, videoRef, rate }) => {
90
- // This is done so that the audio of the video is not muted when the phone is in silent mode for iOS.
91
- useEffect(() => {
92
- const initializeSound = async () => {
93
- await AudioComponent.setAudioModeAsync({
94
- playsInSilentModeIOS: true,
95
- });
96
- };
97
- initializeSound();
98
- }, []);
99
-
100
- return (
101
- <VideoComponent
102
- onPlaybackStatusUpdate={onPlaybackStatusUpdate}
103
- ref={videoRef}
104
- resizeMode={resizeMode}
105
- shouldPlay={!paused}
106
- source={{
107
- uri,
108
- }}
109
- style={[style]}
110
- playbackRate={rate}
111
- />
112
- );
113
- };
114
- }
115
87
 
116
88
  export { Video };
@@ -0,0 +1,9 @@
1
+ import { createGenerateVideoThumbnails } from 'stream-chat-react-native-core/src/utils/createGenerateVideoThumbnails';
2
+
3
+ import { createVideoThumbnails, type VideoThumbnailResult } from '../native/videoThumbnail';
4
+
5
+ export const generateThumbnails: (
6
+ uris: string[],
7
+ ) => Promise<Record<string, VideoThumbnailResult>> = createGenerateVideoThumbnails({
8
+ createVideoThumbnails,
9
+ });
@@ -4,6 +4,9 @@ import mime from 'mime';
4
4
 
5
5
  import type { File } from 'stream-chat-react-native-core';
6
6
 
7
+ import { generateThumbnails } from './generateThumbnail';
8
+ import { getLocalAssetUri } from './getLocalAssetUri';
9
+
7
10
  let MediaLibrary;
8
11
 
9
12
  try {
@@ -18,8 +21,6 @@ if (!MediaLibrary) {
18
21
  );
19
22
  }
20
23
 
21
- import { getLocalAssetUri } from './getLocalAssetUri';
22
-
23
24
  type ReturnType = {
24
25
  assets: File[];
25
26
  endCursor: string | undefined;
@@ -52,24 +53,41 @@ export const getPhotos = MediaLibrary
52
53
  mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
53
54
  sortBy: [MediaLibrary.SortBy.modificationTime],
54
55
  });
55
- const assets = await Promise.all(
56
+ const assetEntries = await Promise.all(
56
57
  results.assets.map(async (asset) => {
57
58
  const localUri = await getLocalAssetUri(asset.id);
58
59
  const mimeType =
59
60
  mime.getType(asset.filename || asset.uri) ||
60
61
  (asset.mediaType === MediaLibrary.MediaType.video ? 'video/*' : 'image/*');
62
+ const uri = localUri || asset.uri;
63
+
61
64
  return {
62
- duration: asset.duration * 1000,
63
- height: asset.height,
64
- name: asset.filename,
65
- size: 0,
66
- thumb_url: asset.mediaType === 'photo' ? undefined : asset.uri,
67
- type: mimeType,
68
- uri: localUri || asset.uri,
69
- width: asset.width,
65
+ asset,
66
+ isVideo: asset.mediaType === MediaLibrary.MediaType.video,
67
+ mimeType,
68
+ uri,
70
69
  };
71
70
  }),
72
71
  );
72
+ const videoUris = assetEntries
73
+ .filter(({ isVideo, uri }) => isVideo && !!uri)
74
+ .map(({ uri }) => uri);
75
+ const videoThumbnailResults = await generateThumbnails(videoUris);
76
+
77
+ const assets = assetEntries.map(({ asset, isVideo, mimeType, uri }) => {
78
+ const thumbnailResult = isVideo && uri ? videoThumbnailResults[uri] : undefined;
79
+
80
+ return {
81
+ duration: asset.duration * 1000,
82
+ height: asset.height,
83
+ name: asset.filename,
84
+ size: 0,
85
+ thumb_url: thumbnailResult?.uri || undefined,
86
+ type: mimeType,
87
+ uri,
88
+ width: asset.width,
89
+ };
90
+ });
73
91
 
74
92
  const hasNextPage = results.hasNextPage;
75
93
  const endCursor = results.endCursor;
@@ -1,5 +1,6 @@
1
1
  export * from './Audio';
2
2
  export * from './deleteFile';
3
+ export * from './generateThumbnail';
3
4
  export * from './getLocalAssetUri';
4
5
  export * from './getPhotos';
5
6
  export * from './iOS14RefreshGallerySelection';
@@ -1,6 +1,11 @@
1
1
  import { Platform } from 'react-native';
2
+
2
3
  import mime from 'mime';
4
+
3
5
  import { PickImageOptions } from 'stream-chat-react-native-core';
6
+
7
+ import { generateThumbnails } from './generateThumbnail';
8
+
4
9
  let ImagePicker;
5
10
 
6
11
  try {
@@ -43,17 +48,37 @@ export const pickImage = ImagePicker
43
48
  const canceled = result.canceled;
44
49
 
45
50
  if (!canceled) {
46
- const assets = result.assets.map((asset) => ({
47
- ...asset,
48
- duration: asset.duration,
49
- name: asset.fileName,
50
- size: asset.fileSize,
51
- type:
51
+ const assetsWithType = result.assets.map((asset) => {
52
+ const type =
52
53
  asset.mimeType ||
53
54
  mime.getType(asset.fileName || asset.uri) ||
54
- (asset.duration ? 'video/*' : 'image/*'),
55
- uri: asset.uri,
56
- }));
55
+ (asset.duration ? 'video/*' : 'image/*');
56
+
57
+ return {
58
+ asset,
59
+ isVideo: type.includes('video'),
60
+ type,
61
+ };
62
+ });
63
+ const videoUris = assetsWithType
64
+ .filter(({ asset, isVideo }) => isVideo && !!asset.uri)
65
+ .map(({ asset }) => asset.uri);
66
+ const videoThumbnailResults = await generateThumbnails(videoUris);
67
+
68
+ const assets = assetsWithType.map(({ asset, isVideo, type }) => {
69
+ const thumbnailResult =
70
+ isVideo && asset.uri ? videoThumbnailResults[asset.uri] : undefined;
71
+
72
+ return {
73
+ ...asset,
74
+ duration: asset.duration,
75
+ name: asset.fileName,
76
+ size: asset.fileSize,
77
+ thumb_url: thumbnailResult?.uri || undefined,
78
+ type,
79
+ uri: asset.uri,
80
+ };
81
+ });
57
82
  return { assets, cancelled: false };
58
83
  } else {
59
84
  return { cancelled: true };
@@ -2,6 +2,8 @@ import { Image, Platform } from 'react-native';
2
2
 
3
3
  import mime from 'mime';
4
4
 
5
+ import { generateThumbnails } from './generateThumbnail';
6
+
5
7
  let ImagePicker;
6
8
 
7
9
  try {
@@ -61,12 +63,16 @@ export const takePhoto = ImagePicker
61
63
  if (mimeType.includes('video')) {
62
64
  const clearFilter = new RegExp('[.:]', 'g');
63
65
  const date = new Date().toISOString().replace(clearFilter, '_');
66
+ const thumbnailResults = await generateThumbnails([photo.uri]);
67
+ const thumbnailResult = thumbnailResults[photo.uri];
68
+
64
69
  return {
65
70
  ...photo,
66
71
  cancelled: false,
67
72
  duration: photo.duration, // in milliseconds
68
73
  name: 'video_recording_' + date + '.' + photo.uri.split('.').pop(),
69
74
  size: photo.fileSize,
75
+ thumb_url: thumbnailResult?.uri || undefined,
70
76
  type: mimeType,
71
77
  uri: photo.uri,
72
78
  };