Skip to content

Unified CDN & Media Loading Architecture (v5)#4056

Merged
nuno-vieira merged 66 commits intodevelopfrom
add/improve-custom-cdn
Apr 16, 2026
Merged

Unified CDN & Media Loading Architecture (v5)#4056
nuno-vieira merged 66 commits intodevelopfrom
add/improve-custom-cdn

Conversation

@nuno-vieira
Copy link
Copy Markdown
Member

@nuno-vieira nuno-vieira commented Apr 8, 2026

🔗 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

  • Add CDNRequester protocol for read-only URL transformation (signing, headers, resize) with async completion handler support
  • Add CDNStorage protocol replacing both CDNClient and AttachmentUploader for upload/delete operations
  • Add CDNRequest value type carrying URL, headers, and caching key
  • Add CDNImageResize model for server-side image resize parameters
  • Add StreamCDNRequester default implementation with Stream CDN resize/cache logic
  • Add StreamCDNStorage default implementation using Stream's REST API for multipart uploads
  • Move ImageResize from StreamChatUI to StreamChatCommonUI
  • Add unified MediaLoader protocol in StreamChatCommonUI replacing separate ImageLoader and VideoLoader
  • Add StreamMediaLoader default implementation handling both image loading and video preview generation
  • Add ImageDownloading protocol so CommonUI never depends on Nuke directly
  • Add StreamImageDownloader as the UIKit ImageDownloading backend
  • Add Options pattern types (ImageLoadOptions, ImageBatchLoadOptions, VideoLoadOptions) for extensible method signatures
  • Add cdnRequester and cdnStorage as public mutable properties on ChatClient
  • Update ChatMessageController.downloadAttachment() to use ChatClient.cdnRequester internally for URL transformation
  • Remove old ImageCDN, StreamImageCDN, ImageLoading, NukeImageLoader, VideoLoading, CDNClient, AttachmentUploader
  • Update Components to use mediaLoader: MediaLoader (replacing separate imageLoader + videoLoader)
  • Update ImageLoaderOptions and ImageDownloadOptions to include cdnRequester in the options struct
  • Update ChatClientConfig with cdnRequester and cdnStorage replacing customCDNClient and customAttachmentUploader

🛠 Implementation

The architecture splits CDN concerns into read and write paths with unified protocols replacing scattered types across both repos:

Protocol Module Responsibility
CDNRequester StreamChat Read-side URL transformation (signing, headers, resize) before loading images/files
CDNStorage StreamChat Write-side attachment upload and delete operations
MediaLoader StreamChatCommonUI Unified image downloading, caching, batch loading, video preview generation
ImageDownloading StreamChatCommonUI Pluggable HTTP download + cache backend (e.g. Nuke)

CDNRequester vs CDNStorage:

  • CDNRequester handles the read path — transforming URLs before GET requests (adding resize query params, signing, injecting headers, computing cache keys). The default StreamCDNRequester adds width/height/resize query params for stream-io-cdn.com URLs and builds stable caching keys.
  • CDNStorage handles the write path — uploading attachments (multipart, with progress) and deleting them by remote URL. The default StreamCDNStorage uses Stream's REST API. Configured via ChatClientConfig.cdnStorage (optional; nil uses the default).
  • Both cdnRequester and cdnStorage are public mutable properties on ChatClient, initialized from ChatClientConfig but changeable at runtime.

Unified MediaLoader:

MediaLoader merges the previous ImageLoader and VideoLoader into a single protocol. This eliminates a stale-reference problem: previously, StreamVideoLoader held a reference to ImageLoader at init time, so replacing components.imageLoader wouldn't affect video thumbnail loading. Now there's one mediaLoader property in Components (UIKit) and Utils (SwiftUI).

The protocol uses an Options patternImageLoadOptions, ImageBatchLoadOptions, and VideoLoadOptions struct parameters — so the CDNRequester is passed on every call rather than captured at init. This means changing chatClient.cdnRequester at 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.

StreamMediaLoader handles both image loading (delegates to ImageDownloading backend) and video preview generation (AVFoundation). Video thumbnails are cached in an NSCache.

Attachment downloads:

ChatMessageController.downloadAttachment() and Chat.downloadAttachment() no longer expose a remoteURL parameter. Instead, they internally use ChatClient.cdnRequester.fileRequest() to transform the attachment URL before passing it to the download worker.

