Skip to content

Commit 0d5dbcf

Browse files
authored
Make CDNRequester a constructor dependency of StreamMediaLoader (#4070)
* Make CDNRequester a constructor dependency of StreamMediaLoader Instead of passing CDNRequester through ImageLoadOptions / VideoLoadOptions on every call, it is now provided once via StreamMediaLoader's init. - Add resolveFileURL to MediaLoader protocol for CDN URL resolution - Deprecate cdnRequester on Components (UIKit) and ImageLoadOptions/VideoLoadOptions - Update all call sites to use simplified option constructors - Add deprecated shims for backward compatibility * Enrich MediaLoaderImage and DownloadedImage with animated image data Add isAnimated and animatedImageData fields so that callers like StreamAsyncImage can get GIF metadata through MediaLoader instead of bypassing it with a parallel Nuke pipeline. * Add cachingKey to MediaLoaderImage for sync cache lookups StreamMediaLoader now passes the CDN cachingKey through the result so UI layers can maintain their own synchronous cache lookup tables without needing direct access to CDNRequester. * Rename resolveFileURL to loadFile with options and MediaLoaderFile result Aligns the file loading API with the rest of MediaLoader: - loadFile(at:options:completion:) with FileLoadOptions and MediaLoaderFile - MediaLoaderFile carries the resolved URL and optional headers * Remove deprecated CDNRequester shims This is a breaking change: remove all backward-compatibility shims for the old cdnRequester-in-options pattern. Removed: - StreamMediaLoader.init(downloader:) - ImageLoadOptions.init(resize:cdnRequester:) - VideoLoadOptions.init(cdnRequester:) - Components.cdnRequester - ImageLoaderOptions.init(resize:placeholder:cdnRequester:) - ImageDownloadOptions.init(resize:cdnRequester:) - MediaLoader+UIKit loadImage(into:from:maxResolutionInPixels:cdnRequester:) * Replace loadFile with loadFileRequest returning MediaLoaderFileRequest Replaces the loadFile(at:options:) method with loadFileRequest(for:options:) that returns a MediaLoaderFileRequest wrapping a URLRequest with CDN-resolved URL and headers. The download pipeline (AttachmentDownloader, APIClient, MessageUpdater, Chat, MessageController) now accepts a URLRequest instead of a separate URL + headers, keeping CDN concerns encapsulated in MediaLoader. * Only set animatedImageData for GIF images in StreamImageDownloader * Add convenience MediaLoader extensions that omit options parameter * Use convenience MediaLoader methods without empty options * Fix swiftformat * Update changelog for CDNRequester as StreamMediaLoader dependency * Remove isAnimated property from DownloadedImage and MediaLoaderImage * Reorder StreamMediaLoader init: downloader first, cdnRequester second
1 parent 80efbe8 commit 0d5dbcf

38 files changed

Lines changed: 288 additions & 184 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
33

44
# Upcoming
55

6+
## StreamChatUI
67
### 🔄 Changed
8+
- `CDNRequester` is now passed in the constructor of `StreamMediaLoader` instead of `Components` [#4070](https://github.com/GetStream/stream-chat-swift/pull/4070)
79

810
# [5.0.0](https://github.com/GetStream/stream-chat-swift/releases/tag/5.0.0)
911
_April 16, 2026_

DemoApp/Screens/UserProfile/UserProfileViewController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
158158
.loadImage(
159159
into: imageView,
160160
from: currentUserController.currentUser?.imageURL,
161-
with: ImageLoaderOptions(cdnRequester: Components.default.cdnRequester)
161+
with: ImageLoaderOptions()
162162
)
163163

164164
if let typingIndicatorsEnabled = currentUserController.currentUser?.privacySettings.typingIndicators?.enabled {

Examples/MessengerClone/MessengerChatMessageContentView.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,7 @@ final class MessengerChatMessageContentView: ChatMessageContentView {
125125
from: imageURL,
126126
with: ImageLoaderOptions(
127127
resize: .init(components.avatarThumbnailSize),
128-
placeholder: placeholder,
129-
cdnRequester: components.cdnRequester
128+
placeholder: placeholder
130129
)
131130
)
132131
} else {

Sources/StreamChat/APIClient/APIClient.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,13 +288,13 @@ class APIClient: @unchecked Sendable {
288288
}
289289

290290
func downloadFile(
291-
from remoteURL: URL,
291+
_ request: URLRequest,
292292
to localURL: URL,
293293
progress: (@Sendable (Double) -> Void)?,
294294
completion: @escaping @Sendable (Error?) -> Void
295295
) {
296296
let downloadOperation = AsyncOperation(maxRetries: maximumRequestRetries) { [weak self] operation, done in
297-
self?.attachmentDownloader.download(from: remoteURL, to: localURL, progress: progress) { [weak self] error in
297+
self?.attachmentDownloader.download(request, to: localURL, progress: progress) { [weak self] error in
298298
if let error, self?.isConnectionError(error) == true {
299299
// Do not retry unless its a connection problem and we still have retries left
300300
if operation.canRetry {

Sources/StreamChat/APIClient/AttachmentDownloader/AttachmentDownloader.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ protocol AttachmentDownloader {
99
/// Downloads a file attachment to the specified local URL.
1010
///
1111
/// - Parameters:
12-
/// - remoteURL: A remote URL of the file.
12+
/// - request: The URL request for the remote file.
1313
/// - localURL: The destination URL of the download.
1414
/// - progress: The progress of the download.
1515
/// - completion: The callback with an error if a failure occurred.
1616
func download(
17-
from remoteURL: URL,
17+
_ request: URLRequest,
1818
to localURL: URL,
1919
progress: (@Sendable (Double) -> Void)?,
2020
completion: @escaping @Sendable (Error?) -> Void
@@ -30,12 +30,11 @@ final class StreamAttachmentDownloader: AttachmentDownloader, @unchecked Sendabl
3030
}
3131

3232
func download(
33-
from remoteURL: URL,
33+
_ request: URLRequest,
3434
to localURL: URL,
3535
progress: (@Sendable (Double) -> Void)?,
3636
completion: @escaping @Sendable (Error?) -> Void
3737
) {
38-
let request = URLRequest(url: remoteURL)
3938
let task = session.downloadTask(with: request) { temporaryURL, _, downloadError in
4039
if let downloadError {
4140
completion(downloadError)

Sources/StreamChat/Controllers/MessageController/MessageController.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -789,17 +789,17 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP
789789
///
790790
/// - Parameters:
791791
/// - attachment: The attachment to download.
792-
/// - remoteURL: An optional pre-resolved URL to download from (e.g. after CDN signing).
793-
/// If `nil`, the attachment's original `remoteURL` is used.
792+
/// - request: An optional pre-resolved `URLRequest` (e.g. from ``MediaLoader/loadFileRequest(for:options:completion:)``).
793+
/// If `nil`, a default request using the attachment's `remoteURL` is used.
794794
/// - completion: A completion block with the attachment containing the downloading state.
795795
///
796796
/// - Note: The local storage URL (`attachment.downloadingState?.localFileURL`) can change between app launches.
797797
public func downloadAttachment<Payload>(
798798
_ attachment: ChatMessageAttachment<Payload>,
799-
remoteURL: URL? = nil,
799+
request: URLRequest? = nil,
800800
completion: @escaping @MainActor (Result<ChatMessageAttachment<Payload>, Error>) -> Void
801801
) where Payload: DownloadableAttachmentPayload {
802-
messageUpdater.downloadAttachment(attachment, remoteURL: remoteURL) { [weak self] result in
802+
messageUpdater.downloadAttachment(attachment, request: request) { [weak self] result in
803803
self?.callback {
804804
completion(result)
805805
}

Sources/StreamChat/StateLayer/Chat.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -430,18 +430,18 @@ public class Chat: @unchecked Sendable {
430430
///
431431
/// - Parameters:
432432
/// - attachment: The attachment to download.
433-
/// - remoteURL: An optional pre-resolved URL to download from (e.g. after CDN signing).
434-
/// If `nil`, the attachment's original `remoteURL` is used.
433+
/// - request: An optional pre-resolved `URLRequest` (e.g. from ``MediaLoader/loadFileRequest(for:options:completion:)``).
434+
/// If `nil`, a default request using the attachment's `remoteURL` is used.
435435
///
436436
/// - Note: The local storage URL can change between app launches.
437437
///
438438
/// - Throws: An error while downloading the attachment.
439439
/// - Returns: An instance of the downloaded attachment which includes the local URL.
440440
@discardableResult public func downloadAttachment<Payload>(
441441
_ attachment: ChatMessageAttachment<Payload>,
442-
remoteURL: URL? = nil
442+
request: URLRequest? = nil
443443
) async throws -> ChatMessageAttachment<Payload> where Payload: DownloadableAttachmentPayload {
444-
try await messageUpdater.downloadAttachment(attachment, remoteURL: remoteURL)
444+
try await messageUpdater.downloadAttachment(attachment, request: request)
445445
}
446446

447447
/// Deletes the locally downloaded file.

Sources/StreamChat/Workers/MessageUpdater.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -761,13 +761,14 @@ class MessageUpdater: Worker, @unchecked Sendable {
761761

762762
func downloadAttachment<Payload>(
763763
_ attachment: ChatMessageAttachment<Payload>,
764-
remoteURL: URL? = nil,
764+
request: URLRequest? = nil,
765765
completion: @escaping @Sendable (Result<ChatMessageAttachment<Payload>, Error>) -> Void
766766
) where Payload: DownloadableAttachmentPayload {
767767
let attachmentId = attachment.id
768768
let localURL = URL.streamAttachmentLocalStorageURL(forRelativePath: attachment.relativeStoragePath)
769+
let downloadRequest = request ?? URLRequest(url: attachment.remoteURL)
769770
apiClient.downloadFile(
770-
from: remoteURL ?? attachment.remoteURL,
771+
downloadRequest,
771772
to: localURL,
772773
progress: { [weak self] progress in
773774
self?.updateDownloadProgress(
@@ -1270,10 +1271,10 @@ extension MessageUpdater {
12701271

12711272
func downloadAttachment<Payload>(
12721273
_ attachment: ChatMessageAttachment<Payload>,
1273-
remoteURL: URL? = nil
1274+
request: URLRequest? = nil
12741275
) async throws -> ChatMessageAttachment<Payload> where Payload: DownloadableAttachmentPayload {
12751276
try await withCheckedThrowingContinuation { continuation in
1276-
downloadAttachment(attachment, remoteURL: remoteURL) { result in
1277+
downloadAttachment(attachment, request: request) { result in
12771278
continuation.resume(with: result)
12781279
}
12791280
}

Sources/StreamChatCommonUI/ImageLoading/ImageDownloading.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,11 @@ public struct ImageDownloadingOptions: Sendable {
5151
public struct DownloadedImage: Sendable {
5252
/// The downloaded image.
5353
public var image: UIImage
54+
/// The raw image data for animated rendering. `nil` for static images.
55+
public var animatedImageData: Data?
5456

55-
public init(image: UIImage) {
57+
public init(image: UIImage, animatedImageData: Data? = nil) {
5658
self.image = image
59+
self.animatedImageData = animatedImageData
5760
}
5861
}

Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift

Lines changed: 105 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,20 @@ import AVKit
66
import StreamChat
77
import UIKit
88

9-
/// A unified protocol for loading images and video previews.
9+
/// A unified protocol for loading images, video previews, and resolving file URLs.
1010
///
11-
/// Configuration is passed via options structs on every call, so concrete
12-
/// implementations remain stateless with respect to CDN configuration.
13-
/// Changing the requester on `ChatClientConfig` takes effect immediately without
14-
/// recreating the loader.
11+
/// The ``CDNRequester`` is provided as a constructor dependency of the concrete
12+
/// implementation (e.g. ``StreamMediaLoader``), so callers don't need to pass
13+
/// it on every call. Configuring the CDN requester in one place ensures all
14+
/// content loading automatically picks it up.
1515
public protocol MediaLoader: AnyObject, Sendable {
1616
// MARK: - Image Loading
1717

1818
/// Loads a single image from the given URL.
1919
///
2020
/// - Parameters:
2121
/// - url: The image URL. If nil, the completion is called with a failure.
22-
/// - options: Options controlling resize and CDN behavior.
22+
/// - options: Options controlling resize behavior.
2323
/// - completion: A completion handler called on the main actor with the loaded image.
2424
func loadImage(
2525
url: URL?,
@@ -31,8 +31,8 @@ public protocol MediaLoader: AnyObject, Sendable {
3131

3232
/// Returns a video asset for the given URL.
3333
///
34-
/// Implementers should use the CDN requester in options to adjust the URL
35-
/// before creating the asset.
34+
/// The implementation resolves the URL through its CDN requester before
35+
/// creating the asset.
3636
func loadVideoAsset(
3737
at url: URL,
3838
options: VideoLoadOptions,
@@ -47,7 +47,7 @@ public protocol MediaLoader: AnyObject, Sendable {
4747
///
4848
/// - Parameters:
4949
/// - attachment: A video attachment containing the video URL and optional thumbnail URL.
50-
/// - options: Options controlling CDN behavior.
50+
/// - options: Options controlling video load behavior.
5151
/// - completion: A completion handler called on the main actor with the preview image.
5252
func loadVideoPreview(
5353
with attachment: ChatMessageVideoAttachment,
@@ -67,22 +67,78 @@ public protocol MediaLoader: AnyObject, Sendable {
6767
///
6868
/// - Parameters:
6969
/// - url: The video URL (typically a local `file://` URL).
70-
/// - options: Options controlling CDN behavior.
70+
/// - options: Options controlling video load behavior.
7171
/// - completion: A completion handler called on the main actor with the preview image.
7272
func loadVideoPreview(
7373
at url: URL,
7474
options: VideoLoadOptions,
7575
completion: @escaping @MainActor (Result<MediaLoaderVideoPreview, Error>) -> Void
7676
)
77+
78+
// MARK: - File Request
79+
80+
/// Creates a request for downloading or previewing a file.
81+
///
82+
/// Resolves the URL through the CDN (signing, rewriting) and packages the
83+
/// result into a ready-to-use request with any required HTTP headers.
84+
/// Pass the returned request to `downloadAttachment` or load it in a web view.
85+
///
86+
/// - Parameters:
87+
/// - url: The original file URL to resolve.
88+
/// - options: Options controlling file request behavior.
89+
/// - completion: A completion handler called on the main actor with the resolved request.
90+
func loadFileRequest(
91+
for url: URL,
92+
options: DownloadFileRequestOptions,
93+
completion: @escaping @MainActor (Result<MediaLoaderFileRequest, Error>) -> Void
94+
)
95+
}
96+
97+
// MARK: - Convenience Extensions
98+
99+
extension MediaLoader {
100+
public func loadImage(
101+
url: URL?,
102+
completion: @escaping @MainActor (Result<MediaLoaderImage, Error>) -> Void
103+
) {
104+
loadImage(url: url, options: ImageLoadOptions(), completion: completion)
105+
}
106+
107+
public func loadVideoAsset(
108+
at url: URL,
109+
completion: @escaping @MainActor (Result<MediaLoaderVideoAsset, Error>) -> Void
110+
) {
111+
loadVideoAsset(at: url, options: VideoLoadOptions(), completion: completion)
112+
}
113+
114+
public func loadVideoPreview(
115+
with attachment: ChatMessageVideoAttachment,
116+
completion: @escaping @MainActor (Result<MediaLoaderVideoPreview, Error>) -> Void
117+
) {
118+
loadVideoPreview(with: attachment, options: VideoLoadOptions(), completion: completion)
119+
}
120+
121+
public func loadVideoPreview(
122+
at url: URL,
123+
completion: @escaping @MainActor (Result<MediaLoaderVideoPreview, Error>) -> Void
124+
) {
125+
loadVideoPreview(at: url, options: VideoLoadOptions(), completion: completion)
126+
}
127+
128+
public func loadFileRequest(
129+
for url: URL,
130+
completion: @escaping @MainActor (Result<MediaLoaderFileRequest, Error>) -> Void
131+
) {
132+
loadFileRequest(for: url, options: DownloadFileRequestOptions(), completion: completion)
133+
}
77134
}
78135

79136
// MARK: - Async/Await Extensions
80137

81138
extension MediaLoader {
82-
/// Loads a single image from the given URL.
83139
public func loadImage(
84140
url: URL?,
85-
options: ImageLoadOptions
141+
options: ImageLoadOptions = ImageLoadOptions()
86142
) async throws -> MediaLoaderImage {
87143
try await withCheckedThrowingContinuation { continuation in
88144
loadImage(url: url, options: options) { result in
@@ -91,10 +147,9 @@ extension MediaLoader {
91147
}
92148
}
93149

94-
/// Returns a video asset for the given URL.
95150
public func loadVideoAsset(
96151
at url: URL,
97-
options: VideoLoadOptions
152+
options: VideoLoadOptions = VideoLoadOptions()
98153
) async throws -> MediaLoaderVideoAsset {
99154
try await withCheckedThrowingContinuation { continuation in
100155
loadVideoAsset(at: url, options: options) { result in
@@ -103,17 +158,27 @@ extension MediaLoader {
103158
}
104159
}
105160

106-
/// Generates a video preview thumbnail from a URL.
107161
public func loadVideoPreview(
108162
at url: URL,
109-
options: VideoLoadOptions
163+
options: VideoLoadOptions = VideoLoadOptions()
110164
) async throws -> MediaLoaderVideoPreview {
111165
try await withCheckedThrowingContinuation { continuation in
112166
loadVideoPreview(at: url, options: options) { result in
113167
continuation.resume(with: result)
114168
}
115169
}
116170
}
171+
172+
public func loadFileRequest(
173+
for url: URL,
174+
options: DownloadFileRequestOptions = DownloadFileRequestOptions()
175+
) async throws -> MediaLoaderFileRequest {
176+
try await withCheckedThrowingContinuation { continuation in
177+
loadFileRequest(for: url, options: options) { result in
178+
continuation.resume(with: result)
179+
}
180+
}
181+
}
117182
}
118183

119184
// MARK: - Options
@@ -122,23 +187,20 @@ extension MediaLoader {
122187
public struct ImageLoadOptions: Sendable {
123188
/// Optional resize parameters for server-side resizing.
124189
public var resize: ImageResize?
125-
/// The CDN requester for URL transformation (signing, headers, resizing).
126-
public var cdnRequester: CDNRequester
127190

128-
public init(resize: ImageResize? = nil, cdnRequester: CDNRequester) {
191+
public init(resize: ImageResize? = nil) {
129192
self.resize = resize
130-
self.cdnRequester = cdnRequester
131193
}
132194
}
133195

134196
/// Options for loading video content through a ``MediaLoader``.
135197
public struct VideoLoadOptions: Sendable {
136-
/// The CDN requester for URL transformation (signing, headers).
137-
public var cdnRequester: CDNRequester
198+
public init() {}
199+
}
138200

139-
public init(cdnRequester: CDNRequester) {
140-
self.cdnRequester = cdnRequester
141-
}
201+
/// Options for creating a file download request through a ``MediaLoader``.
202+
public struct DownloadFileRequestOptions: Sendable {
203+
public init() {}
142204
}
143205

144206
// MARK: - Result Types
@@ -147,9 +209,16 @@ public struct VideoLoadOptions: Sendable {
147209
public struct MediaLoaderImage: Sendable {
148210
/// The loaded image.
149211
public var image: UIImage
212+
/// The raw image data for animated rendering. `nil` for static images.
213+
public var animatedImageData: Data?
214+
/// The caching key used by the CDN requester, if any.
215+
/// UI layers can use this to maintain a synchronous cache lookup table.
216+
public var cachingKey: String?
150217

151-
public init(image: UIImage) {
218+
public init(image: UIImage, animatedImageData: Data? = nil, cachingKey: String? = nil) {
152219
self.image = image
220+
self.animatedImageData = animatedImageData
221+
self.cachingKey = cachingKey
153222
}
154223
}
155224

@@ -172,3 +241,13 @@ public struct MediaLoaderVideoPreview: Sendable {
172241
self.image = image
173242
}
174243
}
244+
245+
/// The result of resolving a file download request through a ``MediaLoader``.
246+
public struct MediaLoaderFileRequest: Sendable {
247+
/// A ready-to-use URL request with CDN-resolved URL and any required HTTP headers.
248+
public var urlRequest: URLRequest
249+
250+
public init(urlRequest: URLRequest) {
251+
self.urlRequest = urlRequest
252+
}
253+
}

0 commit comments

Comments
 (0)