stream-chat-react-native 9.0.0-beta.9 → 9.0.1-beta.1

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.
@@ -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-react-native",
3
3
  "description": "The official React Native SDK for Stream Chat, a service for building chat applications",
4
- "version": "9.0.0-beta.9",
4
+ "version": "9.0.1-beta.1",
5
5
  "homepage": "https://www.npmjs.com/package/stream-chat-react-native",
6
6
  "author": {
7
7
  "company": "Stream.io Inc",
@@ -21,6 +21,7 @@
21
21
  "android/gradle",
22
22
  "ios",
23
23
  "*.podspec",
24
+ "react-native.config.js",
24
25
  "package.json"
25
26
  ],
26
27
  "license": "SEE LICENSE IN LICENSE",
@@ -29,21 +30,20 @@
29
30
  "dependencies": {
30
31
  "es6-symbol": "^3.1.3",
31
32
  "mime": "^4.0.7",
32
- "stream-chat-react-native-core": "9.0.0-beta.9"
33
+ "stream-chat-react-native-core": "9.0.1-beta.1"
33
34
  },
34
35
  "peerDependencies": {
35
- "@react-native-camera-roll/camera-roll": ">=7.8.0",
36
- "@react-native-clipboard/clipboard": ">=1.14.1",
36
+ "@react-native-camera-roll/camera-roll": ">=7.9.0",
37
+ "@react-native-clipboard/clipboard": ">=1.16.0",
37
38
  "@react-native-documents/picker": ">=10.1.1",
38
- "@stream-io/flat-list-mvcp": ">=0.10.3",
39
- "react-native": ">=0.73.0",
39
+ "react-native": ">=0.76.0",
40
40
  "react-native-audio-recorder-player": ">=3.6.13",
41
41
  "react-native-nitro-sound": ">=0.2.9",
42
- "react-native-blob-util": ">=0.21.1",
42
+ "react-native-blob-util": ">=0.22.0",
43
43
  "react-native-haptic-feedback": ">=2.3.0",
44
44
  "react-native-image-picker": ">=7.1.2",
45
45
  "react-native-share": ">=11.0.0",
46
- "react-native-video": ">=6.4.2"
46
+ "react-native-video": ">=6.18.0"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@react-native-camera-roll/camera-roll": {
@@ -52,9 +52,6 @@
52
52
  "@react-native-clipboard/clipboard": {
53
53
  "optional": true
54
54
  },
55
- "@stream-io/flat-list-mvcp": {
56
- "optional": true
57
- },
58
55
  "react-native-share": {
59
56
  "optional": true
60
57
  },
@@ -81,7 +78,6 @@
81
78
  }
82
79
  },
83
80
  "scripts": {
84
- "postinstall": "if [ -f ../scripts/sync-shared-native.sh ] && [ -d ../shared-native/ios ]; then bash ../scripts/sync-shared-native.sh native-package; fi",
85
81
  "prepack": "bash ../scripts/sync-shared-native.sh native-package && cp ../../README.md .",
86
82
  "postpack": "rm README.md && bash ../scripts/clean-shared-native-copies.sh native-package"
87
83
  },
@@ -92,7 +88,14 @@
92
88
  "name": "StreamChatReactNativeSpec",
93
89
  "type": "all",
94
90
  "jsSrcsDir": "src/native",
91
+ "android": {
92
+ "javaPackageName": "com.streamchatreactnative"
93
+ },
95
94
  "ios": {
95
+ "modulesProvider": {
96
+ "StreamChatReactNative": "StreamChatReactNative",
97
+ "StreamVideoThumbnail": "StreamVideoThumbnail"
98
+ },
96
99
  "componentProvider": {
97
100
  "StreamShimmerView": "StreamShimmerViewComponentView"
98
101
  }
@@ -0,0 +1,13 @@
1
+ module.exports = {
2
+ dependency: {
3
+ platforms: {
4
+ android: {
5
+ packageImportPath: 'import com.streamchatreactnative.StreamChatReactNativePackage;',
6
+ packageInstance: 'new StreamChatReactNativePackage()',
7
+ },
8
+ ios: {
9
+ podspecPath: 'stream-chat-react-native.podspec',
10
+ },
11
+ },
12
+ },
13
+ };
@@ -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');
@@ -7,6 +7,8 @@ export interface Response {
7
7
  width: number;
8
8
  }
9
9
 
10
+ export interface VideoThumbnailResponse extends Response {}
11
+
10
12
  export type ResizeFormat = 'PNG' | 'JPEG' | 'WEBP';
11
13
  export type ResizeMode = 'contain' | 'cover' | 'stretch';
12
14
 
@@ -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
+ };
@@ -136,7 +136,7 @@ class _Audio {
136
136
  };
137
137
  startPlayer = async (uri, _, onPlaybackStatusUpdate) => {
138
138
  try {
139
- const playback = await audioRecorderPlayer.startPlayer(uri);
139
+ await audioRecorderPlayer.startPlayer(uri);
140
140
  audioRecorderPlayer.addPlayBackListener((status) => {
141
141
  onPlaybackStatusUpdate(status);
142
142
  });