Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
aeb64e3
Add CDN, CDNUploader protocols and replace CDNClient/AttachmentUploader
nuno-vieira Apr 8, 2026
6834ee5
Add ImageLoader, VideoLoader, and ImageDownloading protocols in Strea…
nuno-vieira Apr 8, 2026
5657a91
Replace old ImageCDN/ImageLoading/VideoLoading in StreamChatUI
nuno-vieira Apr 8, 2026
e6586f4
Update test infrastructure for new CDN/loader protocols
nuno-vieira Apr 8, 2026
c306d8b
Rename NukeImageDownloader and NukeImageProcessor to Stream prefix
nuno-vieira Apr 13, 2026
926a655
Rename CDNUploader to CDNStorage
nuno-vieira Apr 14, 2026
6ee5c32
Remove maxAttachmentSize from CDNStorage protocol
nuno-vieira Apr 14, 2026
ea6adf9
Rename CDN to CDNRequester
nuno-vieira Apr 14, 2026
65212f7
Introduce options parameters to the CDNStorage and CDNRequester
nuno-vieira Apr 14, 2026
d4bcbbd
Move CDNRequest object to CDNRequester
nuno-vieira Apr 14, 2026
5ef94cf
PR Feedback
nuno-vieira Apr 14, 2026
893ddca
Merge branch 'develop' into add/improve-custom-cdn
nuno-vieira Apr 14, 2026
e587f1d
Fix import issues
nuno-vieira Apr 14, 2026
76d1f77
Fix tests not compiling
nuno-vieira Apr 14, 2026
8670250
Fix Xcode project
nuno-vieira Apr 14, 2026
7d70b7a
Fix targets of loaders in common module
nuno-vieira Apr 14, 2026
7024b1c
Missing StreamChat import
nuno-vieira Apr 14, 2026
62ceeac
Move ImageResize to common module
nuno-vieira Apr 14, 2026
f6efc35
Fix compiling mock loaders
nuno-vieira Apr 14, 2026
8bfa60c
Merge branch 'develop' into add/improve-custom-cdn
nuno-vieira Apr 14, 2026
b0a30c2
Fix snapshot tests
nuno-vieira Apr 14, 2026
499d81d
Re-record snapshots
nuno-vieira Apr 14, 2026
70e066d
Fix StreamChatUI tests
nuno-vieira Apr 14, 2026
0ad546d
Fix swiftformat
nuno-vieira Apr 14, 2026
0187194
Fix sendable warning in StreamVideoLoader
nuno-vieira Apr 14, 2026
fbb7826
Add remoteURL parameter to downloadAttachment
nuno-vieira Apr 14, 2026
113ad3b
Add tests for downloadAttachment remoteURL parameter
nuno-vieira Apr 15, 2026
8b83aa8
Update changelog
nuno-vieira Apr 15, 2026
c60a3e9
Pass CDNRequester on every loader call instead of storing it
nuno-vieira Apr 15, 2026
f1ec6cb
Introduce Options pattern for ImageLoader and VideoLoader protocols
nuno-vieira Apr 15, 2026
549ec4e
Merge ImageLoader and VideoLoader into unified MediaLoader protocol
nuno-vieira Apr 15, 2026
49ce1cd
Add wrapper result types and make videoAsset async
nuno-vieira Apr 15, 2026
7e412fc
Remove cdnRequester and cdnStorage from ChatClient
nuno-vieira Apr 15, 2026
d0aa17a
Refine ImageDownloading with Options pattern and DownloadedImage output
nuno-vieira Apr 15, 2026
e288332
Fix caching key using original URL instead of final resized URL
nuno-vieira Apr 15, 2026
5830e77
Unify video loading via MediaLoader and rename videoAsset to loadVide…
nuno-vieira Apr 15, 2026
8e78882
Merge branch 'develop' into add/improve-custom-cdn
nuno-vieira Apr 15, 2026
c84b40e
Make UploadedFile sendable
nuno-vieira Apr 15, 2026
197db4f
Make StreamCDNRequester Sendable
nuno-vieira Apr 15, 2026
1594527
Fix silent no-op when StreamMediaLoader is deallocated during image load
nuno-vieira Apr 15, 2026
8308a3d
Make StreamImageDownloader final Sendable
nuno-vieira Apr 15, 2026
c0b0d64
Fix loadImages results order
nuno-vieira Apr 15, 2026
bfadff8
Move COmponents initializer
nuno-vieira Apr 15, 2026
8b65898
Improve test coverage
nuno-vieira Apr 15, 2026
71d5083
Remove loadImages from MediaLoader, since it is not used
nuno-vieira Apr 15, 2026
82db0b3
Fix compilation
nuno-vieira Apr 15, 2026
383b3d3
Refactor avatar views to use the correct components
nuno-vieira Apr 15, 2026
a2d6ad9
Fix avatar tests
nuno-vieira Apr 15, 2026
108de47
Update CDNStorage test coverage
nuno-vieira Apr 15, 2026
d7c1019
Fix completion handler not always called in StreamMediaLoader
nuno-vieira Apr 15, 2026
0df573c
Fix completion handler not always called in StreamCDNStorage
nuno-vieira Apr 15, 2026
6d43eb6
Rename StreamCDN_Tests.swift to StreamCDNRequester_Tests.swift
nuno-vieira Apr 15, 2026
b2c26fd
Rename CustomCDNClient.swift and StreamCDNClient_Tests.swift
nuno-vieira Apr 15, 2026
f7f3874
Fix stale CDNClient references and misleading log strings
nuno-vieira Apr 15, 2026
2fca66f
Improve StreamCDNStorage test coverage
nuno-vieira Apr 15, 2026
fb7f51c
Remove URL-based loadVideoPreview, use attachment variant only
nuno-vieira Apr 15, 2026
9bd596c
Explain video preview local caching
nuno-vieira Apr 15, 2026
b53149a
Remove default implementation of loadVideoAsset from MediaLoader
nuno-vieira Apr 15, 2026
fdcbfa0
Update Sources/StreamChatCommonUI/ImageLoading/MediaLoader.swift
nuno-vieira Apr 15, 2026
0ae2e26
Move cdnRequester out of ChatClientConfig into UI layer
nuno-vieira Apr 15, 2026
df96c6b
Fix tests related to CDNRequester moving
nuno-vieira Apr 15, 2026
c5a0aea
Fix StreamCDNStorage tests
nuno-vieira Apr 16, 2026
9a09605
Fix wrong log message
nuno-vieira Apr 16, 2026
b6735e7
Introduce back the local only video preview
nuno-vieira Apr 16, 2026
9c5dccf
Fix message content view tests
nuno-vieira Apr 16, 2026
2bd362a
Fix video composer preview tests
nuno-vieira Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions Sources/StreamChat/APIClient/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,9 @@ class APIClient: @unchecked Sendable {

/// The attachment downloader.
let attachmentDownloader: AttachmentDownloader

/// The attachment uploader.
let attachmentUploader: AttachmentUploader

/// The CDN Client to store and delete attachments.
let cdnClient: CDNClient
/// The CDN uploader for storing and deleting attachments.
let cdnUploader: CDNUploader

/// Queue in charge of handling incoming requests
private let operationQueue: OperationQueue = {
Expand Down Expand Up @@ -66,15 +63,13 @@ class APIClient: @unchecked Sendable {
requestEncoder: RequestEncoder,
requestDecoder: RequestDecoder,
attachmentDownloader: AttachmentDownloader,
attachmentUploader: AttachmentUploader,
cdnClient: CDNClient
cdnUploader: CDNUploader
) {
encoder = requestEncoder
decoder = requestDecoder
session = URLSession(configuration: sessionConfiguration)
self.attachmentDownloader = attachmentDownloader
self.attachmentUploader = attachmentUploader
self.cdnClient = cdnClient
self.cdnUploader = cdnUploader
}

/// Performs a network request and retries in case of network failures
Expand Down Expand Up @@ -323,18 +318,24 @@ class APIClient: @unchecked Sendable {
completion: @escaping @Sendable (Result<UploadedAttachment, Error>) -> Void
) {
let uploadOperation = AsyncOperation(maxRetries: maximumRequestRetries) { [weak self] operation, done in
self?.attachmentUploader.upload(attachment, progress: progress) { [weak self] result in
switch result {
self?.cdnUploader.uploadAttachment(attachment, progress: progress) { [weak self] result in
let mappedResult = result.map { file in
UploadedAttachment(
attachment: attachment,
remoteURL: file.fileURL,
thumbnailURL: file.thumbnailURL
)
}
switch mappedResult {
case let .failure(error) where self?.isConnectionError(error) == true:
// Do not retry unless its a connection problem and we still have retries left
if operation.canRetry {
done(.retry)
} else {
completion(result)
completion(mappedResult)
done(.continue)
}
case .success, .failure:
completion(result)
completion(mappedResult)
done(.continue)
}
}
Expand Down

This file was deleted.

64 changes: 64 additions & 0 deletions Sources/StreamChat/APIClient/CDNClient/CDN.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// Copyright Β© 2026 Stream.io Inc. All rights reserved.
//

import Foundation

/// A protocol for transforming CDN URLs before loading images and files.
///
/// Implement this protocol to add signing, authentication headers,
/// resize query parameters, or to rewrite the CDN host.
///
/// All methods use completion handlers to support asynchronous operations
/// such as fetching pre-signed URLs from a server.
public protocol CDN: Sendable {
/// Transforms an image URL for loading.
///
/// Called before every image load. Use this to add signing,
/// auth headers, resize query params, or rewrite the host.
///
/// - Parameters:
/// - url: The original image URL.
/// - resize: Optional resize parameters for server-side resizing.
/// - completion: A completion handler with the transformed request.
func imageRequest(
for url: URL,
resize: ImageResize?,
completion: @escaping (Result<CDNRequest, Error>) -> Void
)

/// Transforms a file or video URL for loading or playback.
///
/// Called before loading non-image media. Use this to add signing
/// or auth headers for file and video access.
///
/// - Parameters:
/// - url: The original file/video URL.
/// - completion: A completion handler with the transformed request.
func fileRequest(
for url: URL,
completion: @escaping (Result<CDNRequest, Error>) -> Void
)
}

// MARK: - Async/Await Extensions

extension CDN {
/// Transforms an image URL for loading.
public func imageRequest(for url: URL, resize: ImageResize? = nil) async throws -> CDNRequest {
try await withCheckedThrowingContinuation { continuation in
imageRequest(for: url, resize: resize) { result in
continuation.resume(with: result)
}
}
}

/// Transforms a file or video URL for loading or playback.
public func fileRequest(for url: URL) async throws -> CDNRequest {
try await withCheckedThrowingContinuation { continuation in
fileRequest(for: url) { result in
continuation.resume(with: result)
}
}
}
}
114 changes: 22 additions & 92 deletions Sources/StreamChat/APIClient/CDNClient/CDNClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,78 +15,13 @@ public struct UploadedFile: Decodable {
}
}

/// The CDN client is responsible to upload files to a CDN.
public protocol CDNClient: Sendable {
static var maxAttachmentSize: Int64 { get }

/// Uploads attachment as a multipart/form-data and returns only the uploaded remote file.
/// - Parameters:
/// - attachment: An attachment to upload.
/// - progress: A closure that broadcasts upload progress.
/// - completion: Returns the uploaded file's information.
func uploadAttachment(
_ attachment: AnyChatMessageAttachment,
progress: (@Sendable (Double) -> Void)?,
completion: @escaping @Sendable (Result<URL, Error>) -> Void
)

/// Uploads attachment as a multipart/form-data and returns the uploaded remote file and its thumbnail.
/// - Parameters:
/// - attachment: An attachment to upload.
/// - progress: A closure that broadcasts upload progress.
/// - completion: Returns the uploaded file's information.
func uploadAttachment(
_ attachment: AnyChatMessageAttachment,
progress: (@Sendable (Double) -> Void)?,
completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
)

/// Uploads standalone attachment as a multipart/form-data and returns the uploaded remote file and its thumbnail.
/// - Parameters:
/// - attachment: An attachment to upload.
/// - progress: A closure that broadcasts upload progress.
/// - completion: Returns the uploaded file's information.
func uploadStandaloneAttachment<Payload>(
_ attachment: StreamAttachment<Payload>,
progress: (@Sendable (Double) -> Void)?,
completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
)

/// Deletes the attachment from the CDN, given the remote URL.
/// - Parameters:
/// - remoteUrl: The remote url of the attachment.
/// - completion: Returns an error in case the delete operation fails.
func deleteAttachment(
remoteUrl: URL,
completion: @escaping @Sendable (Error?) -> Void
)
}

public extension CDNClient {
func uploadAttachment(
_ attachment: AnyChatMessageAttachment,
progress: (@Sendable (Double) -> Void)?,
completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
) {
uploadAttachment(attachment, progress: progress, completion: { (result: Result<URL, Error>) in
switch result {
case let .success(url):
completion(.success(UploadedFile(fileURL: url, thumbnailURL: nil)))
case let .failure(error):
completion(.failure(error))
}
})
}
}

/// Default implementation of CDNClient that uses Stream CDN
final class StreamCDNClient: CDNClient, @unchecked Sendable {
/// Default implementation of CDNUploader that uses Stream's API.
final class StreamCDNUploader: CDNUploader, @unchecked Sendable {
static var maxAttachmentSize: Int64 { 100 * 1024 * 1024 }

private let decoder: RequestDecoder
private let encoder: RequestEncoder
private let session: URLSession
/// Keeps track of uploading tasks progress
@Atomic private var taskProgressObservers: [Int: NSKeyValueObservation] = [:]

init(
Expand All @@ -99,21 +34,6 @@ final class StreamCDNClient: CDNClient, @unchecked Sendable {
self.decoder = decoder
}

func uploadAttachment(
_ attachment: AnyChatMessageAttachment,
progress: (@Sendable (Double) -> Void)? = nil,
completion: @escaping @Sendable (Result<URL, Error>) -> Void
) {
uploadAttachment(attachment, progress: progress, completion: { (result: Result<UploadedFile, Error>) in
switch result {
case let .success(file):
completion(.success(file.fileURL))
case let .failure(error):
completion(.failure(error))
}
})
}

func uploadAttachment(
_ attachment: AnyChatMessageAttachment,
progress: (@Sendable (Double) -> Void)? = nil,
Expand All @@ -125,7 +45,7 @@ final class StreamCDNClient: CDNClient, @unchecked Sendable {
return completion(.failure(ClientError.AttachmentUploading(id: attachment.id)))
}
let endpoint = Endpoint<FileUploadPayload>.uploadAttachment(with: attachment.id.cid, type: attachment.type)

uploadAttachment(
endpoint: endpoint,
fileData: fileData,
Expand All @@ -134,20 +54,31 @@ final class StreamCDNClient: CDNClient, @unchecked Sendable {
completion: completion
)
}
func uploadStandaloneAttachment<Payload>(
_ attachment: StreamAttachment<Payload>,

func uploadAttachment(
localUrl: URL,
progress: (@Sendable (Double) -> Void)? = nil,
completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
) {
guard
let uploadingState = attachment.uploadingState,
let fileData = try? Data(contentsOf: uploadingState.localFileURL) else {
let uploadingState: AttachmentUploadingState
do {
uploadingState = AttachmentUploadingState(
localFileURL: localUrl,
state: .pendingUpload,
file: try .init(url: localUrl)
)
} catch {
completion(.failure(error))
return
}

guard let fileData = try? Data(contentsOf: localUrl) else {
return completion(.failure(ClientError.Unknown()))
}
Comment thread
nuno-vieira marked this conversation as resolved.
Outdated

let endpoint = Endpoint<FileUploadPayload>.uploadAttachment(type: attachment.type)

let isImage = uploadingState.file.type.isImage
let endpoint = Endpoint<FileUploadPayload>.uploadAttachment(type: isImage ? .image : .file)

uploadAttachment(
endpoint: endpoint,
fileData: fileData,
Expand Down Expand Up @@ -196,7 +127,6 @@ final class StreamCDNClient: CDNClient, @unchecked Sendable {
progress: (@Sendable (Double) -> Void)? = nil,
completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
) {
// Encode locally stored attachment into multipart form data
let multipartFormData = MultipartFormData(
fileData,
fileName: uploadingState.localFileURL.lastPathComponent,
Expand Down
22 changes: 22 additions & 0 deletions Sources/StreamChat/APIClient/CDNClient/CDNRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Copyright Β© 2026 Stream.io Inc. All rights reserved.
//

import Foundation

/// The result of a CDN URL transformation, containing the final URL,
/// optional HTTP headers, and an optional caching key.
public struct CDNRequest: Sendable {
Comment thread
nuno-vieira marked this conversation as resolved.
Outdated
/// The (potentially rewritten/signed) URL to load.
public var url: URL
/// Optional HTTP headers to include in the load request.
public var headers: [String: String]?
/// Optional caching key. If nil, the loader defaults to using the URL string.
public var cachingKey: String?

public init(url: URL, headers: [String: String]? = nil, cachingKey: String? = nil) {
self.url = url
self.headers = headers
self.cachingKey = cachingKey
}
}
Loading
Loading