Unified CDN & Media Loading Architecture (v5)#4056
Conversation
Introduce 4 unified protocols for CDN operations: - CDN: read-only URL transformation (signing, headers, resize) - CDNUploader: upload and delete attachments - ImageResize moved from StreamChatUI to StreamChat Replace the old CDNClient and AttachmentUploader protocols with the new CDNUploader. Refactor StreamCDNClient into StreamCDNUploader. Simplify ChatClient upload API to use local URL directly.
…mChatCommonUI Add shared image/video loading protocols and implementations: - ImageLoader protocol with StreamImageLoader - VideoLoader protocol with StreamVideoLoader (AVFoundation-based) - ImageDownloading protocol: thin abstraction over an image pipeline StreamImageLoader delegates actual downloading to an ImageDownloading backend provided by each UI SDK, keeping Nuke out of CommonUI entirely. StreamVideoLoader uses CDN for URL signing and ImageLoader for thumbnail fallback.
Remove old protocols and their implementations: - ImageCDN, StreamImageCDN, ImageLoading, NukeImageLoader - VideoLoading, StreamVideoLoader Replace with unified types from StreamChatCommonUI: - Components.imageLoader uses StreamImageLoader with NukeImageDownloader - Components.videoLoader uses StreamVideoLoader - Add NukeImageDownloader as the ImageDownloading backend - Add UIKit convenience extensions (ImageLoader+UIKit, VideoLoader+UIKit) - Update all view call sites to new APIs
- Rename CDNClient_Spy to CDNUploader_Spy - Remove AttachmentUploader_Spy (merged into CDNUploader_Spy) - Rename CustomCDNClient to CustomCDNUploader - Update APIClient_Tests and APIClient_Spy for new constructor - Update StreamCDNClient_Tests for uploadAttachment(localUrl:) - Replace ImageLoader_Mock and VideoLoader_Mock with new protocol conformances - Add StreamCDN_Tests, StreamImageLoader_Tests, StreamVideoLoader_Tests - Remove obsolete NukeImageLoader_Tests, ImageLoading_Tests, StreamImageCDN_Tests
📝 WalkthroughWalkthroughThis PR replaces the AttachmentUploader/CDNClient pair with a unified CDNStorage abstraction, adds CDNRequester for CDN URL transformations, introduces MediaLoader/ImageDownloading for unified media loading (images & video), updates API/Components/UI/test wiring, and extends downloadAttachment to accept an optional remoteURL. Changes
Sequence Diagram(s)sequenceDiagram
participant UI as UI/View
participant Components as Components
participant StreamLoader as StreamMediaLoader
participant CDNReq as CDNRequester
participant Downloader as ImageDownloading
UI->>Components: mediaLoader.loadImage(url, options)
Components->>StreamLoader: loadImage(url, options)
StreamLoader->>CDNReq: imageRequest(for: url, options)
CDNReq-->>StreamLoader: CDNRequest(url, headers, cachingKey)
StreamLoader->>Downloader: downloadImage(url: CDNRequest.url, options: {headers, cachingKey, resize})
Downloader-->>StreamLoader: Result<DownloadedImage, Error>
StreamLoader-->>UI: Result<MediaLoaderImage, Error> (main actor)
sequenceDiagram
participant Client as ChatClient
participant API as APIClient
participant Storage as CDNStorage
participant Network as Network
Client->>API: uploadAttachment(localUrl, options)
API->>Storage: uploadAttachment(localUrl, options)
Storage->>Network: multipart upload / PUT
Network-->>Storage: response -> UploadedFile
Storage-->>API: Result<UploadedFile, Error>
API-->>Client: Result<UploadedAttachment, Error> (mapped)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
Generated by 🚫 Danger |
martinmitrevski
left a comment
There was a problem hiding this comment.
Looks good! We should make sure we test this extensively, since it's an important part of the SDK.
Rename to StreamImageDownloader and StreamImageProcessor to decouple public API naming from the Nuke dependency.
# Conflicts: # Sources/StreamChatUI/Components.swift # Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift # Tests/StreamChatUITests/Utils/NukeImageLoader_Tests.swift # Tests/StreamChatUITests/Utils/StreamImageCDN_Tests.swift
Public Interface+ public final class StreamImageDownloader: ImageDownloading, Sendable
+
+ public init()
+
+
+ public func downloadImage(url: URL,options: ImageDownloadingOptions,completion: @escaping @MainActor (Result<DownloadedImage, Error>) -> Void)
+ public struct ImageRequestOptions: Sendable
+
+ public var resize: CDNImageResize?
+
+
+ public init(resize: CDNImageResize? = nil)
+ extension CDNStorage
+
+ public func uploadAttachment(_ attachment: AnyChatMessageAttachment,options: AttachmentUploadOptions = .init())async throws -> UploadedFile
+ public func uploadAttachment(localUrl: URL,options: AttachmentUploadOptions = .init())async throws -> UploadedFile
+ public func deleteAttachment(remoteUrl: URL,options: AttachmentDeleteOptions = .init())async throws
+ public struct CDNRequest: Sendable
+
+ public var url: URL
+ public var headers: [String: String]?
+ public var cachingKey: String?
+
+
+ public init(url: URL,headers: [String: String]? = nil,cachingKey: String? = nil)
+ public struct ImageDownloadingOptions: Sendable
+
+ public var headers: [String: String]?
+ public var cachingKey: String?
+ public var resize: CGSize?
+
+
+ public init(headers: [String: String]? = nil,cachingKey: String? = nil,resize: CGSize? = nil)
+ public struct AttachmentDeleteOptions: Sendable
+
+ public init()
+ public protocol ImageDownloading: Sendable
+ public struct MediaLoaderVideoAsset: Sendable
+
+ public var asset: AVURLAsset
+
+
+ public init(asset: AVURLAsset)
+ open class StreamImageProcessor: ImageProcessor, @unchecked Sendable
+
+ open func crop(image: UIImage,to size: CGSize)-> UIImage?
+ open func scale(image: UIImage,to size: CGSize)-> UIImage
+ public struct ImageLoadOptions: Sendable
+
+ public var resize: ImageResize?
+ public var cdnRequester: CDNRequester
+
+
+ public init(resize: ImageResize? = nil,cdnRequester: CDNRequester)
+ public class ImageLoadingTask: Cancellable, @unchecked Sendable
+
+ public private var isCancelled
+
+
+ public func cancel()
+ public struct VideoLoadOptions: Sendable
+
+ public var cdnRequester: CDNRequester
+
+
+ public init(cdnRequester: CDNRequester)
+ public final class StreamCDNRequester: CDNRequester, Sendable
+
+ public let cdnHost: String
+
+
+ public init(cdnHost: String = "stream-io-cdn.com")
+
+
+ public func imageRequest(for url: URL,options: ImageRequestOptions,completion: @escaping (Result<CDNRequest, Error>) -> Void)
+ public func fileRequest(for url: URL,options: FileRequestOptions,completion: @escaping (Result<CDNRequest, Error>) -> Void)
+ public struct MediaLoaderVideoPreview: Sendable
+
+ public var image: UIImage
+
+
+ public init(image: UIImage)
+ public struct AttachmentUploadOptions: Sendable
+
+ public var progress: (@Sendable (Double) -> Void)?
+
+
+ public init(progress: (@Sendable (Double) -> Void)? = nil)
+ public protocol MediaLoader: AnyObject, Sendable
+ public struct MediaLoaderImage: Sendable
+
+ public var image: UIImage
+
+
+ public init(image: UIImage)
+ public struct FileRequestOptions: Sendable
+
+ public init()
+ public struct CDNImageResize: Sendable
+
+ public var width: CGFloat
+ public var height: CGFloat
+ public var resizeMode: String
+ public var crop: String?
+
+
+ public init(width: CGFloat,height: CGFloat,resizeMode: String,crop: String? = nil)
+ public struct DownloadedImage: Sendable
+
+ public var image: UIImage
+
+
+ public init(image: UIImage)
+ open class StreamMediaLoader: MediaLoader, @unchecked Sendable
+
+ public let downloader: ImageDownloading
+
+
+ public init(downloader: ImageDownloading,videoPreviewCacheCountLimit: Int = 50)
+
+
+ open func loadImage(url: URL?,options: ImageLoadOptions,completion: @escaping @MainActor (Result<MediaLoaderImage, Error>) -> Void)
+ open func loadVideoAsset(at url: URL,options: VideoLoadOptions,completion: @escaping @MainActor (Result<MediaLoaderVideoAsset, Error>) -> Void)
+ open func loadVideoPreview(at url: URL,options: VideoLoadOptions,completion: @escaping @MainActor (Result<MediaLoaderVideoPreview, Error>) -> Void)
+ open func loadVideoPreview(with attachment: ChatMessageVideoAttachment,options: VideoLoadOptions,completion: @escaping @MainActor (Result<MediaLoaderVideoPreview, Error>) -> Void)
+ extension MediaLoader
+
+ @discardableResult @MainActor public func loadImage(into imageView: UIImageView,from url: URL?,with options: ImageLoaderOptions,completion: (@MainActor (Result<UIImage, Error>) -> Void)? = nil)-> ImageLoadingTask
+ public func downloadImage(with request: ImageDownloadRequest,completion: @escaping @MainActor (Result<UIImage, Error>) -> Void)
+ public func downloadMultipleImages(with requests: [ImageDownloadRequest],completion: @escaping @MainActor ([Result<UIImage, Error>]) -> Void)
+ @discardableResult @MainActor public func loadImage(into imageView: UIImageView,from attachmentPayload: ImageAttachmentPayload?,maxResolutionInPixels: Double,cdnRequester: CDNRequester,completion: (@MainActor (Result<UIImage, Error>) -> Void)? = nil)-> ImageLoadingTask
+ extension CDNRequester
+
+ public func imageRequest(for url: URL,options: ImageRequestOptions = .init())async throws -> CDNRequest
+ public func fileRequest(for url: URL,options: FileRequestOptions = .init())async throws -> CDNRequest
+ public protocol CDNStorage: Sendable
+ public protocol CDNRequester: Sendable
- public extension VideoLoading
- public protocol CDNClient: Sendable
- open class NukeImageLoader: ImageLoading
-
- open var avatarThumbnailSize: CGSize
- open var imageCDN: ImageCDN
-
-
- public init()
-
-
- @discardableResult @MainActor open func loadImage(into imageView: UIImageView,from url: URL?,with options: ImageLoaderOptions,completion: (@MainActor (Result<UIImage, Error>) -> Void)?)-> Cancellable?
- @discardableResult open func downloadImage(with request: ImageDownloadRequest,completion: @escaping @MainActor (Result<UIImage, Error>) -> Void)-> Cancellable?
- open func downloadMultipleImages(with requests: [ImageDownloadRequest],completion: @escaping @MainActor ([Result<UIImage, Error>]) -> Void)
- @MainActor public protocol ImageLoading: AnyObject
- open class StreamVideoLoader: VideoLoading, @unchecked Sendable
-
- public init(cachedVideoPreviewsCountLimit: Int = 50)
-
-
- open func loadPreviewForVideo(at url: URL,completion: @escaping @MainActor (Result<UIImage, Error>) -> Void)
- open func videoAsset(at url: URL)-> AVURLAsset
- @objc open func handleMemoryWarning(_ notification: NSNotification)
- public class StreamAttachmentUploader: AttachmentUploader, @unchecked Sendable
-
- public func upload(_ attachment: AnyChatMessageAttachment,progress: (@Sendable (Double) -> Void)?,completion: @escaping @Sendable (Result<UploadedAttachment, Error>) -> Void)
- public func uploadStandaloneAttachment(_ attachment: StreamAttachment<Payload>,progress: (@Sendable (Double) -> Void)?,completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void)
- public protocol ImageCDN: Sendable
- open class StreamImageCDN: ImageCDN, @unchecked Sendable
-
- public nonisolated static var streamCDNURL
-
-
- public init()
-
-
- open func urlRequest(forImageUrl url: URL,resize: ImageResize?)-> URLRequest
- open func cachingKey(forImageUrl url: URL)-> String
- public extension CDNClient
- public protocol AttachmentUploader: Sendable
- public extension ImageLoading
- open class NukeImageProcessor: ImageProcessor, @unchecked Sendable
-
- open func crop(image: UIImage,to size: CGSize)-> UIImage?
- open func scale(image: UIImage,to size: CGSize)-> UIImage
- public protocol VideoLoading: AnyObject
public struct ChatClientConfig: Sendable
- public var customCDNClient: CDNClient?
+ public var cdnRequester: CDNRequester
- public var customAttachmentUploader: AttachmentUploader?
+ public var cdnStorage: CDNStorage?
public class ChatClient: @unchecked Sendable
- public func upload(_ attachment: StreamAttachment<Payload>,progress: (@Sendable (Double) -> Void)?,completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void)
+ public func uploadAttachment(localUrl: URL,progress: (@Sendable (Double) -> Void)?,completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void)
- public func uploadAttachment(localUrl: URL,progress: (@Sendable (Double) -> Void)?,completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void)
+ public func deleteAttachment(remoteUrl: URL,completion: @escaping @Sendable (Error?) -> Void)
- public func deleteAttachment(remoteUrl: URL,completion: @escaping @Sendable (Error?) -> Void)
- public struct ImageDownloadOptions
+ public struct ImageDownloadOptions: Sendable
-
+ public var cdnRequester: CDNRequester
-
+
- public init(resize: ImageResize? = nil)
+
+ public init(resize: ImageResize? = nil,cdnRequester: CDNRequester)
open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate
- public private var authorAvatarView: ChatAvatarView?
+ public private var authorAvatarView: ChatUserAvatarView?
- public private var threadAvatarView: ChatAvatarView?
+ public private var threadAvatarView: ChatUserAvatarView?
- open func createAvatarView()-> ChatAvatarView
+ open func createAvatarView()-> ChatUserAvatarView
- open func createThreadAvatarView()-> ChatAvatarView
+ open func createThreadAvatarView()-> ChatUserAvatarView
@MainActor public struct Components
- public var imageCDN: ImageCDN
+ public var cdnRequester: CDNRequester
- public var imageLoader: ImageLoading
+ public var mediaLoader: MediaLoader
- public var videoLoader: VideoLoading
+ public var maxAttachmentSize: Int64
open class ChatMessageReactionAuthorViewCell: _CollectionViewCell, ThemeProvider
- open lazy var authorAvatarView: ChatAvatarView
+ open lazy var authorAvatarView: ChatUserAvatarView
open class CurrentChatUserAvatarView: _Control, ThemeProvider
- open private lazy var avatarView: ChatAvatarView
+ open private lazy var avatarView: ChatUserAvatarView
open class QuotedChatMessageView: _View, ThemeProvider
- open private lazy var authorAvatarView: ChatAvatarView
+ open private lazy var authorAvatarView: ChatUserAvatarView
- open func setAvatar(imageUrl: URL?,authorName: String?)
+ open func setAvatarAlignment(_ alignment: QuotedAvatarAlignment)
- open func setAvatarAlignment(_ alignment: QuotedAvatarAlignment)
+ open func setAttachmentPreview(for message: ChatMessage)
- open func setAttachmentPreview(for message: ChatMessage)
+ open func setAttachmentPreviewImage(url: URL?)
- open func setAttachmentPreviewImage(url: URL?)
+ open func setVideoAttachmentThumbnail(url: URL)
- open func setVideoAttachmentThumbnail(url: URL)
+ open func setVideoAttachmentPreviewImage(url: URL?)
- open func setVideoAttachmentPreviewImage(url: URL?)
+ open func showAttachmentPreview()
- open func showAttachmentPreview()
+ open func hideAttachmentPreview()
- open func hideAttachmentPreview()
+ open func setUnsupportedAttachmentPreview(for message: ChatMessage)
- open func setUnsupportedAttachmentPreview(for message: ChatMessage)
+
-
+
-
+ public struct Content
- public struct Content
+
-
+ public let message: ChatMessage
- public let message: ChatMessage
+ public let avatarAlignment: QuotedAvatarAlignment
- public let avatarAlignment: QuotedAvatarAlignment
+ public let channel: ChatChannel?
- public let channel: ChatChannel?
+
-
+
-
+ public init(message: ChatMessage,avatarAlignment: QuotedAvatarAlignment,channel: ChatChannel? = nil)
- public init(message: ChatMessage,avatarAlignment: QuotedAvatarAlignment,channel: ChatChannel? = nil)
public struct ImageLoaderOptions: Sendable
-
+ public var cdnRequester: CDNRequester
-
+
- public init(resize: ImageResize? = nil,placeholder: UIImage? = nil)
+
+ public init(resize: ImageResize? = nil,placeholder: UIImage? = nil,cdnRequester: CDNRequester)
- public struct UploadedFile: Decodable
+ public struct UploadedFile: Sendable, Decodable |
All callers now use loadVideoPreview(with:) which receives the full ChatMessageVideoAttachment. This lets the MediaLoader use the thumbnailURL when available, removing duplicated thumbnail-vs-fallback logic from every call site.
All conformers must now explicitly implement every MediaLoader method, making the protocol contract clearer and avoiding hidden behavior from default implementations.
CDNRequester is a UI-level concern for URL transformation (signing, headers, resizing) during media loading. It now lives exclusively in Components (UIKit) and Utils (SwiftUI). The downloadAttachment functions now accept an optional pre-resolved remoteURL parameter, letting the UI layer resolve CDN URLs before triggering the download.
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift (1)
1029-1042:⚠️ Potential issue | 🟡 MinorLog message in catch block is misleading.
Line 1037 logs "Downloaded attachment" inside the
catchblock, but this path is taken when the download fails. The log level isdebugwhich suggests success, but the context is an error handler.🐛 Proposed fix
} catch { - log.debug("Downloaded attachment for id \(attachment.id)") + log.error("Failed to download attachment for id \(attachment.id): \(error)") }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift` around lines 1029 - 1042, The catch block inside the Task that calls client.makeChat(for:), cdnRequester.fileRequest(for:) and chat.downloadAttachment(...) logs a misleading success message; update the handler to log an error (or warning) that includes the failure context and the caught error (and attachment.id or attachment.remoteURL) so failures are clear — i.e. replace the log.debug("Downloaded attachment for id \(attachment.id)") in the catch with log.error/log.warning including error and identifying info from attachment.Tests/StreamChatTests/APIClient/APIClient_Tests.swift (1)
347-369:⚠️ Potential issue | 🟡 MinorCompare the mapped remote URL to the fixture, not to itself.
Line 368 is a tautology, so this test still passes if
APIClientmaps the uploaded file to the wrongUploadedAttachment. Assert againstmockedURLto keep the regression signal.Suggested fix
XCTAssertCall("uploadAttachment(_:options:completion:)", on: cdnStorage, times: 1) XCTAssertEqual(receivedProgress, mockedProgress) - XCTAssertEqual(receivedResult?.value?.remoteURL, receivedResult?.value?.remoteURL) + XCTAssertEqual(receivedResult?.value?.remoteURL, mockedURL)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Tests/StreamChatTests/APIClient/APIClient_Tests.swift` around lines 347 - 369, The test test_uploadAttachment_calls_CDNStorage currently compares receivedResult?.value?.remoteURL to itself which is tautological; update the assertion to compare the mapped UploadedAttachment remoteURL from receivedResult (the result of apiClient.uploadAttachment) against the expected mockedURL (the UploadedFile/fileURL fixture), i.e. assert receivedResult?.value?.remoteURL == mockedURL so the test fails if APIClient maps the uploaded file incorrectly.
♻️ Duplicate comments (7)
Sources/StreamChat/APIClient/CDNClient/StreamCDNRequester.swift (2)
99-105:⚠️ Potential issue | 🟠 MajorDon't memoize
UITraitCollection.current.displayScalein a static.
UITraitCollection.currentis thread-local. If the first access happens on a background thread, this can permanently lock resize requests to1.0xfor the process lifetime. Read the scale per request, or inject it.What does Apple's documentation say about `UITraitCollection.current` being thread-local, and why is caching `UITraitCollection.current.displayScale` in a static property unsafe when first accessed from a background thread?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/StreamChat/APIClient/CDNClient/StreamCDNRequester.swift` around lines 99 - 105, The static memoization of UITraitCollection.current.displayScale in StreamCDNRequester.screenScale is unsafe because UITraitCollection.current is thread-local and a first access on a background thread can lock the scale to an incorrect value for the process lifetime; change the implementation to stop caching displayScale in a static and instead read UITraitCollection.current.displayScale at the point of use (e.g., inside the image resize/request method) or accept the scale via dependency injection/parameter so each request uses the current main-thread trait collection; update code paths that reference StreamCDNRequester.screenScale to use the non-memoized per-request value.
49-52:⚠️ Potential issue | 🟠 MajorUse an exact-or-subdomain host match here.
contains(cdnHost)also accepts hosts likestream-io-cdn.com.evil.example, so resize rewriting and cache-key normalization can run for non-Stream domains. Matchhost == cdnHost || host.hasSuffix(".\(cdnHost)")instead.Also applies to: 86-88
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/StreamChat/APIClient/CDNClient/StreamCDNRequester.swift` around lines 49 - 52, Update the host matching logic in buildImageURL(from:resize:) to reject deceptive hosts by replacing host.contains(cdnHost) with an exact-or-subdomain check (host == cdnHost || host.hasSuffix(".\(cdnHost)")), so only the CDN host or its subdomains are rewritten; make the same replacement for the analogous host-check in the other URL-building method in StreamCDNRequester to ensure both image and file URL handling use exact-or-subdomain matching.Sources/StreamChat/Controllers/MessageController/MessageController.swift (1)
802-805:⚠️ Potential issue | 🟠 MajorDon't drop the caller's completion when the controller deallocates.
If
selfis gone before this closure runs,self?.callbackbecomes a silent no-op and the caller never learns the download finished or failed. Return a cancellation/deallocation error through the same completion path instead.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/StreamChat/Controllers/MessageController/MessageController.swift` around lines 802 - 805, The current closure passed to messageUpdater.downloadAttachment uses [weak self] and calls self?.callback { completion(result) }, which silently drops the caller's completion if the MessageController deallocates; change the closure to capture completion (and any necessary context) and, after the download finishes, if self is still present run self.callback { completion(result) } else invoke the same completion path with a cancellation/deallocation error (e.g. a defined .cancelled or .deallocated error) so callers always get a result; update the MessageController error type if needed and ensure this behavior is applied in the messageUpdater.downloadAttachment completion block and any similar usages of self?.callback.Sources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swift (1)
148-167:⚠️ Potential issue | 🟠 MajorForward CDN headers into preview generation too.
loadVideoAssetalready preservescdnRequest.headers, but this path drops them and creates the preview asset from the URL alone. Header-authenticated videos can therefore play successfully while thumbnail generation still fails.Suggested fix
- let adjustedUrl: URL + let adjustedURL: URL + var assetOptions: [String: Any] = [:] switch result { case let .success(cdnRequest): - adjustedUrl = cdnRequest.url + adjustedURL = cdnRequest.url + if let headers = cdnRequest.headers, !headers.isEmpty { + assetOptions[AVURLAssetHTTPHeaderFieldsKey] = headers + } case let .failure(error): StreamConcurrency.onMain { completion(.failure(error)) } return } - let asset = AVURLAsset(url: adjustedUrl) + let asset = AVURLAsset(url: adjustedURL, options: assetOptions.isEmpty ? nil : assetOptions)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swift` around lines 148 - 167, The preview-generation branch drops CDN auth headers: in the closure started by options.cdnRequester.fileRequest(for:), capture the successful cdnRequest and pass its headers into the AVURLAsset creation (use AVURLAsset(url: adjustedUrl, options: ["AVURLAssetHTTPHeaderFieldsKey": cdnRequest.headers]) or equivalent) instead of creating the asset from the URL alone; update the code around the success case where cdnRequest is available and ensure the same header forwarding used by loadVideoAsset is applied when constructing the preview AVURLAsset.TestTools/StreamChatTestTools/TestData/CustomCDNStorage.swift (1)
10-26:⚠️ Potential issue | 🟠 MajorInvoke the stub completions instead of leaving callers hanging.
CustomCDNStorageisfinal, so these empty bodies cannot be specialized elsewhere. Any test waiting on upload/delete completion can block forever unless each method terminates with a default success/failure result.Possible stub behavior
public final class CustomCDNStorage: CDNStorage { public func uploadAttachment( _ attachment: AnyChatMessageAttachment, options: AttachmentUploadOptions, completion: `@escaping` `@Sendable` (Result<UploadedFile, Error>) -> Void - ) {} + ) { + completion(.failure(ClientError.Unknown("CustomCDNStorage is not implemented"))) + } public func uploadAttachment( localUrl: URL, options: AttachmentUploadOptions, completion: `@escaping` `@Sendable` (Result<UploadedFile, Error>) -> Void - ) {} + ) { + completion(.failure(ClientError.Unknown("CustomCDNStorage is not implemented"))) + } public func deleteAttachment( remoteUrl: URL, options: AttachmentDeleteOptions, completion: `@escaping` `@Sendable` (Error?) -> Void - ) {} + ) { + completion(nil) + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@TestTools/StreamChatTestTools/TestData/CustomCDNStorage.swift` around lines 10 - 26, The three empty methods in final class CustomCDNStorage (uploadAttachment(_:options:completion:) for AnyChatMessageAttachment, uploadAttachment(localUrl:options:completion:) and deleteAttachment(remoteUrl:options:completion:)) must invoke their completions so callers don't block; implement each stub to call the provided completion immediately with a sensible default: for both uploadAttachment variants call completion(.success(some UploadedFile instance constructed from the attachment or localUrl metadata / a generated remote URL and size)), and for deleteAttachment call completion(nil) to indicate success (or completion(.failure(error)) on a simulated error), ensuring you construct or reuse UploadedFile and error types available in the test target.Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift (1)
808-819:⚠️ Potential issue | 🟡 MinorTap target registered on every call, not just on creation.
Line 817 adds the tap target outside the
if authorAvatarView == nilblock, sohandleTapOnAvatarViewgets registered multiple times ifcreateAvatarView()is called more than once. This can cause duplicate callbacks.🐛 Proposed fix - move target registration inside creation block
open func createAvatarView() -> ChatUserAvatarView { if authorAvatarView == nil { let avatarView = components .userAvatarView .init() .withoutAutoresizingMaskConstraints avatarView.shouldShowOnlineIndicator = false authorAvatarView = avatarView + authorAvatarView?.presenceAvatarView.avatarView.addTarget(self, action: `#selector`(handleTapOnAvatarView), for: .touchUpInside) } - authorAvatarView?.presenceAvatarView.avatarView.addTarget(self, action: `#selector`(handleTapOnAvatarView), for: .touchUpInside) return authorAvatarView! }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift` around lines 808 - 819, The tap target is being added on every call to createAvatarView(), causing duplicate registrations; update createAvatarView() so the presenceAvatarView.avatarView.addTarget(..., action: `#selector`(handleTapOnAvatarView), for: .touchUpInside) call is performed only when a new avatarView is created (inside the if authorAvatarView == nil block) after initializing authorAvatarView, so that handleTapOnAvatarView is registered once for authorAvatarView.Sources/StreamChatUI/Utils/ImageLoading/MediaLoader+UIKit.swift (1)
18-37:⚠️ Potential issue | 🟠 MajorApply the placeholder before starting a new load and restore it on failure.
Reused views keep showing the previous image until the new request succeeds, and they keep showing it forever if the request fails. That leaks stale content across reused cells.
🐛 Proposed fix
) -> ImageLoadingTask { let task = ImageLoadingTask() imageView.currentImageLoadingTask?.cancel() + imageView.image = options.placeholder guard let url else { - imageView.image = options.placeholder return task } imageView.currentImageLoadingTask = task @@ switch result { case let .success(loaded): imageView.image = loaded.image completion?(.success(loaded.image)) case let .failure(error): + imageView.image = options.placeholder completion?(.failure(error)) } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/StreamChatUI/Utils/ImageLoading/MediaLoader`+UIKit.swift around lines 18 - 37, Before starting the new load in MediaLoader+UIKit (around ImageLoadingTask creation and imageView.currentImageLoadingTask assignment), set imageView.image = options.placeholder so the placeholder is applied immediately; keep cancelling the previous task via imageView.currentImageLoadingTask?.cancel(), then proceed to create and assign the new ImageLoadingTask; in the loadImage completion handler (inside the switch), on .failure(error) restore imageView.image = options.placeholder before calling completion?(.failure(error)) so failures don't leave a stale image visible (use the same placeholder handling for the guard let url nil path as well).
🧹 Nitpick comments (3)
Tests/StreamChatCommonUITests/ImageLoading/StreamMediaLoader_Image_Tests.swift (1)
194-212: UnusedresultsByURLproperty in mock.The
resultsByURLdictionary on Line 196 is declared but never used in the test file. Consider removing it to keep the mock minimal, or add tests that exercise URL-specific result mapping if that behavior is needed.♻️ Suggested cleanup
private final class MockImageDownloader: ImageDownloading, `@unchecked` Sendable { var result: Result<DownloadedImage, Error> = .failure(NSError(domain: "MockImageDownloader", code: 0)) - var resultsByURL: [URL: Result<DownloadedImage, Error>] = [:] var lastURL: URL? var lastOptions: ImageDownloadingOptions? func downloadImage( url: URL, options: ImageDownloadingOptions, completion: `@escaping` `@MainActor` (Result<DownloadedImage, Error>) -> Void ) { lastURL = url lastOptions = options - let resolvedResult = resultsByURL[url] ?? result + let resolvedResult = result DispatchQueue.main.async { completion(resolvedResult) } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Tests/StreamChatCommonUITests/ImageLoading/StreamMediaLoader_Image_Tests.swift` around lines 194 - 212, The MockImageDownloader class declares resultsByURL but it isn't used anywhere; either remove the unused property or implement URL-specific behavior in downloadImage to consult resultsByURL before falling back to result. Update the downloadImage method in MockImageDownloader so it resolves let resolvedResult = resultsByURL[url] ?? result (or delete the resultsByURL property if you prefer the simpler mock), and ensure lastURL/lastOptions remain set as they are.Tests/StreamChatCommonUITests/ImageLoading/StreamMediaLoader_Video_Tests.swift (1)
347-361: UnusedAsyncMockCDNRequesterclass.
AsyncMockCDNRequesteris declared but never instantiated in any test. Consider removing it to reduce test file size, or add tests that need deferred CDN completion if that scenario should be covered.♻️ Remove unused mock
-private final class AsyncMockCDNRequester: CDNRequester, `@unchecked` Sendable { - private var fileCompletion: ((Result<CDNRequest, Error>) -> Void)? - - func imageRequest(for url: URL, options: ImageRequestOptions, completion: `@escaping` (Result<CDNRequest, Error>) -> Void) { - completion(.success(CDNRequest(url: url))) - } - - func fileRequest(for url: URL, options: FileRequestOptions, completion: `@escaping` (Result<CDNRequest, Error>) -> Void) { - fileCompletion = completion - } - - func triggerFileCompletion(_ result: Result<CDNRequest, Error>) { - fileCompletion?(result) - } -}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Tests/StreamChatCommonUITests/ImageLoading/StreamMediaLoader_Video_Tests.swift` around lines 347 - 361, The AsyncMockCDNRequester class is unused; either delete the entire AsyncMockCDNRequester declaration or add tests that instantiate AsyncMockCDNRequester and exercise its deferred fileRequest flow by calling fileRequest(...) to capture the completion, then invoking triggerFileCompletion(...) with success/error to simulate CDN responses; locate the class and its methods (AsyncMockCDNRequester, imageRequest(for:options:completion:), fileRequest(for:options:completion:), triggerFileCompletion(_:)) and implement the chosen change to remove dead code or cover the async completion scenario in tests.TestTools/StreamChatTestTools/SpyPattern/Spy/CDNStorage_Spy.swift (1)
27-31: Avoid implicit “no-callback” behavior whenuploadAttachmentResultis unset.If
uploadAttachmentResultisnil, completion is never called, which can leave tests waiting indefinitely. Consider making this explicit and deterministic (default failure or an explicit opt-out flag).Proposed deterministic completion pattern
+enum CDNStorageSpyError: Error { + case uploadResultNotConfigured +} + +var callbackDelay: TimeInterval = 0 if let uploadAttachmentProgress = uploadAttachmentProgress { options.progress?(uploadAttachmentProgress) } - if let uploadAttachmentResult = uploadAttachmentResult { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - completion(uploadAttachmentResult) - } - } + let result = uploadAttachmentResult ?? .failure(CDNStorageSpyError.uploadResultNotConfigured) + DispatchQueue.main.asyncAfter(deadline: .now() + callbackDelay) { + completion(result) + }Also applies to: 44-47
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@TestTools/StreamChatTestTools/SpyPattern/Spy/CDNStorage_Spy.swift` around lines 27 - 31, The test spy currently only calls completion when the stored uploadAttachmentResult is non-nil, leaving callers hanging if it is nil; update the Spy's upload attachment path (references: uploadAttachmentResult and the completion closure) to always invoke completion deterministically — either call completion with a default failure/result when uploadAttachmentResult is nil or add an explicit opt-out flag (e.g., shouldCallUploadCompletion) that the caller can set; apply the same change for the second occurrence referenced in the review (the block around lines 44-47) so completion is never implicitly skipped.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift`:
- Around line 24-55: Update the three public async callback signatures to mark
their escaping completion closures as `@Sendable`: in loadImage, loadVideoAsset,
and loadVideoPreview change the completion parameter types from "@escaping
`@MainActor` (Result<..., Error>) -> Void" to "@escaping `@Sendable` `@MainActor`
(Result<..., Error>) -> Void" so the escaping closures satisfy Swift 6
concurrency safety when crossing executor boundaries.
In `@Sources/StreamChatUI/Utils/ImageLoading/MediaLoader`+UIKit.swift:
- Around line 29-30: ImageLoadingTask's cancellation flag is accessed from
different executors (cancel() vs. completion closure checking isCancelled)
causing a data race; fix by synchronizing access (or confining to main actor).
Update the ImageLoadingTask type: add a private NSLock (or similar) and wrap all
reads/writes of the internal Bool (used by cancel() and the isCancelled getter)
with lock/unlock, then keep the Sendable conformance; alternatively mark the
whole ImageLoadingTask `@MainActor` if it will only be used on the main thread.
Ensure the completion closure that checks task.isCancelled uses the synchronized
getter (or main-actor-confined property) so the race is eliminated.
In `@Tests/StreamChatTests/APIClient/StreamCDNStorage_Tests.swift`:
- Around line 446-475: The tests must assert that local-validation stops
execution before request encoding: change each test to install a spy/failing
stub on TestBuilder.encoder.encodeRequest that records or immediately fails if
called, then call client.uploadAttachment (using the same parameters) and assert
both that the result is an error and that the encoder spy was NOT invoked;
reference TestBuilder, builder.encoder.encodeRequest and client.uploadAttachment
to locate and update the two tests
(test_uploadAttachment_withoutUploadingState_fails and
test_uploadAttachmentLocalUrl_invalidURL_fails).
In `@Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift`:
- Around line 51-60: The test mock currently always invokes completion after
calling videoAssetMockFunc, preventing stubs from returning custom results and
causing double callbacks; update loadVideoAsset (and the analogous image loader
methods) to only call completion when the mock did not already handle it — e.g.,
change how you invoke videoAssetMockFunc so it can indicate whether it handled
the call (return Bool or optional Result) and then only call
completion(.success(...)) when that indicator shows the mock did not complete
the request; reference videoAssetMockFunc, loadVideoAsset, and the image asset
mock methods to make the change consistently across both implementations.
In `@TestTools/StreamChatTestTools/SpyPattern/Spy/CDNStorage_Spy.swift`:
- Around line 8-16: CDNStorage_Spy is marked `@unchecked` Sendable but has mutable
shared state (uploadAttachmentProgress, uploadAttachmentResult,
deleteAttachmentRemoteUrl, deleteAttachmentResult, deleteAttachmentCalled) that
is mutated in its protocol methods; remove the unsafe `@unchecked` Sendable and
fix data races by either: (A) converting CDNStorage_Spy into an actor (or
marking relevant methods/properties with actor isolation) so mutations occur on
the actor, or (B) remove Sendable conformance and protect all mutable properties
with a synchronization primitive (e.g., a serial DispatchQueue or a lock) used
inside the methods that write/read uploadAttachmentProgress,
uploadAttachmentResult, deleteAttachmentRemoteUrl, deleteAttachmentResult and
the deleteAttachmentCalled flag; pick one approach and apply it consistently
across the class (references: class CDNStorage_Spy, uploadAttachmentProgress,
uploadAttachmentResult, deleteAttachmentRemoteUrl, deleteAttachmentResult,
deleteAttachmentCalled, and the protocol methods that mutate them).
---
Outside diff comments:
In `@Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift`:
- Around line 1029-1042: The catch block inside the Task that calls
client.makeChat(for:), cdnRequester.fileRequest(for:) and
chat.downloadAttachment(...) logs a misleading success message; update the
handler to log an error (or warning) that includes the failure context and the
caught error (and attachment.id or attachment.remoteURL) so failures are clear —
i.e. replace the log.debug("Downloaded attachment for id \(attachment.id)") in
the catch with log.error/log.warning including error and identifying info from
attachment.
In `@Tests/StreamChatTests/APIClient/APIClient_Tests.swift`:
- Around line 347-369: The test test_uploadAttachment_calls_CDNStorage currently
compares receivedResult?.value?.remoteURL to itself which is tautological;
update the assertion to compare the mapped UploadedAttachment remoteURL from
receivedResult (the result of apiClient.uploadAttachment) against the expected
mockedURL (the UploadedFile/fileURL fixture), i.e. assert
receivedResult?.value?.remoteURL == mockedURL so the test fails if APIClient
maps the uploaded file incorrectly.
---
Duplicate comments:
In `@Sources/StreamChat/APIClient/CDNClient/StreamCDNRequester.swift`:
- Around line 99-105: The static memoization of
UITraitCollection.current.displayScale in StreamCDNRequester.screenScale is
unsafe because UITraitCollection.current is thread-local and a first access on a
background thread can lock the scale to an incorrect value for the process
lifetime; change the implementation to stop caching displayScale in a static and
instead read UITraitCollection.current.displayScale at the point of use (e.g.,
inside the image resize/request method) or accept the scale via dependency
injection/parameter so each request uses the current main-thread trait
collection; update code paths that reference StreamCDNRequester.screenScale to
use the non-memoized per-request value.
- Around line 49-52: Update the host matching logic in
buildImageURL(from:resize:) to reject deceptive hosts by replacing
host.contains(cdnHost) with an exact-or-subdomain check (host == cdnHost ||
host.hasSuffix(".\(cdnHost)")), so only the CDN host or its subdomains are
rewritten; make the same replacement for the analogous host-check in the other
URL-building method in StreamCDNRequester to ensure both image and file URL
handling use exact-or-subdomain matching.
In `@Sources/StreamChat/Controllers/MessageController/MessageController.swift`:
- Around line 802-805: The current closure passed to
messageUpdater.downloadAttachment uses [weak self] and calls self?.callback {
completion(result) }, which silently drops the caller's completion if the
MessageController deallocates; change the closure to capture completion (and any
necessary context) and, after the download finishes, if self is still present
run self.callback { completion(result) } else invoke the same completion path
with a cancellation/deallocation error (e.g. a defined .cancelled or
.deallocated error) so callers always get a result; update the MessageController
error type if needed and ensure this behavior is applied in the
messageUpdater.downloadAttachment completion block and any similar usages of
self?.callback.
In `@Sources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swift`:
- Around line 148-167: The preview-generation branch drops CDN auth headers: in
the closure started by options.cdnRequester.fileRequest(for:), capture the
successful cdnRequest and pass its headers into the AVURLAsset creation (use
AVURLAsset(url: adjustedUrl, options: ["AVURLAssetHTTPHeaderFieldsKey":
cdnRequest.headers]) or equivalent) instead of creating the asset from the URL
alone; update the code around the success case where cdnRequest is available and
ensure the same header forwarding used by loadVideoAsset is applied when
constructing the preview AVURLAsset.
In
`@Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift`:
- Around line 808-819: The tap target is being added on every call to
createAvatarView(), causing duplicate registrations; update createAvatarView()
so the presenceAvatarView.avatarView.addTarget(..., action:
`#selector`(handleTapOnAvatarView), for: .touchUpInside) call is performed only
when a new avatarView is created (inside the if authorAvatarView == nil block)
after initializing authorAvatarView, so that handleTapOnAvatarView is registered
once for authorAvatarView.
In `@Sources/StreamChatUI/Utils/ImageLoading/MediaLoader`+UIKit.swift:
- Around line 18-37: Before starting the new load in MediaLoader+UIKit (around
ImageLoadingTask creation and imageView.currentImageLoadingTask assignment), set
imageView.image = options.placeholder so the placeholder is applied immediately;
keep cancelling the previous task via
imageView.currentImageLoadingTask?.cancel(), then proceed to create and assign
the new ImageLoadingTask; in the loadImage completion handler (inside the
switch), on .failure(error) restore imageView.image = options.placeholder before
calling completion?(.failure(error)) so failures don't leave a stale image
visible (use the same placeholder handling for the guard let url nil path as
well).
In `@TestTools/StreamChatTestTools/TestData/CustomCDNStorage.swift`:
- Around line 10-26: The three empty methods in final class CustomCDNStorage
(uploadAttachment(_:options:completion:) for AnyChatMessageAttachment,
uploadAttachment(localUrl:options:completion:) and
deleteAttachment(remoteUrl:options:completion:)) must invoke their completions
so callers don't block; implement each stub to call the provided completion
immediately with a sensible default: for both uploadAttachment variants call
completion(.success(some UploadedFile instance constructed from the attachment
or localUrl metadata / a generated remote URL and size)), and for
deleteAttachment call completion(nil) to indicate success (or
completion(.failure(error)) on a simulated error), ensuring you construct or
reuse UploadedFile and error types available in the test target.
---
Nitpick comments:
In
`@Tests/StreamChatCommonUITests/ImageLoading/StreamMediaLoader_Image_Tests.swift`:
- Around line 194-212: The MockImageDownloader class declares resultsByURL but
it isn't used anywhere; either remove the unused property or implement
URL-specific behavior in downloadImage to consult resultsByURL before falling
back to result. Update the downloadImage method in MockImageDownloader so it
resolves let resolvedResult = resultsByURL[url] ?? result (or delete the
resultsByURL property if you prefer the simpler mock), and ensure
lastURL/lastOptions remain set as they are.
In
`@Tests/StreamChatCommonUITests/ImageLoading/StreamMediaLoader_Video_Tests.swift`:
- Around line 347-361: The AsyncMockCDNRequester class is unused; either delete
the entire AsyncMockCDNRequester declaration or add tests that instantiate
AsyncMockCDNRequester and exercise its deferred fileRequest flow by calling
fileRequest(...) to capture the completion, then invoking
triggerFileCompletion(...) with success/error to simulate CDN responses; locate
the class and its methods (AsyncMockCDNRequester,
imageRequest(for:options:completion:), fileRequest(for:options:completion:),
triggerFileCompletion(_:)) and implement the chosen change to remove dead code
or cover the async completion scenario in tests.
In `@TestTools/StreamChatTestTools/SpyPattern/Spy/CDNStorage_Spy.swift`:
- Around line 27-31: The test spy currently only calls completion when the
stored uploadAttachmentResult is non-nil, leaving callers hanging if it is nil;
update the Spy's upload attachment path (references: uploadAttachmentResult and
the completion closure) to always invoke completion deterministically — either
call completion with a default failure/result when uploadAttachmentResult is nil
or add an explicit opt-out flag (e.g., shouldCallUploadCompletion) that the
caller can set; apply the same change for the second occurrence referenced in
the review (the block around lines 44-47) so completion is never implicitly
skipped.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 036639c3-17f2-40a4-aed1-426c78f0cf14
⛔ Files ignored due to path filters (8)
Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_customAppearance.default-dark.pngis excluded by!**/*.pngTests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_customAppearance.default-light.pngis excluded by!**/*.pngTests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance.default-light.pngis excluded by!**/*.pngTests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance.extraExtraExtraLarge-light.pngis excluded by!**/*.pngTests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance.rightToLeftLayout-default.pngis excluded by!**/*.pngTests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance.small-dark.pngis excluded by!**/*.pngTests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance_shouldNotRenderUnavailableReactions.default-light.pngis excluded by!**/*.pngTests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance_whenOnlyOneReaction_shouldUseSingularLocalization.default-light.pngis excluded by!**/*.png
📒 Files selected for processing (52)
CHANGELOG.mdDemoApp/Screens/UserProfile/UserProfileViewController.swiftExamples/MessengerClone/MessengerChatMessageContentView.swiftSources/StreamChat/APIClient/CDNClient/CDNRequester.swiftSources/StreamChat/APIClient/CDNClient/CDNStorage.swiftSources/StreamChat/APIClient/CDNClient/StreamCDNRequester.swiftSources/StreamChat/APIClient/CDNClient/StreamCDNStorage.swiftSources/StreamChat/Config/ChatClientConfig.swiftSources/StreamChat/Controllers/MessageController/MessageController.swiftSources/StreamChat/StateLayer/Chat.swiftSources/StreamChat/Workers/Background/ActiveLiveLocationsEndTimeTracker.swiftSources/StreamChat/Workers/Background/AttachmentQueueUploader.swiftSources/StreamChatCommonUI/ImageLoading/ImageDownloading.swiftSources/StreamChatCommonUI/ImageLoading/MediaLoader.swiftSources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swiftSources/StreamChatUI/ChatMessageList/Attachments/Gallery/ChatMessageImageGallery+ImagePreview.swiftSources/StreamChatUI/ChatMessageList/Attachments/Gallery/VideoAttachmentGalleryPreview.swiftSources/StreamChatUI/ChatMessageList/Attachments/Link/ChatMessageLinkPreviewView.swiftSources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swiftSources/StreamChatUI/ChatMessageList/ChatMessageListVC.swiftSources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorViewCell.swiftSources/StreamChatUI/CommonViews/Attachments/AttachmentViews/ImageAttachmentComposerPreview.swiftSources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview.swiftSources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swiftSources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swiftSources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swiftSources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swiftSources/StreamChatUI/Components.swiftSources/StreamChatUI/Composer/ComposerLinkPreviewView.swiftSources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swiftSources/StreamChatUI/Gallery/Cells/VideoAttachmentGalleryCell.swiftSources/StreamChatUI/Utils/ImageLoading/ImageDownloadOptions.swiftSources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swiftSources/StreamChatUI/Utils/ImageLoading/MediaLoader+UIKit.swiftSources/StreamChatUI/Utils/ImageLoading/StreamImageDownloader.swiftStreamChat.xcodeproj/project.pbxprojTestTools/StreamChatTestTools/SpyPattern/Spy/CDNStorage_Spy.swiftTestTools/StreamChatTestTools/TestData/CustomCDNStorage.swiftTests/StreamChatCommonUITests/ImageLoading/StreamMediaLoader_Image_Tests.swiftTests/StreamChatCommonUITests/ImageLoading/StreamMediaLoader_Video_Tests.swiftTests/StreamChatTests/APIClient/APIClient_Tests.swiftTests/StreamChatTests/APIClient/CDNClient/CDNStorage_Tests.swiftTests/StreamChatTests/APIClient/CDNClient/StreamCDNRequester_Tests.swiftTests/StreamChatTests/APIClient/StreamCDNStorage_Tests.swiftTests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swiftTests/StreamChatTests/StateLayer/Chat_Tests.swiftTests/StreamChatUITests/Mocks/Components_Mock.swiftTests/StreamChatUITests/Mocks/ImageLoader_Mock.swiftTests/StreamChatUITests/Mocks/Utils/VideoLoader_Mock.swiftTests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/VideoAttachmentGalleryPreview_Tests.swiftTests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview_Tests.swiftTests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/CurrentChatUserAvatarView_Tests.swift
💤 Files with no reviewable changes (1)
- Tests/StreamChatUITests/Mocks/Utils/VideoLoader_Mock.swift
✅ Files skipped from review due to trivial changes (3)
- Sources/StreamChat/Workers/Background/ActiveLiveLocationsEndTimeTracker.swift
- Sources/StreamChat/Workers/Background/AttachmentQueueUploader.swift
- CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (14)
- Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/ImageAttachmentComposerPreview.swift
- Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift
- Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift
- Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/VideoAttachmentGalleryPreview.swift
- Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview.swift
- Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift
- Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift
- Sources/StreamChat/Config/ChatClientConfig.swift
- Sources/StreamChat/StateLayer/Chat.swift
- Sources/StreamChatUI/Utils/ImageLoading/StreamImageDownloader.swift
- Sources/StreamChatUI/Utils/ImageLoading/ImageDownloadOptions.swift
- StreamChat.xcodeproj/project.pbxproj
- Sources/StreamChat/APIClient/CDNClient/CDNStorage.swift
- Sources/StreamChat/APIClient/CDNClient/CDNRequester.swift
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (3)
Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift (2)
24-76:⚠️ Potential issue | 🟠 MajorReturn a cancellation handle from the core media-loading API.
These methods are all fire-and-forget, so reuse paths cannot cancel in-flight downloads or thumbnail generation. That keeps network/CPU work running after the caller no longer needs the result and pushes every UI layer toward stale-callback workarounds instead of stopping the actual task.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift` around lines 24 - 76, The media-loading APIs (loadImage, loadVideoAsset, loadVideoPreview(with:), loadVideoPreview(at:)) are fire-and-forget and need to return a cancellation handle so callers can cancel in-flight downloads and thumbnail generation; change each method signature to return a cancellable token (e.g., a protocol like Cancellable or a concrete CancellationToken) instead of Void, update all implementations to wire that token to the underlying network/AV tasks so cancel() aborts work and completes no callbacks, and update callers/tests to store and call cancel() when appropriate to stop unnecessary CPU/network use.
24-76:⚠️ Potential issue | 🟠 MajorMark the escaping completions as
@Sendable.These callbacks escape and are invoked after async work, but their types still omit
@Sendable. Under Swift 6 strict concurrency that weakens the isolation contract for both callers and implementers.♻️ Suggested fix
func loadImage( url: URL?, options: ImageLoadOptions, - completion: `@escaping` `@MainActor` (Result<MediaLoaderImage, Error>) -> Void + completion: `@escaping` `@Sendable` `@MainActor` (Result<MediaLoaderImage, Error>) -> Void ) @@ func loadVideoAsset( at url: URL, options: VideoLoadOptions, - completion: `@escaping` `@MainActor` (Result<MediaLoaderVideoAsset, Error>) -> Void + completion: `@escaping` `@Sendable` `@MainActor` (Result<MediaLoaderVideoAsset, Error>) -> Void ) @@ func loadVideoPreview( with attachment: ChatMessageVideoAttachment, options: VideoLoadOptions, - completion: `@escaping` `@MainActor` (Result<MediaLoaderVideoPreview, Error>) -> Void + completion: `@escaping` `@Sendable` `@MainActor` (Result<MediaLoaderVideoPreview, Error>) -> Void ) @@ func loadVideoPreview( at url: URL, options: VideoLoadOptions, - completion: `@escaping` `@MainActor` (Result<MediaLoaderVideoPreview, Error>) -> Void + completion: `@escaping` `@Sendable` `@MainActor` (Result<MediaLoaderVideoPreview, Error>) -> Void )As per coding guidelines:
**/*.swift: Use Swift 6.0 with strict concurrency enabled; useSendableconformances for cross-isolation transfers and avoid introducing data races with actor isolation.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift` around lines 24 - 76, All escaping completion closures in MediaLoader's API should be marked `@Sendable` to satisfy Swift 6 strict-concurrency; update the signatures for loadImage(url:options:completion:), loadVideoAsset(at:options:completion:), loadVideoPreview(with:options:completion:), and loadVideoPreview(at:options:completion:) so the completion parameter is declared as `@escaping` `@Sendable` `@MainActor` (Result<..., Error>) -> Void (preserving `@MainActor` and existing Result types), and run the build to ensure no additional Sendable conformance fixes are required.Sources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swift (1)
164-183:⚠️ Potential issue | 🟠 MajorForward CDN headers when generating video previews.
fileRequestcan return auth headers, but this path still builds the preview asset from the URL alone. Protected videos will fail here even thoughloadVideoAssetalready supports headers.♻️ Suggested fix
- let adjustedUrl: URL + let adjustedURL: URL + var assetOptions: [String: Any] = [:] switch result { case let .success(cdnRequest): - adjustedUrl = cdnRequest.url + adjustedURL = cdnRequest.url + if let headers = cdnRequest.headers, !headers.isEmpty { + assetOptions[AVURLAssetHTTPHeaderFieldsKey] = headers + } case let .failure(error): StreamConcurrency.onMain { completion(.failure(error)) } return } - let asset = AVURLAsset(url: adjustedUrl) + let asset = AVURLAsset(url: adjustedURL, options: assetOptions.isEmpty ? nil : assetOptions) let imageGenerator = AVAssetImageGenerator(asset: asset)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swift` around lines 164 - 183, The CDN fileRequest may return auth headers which must be forwarded when building the preview asset: instead of creating AVURLAsset(url: adjustedUrl) with no headers, pass the returned cdnRequest headers into the AVURLAsset options (AVURLAssetHTTPHeaderFieldsKey) or call the existing loadVideoAsset helper that accepts headers so protected videos load correctly; update the code paths around options.cdnRequester.fileRequest(...) and the creation of the AVURLAsset/preview logic to include cdnRequest.headers when constructing the asset.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview.swift`:
- Around line 95-112: The async callbacks from
components.mediaLoader.loadVideoPreview and loadVideoAsset can apply stale
results if self.content changes; add a guard in each completion to verify the
response matches the current request (e.g., check self?.content == url or
validate a per-request token) before updating loadingIndicator,
previewImageView.image, or videoDurationLabel; do this in the closure blocks for
loadVideoPreview(at:options:) and loadVideoAsset(at:options:) so only the latest
content updates the UI.
---
Duplicate comments:
In `@Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift`:
- Around line 24-76: The media-loading APIs (loadImage, loadVideoAsset,
loadVideoPreview(with:), loadVideoPreview(at:)) are fire-and-forget and need to
return a cancellation handle so callers can cancel in-flight downloads and
thumbnail generation; change each method signature to return a cancellable token
(e.g., a protocol like Cancellable or a concrete CancellationToken) instead of
Void, update all implementations to wire that token to the underlying network/AV
tasks so cancel() aborts work and completes no callbacks, and update
callers/tests to store and call cancel() when appropriate to stop unnecessary
CPU/network use.
- Around line 24-76: All escaping completion closures in MediaLoader's API
should be marked `@Sendable` to satisfy Swift 6 strict-concurrency; update the
signatures for loadImage(url:options:completion:),
loadVideoAsset(at:options:completion:),
loadVideoPreview(with:options:completion:), and
loadVideoPreview(at:options:completion:) so the completion parameter is declared
as `@escaping` `@Sendable` `@MainActor` (Result<..., Error>) -> Void (preserving
`@MainActor` and existing Result types), and run the build to ensure no additional
Sendable conformance fixes are required.
In `@Sources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swift`:
- Around line 164-183: The CDN fileRequest may return auth headers which must be
forwarded when building the preview asset: instead of creating AVURLAsset(url:
adjustedUrl) with no headers, pass the returned cdnRequest headers into the
AVURLAsset options (AVURLAssetHTTPHeaderFieldsKey) or call the existing
loadVideoAsset helper that accepts headers so protected videos load correctly;
update the code paths around options.cdnRequester.fileRequest(...) and the
creation of the AVURLAsset/preview logic to include cdnRequest.headers when
constructing the asset.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: dff936ac-de73-4e5e-a31b-68126dcd50b7
📒 Files selected for processing (4)
Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swiftSources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swiftSources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview.swiftTests/StreamChatUITests/Mocks/ImageLoader_Mock.swift
🚧 Files skipped from review as they are similar to previous changes (1)
- Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift (1)
52-94:⚠️ Potential issue | 🟠 MajorLet the mock hooks short-circuit the default path.
loadVideoAsset,loadVideoPreview(with:), andloadVideoPreview(at:)all invoke theirMockFuncfirst and then continue with the fallback implementation anyway. A stub that completes with a custom success/failure will therefore be followed by another completion, which makes these tests flaky and prevents the hook from fully overriding behavior.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift` around lines 52 - 94, The mock methods (videoAssetMockFunc, loadVideoPreviewMockFunc, loadVideoPreviewAtURLMockFunc) call their MockFunc and then always continue to the fallback implementation, causing double completions; change each method (loadVideoAsset, loadVideoPreview(with:), loadVideoPreview(at:)) to short-circuit after calling the MockFunc by returning immediately when the mock has handled the call—i.e., detect the mock's handled/returned/completed state (use the MockFunc's existing flag or return value such as a boolean like "handled" or "didInvokeCompletion") and if true, do not run the fallback code or call completion again. Ensure you apply this early return in all three methods (videoAssetMockFunc.call(...), loadVideoPreviewMockFunc.call(...), loadVideoPreviewAtURLMockFunc.call(...)).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift`:
- Around line 24-39: Both loadImage and loadVideoAsset call
MainActor.assumeIsolated unconditionally which can trap off the main actor;
extract a small helper (e.g., completeOnMain or finishOnMain) that checks
Thread.isMainThread and if true calls the completion directly, otherwise
dispatches to the main actor (using MainActor.run or MainActor.assumeIsolated
safely) to invoke the completion; replace direct MainActor.assumeIsolated calls
in loadImage and loadVideoAsset with this helper (follow the pattern already
used in loadVideoPreview(at:)) so completions are delivered without violating
Swift 6 concurrency rules.
---
Duplicate comments:
In `@Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift`:
- Around line 52-94: The mock methods (videoAssetMockFunc,
loadVideoPreviewMockFunc, loadVideoPreviewAtURLMockFunc) call their MockFunc and
then always continue to the fallback implementation, causing double completions;
change each method (loadVideoAsset, loadVideoPreview(with:),
loadVideoPreview(at:)) to short-circuit after calling the MockFunc by returning
immediately when the mock has handled the call—i.e., detect the mock's
handled/returned/completed state (use the MockFunc's existing flag or return
value such as a boolean like "handled" or "didInvokeCompletion") and if true, do
not run the fallback code or call completion again. Ensure you apply this early
return in all three methods (videoAssetMockFunc.call(...),
loadVideoPreviewMockFunc.call(...), loadVideoPreviewAtURLMockFunc.call(...)).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 942b4a9e-2ea2-42ae-a455-e47660acb2b5
📒 Files selected for processing (2)
Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swiftTests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview_Tests.swift
✅ Files skipped from review due to trivial changes (1)
- Tests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview_Tests.swift
| func loadImage( | ||
| url: URL?, | ||
| options: ImageLoadOptions, | ||
| completion: @escaping @MainActor (Result<MediaLoaderImage, Error>) -> Void | ||
| ) { | ||
| guard let url else { | ||
| MainActor.assumeIsolated { | ||
| completion(.failure(NSError(domain: "mock", code: 0))) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
| guard let data = try? Data(contentsOf: url), var image = UIImage(data: data) else { | ||
| MainActor.assumeIsolated { | ||
| completion(.failure(NSError(domain: "mock", code: 0))) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, read the entire ImageLoader_Mock.swift file
cat -n Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swiftRepository: GetStream/stream-chat-swift
Length of output: 4311
🏁 Script executed:
# Check Swift version and concurrency settings in the project
find . -name "*.xcconfig" -o -name "*.pbxproj" -o -name "project.yml" | head -5Repository: GetStream/stream-chat-swift
Length of output: 173
🏁 Script executed:
# Look for swift version config or Package.swift
fd -e swift -e toml -e yml | grep -E "(Package|setup|project|config)" | head -10Repository: GetStream/stream-chat-swift
Length of output: 251
🏁 Script executed:
# Check for SwiftLint and concurrency configuration
cat .swiftlint.yml 2>/dev/null || echo "No .swiftlint.yml found"Repository: GetStream/stream-chat-swift
Length of output: 2150
Guard MainActor.assumeIsolated before using it.
loadImage() (lines 24-50) and loadVideoAsset() (lines 52-60) unconditionally call MainActor.assumeIsolated without checking if the calling thread is the main thread. If either method is reached off the main actor, this will trap instead of delivering a result. loadVideoPreview(at:) already demonstrates the correct pattern at lines 85-93 with a Thread.isMainThread check.
Extract a helper to safely complete on the main actor:
Suggested fix
+ private func completeOnMain<T>(
+ _ completion: `@escaping` `@MainActor` (Result<T, Error>) -> Void,
+ with result: Result<T, Error>
+ ) {
+ if Thread.isMainThread {
+ MainActor.assumeIsolated {
+ completion(result)
+ }
+ } else {
+ DispatchQueue.main.async {
+ completion(result)
+ }
+ }
+ }
+
func loadImage(
url: URL?,
options: ImageLoadOptions,
completion: `@escaping` `@MainActor` (Result<MediaLoaderImage, Error>) -> Void
) {
guard let url else {
- MainActor.assumeIsolated {
- completion(.failure(NSError(domain: "mock", code: 0)))
- }
+ completeOnMain(completion, with: .failure(NSError(domain: "mock", code: 0)))
return
}
guard let data = try? Data(contentsOf: url), var image = UIImage(data: data) else {
- MainActor.assumeIsolated {
- completion(.failure(NSError(domain: "mock", code: 0)))
- }
+ completeOnMain(completion, with: .failure(NSError(domain: "mock", code: 0)))
return
}
if let resize = options.resize {
let size = CGSize(width: resize.width, height: resize.height)
image = imageProcessor.scale(image: image, to: size)
}
- MainActor.assumeIsolated {
- completion(.success(MediaLoaderImage(image: image)))
- }
+ completeOnMain(completion, with: .success(MediaLoaderImage(image: image)))
}
func loadVideoAsset(
at url: URL,
options: VideoLoadOptions,
completion: `@escaping` `@MainActor` (Result<MediaLoaderVideoAsset, Error>) -> Void
) {
videoAssetMockFunc.call(with: (url, options, completion))
- MainActor.assumeIsolated {
- completion(.success(MediaLoaderVideoAsset(asset: AVURLAsset(url: url))))
- }
+ completeOnMain(completion, with: .success(MediaLoaderVideoAsset(asset: AVURLAsset(url: url))))
}Violates Swift 6.0 strict concurrency requirement: use Sendable conformances and avoid actor isolation violations.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift` around lines 24 - 39,
Both loadImage and loadVideoAsset call MainActor.assumeIsolated unconditionally
which can trap off the main actor; extract a small helper (e.g., completeOnMain
or finishOnMain) that checks Thread.isMainThread and if true calls the
completion directly, otherwise dispatches to the main actor (using MainActor.run
or MainActor.assumeIsolated safely) to invoke the completion; replace direct
MainActor.assumeIsolated calls in loadImage and loadVideoAsset with this helper
(follow the pattern already used in loadVideoPreview(at:)) so completions are
delivered without violating Swift 6 concurrency rules.
SDK Size
|
StreamChat XCSize
Show 32 more objects
|
StreamChatUI XCSize
Show 37 more objects
|
StreamChatCommonUI XCSize
Show 6 more objects
|
|



🔗 Issue Links
🎯 Goal
Consolidate and simplify all CDN and media loading logic into unified protocols shared between UIKit and SwiftUI SDKs, with async support for pre-signed URLs.
📝 Summary
CDNRequesterprotocol for read-only URL transformation (signing, headers, resize) with async completion handler supportCDNStorageprotocol replacing bothCDNClientandAttachmentUploaderfor upload/delete operationsCDNRequestvalue type carrying URL, headers, and caching keyCDNImageResizemodel for server-side image resize parametersStreamCDNRequesterdefault implementation with Stream CDN resize/cache logicStreamCDNStoragedefault implementation using Stream's REST API for multipart uploadsImageResizefrom StreamChatUI to StreamChatCommonUIMediaLoaderprotocol in StreamChatCommonUI replacing separateImageLoaderandVideoLoaderStreamMediaLoaderdefault implementation handling both image loading and video preview generationImageDownloadingprotocol so CommonUI never depends on Nuke directlyStreamImageDownloaderas the UIKitImageDownloadingbackendImageLoadOptions,ImageBatchLoadOptions,VideoLoadOptions) for extensible method signaturescdnRequesterandcdnStorageas public mutable properties onChatClientChatMessageController.downloadAttachment()to useChatClient.cdnRequesterinternally for URL transformationImageCDN,StreamImageCDN,ImageLoading,NukeImageLoader,VideoLoading,CDNClient,AttachmentUploaderComponentsto usemediaLoader: MediaLoader(replacing separateimageLoader+videoLoader)ImageLoaderOptionsandImageDownloadOptionsto includecdnRequesterin the options structChatClientConfigwithcdnRequesterandcdnStoragereplacingcustomCDNClientandcustomAttachmentUploader🛠 Implementation
The architecture splits CDN concerns into read and write paths with unified protocols replacing scattered types across both repos:
CDNRequesterCDNStorageMediaLoaderImageDownloadingCDNRequester vs CDNStorage:
CDNRequesterhandles the read path — transforming URLs before GET requests (adding resize query params, signing, injecting headers, computing cache keys). The defaultStreamCDNRequesteradds width/height/resize query params forstream-io-cdn.comURLs and builds stable caching keys.CDNStoragehandles the write path — uploading attachments (multipart, with progress) and deleting them by remote URL. The defaultStreamCDNStorageuses Stream's REST API. Configured viaChatClientConfig.cdnStorage(optional; nil uses the default).cdnRequesterandcdnStorageare public mutable properties onChatClient, initialized fromChatClientConfigbut changeable at runtime.Unified MediaLoader:
MediaLoadermerges the previousImageLoaderandVideoLoaderinto a single protocol. This eliminates a stale-reference problem: previously,StreamVideoLoaderheld a reference toImageLoaderat init time, so replacingcomponents.imageLoaderwouldn't affect video thumbnail loading. Now there's onemediaLoaderproperty inComponents(UIKit) andUtils(SwiftUI).The protocol uses an Options pattern —
ImageLoadOptions,ImageBatchLoadOptions, andVideoLoadOptionsstruct parameters — so theCDNRequesteris passed on every call rather than captured at init. This means changingchatClient.cdnRequesterat runtime takes effect immediately. The Options pattern also makes the protocol extensible: adding future parameters requires only adding a field with a default value, not changing the method signature.StreamMediaLoaderhandles both image loading (delegates toImageDownloadingbackend) and video preview generation (AVFoundation). Video thumbnails are cached in anNSCache.Attachment downloads:
ChatMessageController.downloadAttachment()andChat.downloadAttachment()no longer expose aremoteURLparameter. Instead, they internally useChatClient.cdnRequester.fileRequest()to transform the attachment URL before passing it to the download worker.🧪 Manual Testing Notes
☑️ Contributor Checklist
docs-contentrepoSummary by CodeRabbit
New Features
Improvements