Skip to content

Commit f1ec6cb

Browse files
committed
Introduce Options pattern for ImageLoader and VideoLoader protocols
Group additional parameters into options structs (ImageLoadOptions, ImageBatchLoadOptions, VideoLoadOptions) so future parameters can be added without breaking the protocol signature. UIKit's ImageLoaderOptions and ImageDownloadOptions now include cdnRequester.
1 parent c60a3e9 commit f1ec6cb

23 files changed

Lines changed: 163 additions & 138 deletions

Sources/StreamChatCommonUI/ImageLoading/ImageLoader.swift

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,71 @@
55
import StreamChat
66
import UIKit
77

8+
/// Options for loading a single image through an ``ImageLoader``.
9+
public struct ImageLoadOptions: Sendable {
10+
/// Optional resize parameters for server-side resizing.
11+
public var resize: ImageResize?
12+
/// The CDN requester for URL transformation (signing, headers, resizing).
13+
public var cdnRequester: CDNRequester
14+
15+
public init(resize: ImageResize? = nil, cdnRequester: CDNRequester) {
16+
self.resize = resize
17+
self.cdnRequester = cdnRequester
18+
}
19+
}
20+
21+
/// Options for loading multiple images through an ``ImageLoader``.
22+
public struct ImageBatchLoadOptions: Sendable {
23+
/// Placeholder images used rotationally when a URL fails to load.
24+
public var placeholders: [UIImage]
25+
/// Whether to load thumbnail-sized versions of the images.
26+
public var loadThumbnails: Bool
27+
/// The desired thumbnail size in points.
28+
public var thumbnailSize: CGSize
29+
/// The CDN requester for URL transformation (signing, headers, resizing).
30+
public var cdnRequester: CDNRequester
31+
32+
public init(
33+
placeholders: [UIImage] = [],
34+
loadThumbnails: Bool = true,
35+
thumbnailSize: CGSize = CGSize(width: 40, height: 40),
36+
cdnRequester: CDNRequester
37+
) {
38+
self.placeholders = placeholders
39+
self.loadThumbnails = loadThumbnails
40+
self.thumbnailSize = thumbnailSize
41+
self.cdnRequester = cdnRequester
42+
}
43+
}
44+
845
/// A protocol for loading and caching images.
946
///
10-
/// The `CDNRequester` is passed on every call, so concrete implementations
11-
/// remain stateless with respect to CDN configuration. Changing the requester
12-
/// on `ChatClient` takes effect immediately without recreating loaders.
47+
/// Configuration is passed via options structs on every call, so concrete
48+
/// implementations remain stateless with respect to CDN configuration.
49+
/// Changing the requester on `ChatClient` takes effect immediately
50+
/// without recreating loaders.
1351
public protocol ImageLoader: AnyObject, Sendable {
1452
/// Loads a single image from the given URL.
1553
///
1654
/// - Parameters:
1755
/// - url: The image URL. If nil, the completion is called with a failure.
18-
/// - resize: Optional resize parameters for server-side resizing.
19-
/// - cdnRequester: The CDN requester for URL transformation (signing, headers, resizing).
56+
/// - options: Options controlling resize and CDN behavior.
2057
/// - completion: A completion handler called on the main actor with the loaded image.
2158
func loadImage(
2259
url: URL?,
23-
resize: ImageResize?,
24-
cdnRequester: CDNRequester,
60+
options: ImageLoadOptions,
2561
completion: @escaping @MainActor (Result<UIImage, Error>) -> Void
2662
)
2763

2864
/// Loads multiple images from the given URLs.
2965
///
3066
/// - Parameters:
3167
/// - urls: The image URLs to load.
32-
/// - placeholders: Placeholder images used rotationally when a URL fails to load.
33-
/// - loadThumbnails: Whether to load thumbnail-sized versions of the images.
34-
/// - thumbnailSize: The desired thumbnail size in points.
35-
/// - cdnRequester: The CDN requester for URL transformation (signing, headers, resizing).
68+
/// - options: Options controlling placeholders, thumbnails, and CDN behavior.
3669
/// - completion: A completion handler called on the main actor with all loaded images.
3770
func loadImages(
3871
from urls: [URL],
39-
placeholders: [UIImage],
40-
loadThumbnails: Bool,
41-
thumbnailSize: CGSize,
42-
cdnRequester: CDNRequester,
72+
options: ImageBatchLoadOptions,
4373
completion: @escaping @MainActor ([UIImage]) -> Void
4474
)
4575
}
@@ -50,11 +80,10 @@ extension ImageLoader {
5080
/// Loads a single image from the given URL.
5181
public func loadImage(
5282
url: URL?,
53-
resize: ImageResize? = nil,
54-
cdnRequester: CDNRequester
83+
options: ImageLoadOptions
5584
) async throws -> UIImage {
5685
try await withCheckedThrowingContinuation { continuation in
57-
loadImage(url: url, resize: resize, cdnRequester: cdnRequester) { result in
86+
loadImage(url: url, options: options) { result in
5887
continuation.resume(with: result)
5988
}
6089
}
@@ -63,13 +92,10 @@ extension ImageLoader {
6392
/// Loads multiple images from the given URLs.
6493
public func loadImages(
6594
from urls: [URL],
66-
placeholders: [UIImage],
67-
loadThumbnails: Bool = true,
68-
thumbnailSize: CGSize = CGSize(width: 40, height: 40),
69-
cdnRequester: CDNRequester
95+
options: ImageBatchLoadOptions
7096
) async -> [UIImage] {
7197
await withCheckedContinuation { continuation in
72-
loadImages(from: urls, placeholders: placeholders, loadThumbnails: loadThumbnails, thumbnailSize: thumbnailSize, cdnRequester: cdnRequester) { images in
98+
loadImages(from: urls, options: options) { images in
7399
continuation.resume(returning: images)
74100
}
75101
}

Sources/StreamChatCommonUI/ImageLoading/StreamImageLoader.swift

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import UIKit
77

88
/// The default image loader implementation.
99
///
10-
/// Delegates URL transformation to the ``CDNRequester`` passed on each call,
11-
/// and actual downloading to an ``ImageDownloading`` backend
12-
/// (typically Nuke, supplied by each UI SDK).
10+
/// Delegates URL transformation to the ``CDNRequester`` provided via
11+
/// ``ImageLoadOptions``, and actual downloading to an ``ImageDownloading``
12+
/// backend (typically Nuke, supplied by each UI SDK).
1313
open class StreamImageLoader: ImageLoader, @unchecked Sendable {
1414
/// The backend that performs the actual image download and caching.
1515
public let downloader: ImageDownloading
@@ -20,8 +20,7 @@ open class StreamImageLoader: ImageLoader, @unchecked Sendable {
2020

2121
open func loadImage(
2222
url: URL?,
23-
resize: ImageResize?,
24-
cdnRequester: CDNRequester,
23+
options: ImageLoadOptions,
2524
completion: @escaping @MainActor (Result<UIImage, Error>) -> Void
2625
) {
2726
guard let url else {
@@ -31,10 +30,10 @@ open class StreamImageLoader: ImageLoader, @unchecked Sendable {
3130
return
3231
}
3332

34-
cdnRequester.imageRequest(for: url, options: ImageRequestOptions(imageResize: resize)) { [weak self] result in
33+
options.cdnRequester.imageRequest(for: url, options: ImageRequestOptions(imageResize: options.resize)) { [weak self] result in
3534
switch result {
3635
case let .success(cdnRequest):
37-
let resizeSize: CGSize? = resize.map { CGSize(width: $0.width, height: $0.height) }
36+
let resizeSize: CGSize? = options.resize.map { CGSize(width: $0.width, height: $0.height) }
3837
self?.downloader.downloadImage(
3938
url: cdnRequest.url,
4039
headers: cdnRequest.headers,
@@ -52,10 +51,7 @@ open class StreamImageLoader: ImageLoader, @unchecked Sendable {
5251

5352
open func loadImages(
5453
from urls: [URL],
55-
placeholders: [UIImage],
56-
loadThumbnails: Bool,
57-
thumbnailSize: CGSize,
58-
cdnRequester: CDNRequester,
54+
options: ImageBatchLoadOptions,
5955
completion: @escaping @MainActor ([UIImage]) -> Void
6056
) {
6157
let group = DispatchGroup()
@@ -64,15 +60,16 @@ open class StreamImageLoader: ImageLoader, @unchecked Sendable {
6460
for (index, avatarUrl) in urls.enumerated() {
6561
group.enter()
6662

67-
let resize: ImageResize? = loadThumbnails ? ImageResize(thumbnailSize) : nil
68-
loadImage(url: avatarUrl, resize: resize, cdnRequester: cdnRequester) { result in
63+
let resize: ImageResize? = options.loadThumbnails ? ImageResize(options.thumbnailSize) : nil
64+
let imageOptions = ImageLoadOptions(resize: resize, cdnRequester: options.cdnRequester)
65+
loadImage(url: avatarUrl, options: imageOptions) { result in
6966
switch result {
7067
case let .success(image):
7168
batchLoadingResult.images.append(image)
7269
case .failure:
73-
if !placeholders.isEmpty {
74-
let placeholderIndex = index % placeholders.count
75-
batchLoadingResult.images.append(placeholders[placeholderIndex])
70+
if !options.placeholders.isEmpty {
71+
let placeholderIndex = index % options.placeholders.count
72+
batchLoadingResult.images.append(options.placeholders[placeholderIndex])
7673
}
7774
}
7875
group.leave()

Sources/StreamChatCommonUI/ImageLoading/StreamVideoLoader.swift

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import UIKit
88

99
/// The default video loader implementation using AVFoundation.
1010
///
11-
/// Uses the ``CDNRequester`` passed on each call to sign video URLs before
12-
/// generating previews, and falls back to the ``ImageLoader`` for loading
13-
/// thumbnail URLs.
11+
/// Uses the ``CDNRequester`` from ``VideoLoadOptions`` to sign video URLs
12+
/// before generating previews, and falls back to the ``ImageLoader`` for
13+
/// loading thumbnail URLs.
1414
open class StreamVideoLoader: VideoLoader, @unchecked Sendable {
1515
/// The image loader used for loading video thumbnail URLs.
1616
public let imageLoader: ImageLoader
@@ -34,13 +34,13 @@ open class StreamVideoLoader: VideoLoader, @unchecked Sendable {
3434
NotificationCenter.default.removeObserver(self)
3535
}
3636

37-
open func videoAsset(at url: URL, cdnRequester: CDNRequester) -> AVURLAsset {
37+
open func videoAsset(at url: URL, options: VideoLoadOptions) -> AVURLAsset {
3838
AVURLAsset(url: url)
3939
}
4040

4141
open func loadPreview(
4242
at url: URL,
43-
cdnRequester: CDNRequester,
43+
options: VideoLoadOptions,
4444
completion: @escaping @MainActor (Result<UIImage, Error>) -> Void
4545
) {
4646
if let cached = cache.object(forKey: url as NSURL) {
@@ -50,12 +50,12 @@ open class StreamVideoLoader: VideoLoader, @unchecked Sendable {
5050
return
5151
}
5252

53-
generateVideoPreview(for: url, cdnRequester: cdnRequester, completion: completion)
53+
generateVideoPreview(for: url, options: options, completion: completion)
5454
}
5555

5656
open func loadPreview(
5757
with attachment: ChatMessageVideoAttachment,
58-
cdnRequester: CDNRequester,
58+
options: VideoLoadOptions,
5959
completion: @escaping @MainActor (Result<UIImage, Error>) -> Void
6060
) {
6161
let videoURL = attachment.videoURL
@@ -67,7 +67,8 @@ open class StreamVideoLoader: VideoLoader, @unchecked Sendable {
6767
}
6868

6969
if let thumbnailURL = attachment.payload.thumbnailURL {
70-
imageLoader.loadImage(url: thumbnailURL, resize: nil, cdnRequester: cdnRequester) { [weak self] result in
70+
let imageOptions = ImageLoadOptions(cdnRequester: options.cdnRequester)
71+
imageLoader.loadImage(url: thumbnailURL, options: imageOptions) { [weak self] result in
7172
guard let self else { return }
7273
switch result {
7374
case let .success(image):
@@ -76,22 +77,22 @@ open class StreamVideoLoader: VideoLoader, @unchecked Sendable {
7677
completion(.success(image))
7778
}
7879
case .failure:
79-
self.generateVideoPreview(for: videoURL, cdnRequester: cdnRequester, completion: completion)
80+
self.generateVideoPreview(for: videoURL, options: options, completion: completion)
8081
}
8182
}
8283
} else {
83-
generateVideoPreview(for: videoURL, cdnRequester: cdnRequester, completion: completion)
84+
generateVideoPreview(for: videoURL, options: options, completion: completion)
8485
}
8586
}
8687

8788
// MARK: - Private
8889

8990
private func generateVideoPreview(
9091
for url: URL,
91-
cdnRequester: CDNRequester,
92+
options: VideoLoadOptions,
9293
completion: @escaping @MainActor (Result<UIImage, Error>) -> Void
9394
) {
94-
cdnRequester.fileRequest(for: url, options: .init()) { [weak self] result in
95+
options.cdnRequester.fileRequest(for: url, options: .init()) { [weak self] result in
9596
guard let self else { return }
9697

9798
let adjustedUrl: URL

Sources/StreamChatCommonUI/ImageLoading/VideoLoader.swift

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,51 +6,61 @@ import AVKit
66
import StreamChat
77
import UIKit
88

9+
/// Options for loading video content through a ``VideoLoader``.
10+
public struct VideoLoadOptions: Sendable {
11+
/// The CDN requester for URL transformation (signing, headers).
12+
public var cdnRequester: CDNRequester
13+
14+
public init(cdnRequester: CDNRequester) {
15+
self.cdnRequester = cdnRequester
16+
}
17+
}
18+
919
/// A protocol for loading video preview thumbnails.
1020
///
11-
/// The `CDNRequester` is passed on every call, so concrete implementations
12-
/// remain stateless with respect to CDN configuration.
21+
/// Configuration is passed via ``VideoLoadOptions`` on every call, so
22+
/// concrete implementations remain stateless with respect to CDN configuration.
1323
public protocol VideoLoader: AnyObject, Sendable {
1424
/// Returns a video asset for the given URL.
1525
///
16-
/// Implementers should use the CDN requester to adjust the URL
26+
/// Implementers should use the CDN requester in options to adjust the URL
1727
/// before creating the asset.
18-
func videoAsset(at url: URL, cdnRequester: CDNRequester) -> AVURLAsset
28+
func videoAsset(at url: URL, options: VideoLoadOptions) -> AVURLAsset
1929

2030
/// Loads a video preview thumbnail from a URL.
2131
///
2232
/// - Parameters:
2333
/// - url: The video URL.
24-
/// - cdnRequester: The CDN requester for URL transformation.
34+
/// - options: Options controlling CDN behavior.
2535
/// - completion: A completion handler called on the main actor with the preview image.
2636
func loadPreview(
2737
at url: URL,
28-
cdnRequester: CDNRequester,
38+
options: VideoLoadOptions,
2939
completion: @escaping @MainActor (Result<UIImage, Error>) -> Void
3040
)
3141

3242
/// Loads a video preview from a video attachment.
3343
///
34-
/// The default implementation calls ``loadPreview(at:cdnRequester:completion:)`` with the video URL.
44+
/// The default implementation calls ``loadPreview(at:options:completion:)`` with the video URL.
3545
/// Override this method to use the attachment's thumbnail URL for preview generation.
3646
///
3747
/// - Parameters:
3848
/// - attachment: A video attachment containing the video URL and optional thumbnail URL.
39-
/// - cdnRequester: The CDN requester for URL transformation.
49+
/// - options: Options controlling CDN behavior.
4050
/// - completion: A completion handler called on the main actor with the preview image.
4151
func loadPreview(
4252
with attachment: ChatMessageVideoAttachment,
43-
cdnRequester: CDNRequester,
53+
options: VideoLoadOptions,
4454
completion: @escaping @MainActor (Result<UIImage, Error>) -> Void
4555
)
4656
}
4757

4858
extension VideoLoader {
4959
public func loadPreview(
5060
with attachment: ChatMessageVideoAttachment,
51-
cdnRequester: CDNRequester,
61+
options: VideoLoadOptions,
5262
completion: @escaping @MainActor (Result<UIImage, Error>) -> Void
5363
) {
54-
loadPreview(at: attachment.videoURL, cdnRequester: cdnRequester, completion: completion)
64+
loadPreview(at: attachment.videoURL, options: options, completion: completion)
5565
}
5666
}

Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/VideoAttachmentGalleryPreview.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ open class VideoAttachmentGalleryPreview: _View, ThemeProvider {
8686
if let thumbnailURL = content?.thumbnailURL {
8787
showPreview(using: thumbnailURL)
8888
} else if let url = content?.videoURL {
89-
components.videoLoader.loadPreview(at: url, cdnRequester: components.cdnRequester) { [weak self] in
89+
components.videoLoader.loadPreview(at: url, options: VideoLoadOptions(cdnRequester: components.cdnRequester)) { [weak self] in
9090
self?.loadingIndicator.isHidden = true
9191
switch $0 {
9292
case let .success(preview):
@@ -102,7 +102,7 @@ open class VideoAttachmentGalleryPreview: _View, ThemeProvider {
102102
}
103103

104104
private func showPreview(using thumbnailURL: URL) {
105-
components.imageLoader.downloadImage(with: .init(url: thumbnailURL, options: ImageDownloadOptions()), cdnRequester: components.cdnRequester) { [weak self] result in
105+
components.imageLoader.downloadImage(with: .init(url: thumbnailURL, options: ImageDownloadOptions(cdnRequester: components.cdnRequester))) { [weak self] result in
106106
StreamConcurrency.onMain {
107107
self?.loadingIndicator.isHidden = true
108108
guard case let .success(image) = result else { return }

Sources/StreamChatUI/ChatMessageList/Attachments/Link/ChatMessageLinkPreviewView.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,7 @@ open class ChatMessageLinkPreviewView: _Control, ThemeProvider {
144144
components.imageLoader.loadImage(
145145
into: imagePreview,
146146
from: payload?.previewURL,
147-
with: ImageLoaderOptions(),
148-
cdnRequester: components.cdnRequester
147+
with: ImageLoaderOptions(cdnRequester: components.cdnRequester)
149148
)
150149
imagePreview.isHidden = isImageHidden
151150

Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -655,9 +655,9 @@ open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate {
655655
from: imageURL,
656656
with: ImageLoaderOptions(
657657
resize: .init(components.avatarThumbnailSize),
658-
placeholder: placeholder
659-
),
660-
cdnRequester: components.cdnRequester
658+
placeholder: placeholder,
659+
cdnRequester: components.cdnRequester
660+
)
661661
)
662662
} else {
663663
authorAvatarView?.imageView.image = placeholder
@@ -723,9 +723,9 @@ open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate {
723723
from: threadAvatarUrl,
724724
with: ImageLoaderOptions(
725725
resize: .init(components.avatarThumbnailSize),
726-
placeholder: threadAvatarPlaceholder
727-
),
728-
cdnRequester: components.cdnRequester
726+
placeholder: threadAvatarPlaceholder,
727+
cdnRequester: components.cdnRequester
728+
)
729729
)
730730
}
731731

0 commit comments

Comments
 (0)