// Example of your Upload API
protocol UploadFileAPI {
func uploadFile(data: Data, progress: ((Double) -> Void)?, completion: (@escaping (FileDetails) -> Void))
}
protocol FileDetails {
var url: URL { get }
var thumbnailURL: URL { get }
}
final class CustomCDNClient: CDNClient {
/// Example, max 100 MB. Required by CDNClient protocol.
static var maxAttachmentSize: Int64 { 100 * 1024 * 1024 }
let uploadFileApi: UploadFileAPI
init(uploadFileApi: UploadFileAPI) {
self.uploadFileApi = uploadFileApi
}
func uploadAttachment(
_ attachment: AnyChatMessageAttachment,
progress: ((Double) -> Void)?,
completion: @escaping (Result<UploadedFile, Error>
) -> Void) {
// The local file url is present in attachment.uploadingState.
guard let uploadingState = attachment.uploadingState,
let fileData = try? Data(contentsOf: uploadingState.localFileURL) else {
return completion(.failure(ClientError("Failed to upload attachment with id: \(attachment.id)")))
}
uploadFileApi.uploadFile(data: fileData, progress: progress) { file in
let uploadedFile = UploadedFile(url: file.url, thumbnailURL: file.thumbnailURL)
completion(.success(uploadedFile))
}
}
}Custom CDN
By default, files are uploaded to Stream’s CDN, but you can also use your own CDN. Currently, there are two options to provide your custom file uploading logic.
Providing a
CDNClientimplementation. This is the simplest one and it is useful if you are only interested in changing the CDN URL and do not want to update the attachment payload.Providing an
AttachmentUploaderimplementation. This one can be used for more fine-grain control, since you can change not only the URL but the attachment payload as well.
You should only pick 1 of the 2 options provided. Since using an AttachmentUploader will override the custom CDNClient implementation.
CDN Client
In case you simply want to change the URL, here is an example of a custom CDNClient implementation.
Then, you can set your custom implementation in the ChatClientConfig:
var config = ChatClientConfig(apiKeyString: apiKeyString)
config.customCDNClient = CustomCDNClient(uploadFileApi: YourUploadFileApi())In case your API does not support progress updates, you can simple ignore the argument.
Firebase
You can also use FirebaseStorage as your custom CDN. There is a Swift Package called StreamFirebaseCDN, built by the community to facilitate this, which can be found here.
Attachment Uploader
If you require changing more details of the attachment, in case your upload file API supports more features, you can provide a custom AttachmentUploader.
// Example of your Upload API
protocol UploadFileAPI {
func uploadFile(data: Data, progress: ((Double) -> Void)?, completion: (@escaping (FileDetails) -> Void))
}
protocol FileDetails {
var name: String { get set }
var url: URL { get }
var thumbnailURL: URL { get set }
var codec: String { get set }
}
final class CustomUploader: AttachmentUploader {
private let attachmentUpdater = AnyAttachmentUpdater()
let uploadFileApi: UploadFileAPI
init(uploadFileApi: UploadFileAPI) {
self.uploadFileApi = uploadFileApi
}
func upload(
_ attachment: AnyChatMessageAttachment,
progress: ((Double) -> Void)?,
completion: @escaping (Result<UploadedAttachment, Error>) -> Void
) {
// The local file url is present in attachment.uploadingState.
guard let uploadingState = attachment.uploadingState,
let fileData = try? Data(contentsOf: uploadingState.localFileURL) else {
return completion(.failure(ClientError("Failed to upload attachment with id: \(attachment.id)")))
}
uploadFileApi.uploadFile(data: fileData, progress: progress) { [weak self] file in
var uploadedAttachment = UploadedAttachment(
attachment: attachment,
remoteURL: file.url
)
// Update the image payload, in case the attachment is an image.
self?.attachmentUpdater.update(
&uploadedAttachment.attachment,
forPayload: ImageAttachmentPayload.self
) { payload in
payload.title = file.name
payload.extraData = [
"thumbnailUrl": .string(file.thumbnailURL)
]
}
// Update the audio payload, in case the attachment is a audio.
self?.attachmentUpdater.update(
&uploadedAttachment.attachment,
forPayload: AudioAttachmentPayload.self
) { payload in
payload.title = file.name
payload.extraData = [
"thumbnailUrl": .string(file.thumbnailURL),
"codec": .string(file.codec)
]
}
completion(.success(uploadedAttachment))
}
}
}The AnyAttachmentUpdater is a helper component provided by Stream, to make it easier to update the underlying payload of a type-erased attachment. You should pass a reference of the attachment with & and say which payload to update depending on what type is the attachment.
Finally, you should set your custom implementation in the ChatClientConfig:
var config = ChatClientConfig(apiKeyString: apiKeyString)
config.customAttachmentUploader = CustomUploader(uploadFileApi: YourUploadFileApi())Image CDN
While the options above are for uploading files to a CDN, the SDK also supports customizing how images are loaded from a CDN through the ImageCDN protocol. This is particularly useful for:
- Signed URLs: Adding authentication parameters to image requests.
- Custom Headers: Adding additional HTTP headers.
- Image Resizing: Leveraging your CDN’s resizing capabilities to load optimized image sizes.
- CDN Migration: Redirecting image requests to a different CDN host.
The ImageCDN protocol has two methods:
protocol ImageCDN {
func urlRequest(forImageUrl url: URL, resize: ImageResize?) -> URLRequest
func cachingKey(forImageUrl url: URL) -> String
}urlRequest(forImageUrl:resize:): This method is called when an image is loaded from the CDN. It allows you to modify the URL request before it is sent to the CDN.cachingKey(forImageUrl:): This method is called when an image is cached. It allows you to modify the caching key for the image. It is important to remove any dynamic parameters of the URL, otherwise the key will always be different and the image will not be cached.
Signed URLs
Here’s an example of implementing ImageCDN to add signature parameters to image URLs:
final class SignedImageCDN: ImageCDN {
private let signingService: ImageSigningService
init(signingService: ImageSigningService) {
self.signingService = signingService
}
func urlRequest(forImageUrl url: URL, resize: ImageResize?) -> URLRequest {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return URLRequest(url: url)
}
// Add signature parameters
let signature = signingService.sign(url: url)
var queryItems = components.queryItems ?? []
queryItems.append(URLQueryItem(name: "signature", value: signature.token))
queryItems.append(URLQueryItem(name: "expires", value: String(signature.expiresAt)))
components.queryItems = queryItems
return URLRequest(url: components.url ?? url)
}
func cachingKey(forImageUrl url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return url.absoluteString
}
// Remove signature and expiry parameters from cache key
// so that the same image can be cached regardless of signature
let filteredItems = components.queryItems?.filter {
$0.name != "signature" && $0.name != "expires"
}
components.queryItems = filteredItems?.isEmpty == true ? nil : filteredItems
return components.string ?? url.absoluteString
}
}Image Resizing
If your custom CDN supports image resizing like the Stream CDN, you can provide a custom implementation to handle the resize parameters:
final class CustomResizeCDN: ImageCDN {
private let cdnHost = "your-cdn.com"
func urlRequest(forImageUrl url: URL, resize: ImageResize?) -> URLRequest {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let host = components.host, host.contains(cdnHost) else {
return URLRequest(url: url)
}
// Apply resize parameters if provided
guard let resize = resize else {
return URLRequest(url: url)
}
let scale = UIScreen.main.scale
var queryItems = components.queryItems ?? []
// Add your CDN's specific resize parameters
queryItems.append(URLQueryItem(
name: "width",
value: String(format: "%.0f", resize.width * scale)
))
queryItems.append(URLQueryItem(
name: "height",
value: String(format: "%.0f", resize.height * scale)
))
queryItems.append(URLQueryItem(
name: "fit",
value: resize.mode.value
))
components.queryItems = queryItems
return URLRequest(url: components.url ?? url)
}
func cachingKey(forImageUrl url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let host = components.host, host.contains(cdnHost) else {
return url.absoluteString
}
// Keep resize parameters in the cache key so different sizes are cached separately
let persistedParameters = ["width", "height", "fit"]
let filteredItems = components.queryItems?.filter {
persistedParameters.contains($0.name)
}
components.queryItems = filteredItems?.isEmpty == true ? nil : filteredItems
return components.string ?? url.absoluteString
}
}Configuration
To use your custom ImageCDN implementation, you need to provide it in the Components configuration or the Utils object depending on the UI framework you are using:
// For UIKit:
var components = Components.default
components.imageCDN = CustomImageCDN()
// For SwiftUI:
let utils = Utils(
imageCDN: CustomImageCDN()
)
let streamChat = StreamChat(chatClient: chatClient, utils: utils)The ImageCDN protocol differs slightly between UIKit and SwiftUI.
The main differences are:
- The
urlRequest(url:)method does not have aresizeparameter. - A separate
thumbnailURL(originalURL:preferredSize:)method handles image resizing.
When implementing ImageCDN for SwiftUI, you should implement the resize logic in the thumbnailURL method instead of in the urlRequest method.