🧪 Manual Testing Notes

  1. Open the Demo App
  2. Verify images load correctly in channel list (avatars) and message list (image/video attachments)
  3. Verify video previews load correctly
  4. Test file attachment downloads
  5. Test file attachment previews

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change should be manually QAed
  • Changelog is updated with client-facing changes
  • Changelog is updated with new localization keys
  • New code is covered by unit tests
  • Documentation has been updated in the docs-content repo

Summary by CodeRabbit

  • New Features

    • CDN image resize parameters and signed-URL support for downloads.
    • Unified MediaLoader handling images, video previews and assets (async APIs).
  • Improvements

    • CDN-backed storage for attachment uploads/deletes with progress and better error handling.
    • More reliable thumbnail/video preview generation, caching, and deallocation safety.
    • Simplified avatar rendering and consistent composer attachment size fallback.
    • downloadAttachment now accepts an optional remoteURL for CDN workflows.

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
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 8, 2026

📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
API client & CDN storage
Sources/StreamChat/APIClient/APIClient.swift, Sources/StreamChat/APIClient/CDNClient/CDNStorage.swift, Sources/StreamChat/APIClient/CDNClient/StreamCDNStorage.swift
Replaced attachmentUploader/cdnClient with cdnStorage: CDNStorage in APIClient; added CDNStorage protocol + async wrappers and StreamCDNStorage implementation. Upload/delete APIs now use AttachmentUploadOptions/AttachmentDeleteOptions and return UploadedFile.
CDN requester & image resize
Sources/StreamChat/APIClient/CDNClient/CDNRequester.swift, Sources/StreamChat/APIClient/CDNClient/StreamCDNRequester.swift, Sources/StreamChat/APIClient/CDNClient/CDNImageResize.swift
Added CDNRequester protocol, request/option types and CDNImageResize; implemented StreamCDNRequester with resize URL construction and caching-key logic.
Media loader & image downloader
Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift, Sources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swift, Sources/StreamChatCommonUI/ImageLoading/ImageDownloading.swift, Sources/StreamChatUI/Utils/ImageLoading/StreamImageDownloader.swift
Introduced MediaLoader and StreamMediaLoader (image/video asset/preview, caching, memory-warning handling). Added ImageDownloading abstraction and StreamImageDownloader that bridges to image pipeline.
UIKit media integration
Sources/StreamChatUI/Utils/ImageLoading/MediaLoader+UIKit.swift
Added UIKit adapters to map MediaLoader results to UIImageView APIs, batch downloads, cancellation task type, and compatibility helpers.
Components & defaults
Sources/StreamChatUI/Components.swift
Replaced imageCDN/imageLoader/videoLoader with cdnRequester: CDNRequester and mediaLoader: MediaLoader; default image processor switched to StreamImageProcessor; added maxAttachmentSize.
UI view updates
multiple Sources/StreamChatUI/... (avatars, galleries, previews, composer, message content)
Updated many UI call sites to use components.mediaLoader + components.cdnRequester, switched several avatar views to ChatUserAvatarView and content-driven avatar updates, and migrated video/image preview flows to media loader APIs.
Removed legacy implementations
Sources/StreamChat/APIClient/AttachmentUploader/..., Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift, .../NukeImageLoader.swift, .../VideoLoading.swift, .../ImageCDN/*
Deleted legacy AttachmentUploader, ImageLoading, VideoLoading, ImageCDN and Nuke-based loader files, plus associated tests/mocks.
Chat client / config / factory wiring
Sources/StreamChat/ChatClient+Environment.swift, Sources/StreamChat/ChatClientFactory.swift, Sources/StreamChat/Config/ChatClientConfig.swift, Sources/StreamChat/ChatClient.swift
Wired factory/environment to create/pass cdnStorage; removed customCDNClient/customAttachmentUploader hooks; ChatClient upload/delete now delegate to cdnStorage.
Download API extension
Sources/StreamChat/Controllers/MessageController/MessageController.swift, Sources/StreamChat/StateLayer/Chat.swift, Sources/StreamChat/Workers/MessageUpdater.swift
Extended downloadAttachment APIs to accept remoteURL: URL? = nil and forward it through to the message updater.
Tests & test helpers
TestTools/, Tests/ (many files)
Updated spies/mocks to CDNStorage/MediaLoader shapes; added tests for StreamMediaLoader, StreamCDNRequester, CDNStorage and StreamCDNStorage edge cases; removed tests for deleted APIs; updated snapshot tests to use mocks.
Project, demos & changelog
StreamChat.xcodeproj, DemoApp/..., Examples/..., CHANGELOG.md
Updated project memberships, demo/example usages to mediaLoader/cdnRequester, and added changelog note for downloadAttachment(remoteURL:).

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)
Loading
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)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇
I hopped through bytes and CDN streams,
One storage now, no messy seams.
Previews bloom and videos play,
Signed URLs guide the download way.
A happy rabbit digs the new media dreams!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch add/improve-custom-cdn

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 8, 2026

1 Warning
⚠️ Big PR

Generated by 🚫 Danger

Comment thread Sources/StreamChat/APIClient/CDNClient/CDNClient.swift Outdated
Comment thread Sources/StreamChatCommonUI/ImageLoading/StreamVideoLoader.swift Outdated
Comment thread Sources/StreamChatUI/Utils/ImageLoading/NukeImageDownloader.swift Outdated
Copy link
Copy Markdown
Contributor

@martinmitrevski martinmitrevski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! We should make sure we test this extensively, since it's an important part of the SDK.

Comment thread Sources/StreamChat/APIClient/CDNClient/CDNRequest.swift Outdated
Comment thread Sources/StreamChatUI/Utils/ImageLoading/ImageLoader+UIKit.swift Outdated
Comment thread Sources/StreamChatUI/Utils/ImageLoading/NukeImageDownloader.swift Outdated
Comment thread Sources/StreamChatUI/Utils/ImageLoading/NukeImageDownloader.swift Outdated
@github-actions
Copy link
Copy Markdown

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  

Comment thread Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift Outdated
Comment thread Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift Outdated
Comment thread Sources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swift
Comment thread Sources/StreamChatUI/Components.swift
Comment thread Sources/StreamChatUI/Components.swift
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.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Log message in catch block is misleading.

Line 1037 logs "Downloaded attachment" inside the catch block, but this path is taken when the download fails. The log level is debug which 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 | 🟡 Minor

Compare the mapped remote URL to the fixture, not to itself.

Line 368 is a tautology, so this test still passes if APIClient maps the uploaded file to the wrong UploadedAttachment. Assert against mockedURL to 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 | 🟠 Major

Don't memoize UITraitCollection.current.displayScale in a static.

UITraitCollection.current is thread-local. If the first access happens on a background thread, this can permanently lock resize requests to 1.0x for 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 | 🟠 Major

Use an exact-or-subdomain host match here.

contains(cdnHost) also accepts hosts like stream-io-cdn.com.evil.example, so resize rewriting and cache-key normalization can run for non-Stream domains. Match host == 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 | 🟠 Major

Don't drop the caller's completion when the controller deallocates.

If self is gone before this closure runs, self?.callback becomes 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 | 🟠 Major

Forward CDN headers into preview generation too.

loadVideoAsset already preserves cdnRequest.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 | 🟠 Major

Invoke the stub completions instead of leaving callers hanging.

CustomCDNStorage is final, 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 | 🟡 Minor

Tap target registered on every call, not just on creation.

Line 817 adds the tap target outside the if authorAvatarView == nil block, so handleTapOnAvatarView gets registered multiple times if createAvatarView() 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 | 🟠 Major

Apply 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: Unused resultsByURL property in mock.

The resultsByURL dictionary 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: Unused AsyncMockCDNRequester class.

AsyncMockCDNRequester is 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 when uploadAttachmentResult is unset.

If uploadAttachmentResult is nil, 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

📥 Commits

Reviewing files that changed from the base of the PR and between 113ad3b and c5a0aea.

⛔ Files ignored due to path filters (8)
  • Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_customAppearance.default-dark.png is excluded by !**/*.png
  • Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_customAppearance.default-light.png is excluded by !**/*.png
  • Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance.default-light.png is excluded by !**/*.png
  • Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance.extraExtraExtraLarge-light.png is excluded by !**/*.png
  • Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance.rightToLeftLayout-default.png is excluded by !**/*.png
  • Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance.small-dark.png is excluded by !**/*.png
  • Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance_shouldNotRenderUnavailableReactions.default-light.png is excluded by !**/*.png
  • Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/__Snapshots__/ChatMessageReactionAuthorsVC_Tests/test_defaultAppearance_whenOnlyOneReaction_shouldUseSingularLocalization.default-light.png is excluded by !**/*.png
📒 Files selected for processing (52)
  • CHANGELOG.md
  • DemoApp/Screens/UserProfile/UserProfileViewController.swift
  • Examples/MessengerClone/MessengerChatMessageContentView.swift
  • Sources/StreamChat/APIClient/CDNClient/CDNRequester.swift
  • Sources/StreamChat/APIClient/CDNClient/CDNStorage.swift
  • Sources/StreamChat/APIClient/CDNClient/StreamCDNRequester.swift
  • Sources/StreamChat/APIClient/CDNClient/StreamCDNStorage.swift
  • Sources/StreamChat/Config/ChatClientConfig.swift
  • Sources/StreamChat/Controllers/MessageController/MessageController.swift
  • Sources/StreamChat/StateLayer/Chat.swift
  • Sources/StreamChat/Workers/Background/ActiveLiveLocationsEndTimeTracker.swift
  • Sources/StreamChat/Workers/Background/AttachmentQueueUploader.swift
  • Sources/StreamChatCommonUI/ImageLoading/ImageDownloading.swift
  • Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift
  • Sources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swift
  • Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/ChatMessageImageGallery+ImagePreview.swift
  • Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/VideoAttachmentGalleryPreview.swift
  • Sources/StreamChatUI/ChatMessageList/Attachments/Link/ChatMessageLinkPreviewView.swift
  • Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift
  • Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift
  • Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorViewCell.swift
  • Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/ImageAttachmentComposerPreview.swift
  • Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview.swift
  • Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift
  • Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift
  • Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift
  • Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift
  • Sources/StreamChatUI/Components.swift
  • Sources/StreamChatUI/Composer/ComposerLinkPreviewView.swift
  • Sources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swift
  • Sources/StreamChatUI/Gallery/Cells/VideoAttachmentGalleryCell.swift
  • Sources/StreamChatUI/Utils/ImageLoading/ImageDownloadOptions.swift
  • Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift
  • Sources/StreamChatUI/Utils/ImageLoading/MediaLoader+UIKit.swift
  • Sources/StreamChatUI/Utils/ImageLoading/StreamImageDownloader.swift
  • StreamChat.xcodeproj/project.pbxproj
  • TestTools/StreamChatTestTools/SpyPattern/Spy/CDNStorage_Spy.swift
  • TestTools/StreamChatTestTools/TestData/CustomCDNStorage.swift
  • Tests/StreamChatCommonUITests/ImageLoading/StreamMediaLoader_Image_Tests.swift
  • Tests/StreamChatCommonUITests/ImageLoading/StreamMediaLoader_Video_Tests.swift
  • Tests/StreamChatTests/APIClient/APIClient_Tests.swift
  • Tests/StreamChatTests/APIClient/CDNClient/CDNStorage_Tests.swift
  • Tests/StreamChatTests/APIClient/CDNClient/StreamCDNRequester_Tests.swift
  • Tests/StreamChatTests/APIClient/StreamCDNStorage_Tests.swift
  • Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift
  • Tests/StreamChatTests/StateLayer/Chat_Tests.swift
  • Tests/StreamChatUITests/Mocks/Components_Mock.swift
  • Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift
  • Tests/StreamChatUITests/Mocks/Utils/VideoLoader_Mock.swift
  • Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/VideoAttachmentGalleryPreview_Tests.swift
  • Tests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview_Tests.swift
  • Tests/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

Comment thread Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift
Comment thread Sources/StreamChatUI/Utils/ImageLoading/MediaLoader+UIKit.swift
Comment thread Tests/StreamChatTests/APIClient/StreamCDNStorage_Tests.swift
Comment thread Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift
Comment thread TestTools/StreamChatTestTools/SpyPattern/Spy/CDNStorage_Spy.swift
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (3)
Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift (2)

24-76: ⚠️ Potential issue | 🟠 Major

Return 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 | 🟠 Major

Mark 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; use Sendable conformances 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 | 🟠 Major

Forward CDN headers when generating video previews.

fileRequest can return auth headers, but this path still builds the preview asset from the URL alone. Protected videos will fail here even though loadVideoAsset already 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9a09605 and b6735e7.

📒 Files selected for processing (4)
  • Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift
  • Sources/StreamChatCommonUI/ImageLoading/StreamMediaLoader.swift
  • Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview.swift
  • Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift (1)

52-94: ⚠️ Potential issue | 🟠 Major

Let the mock hooks short-circuit the default path.

loadVideoAsset, loadVideoPreview(with:), and loadVideoPreview(at:) all invoke their MockFunc first 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

📥 Commits

Reviewing files that changed from the base of the PR and between b6735e7 and 2bd362a.

📒 Files selected for processing (2)
  • Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift
  • Tests/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

Comment on lines +24 to +39
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)))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, read the entire ImageLoader_Mock.swift file
cat -n Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift

Repository: 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 -5

Repository: 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 -10

Repository: 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.

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

SDK Size

title develop branch diff status
StreamChat 6.74 MB 6.74 MB +1 KB 🟢
StreamChatUI 4.29 MB 4.25 MB -38 KB 🚀
StreamChatCommonUI 0.75 MB 0.78 MB +27 KB 🟢

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

StreamChat XCSize

Object Diff (bytes)
CDNClient.o -65394
StreamCDNStorage.o +62039
MessageTranslationsPayload.o -10591
AttachmentTypes.o +10543
CDNRequester.o +6570
Show 32 more objects
Object Diff (bytes)
StreamCDNRequester.o +5630
CDNStorage.o +5488
AttachmentUploader.o -3107
RequestEncoder.o -1540
APIClient.o +1330
CDNImageResize.o +1112
ChatClient.o -1062
ChatClientConfig.o -538
AttachmentQueueUploader.o +206
MessageUpdater.o +190
ChannelMemberUpdater.o -156
StreamCore.o -132
UserPayloads.o -124
MessageController.o +108
ChannelListUpdater.o -100
ChannelListPayload.o -84
UploadedAttachment.o +84
ReadStateHandler.o -80
Token.o -72
MessageDeliveryCriteriaValidator.o +72
DevicePayloads.o -64
AudioSessionConfiguring.o +64
ChannelUpdater.o -64
URLRequest+cURL.o +60
Foundation.tbd +60
MessagePayloads.o -60
UnknownChannelEvent.o +52
ChatClient+Environment.o -52
AppSettingsPayload.o -52
TextLinkDetector.o -52
ChatState.o +48
PollVoteListQuery.o -44

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

StreamChatUI XCSize

Object Diff (bytes)
NukeImageLoader.o -11152
ImageViewExtensions.o -8092
MediaLoader+UIKit.o +8082
VideoLoading.o -5200
StreamCDN.o -5200
Show 37 more objects
Object Diff (bytes)
ImageResize.o -3918
StreamImageDownloader.o +3096
ImagePipeline.o +2772
TaskLoadImage.o -2662
ChatMessageContentView.o -1888
StreamImageProcessor.o +1842
QuotedChatMessageView.o -1841
NukeImageProcessor.o -1838
ImageLoading.o -1773
VideoAttachmentGalleryPreview.o -1348
ChatMessageListVC.o +1165
VideoAttachmentGalleryCell.o -836
VideoAttachmentComposerPreview.o +592
ChatMessageReactionAuthorViewCell.o -528
ChatChannelHeaderView.o -523
CurrentChatUserAvatarView.o -440
ChatChannelListItemView.o +316
FileAttachmentViewInjector.o -264
UIImageView+SwiftyGif.o +248
Foundation.tbd -244
Components.o -236
ImageLoadingOptions.o -232
AsyncTask.o -210
ImageDownloadOptions.o +165
ChatChannelVC.o -154
StreamChatCommonUI.tbd +116
ImageCDN.o -104
ChatMessageImageGallery+ImagePreview.o +96
ChatMessageLinkPreviewView.o -88
ChatChannelAvatarView.o +88
StreamChat.tbd +84
ComposerVC.o -82
InputTextView.o +72
ComposerLinkPreviewView.o +68
TaskFetchOriginalData.o +60
ImageAttachmentGalleryCell.o +56
ImageDownloadRequest.o +56

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

StreamChatCommonUI XCSize

Object Diff (bytes)
StreamMediaLoader.o +13119
MediaLoader.o +8309
ImageResize.o +4856
ImageDownloading.o +1392
Appearance+Images.o +740
Show 6 more objects
Object Diff (bytes)
MarkdownFormatter.o +722
ImageRequestOptions+ImageResize.o +228
AudioPlaybackRateFormatter.o +228
ChatChannelNamer.o -151
StreamChat.tbd +148
Foundation.tbd +68

@sonarqubecloud
Copy link
Copy Markdown

@nuno-vieira nuno-vieira merged commit ca835ce into develop Apr 16, 2026
13 of 14 checks passed
@nuno-vieira nuno-vieira deleted the add/improve-custom-cdn branch April 16, 2026 10:45
@Stream-SDK-Bot Stream-SDK-Bot mentioned this pull request Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants