diff --git a/CHANGELOG.md b/CHANGELOG.md index efc09eeb080..77507c4d52b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ## StreamChat +### ✅ Added +- Added support for Stream's Image CDN v2 [#2339](https://github.com/GetStream/stream-chat-swift/pull/2339) + ### 🐞 Fixed - Fix CurrentChatUserController+Combine initialValue hard coded to `.noUnread` instead of using the initial value from the current user data model [#2334](https://github.com/GetStream/stream-chat-swift/pull/2334) ## StreamChatUI ### ✅ Added +- Uses Stream's Image CDN v2 to reduce the memory footprint [#2339](https://github.com/GetStream/stream-chat-swift/pull/2339) - Make ChatMessageListVC.tableView(heightForRowAt:) open [#2342](https://github.com/GetStream/stream-chat-swift/pull/2342) ### 🐞 Fixed - Fix message text not dynamically scalable with content size category changes [#2328](https://github.com/GetStream/stream-chat-swift/pull/2328) +### 🚨 Minor Breaking Changes +Although we don't usually ship breaking changes in minor releases, in some cases where they are minimal and important, we have to do them to keep improving the SDK long-term. Either way, these changes are for advanced customizations which won't affect most of the customers. + +- The `ImageCDN` protocol has some minor breaking changes that were needed to support the new Stream CDN v2 and to make it more scalable in the future. + - `urlRequest(forImage:)` -> `urlRequest(forImageUrl:resize:)`. + - `cachingKey(forImage:)` -> `cachingKey(forImageUrl:)`. + - Removed `thumbnail(originalURL:preferreSize:)`. This is now handled by `urlRequest(forImageUrl:resize:)` as well. If your CDN does not support resizing, you can ignore the resize parameter. + # [4.22.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.22.0) _September 26, 2022_ ## StreamChat diff --git a/Examples/YouTubeClone/YTChatMessageContentView.swift b/Examples/YouTubeClone/YTChatMessageContentView.swift index a2d5e628d6a..b286eb71667 100644 --- a/Examples/YouTubeClone/YTChatMessageContentView.swift +++ b/Examples/YouTubeClone/YTChatMessageContentView.swift @@ -31,7 +31,7 @@ final class YTChatMessageContentView: ChatMessageContentView { } override var messageAuthorAvatarSize: CGSize { - .init(width: 40, height: 40) + components.avatarThumbnailSize } override func layout(options: ChatMessageLayoutOptions) { diff --git a/Sources/StreamChat/Config/ChatClientConfig.swift b/Sources/StreamChat/Config/ChatClientConfig.swift index a49e617c0fa..e485157474c 100644 --- a/Sources/StreamChat/Config/ChatClientConfig.swift +++ b/Sources/StreamChat/Config/ChatClientConfig.swift @@ -117,7 +117,6 @@ public struct ChatClientConfig { /// Returns max possible attachment size in bytes. /// The value is taken from custom `maxAttachmentSize` type custom `CDNClient` type. - /// The default value is 20 MiB. public var maxAttachmentSize: Int64 { if let customCDNClient = customCDNClient { return type(of: customCDNClient).maxAttachmentSize diff --git a/Sources/StreamChat/Models/Attachments/AnyAttachmentPayload.swift b/Sources/StreamChat/Models/Attachments/AnyAttachmentPayload.swift index 2e3040af0b6..f6a30c66595 100644 --- a/Sources/StreamChat/Models/Attachments/AnyAttachmentPayload.swift +++ b/Sources/StreamChat/Models/Attachments/AnyAttachmentPayload.swift @@ -25,6 +25,15 @@ public struct AnyAttachmentPayload { public let localFileURL: URL? } +/// Local Metadata related to an attachment. +/// It is used to describe additional information of a local attachment. +public struct AnyAttachmentLocalMetadata { + /// The original width and height of an image or video attachment in Pixels. + public var originalResolution: (width: Double, height: Double)? + + public init() {} +} + public extension AnyAttachmentPayload { /// Creates an instance of `AnyAttachmentPayload` with the given payload. /// @@ -64,8 +73,9 @@ public extension AnyAttachmentPayload { /// - Throws: The error if `localFileURL` is not the file URL or if `extraData` can not be represented as /// a dictionary. init( - localFileURL: URL, attachmentType: AttachmentType, + localFileURL: URL, + localMetadata: AnyAttachmentLocalMetadata? = nil, extraData: Encodable? = nil ) throws { let file = try AttachmentFile(url: localFileURL) @@ -79,7 +89,8 @@ public extension AnyAttachmentPayload { payload = ImageAttachmentPayload( title: localFileURL.lastPathComponent, imageRemoteURL: localFileURL, - imagePreviewRemoteURL: localFileURL, + originalWidth: localMetadata?.originalResolution?.width, + originalHeight: localMetadata?.originalResolution?.height, extraData: extraData ) case .video: diff --git a/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift b/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift index e73c0d90298..bb51a596cb6 100644 --- a/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift +++ b/Sources/StreamChat/Models/Attachments/AttachmentTypes.swift @@ -18,6 +18,8 @@ enum AttachmentCodingKeys: String, CodingKey, CaseIterable { case imageURL = "image_url" case assetURL = "asset_url" case titleLink = "title_link" + case originalWidth = "original_width" + case originalHeight = "original_height" } /// A local state of the attachment. Applies only for attachments linked to the new messages sent from current device. diff --git a/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift b/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift index 20ed2b383d5..5cbb472c8bd 100644 --- a/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift @@ -22,6 +22,10 @@ public struct ImageAttachmentPayload: AttachmentPayload { public var imageURL: URL /// A link to the image preview. public var imagePreviewURL: URL + /// The original width of the image in pixels. + public var originalWidth: Double? + /// The original height of the image in pixels. + public var originalHeight: Double? /// An extra data. public var extraData: [String: RawJSON]? @@ -37,10 +41,19 @@ public struct ImageAttachmentPayload: AttachmentPayload { /// Creates `ImageAttachmentPayload` instance. /// /// Use this initializer if the attachment is already uploaded and you have the remote URLs. - public init(title: String?, imageRemoteURL: URL, imagePreviewRemoteURL: URL? = nil, extraData: [String: RawJSON]?) { + public init( + title: String?, + imageRemoteURL: URL, + imagePreviewRemoteURL: URL? = nil, + originalWidth: Double? = nil, + originalHeight: Double? = nil, + extraData: [String: RawJSON]? = nil + ) { self.title = title imageURL = imageRemoteURL imagePreviewURL = imagePreviewRemoteURL ?? imageRemoteURL + self.originalWidth = originalWidth + self.originalHeight = originalHeight self.extraData = extraData } } @@ -54,6 +67,12 @@ extension ImageAttachmentPayload: Encodable { var values = extraData ?? [:] values[AttachmentCodingKeys.title.rawValue] = title.map { .string($0) } values[AttachmentCodingKeys.imageURL.rawValue] = .string(imageURL.absoluteString) + + if let originalWidth = self.originalWidth, let originalHeight = self.originalHeight { + values[AttachmentCodingKeys.originalWidth.rawValue] = .double(originalWidth) + values[AttachmentCodingKeys.originalHeight.rawValue] = .double(originalHeight) + } + try values.encode(to: encoder) } } @@ -75,11 +94,17 @@ extension ImageAttachmentPayload: Decodable { container.decodeIfPresent(String.self, forKey: .name) )?.trimmingCharacters(in: .whitespacesAndNewlines) + let previewUrl = try container.decodeIfPresent(URL.self, forKey: .thumbURL) ?? imageURL + + let originalWidth = try container.decodeIfPresent(Double.self, forKey: .originalWidth) + let originalHeight = try container.decodeIfPresent(Double.self, forKey: .originalHeight) + self.init( title: title, imageRemoteURL: imageURL, - imagePreviewRemoteURL: try container - .decodeIfPresent(URL.self, forKey: .thumbURL) ?? imageURL, + imagePreviewRemoteURL: previewUrl, + originalWidth: originalWidth, + originalHeight: originalHeight, extraData: try Self.decodeExtraData(from: decoder) ) } diff --git a/Sources/StreamChatUI/Utils/MarkdownFormatter.swift b/Sources/StreamChatUI/Appearance+Formatters/MarkdownFormatter.swift similarity index 100% rename from Sources/StreamChatUI/Utils/MarkdownFormatter.swift rename to Sources/StreamChatUI/Appearance+Formatters/MarkdownFormatter.swift diff --git a/Sources/StreamChatUI/Utils/AppearanceProvider.swift b/Sources/StreamChatUI/AppearanceProvider.swift similarity index 100% rename from Sources/StreamChatUI/Utils/AppearanceProvider.swift rename to Sources/StreamChatUI/AppearanceProvider.swift diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift index 7185a624220..6666a269352 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift @@ -92,13 +92,12 @@ extension ChatMessageGalleryView { loadingIndicator.isVisible = true imageTask = components.imageLoader.loadImage( into: imageView, - url: attachment?.payload.imagePreviewURL, - imageCDN: components.imageCDN, - completion: { [weak self] _ in - self?.loadingIndicator.isVisible = false - self?.imageTask = nil - } - ) + from: attachment?.payload, + maxResolutionInPixels: components.imageAttachmentMaxPixels + ) { [weak self] _ in + self?.loadingIndicator.isVisible = false + self?.imageTask = nil + } uploadingOverlay.content = content?.uploadingState uploadingOverlay.isVisible = attachment?.uploadingState != nil diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageLinkPreviewView.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageLinkPreviewView.swift index 2d3288d174e..6422d376c1a 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageLinkPreviewView.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageLinkPreviewView.swift @@ -124,8 +124,8 @@ open class ChatMessageLinkPreviewView: _Control, ThemeProvider { components.imageLoader.loadImage( into: imagePreview, - url: payload?.previewURL, - imageCDN: components.imageCDN + from: payload?.previewURL, + with: ImageLoaderOptions() ) imagePreview.isHidden = isImageHidden diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift index 7c7ed40b5a7..98437e78101 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift @@ -535,10 +535,11 @@ open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate { if let imageURL = content?.author.imageURL, let imageView = authorAvatarView?.imageView { components.imageLoader.loadImage( into: imageView, - url: imageURL, - imageCDN: components.imageCDN, - placeholder: placeholder, - preferredSize: .avatarThumbnailSize + from: imageURL, + with: ImageLoaderOptions( + resize: .init(components.avatarThumbnailSize), + placeholder: placeholder + ) ) } else { authorAvatarView?.imageView.image = placeholder @@ -595,10 +596,11 @@ open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate { if let imageView = threadAvatarView?.imageView { components.imageLoader.loadImage( into: imageView, - url: threadAvatarUrl, - imageCDN: components.imageCDN, - placeholder: appearance.images.userAvatarPlaceholder4, - preferredSize: .avatarThumbnailSize + from: threadAvatarUrl, + with: ImageLoaderOptions( + resize: .init(components.avatarThumbnailSize), + placeholder: appearance.images.userAvatarPlaceholder4 + ) ) } diff --git a/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorViewCell.swift b/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorViewCell.swift index eef63862973..deaa187173d 100644 --- a/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorViewCell.swift +++ b/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorViewCell.swift @@ -125,10 +125,11 @@ open class ChatMessageReactionAuthorViewCell: _CollectionViewCell, ThemeProvider let placeholder = appearance.images.userAvatarPlaceholder1 components.imageLoader.loadImage( into: authorAvatarView.imageView, - url: content.reaction.author.imageURL, - imageCDN: components.imageCDN, - placeholder: placeholder, - preferredSize: authorAvatarSize + from: content.reaction.author.imageURL, + with: ImageLoaderOptions( + resize: .init(authorAvatarSize), + placeholder: placeholder + ) ) let reactionAuthor = content.reaction.author diff --git a/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/ImageAttachmentComposerPreview.swift b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/ImageAttachmentComposerPreview.swift index 6e41b249eaf..9dc92aabcc8 100644 --- a/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/ImageAttachmentComposerPreview.swift +++ b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/ImageAttachmentComposerPreview.swift @@ -41,10 +41,12 @@ open class ImageAttachmentComposerPreview: _View, ThemeProvider { override open func updateContent() { super.updateContent() + + let size = CGSize(width: width, height: height) components.imageLoader.loadImage( into: imageView, - url: content, - imageCDN: components.imageCDN + from: content, + with: ImageLoaderOptions(resize: ImageResize(size)) ) } } diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift index bb7497b2b7a..2ff510b3685 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift @@ -139,12 +139,15 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { placeholderAvatars.append(placeholderImages.removeFirst()) } } - - components.imageLoader.loadImages( - from: avatarUrls, - placeholders: placeholderImages, - imageCDN: components.imageCDN - ) { images in + + let avatarSize = components.avatarThumbnailSize + let requests = avatarUrls.map { + ImageDownloadRequest(url: $0, options: ImageDownloadOptions(resize: .init(avatarSize))) + } + + components.imageLoader.downloadMultipleImages(with: requests) { results in + let imagesMapper = ImageResultsMapper(results: results) + let images = imagesMapper.mapErrors(with: placeholderImages) completion(images, channelId) } } @@ -162,11 +165,12 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { let imageProcessor = components.imageProcessor let images = avatars.map { - imageProcessor.scale(image: $0, to: .avatarThumbnailSize) + imageProcessor.scale(image: $0, to: components.avatarThumbnailSize) } // The half of the width of the avatar - let halfContainerSize = CGSize(width: CGSize.avatarThumbnailSize.width / 2, height: CGSize.avatarThumbnailSize.height) + let size = components.avatarThumbnailSize + let halfContainerSize = CGSize(width: size.width / 2, height: size.height) if images.count == 1, let image = images.first { combinedImage = image @@ -195,10 +199,13 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { ], orientation: .vertical ) - + let rightImage = imageProcessor.crop( image: imageProcessor - .scale(image: rightCollage ?? appearance.images.userAvatarPlaceholder3, to: .avatarThumbnailSize), + .scale( + image: rightCollage ?? appearance.images.userAvatarPlaceholder3, + to: components.avatarThumbnailSize + ), to: halfContainerSize ) @@ -225,7 +232,10 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { let leftImage = imageProcessor.crop( image: imageProcessor - .scale(image: leftCollage ?? appearance.images.userAvatarPlaceholder1, to: .avatarThumbnailSize), + .scale( + image: leftCollage ?? appearance.images.userAvatarPlaceholder1, + to: components.avatarThumbnailSize + ), to: halfContainerSize ) @@ -239,7 +249,10 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { let rightImage = imageProcessor.crop( image: imageProcessor - .scale(image: rightCollage ?? appearance.images.userAvatarPlaceholder2, to: .avatarThumbnailSize), + .scale( + image: rightCollage ?? appearance.images.userAvatarPlaceholder2, + to: components.avatarThumbnailSize + ), to: halfContainerSize ) @@ -265,10 +278,11 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { open func loadIntoAvatarImageView(from url: URL?, placeholder: UIImage?) { components.imageLoader.loadImage( into: presenceAvatarView.avatarView.imageView, - url: url, - imageCDN: components.imageCDN, - placeholder: placeholder, - preferredSize: .avatarThumbnailSize + from: url, + with: ImageLoaderOptions( + resize: .init(components.avatarThumbnailSize), + placeholder: placeholder + ) ) } } diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift index e9b74cb7911..8bd2ecefb00 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift @@ -25,10 +25,11 @@ open class ChatUserAvatarView: _View, ThemeProvider { override open func updateContent() { components.imageLoader.loadImage( into: presenceAvatarView.avatarView.imageView, - url: content?.imageURL, - imageCDN: components.imageCDN, - placeholder: appearance.images.userAvatarPlaceholder1, - preferredSize: .avatarThumbnailSize + from: content?.imageURL, + with: ImageLoaderOptions( + resize: .init(components.avatarThumbnailSize), + placeholder: appearance.images.userAvatarPlaceholder1 + ) ) presenceAvatarView.isOnlineIndicatorVisible = content?.isOnline ?? false diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift index 2899681cdd2..98af343a5d5 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift @@ -65,12 +65,14 @@ open class CurrentChatUserAvatarView: _Control, ThemeProvider { @objc override open func updateContent() { let currentUserImageUrl = controller?.currentUser?.imageURL let placeholderImage = appearance.images.userAvatarPlaceholder1 + components.imageLoader.loadImage( into: avatarView.imageView, - url: currentUserImageUrl, - imageCDN: components.imageCDN, - placeholder: placeholderImage, - preferredSize: .avatarThumbnailSize + from: currentUserImageUrl, + with: ImageLoaderOptions( + resize: ImageResize(components.avatarThumbnailSize), + placeholder: placeholderImage + ) ) alpha = state == .normal ? 1 : 0.5 diff --git a/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift b/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift index 9dcc6e8dd9e..02e772eb8b9 100644 --- a/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift +++ b/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift @@ -177,10 +177,11 @@ open class QuotedChatMessageView: _View, ThemeProvider, SwiftUIRepresentable { let placeholder = appearance.images.userAvatarPlaceholder1 components.imageLoader.loadImage( into: authorAvatarView.imageView, - url: imageUrl, - imageCDN: components.imageCDN, - placeholder: placeholder, - preferredSize: .avatarThumbnailSize + from: imageUrl, + with: ImageLoaderOptions( + resize: .init(components.avatarThumbnailSize), + placeholder: placeholder + ) ) } @@ -242,9 +243,8 @@ open class QuotedChatMessageView: _View, ThemeProvider, SwiftUIRepresentable { open func setAttachmentPreviewImage(url: URL?) { components.imageLoader.loadImage( into: attachmentPreviewView, - url: url, - imageCDN: components.imageCDN, - preferredSize: attachmentPreviewSize + from: url, + with: ImageLoaderOptions(resize: .init(attachmentPreviewSize)) ) } diff --git a/Sources/StreamChatUI/Components.swift b/Sources/StreamChatUI/Components.swift index c54cc47edf1..1b58bfcc42c 100644 --- a/Sources/StreamChatUI/Components.swift +++ b/Sources/StreamChatUI/Components.swift @@ -13,6 +13,9 @@ public struct Components { /// A view used as an online activity indicator (online/offline). public var onlineIndicatorView: (UIView & MaskProviding).Type = OnlineIndicatorView.self + /// The default avatar thumbnail size. + public var avatarThumbnailSize: CGSize = .init(width: 40, height: 40) + /// A view that displays the avatar image. By default a circular image. public var avatarView: ChatAvatarView.Type = ChatAvatarView.self @@ -141,6 +144,12 @@ public struct Components { /// The view used to display a bubble around a message. public var messageBubbleView: ChatMessageBubbleView.Type = ChatMessageBubbleView.self + /// The maximum image resolution in pixels when loading image attachments in the Message List. + /// + /// By default it is 2MP, 2 Million Pixels. Keep in mind that + /// increasing this value will increase the memory footprint. + public var imageAttachmentMaxPixels: Double = 2_000_000 + /// The class responsible for returning the correct attachment view injector from a message @available(iOSApplicationExtension, unavailable) public var attachmentViewCatalog: AttachmentViewCatalog.Type = AttachmentViewCatalog.self diff --git a/Sources/StreamChatUI/Utils/ComponentsProvider.swift b/Sources/StreamChatUI/ComponentsProvider.swift similarity index 100% rename from Sources/StreamChatUI/Utils/ComponentsProvider.swift rename to Sources/StreamChatUI/ComponentsProvider.swift diff --git a/Sources/StreamChatUI/Composer/ComposerVC.swift b/Sources/StreamChatUI/Composer/ComposerVC.swift index c2ffebb2162..8f60494a018 100644 --- a/Sources/StreamChatUI/Composer/ComposerVC.swift +++ b/Sources/StreamChatUI/Composer/ComposerVC.swift @@ -15,6 +15,16 @@ public enum AttachmentValidationError: Error { case maxAttachmentsCountPerMessageExceeded(limit: Int) } +public struct LocalAttachmentInfoKey: Hashable, Equatable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public static let originalImage: Self = .init(rawValue: "originalImage") +} + /// The possible composer states. An Enum is not used so it does not cause /// future breaking changes and is possible to extend with new cases. public struct ComposerState: RawRepresentable, Equatable { @@ -825,12 +835,28 @@ open class ComposerVC: _ViewController, suggestionsVC.removeFromParent() suggestionsVC.view.removeFromSuperview() } + + /// Creates and adds an attachment from the given URL to the `content` + /// - Parameters: + /// - url: The URL of the attachment + /// - type: The type of the attachment + open func addAttachmentToContent( + from url: URL, + type: AttachmentType + ) throws { + try addAttachmentToContent(from: url, type: type, info: [:]) + } /// Creates and adds an attachment from the given URL to the `content` /// - Parameters: /// - url: The URL of the attachment /// - type: The type of the attachment - open func addAttachmentToContent(from url: URL, type: AttachmentType) throws { + /// - info: The metadata of the attachment + open func addAttachmentToContent( + from url: URL, + type: AttachmentType, + info: [LocalAttachmentInfoKey: Any] + ) throws { guard let chatConfig = channelController?.client.config else { log.assertionFailure("Channel controller must be set at this point") return @@ -847,8 +873,20 @@ open class ComposerVC: _ViewController, guard fileSize < chatConfig.maxAttachmentSize else { throw AttachmentValidationError.maxFileSizeExceeded } + + var localMetadata = AnyAttachmentLocalMetadata() + if let image = info[.originalImage] as? UIImage { + localMetadata.originalResolution = ( + width: Double(image.size.width), + height: Double(image.size.height) + ) + } - let attachment = try AnyAttachmentPayload(localFileURL: url, attachmentType: type) + let attachment = try AnyAttachmentPayload( + attachmentType: type, + localFileURL: url, + localMetadata: localMetadata + ) content.attachments.append(attachment) } @@ -932,9 +970,18 @@ open class ComposerVC: _ViewController, log.error("Unexpected item selected in image picker") return } - + + var localAttachmentInfo: [LocalAttachmentInfoKey: Any] = [:] + if let originalImage = info[.originalImage] { + localAttachmentInfo[.originalImage] = originalImage + } + do { - try self?.addAttachmentToContent(from: urlAndType.0, type: urlAndType.1) + try self?.addAttachmentToContent( + from: urlAndType.0, + type: urlAndType.1, + info: localAttachmentInfo + ) } catch { self?.handleAddAttachmentError( attachmentURL: urlAndType.0, @@ -956,7 +1003,7 @@ open class ComposerVC: _ViewController, attachmentType = .file } do { - try addAttachmentToContent(from: fileURL, type: attachmentType) + try addAttachmentToContent(from: fileURL, type: attachmentType, info: [:]) } catch { handleAddAttachmentError( attachmentURL: fileURL, @@ -988,7 +1035,11 @@ open class ComposerVC: _ViewController, let type: AttachmentType = .image do { - try addAttachmentToContent(from: imageUrl, type: type) + try addAttachmentToContent( + from: imageUrl, + type: type, + info: [.originalImage: image] + ) } catch { handleAddAttachmentError( attachmentURL: imageUrl, diff --git a/Sources/StreamChatUI/Deprecations.swift b/Sources/StreamChatUI/Deprecations.swift index a78051502c7..c0b0e2e8aa7 100644 --- a/Sources/StreamChatUI/Deprecations.swift +++ b/Sources/StreamChatUI/Deprecations.swift @@ -308,3 +308,8 @@ public extension Appearance.Images { set { messageDeliveryStatusRead = newValue } } } + +public extension CGSize { + @available(*, deprecated, message: "use Components.avatarThumbnailSize instead.") + static var avatarThumbnailSize: CGSize { CGSize(width: 40, height: 40) } +} diff --git a/Sources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swift b/Sources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swift index fc7454ecd59..b63130b2ed9 100644 --- a/Sources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swift +++ b/Sources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swift @@ -31,16 +31,12 @@ open class ImageAttachmentGalleryCell: GalleryCollectionViewCell { super.updateContent() let imageAttachment = content?.attachment(payloadType: ImageAttachmentPayload.self) - - if let url = imageAttachment?.imageURL { - components.imageLoader.loadImage( - into: imageView, - url: url, - imageCDN: components.imageCDN - ) - } else { - imageView.image = nil - } + + components.imageLoader.loadImage( + into: imageView, + from: imageAttachment?.payload, + maxResolutionInPixels: components.imageAttachmentMaxPixels + ) } override open func viewForZooming(in scrollView: UIScrollView) -> UIView? { diff --git a/Sources/StreamChatUI/Utils/NavigationVC.swift b/Sources/StreamChatUI/Navigation/NavigationVC.swift similarity index 100% rename from Sources/StreamChatUI/Utils/NavigationVC.swift rename to Sources/StreamChatUI/Navigation/NavigationVC.swift diff --git a/Sources/StreamChatUI/Utils/Array+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/Array+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/Array+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/Array+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/Array+SafeSubscript.swift b/Sources/StreamChatUI/Utils/Extensions/Array+SafeSubscript.swift similarity index 100% rename from Sources/StreamChatUI/Utils/Array+SafeSubscript.swift rename to Sources/StreamChatUI/Utils/Extensions/Array+SafeSubscript.swift diff --git a/Sources/StreamChatUI/Utils/Bundle+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/Bundle+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/Bundle+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/Bundle+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/CACornerMask+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/CACornerMask+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/CACornerMask+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/CACornerMask+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/CALayer+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/CALayer+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/CALayer+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/CALayer+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/CGPoint+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/CGPoint+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/CGPoint+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/CGPoint+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/CGRect+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/CGRect+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/CGRect+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/CGRect+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/ChatChannelNamer.swift b/Sources/StreamChatUI/Utils/Extensions/ChatChannelNamer.swift similarity index 100% rename from Sources/StreamChatUI/Utils/ChatChannelNamer.swift rename to Sources/StreamChatUI/Utils/Extensions/ChatChannelNamer.swift diff --git a/Sources/StreamChatUI/Utils/NSLayoutConstraint+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/NSLayoutConstraint+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/NSLayoutConstraint+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/NSLayoutConstraint+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/Reusable+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/Reusable+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/Reusable+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/Reusable+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/String+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/String+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/String+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/String+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/UIColor+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/UIColor+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/UIColor+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/UIColor+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/UIFont+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/UIFont+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/UIFont+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/UIFont+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/UIImage+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/UIImage+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/UIImage+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/UIImage+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/UILabel+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/UILabel+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/UILabel+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/UILabel+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/UIScrollView+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/UIScrollView+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/UIScrollView+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/UIScrollView+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/UIStackView+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/UIStackView+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/UIStackView+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/UIStackView+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/UITextView+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/UITextView+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/UITextView+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/UITextView+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/UIView+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/UIView+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/UIView+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/UIView+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/UIViewController+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/UIViewController+Extensions.swift similarity index 100% rename from Sources/StreamChatUI/Utils/UIViewController+Extensions.swift rename to Sources/StreamChatUI/Utils/Extensions/UIViewController+Extensions.swift diff --git a/Sources/StreamChatUI/Utils/ImageCDN.swift b/Sources/StreamChatUI/Utils/ImageCDN.swift deleted file mode 100644 index 84540c3d86a..00000000000 --- a/Sources/StreamChatUI/Utils/ImageCDN.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// Copyright © 2022 Stream.io Inc. All rights reserved. -// - -import UIKit - -/// ImageCDN is providing set of functions to improve handling of images from CDN. -public protocol ImageCDN { - /// Customised (filtered) key for image cache. - /// - Parameter imageURL: URL of the image that should be customised (filtered). - /// - Returns: String to be used as an image cache key. - func cachingKey(forImage url: URL) -> String - - /// Prepare and return a `URLRequest` for the given image `URL` - /// This function can be used to inject custom headers for image loading request. - func urlRequest(forImage url: URL) -> URLRequest - - /// Enhance image URL with size parameters to get thumbnail - /// - Parameters: - /// - originalURL: URL of the image to get the thumbnail for. - /// - preferredSize: The requested thumbnail size. - /// - /// Use view size in points for `preferredSize`, point to pixel ratio (scale) of the device is applied inside of this function. - func thumbnailURL(originalURL: URL, preferredSize: CGSize) -> URL -} - -extension ImageCDN { - public func urlRequest(forImage url: URL) -> URLRequest { - URLRequest(url: url) - } -} - -open class StreamImageCDN: ImageCDN { - public static var streamCDNURL = "stream-io-cdn.com" - - // Initializer required for subclasses - public init() {} - - open func cachingKey(forImage url: URL) -> String { - let key = url.absoluteString - - guard - var components = URLComponents(url: url, resolvingAgainstBaseURL: true), - let host = components.host, - host.contains(StreamImageCDN.streamCDNURL) - else { return key } - - // Keep these parameters in the cache key as they determine the image size. - let persistedParameters = ["w", "h"] - - let newParameters = components.queryItems?.filter { persistedParameters.contains($0.name) } ?? [] - components.queryItems = newParameters.isEmpty ? nil : newParameters - return components.string ?? key - } - - // Although this is the same as the default impl, we still need it - // so subclasses can safely override it - open func urlRequest(forImage url: URL) -> URLRequest { - URLRequest(url: url) - } - - open func thumbnailURL(originalURL: URL, preferredSize: CGSize) -> URL { - guard - var components = URLComponents(url: originalURL, resolvingAgainstBaseURL: true), - let host = components.host, - host.contains(StreamImageCDN.streamCDNURL) - else { return originalURL } - - let scale = UIScreen.main.scale - let queryItems: [String: String] = [ - "w": preferredSize.width == 0 ? "*" : String(format: "%.0f", preferredSize.width * scale), - "h": preferredSize.height == 0 ? "*" : String(format: "%.0f", preferredSize.height * scale), - "crop": "center", - "resize": "fill", - "ro": "0" // Required parameter. - ] - - var items = components.queryItems ?? [] - - for (key, value) in queryItems { - if let index = items.firstIndex(where: { $0.name == key }) { - items[index].value = value - } else { - let item = URLQueryItem(name: key, value: value) - items += [item] - } - } - - components.queryItems = items - return components.url ?? originalURL - } -} - -public extension CGSize { - /// Maximum size of avatar used in the UI. - /// - /// It's better to use single size of avatar thumbnail to utilise the cache. - static var avatarThumbnailSize: CGSize { CGSize(width: 40, height: 40) } -} diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/ImageCDN.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/ImageCDN.swift new file mode 100644 index 00000000000..e6aa68a3861 --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/ImageCDN.swift @@ -0,0 +1,31 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +import UIKit + +/// A protocol responsible to configure the image CDN by intercepting the image url requests. +public protocol ImageCDN { + /// Intercept the image url request. + /// + /// This can be used to change the host of the CDN, adding HTTP Headers etc. + /// If your custom CDN supports resizing capabilities, you can also make use of the `resize` parameter. + /// + /// - Parameters: + /// - url: The image url to be loaded. + /// - resize: The resize configuration of the image to be loaded, if resizing was provided. + /// - Returns: An `URLRequest` that represents the image request. + func urlRequest(forImageUrl url: URL, resize: ImageResize?) -> URLRequest + + /// The cachingKey for each image url. + /// + /// If the CDN has unique query parameters in the url like random IDs, it is important to remove + /// those query parameters from it, otherwise it won't be able to load images from the cache, + /// since the key will always be different. If the CDN supports resizing capabilities, it might have + /// width and height query parameters, these ones you should not remove so that there are different + /// caches for each size of the image. + /// + /// - Parameter imageURL: The URL of the loaded image. + /// - Returns: A String to be used as an image cache key. + func cachingKey(forImageUrl url: URL) -> String +} diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/StreamCDN.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/StreamCDN.swift new file mode 100644 index 00000000000..69ee8db04f0 --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/StreamCDN.swift @@ -0,0 +1,65 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +import UIKit + +open class StreamImageCDN: ImageCDN { + public static var streamCDNURL = "stream-io-cdn.com" + + public init() {} + + open func urlRequest(forImageUrl url: URL, resize: ImageResize?) -> URLRequest { + // In case it is not an image from Stream's CDN, don't do nothing. + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let host = components.host, host.contains(StreamImageCDN.streamCDNURL) else { + return URLRequest(url: url) + } + + // If there is not resize, not need to add query parameters to the URL. + guard let resize = resize else { + return URLRequest(url: url) + } + + let scale = UIScreen.main.scale + var queryItems: [String: String] = [ + "w": resize.width == 0 ? "*" : String(format: "%.0f", resize.width * scale), + "h": resize.height == 0 ? "*" : String(format: "%.0f", resize.height * scale), + "resize": resize.mode.value, + "ro": "0" // Required parameter. + ] + if let cropValue = resize.mode.cropValue { + queryItems["crop"] = cropValue + } + + var items = components.queryItems ?? [] + + for (key, value) in queryItems { + if let index = items.firstIndex(where: { $0.name == key }) { + items[index].value = value + } else { + let item = URLQueryItem(name: key, value: value) + items += [item] + } + } + + components.queryItems = items + return URLRequest(url: components.url ?? url) + } + + open func cachingKey(forImageUrl url: URL) -> String { + let key = url.absoluteString + + // In case it is not an image from Stream's CDN, don't do nothing. + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let host = components.host, host.contains(StreamImageCDN.streamCDNURL) else { + return key + } + + let persistedParameters = ["w", "h", "resize", "crop"] + + let newParameters = components.queryItems?.filter { persistedParameters.contains($0.name) } ?? [] + components.queryItems = newParameters.isEmpty ? nil : newParameters + return components.string ?? key + } +} diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageDownloadOptions.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageDownloadOptions.swift new file mode 100644 index 00000000000..a1031387dae --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageDownloadOptions.swift @@ -0,0 +1,15 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// The options for downloading an image. +public struct ImageDownloadOptions { + /// The resize information when loading an image. `Nil` if you want the full resolution of the image. + public var resize: ImageResize? + + public init(resize: ImageResize? = nil) { + self.resize = resize + } +} diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageDownloadRequest.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageDownloadRequest.swift new file mode 100644 index 00000000000..436817d822c --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageDownloadRequest.swift @@ -0,0 +1,16 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// The url and options information of an image download request. +public struct ImageDownloadRequest { + public let url: URL + public let options: ImageDownloadOptions + + public init(url: URL, options: ImageDownloadOptions) { + self.url = url + self.options = options + } +} diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift new file mode 100644 index 00000000000..9bd2ec6f403 --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +import UIKit + +/// The options for loading an image into a view. +public struct ImageLoaderOptions { + // Ideally, the name would be `ImageLoadingOptions`, but this would conflict with Nuke. + + /// The resize information when loading an image. `Nil` if you want the full resolution of the image. + public var resize: ImageResize? + + /// The placeholder to be used while the image is finishing loading. + public var placeholder: UIImage? + + public init(resize: ImageResize? = nil, placeholder: UIImage? = nil) { + self.placeholder = placeholder + self.resize = resize + } +} diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift index fdfbe366670..885280e7bdd 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift @@ -2,31 +2,58 @@ // Copyright © 2022 Stream.io Inc. All rights reserved. // +import StreamChat import UIKit -/// ImageLoading is providing set of functions for downloading of images from URLs. +/// A protocol that provides a set of functions for loading images. public protocol ImageLoading: AnyObject { - /// Load an image from using the given URL request + /// Load an image into an imageView from the given `URL`. /// - Parameters: - /// - urlRequest: The `URLRequest` object used to fetch the image - /// - cachingKey: The key to be used for caching this image - /// - completion: Completion that gets called when the download is finished + /// - imageView: The image view where the image will be loaded. + /// - url: The `URL` of the image. If `nil` it will load the placeholder. + /// - options: The loading options on how to fetch the image. + /// - completion: The completion when the loading is finished. + /// - Returns: A cancellable task. + @discardableResult + func loadImage( + into imageView: UIImageView, + from url: URL?, + with options: ImageLoaderOptions, + completion: ((_ result: Result) -> Void)? + ) -> Cancellable? + + /// Download an image from the given `URL`. + /// - Parameters: + /// - request: The url and options information of an image download request. + /// - completion: The completion when the loading is finished. + /// - Returns: A cancellable task. + @discardableResult + func downloadImage( + with request: ImageDownloadRequest, + completion: @escaping ((_ result: Result) -> Void) + ) -> Cancellable? + + /// Load a batch of images and get notified when all of them complete loading. + /// - Parameters: + /// - requests: The urls and options information of each image download request. + /// - completion: The completion when the loading is finished. + /// It returns an array of image and errors in case the image failed to load. + func downloadMultipleImages( + with requests: [ImageDownloadRequest], + completion: @escaping (([Result]) -> Void) + ) + + // MARK: - Deprecations + + @available(*, deprecated, message: "use downloadImage() instead.") @discardableResult func loadImage( using urlRequest: URLRequest, cachingKey: String?, completion: @escaping ((_ result: Result) -> Void) ) -> Cancellable? - - /// Load an image into an imageView from the given URL - /// - Parameters: - /// - imageView: The `UIImageView` object in which the image should be loaded - /// - url: The `URL` from which the image is to be loaded - /// - imageCDN: The `ImageCDN`object which is to be used - /// - placeholder: The placeholder `UIImage` to be used - /// - resize: Whether to resize the image or not - /// - preferredSize: The preferred size of the image to be loaded - /// - completion: Completion that gets called when the download is finished + + @available(*, deprecated, message: "use loadImage(into:from:with:) instead.") @discardableResult func loadImage( into imageView: UIImageView, @@ -37,15 +64,8 @@ public protocol ImageLoading: AnyObject { preferredSize: CGSize?, completion: ((_ result: Result) -> Void)? ) -> Cancellable? - - /// Load images from a given URLs - /// - Parameters: - /// - urls: The URLs to load the images from - /// - placeholders: The placeholder images. Placeholders are used when an image fails to load from a URL. The placeholders are used rotationally - /// - loadThumbnails: Should load the images as thumbnails. If this is set to `true`, the thumbnail URL is derived from the `imageCDN` object - /// - thumbnailSize: The size of the thumbnail. This parameter is used only if the `loadThumbnails` parameter is true - /// - imageCDN: The imageCDN to be used - /// - completion: Completion that gets called when all the images finish downloading + + @available(*, deprecated, message: "use loadMultipleImages() instead.") func loadImages( from urls: [URL], placeholders: [UIImage], @@ -56,7 +76,77 @@ public protocol ImageLoading: AnyObject { ) } +// MARK: - Image Attachment Helper API + +public extension ImageLoading { + @discardableResult + func loadImage( + into imageView: UIImageView, + from attachmentPayload: ImageAttachmentPayload?, + maxResolutionInPixels: Double, + completion: ((_ result: Result) -> Void)? = nil + ) -> Cancellable? { + guard let originalWidth = attachmentPayload?.originalWidth, + let originalHeight = attachmentPayload?.originalHeight else { + return loadImage( + into: imageView, + from: attachmentPayload?.imageURL, + with: ImageLoaderOptions(), + completion: completion + ) + } + + let imageSizeCalculator = ImageSizeCalculator() + let newSize = imageSizeCalculator.calculateSize( + originalWidthInPixels: originalWidth, + originalHeightInPixels: originalHeight, + maxResolutionTotalPixels: maxResolutionInPixels + ) + + return loadImage( + into: imageView, + from: attachmentPayload?.imageURL, + with: ImageLoaderOptions(resize: .init(newSize)), + completion: completion + ) + } +} + +// MARK: - Default Parameters + public extension ImageLoading { + // Default empty completion block. + @discardableResult + func loadImage( + into imageView: UIImageView, + from url: URL?, + with options: ImageLoaderOptions, + completion: ((_ result: Result) -> Void)? = nil + ) -> Cancellable? { + loadImage(into: imageView, from: url, with: options, completion: completion) + } +} + +// MARK: Deprecation fallbacks + +public extension ImageLoading { + @available(*, deprecated, message: "use downloadImage() instead.") + func loadImage( + using urlRequest: URLRequest, + cachingKey: String?, completion: @escaping ((Result) -> Void) + ) -> Cancellable? { + guard let url = urlRequest.url else { + completion(.failure(NSError(domain: "io.getstream.imageDeprecation.invalidUrl", code: 1))) + return nil + } + + return downloadImage( + with: ImageDownloadRequest(url: url, options: ImageDownloadOptions()), + completion: completion + ) + } + + @available(*, deprecated, message: "use loadImage(into:from:with:) instead.") @discardableResult func loadImage( into imageView: UIImageView, @@ -69,30 +159,32 @@ public extension ImageLoading { ) -> Cancellable? { loadImage( into: imageView, - url: url, - imageCDN: imageCDN, - placeholder: placeholder, - resize: resize, - preferredSize: preferredSize, + from: url, + with: ImageLoaderOptions( + resize: preferredSize.map { ImageResize($0) }, + placeholder: placeholder + ), completion: completion ) } - + + @available(*, deprecated, message: "use loadMultipleImages() instead.") func loadImages( from urls: [URL], placeholders: [UIImage], loadThumbnails: Bool = true, - thumbnailSize: CGSize = .avatarThumbnailSize, + thumbnailSize: CGSize = Components.default.avatarThumbnailSize, imageCDN: ImageCDN, completion: @escaping (([UIImage]) -> Void) ) { - loadImages( - from: urls, - placeholders: placeholders, - loadThumbnails: loadThumbnails, - thumbnailSize: thumbnailSize, - imageCDN: imageCDN, - completion: completion - ) + let requests = urls.map { url in + ImageDownloadRequest(url: url, options: .init(resize: .init(thumbnailSize))) + } + + downloadMultipleImages(with: requests) { results in + let imagesMapper = ImageResultsMapper(results: results) + let images = imagesMapper.mapErrors(with: placeholders) + completion(images) + } } } diff --git a/Sources/StreamChatUI/Utils/ImageMerger.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageMerger.swift similarity index 100% rename from Sources/StreamChatUI/Utils/ImageMerger.swift rename to Sources/StreamChatUI/Utils/ImageLoading/ImageMerger.swift diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift new file mode 100644 index 00000000000..9c8e0508967 --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift @@ -0,0 +1,70 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +import UIKit + +/// The resize information when loading an image. +public struct ImageResize { + /// The new width of the image in points (not pixels). + public var width: CGFloat + /// The new height of the image in points (not pixels). + public var height: CGFloat + /// The resize content mode. + public var mode: Mode + + /// The resize information when loading an image. + /// + /// - Parameters: + /// - size: The new size of the image in points (not pixels). + /// - mode: The resize content mode. + public init(_ size: CGSize, mode: Mode = .clip) { + width = size.width + height = size.height + self.mode = mode + } +} + +extension ImageResize { + /// The way to resize the image. The default value is `clip`. + /// + /// The possible options: + /// - `clip` + /// - `crop` + /// - `fill` + /// - `scale` + public struct Mode { + public var value: String + public var cropValue: String? + + init(value: String, cropValue: String? = nil) { + self.value = value + self.cropValue = cropValue + } + + /// Make the image as large as possible, while maintaining aspect ratio and keeping the + /// height and width less than or equal to the given height and width. + public static var clip = Mode(value: "clip") + + /// Crop to the given dimensions, keeping focus on the portion of the image in the crop mode. + public static func crop(_ value: Crop = .center) -> Self { + Mode(value: "crop", cropValue: value.rawValue) + } + + /// Make the image as large as possible, while maintaining aspect ratio and keeping the height and width + /// less than or equal to the given height and width. Fill any leftover space with a black background. + public static var fill = Mode(value: "fill") + + /// Ignore aspect ratio, and resize the image to the given height and width. + public static var scale = Mode(value: "scale") + + /// The crop position of the image. + public enum Crop: String { + case top + case bottom + case right + case left + case center + } + } +} diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageResultsMapper.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageResultsMapper.swift new file mode 100644 index 00000000000..da5b340bfe5 --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageResultsMapper.swift @@ -0,0 +1,39 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +import UIKit + +/// Helper component to map image results. +struct ImageResultsMapper { + let results: [Result] + + init(results: [Result]) { + self.results = results + } + + /// Replace errors with placeholder images. + /// + /// - Parameter placeholderImages: The placeholder images. + /// - Returns: Returns an array of UIImages without errors. + func mapErrors(with placeholderImages: [UIImage]) -> [UIImage] { + var placeholderImages = placeholderImages + var finalImages: [UIImage] = [] + + for result in results { + switch result { + case let .success(image): + finalImages.append(image) + case .failure: + guard !placeholderImages.isEmpty else { + continue + } + + let placeholder = placeholderImages.removeFirst() + finalImages.append(placeholder) + } + } + + return finalImages + } +} diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift new file mode 100644 index 00000000000..6dc870102e6 --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift @@ -0,0 +1,62 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +import UIKit + +/// Responsible to calculate the image size in points given the original resolution and the max desirable +/// resolution. In case the original resolution is already below the maximum, it uses the +/// original resolution and converts it to points. +struct ImageSizeCalculator { + /// Calculates the image size in points given the original resolution and the max desirable + /// resolution. In case the original resolution is already below the maximum, it uses the + /// original resolution and converts it to points. + /// + /// - Parameters: + /// - originalWidthInPixels: The original width in pixels. + /// - originalHeightInPixels: The original height in pixels. + /// - maxResolutionTotalPixels: The maximum resolution of the new size. + /// - Returns: Returns the original resolution in points or the max resolution in points + /// in case the original resolution is bigger than the maximum. + func calculateSize( + originalWidthInPixels: Double, + originalHeightInPixels: Double, + maxResolutionTotalPixels: Double + ) -> CGSize { + let scale = UIScreen.main.scale + + let originalResolutionTotalPixels = originalWidthInPixels * originalHeightInPixels + guard originalResolutionTotalPixels > maxResolutionTotalPixels else { + let widthInPoints = originalWidthInPixels / scale + let heightInPoints = originalHeightInPixels / scale + let originalSizeInPoints = CGSize(width: widthInPoints, height: heightInPoints) + return originalSizeInPoints + } + + /// The formula to calculate the new resolution is based on the max pixels and + /// the original aspect ratio. To get the formula, a system of questions is needed. + /// + /// { w * h = maxResolutionTotalPixels } + /// { w / h = originalRatio } + /// -> + /// { w = maxResolutionTotalPixels / h + /// { h = w / originalRatio + /// -> + /// { w = maxResolutionTotalPixels / (w / originalRatio) + /// { h = w / originalRatio + /// -> + /// { wˆ2 / originalRatio = maxResolutionTotalPixels + /// { h = w / originalRatio + /// -> + /// { w = sqrt(maxResolutionTotalPixels * originalRatio) + /// { h = w / originalRatio + /// + let originalRatio = originalWidthInPixels / originalHeightInPixels + let newWidthInPixels = sqrt(maxResolutionTotalPixels * originalRatio) + let newHeightInPixels = newWidthInPixels / originalRatio + let newWidthInPoints = newWidthInPixels / scale + let newHeightInPoints = newHeightInPixels / scale + let newSize = CGSize(width: newWidthInPoints, height: newHeightInPoints) + return newSize + } +} diff --git a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift index d6488444721..83d0bac6f03 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift @@ -6,124 +6,52 @@ import UIKit extension ImageTask: Cancellable {} -/// The class which is resposible for loading images from URLs. +/// The class which is responsible for loading images from URLs. /// Internally uses `Nuke`'s shared object of `ImagePipeline` to load the image. open class NukeImageLoader: ImageLoading { public init() {} - @discardableResult - open func loadImage( - using urlRequest: URLRequest, - cachingKey: String?, - completion: @escaping ((Result) -> Void) - ) -> Cancellable? { - var userInfo: [ImageRequest.UserInfoKey: Any]? - if let cachingKey = cachingKey { - userInfo = [.imageIdKey: cachingKey] - } - - let request = ImageRequest( - urlRequest: urlRequest, - userInfo: userInfo - ) - - let imageTask = ImagePipeline.shared.loadImage(with: request) { result in - switch result { - case let .success(imageResponse): - completion(.success(imageResponse.image)) - case let .failure(error): - completion(.failure(error)) - } - } - - return imageTask + open var avatarThumbnailSize: CGSize { + Components.default.avatarThumbnailSize } - - open func loadImages( - from urls: [URL], - placeholders: [UIImage], - loadThumbnails: Bool, - thumbnailSize: CGSize, - imageCDN: ImageCDN, - completion: @escaping (([UIImage]) -> Void) - ) { - let group = DispatchGroup() - var images: [UIImage] = [] - - for avatarUrl in urls { - var placeholderIndex = 0 - - let thumbnailUrl = imageCDN.thumbnailURL(originalURL: avatarUrl, preferredSize: .avatarThumbnailSize) - let imageRequest = imageCDN.urlRequest(forImage: thumbnailUrl) - let cachingKey = imageCDN.cachingKey(forImage: avatarUrl) - group.enter() - - loadImage(using: imageRequest, cachingKey: cachingKey) { result in - switch result { - case let .success(image): - images.append(image) - case .failure: - if !placeholders.isEmpty { - // Rotationally use the placeholders - images.append(placeholders[placeholderIndex]) - placeholderIndex += 1 - if placeholderIndex == placeholders.count { - placeholderIndex = 0 - } - } - } - group.leave() - } - } - - group.notify(queue: .main) { - completion(images) - } + open var imageCDN: ImageCDN { + Components.default.imageCDN } - + @discardableResult open func loadImage( into imageView: UIImageView, - url: URL?, - imageCDN: ImageCDN, - placeholder: UIImage?, - resize: Bool = true, - preferredSize: CGSize? = nil, - completion: ((_ result: Result) -> Void)? = nil + from url: URL?, + with options: ImageLoaderOptions, + completion: ((Result) -> Void)? ) -> Cancellable? { imageView.currentImageLoadingTask?.cancel() - guard var url = url else { - imageView.image = placeholder + guard let url = url else { + imageView.image = options.placeholder return nil } - let urlRequest = imageCDN.urlRequest(forImage: url) - let size = preferredSize ?? .zero - let canResize = resize && size != .zero + let urlRequest = imageCDN.urlRequest(forImageUrl: url, resize: options.resize) + let cachingKey = imageCDN.cachingKey(forImageUrl: url) - // If we don't have a valid size, we will not be able to resize using our CDN. - if canResize { - url = imageCDN.thumbnailURL(originalURL: url, preferredSize: size) + var processors: [ImageProcessing] = [] + if let resize = options.resize { + let cgSize = CGSize(width: resize.width, height: resize.height) + processors.append(ImageProcessors.Resize(size: cgSize)) } - // At this point, if the image will be resized using our CDN, the url will contain the size values as - // parameters, and this will create a unique caching key for that url with this size. Only in this case we can - // successfully cache the resized image. - let cachingKey = imageCDN.cachingKey(forImage: url) - - // If we don't have a size, we cannot properly create a 1:1 matching between a caching key and the resized image - // that LateResize will create. Therefore, we use/cache the original resolution image. - let processors: [ImageProcessing] = canResize - ? [ImageProcessors.LateResize(id: cachingKey, sizeProvider: { imageView.bounds.size })] - : [] + let request = ImageRequest( + urlRequest: urlRequest, + processors: processors, + userInfo: [.imageIdKey: cachingKey] + ) - let request = ImageRequest(urlRequest: urlRequest, processors: processors, userInfo: [.imageIdKey: cachingKey]) - let options = ImageLoadingOptions(placeholder: placeholder) + let nukeOptions = ImageLoadingOptions(placeholder: options.placeholder) imageView.currentImageLoadingTask = StreamChatUI.loadImage( with: request, - options: options, + options: nukeOptions, into: imageView ) { result in switch result { @@ -136,6 +64,66 @@ open class NukeImageLoader: ImageLoading { return imageView.currentImageLoadingTask } + + @discardableResult + open func downloadImage( + with request: ImageDownloadRequest, + completion: @escaping ((Result) -> Void) + ) -> Cancellable? { + let url = request.url + let options = request.options + let urlRequest = imageCDN.urlRequest(forImageUrl: url, resize: options.resize) + let cachingKey = imageCDN.cachingKey(forImageUrl: url) + + var processors: [ImageProcessing] = [] + if let resize = options.resize { + let cgSize = CGSize(width: resize.width, height: resize.height) + processors.append(ImageProcessors.Resize(size: cgSize)) + } + + let request = ImageRequest( + urlRequest: urlRequest, + processors: processors, + userInfo: [.imageIdKey: cachingKey] + ) + + let imageTask = ImagePipeline.shared.loadImage(with: request) { result in + switch result { + case let .success(imageResponse): + completion(.success(imageResponse.image)) + case let .failure(error): + completion(.failure(error)) + } + } + + return imageTask + } + + open func downloadMultipleImages( + with requests: [ImageDownloadRequest], + completion: @escaping (([Result]) -> Void) + ) { + let group = DispatchGroup() + var results: [Result] = [] + + for request in requests { + let url = request.url + let downloadOptions = request.options + + group.enter() + + let request = ImageDownloadRequest(url: url, options: downloadOptions) + downloadImage(with: request) { result in + results.append(result) + + group.leave() + } + } + + group.notify(queue: .main) { + completion(results) + } + } } private extension UIImageView { diff --git a/Sources/StreamChatUI/Utils/NukeImageProcessor.swift b/Sources/StreamChatUI/Utils/ImageProcessor/NukeImageProcessor.swift similarity index 100% rename from Sources/StreamChatUI/Utils/NukeImageProcessor.swift rename to Sources/StreamChatUI/Utils/ImageProcessor/NukeImageProcessor.swift diff --git a/Sources/StreamChatUI/Utils/VideoLoading.swift b/Sources/StreamChatUI/Utils/VideoLoading/VideoLoading.swift similarity index 100% rename from Sources/StreamChatUI/Utils/VideoLoading.swift rename to Sources/StreamChatUI/Utils/VideoLoading/VideoLoading.swift diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index e76345be0eb..f47ca1e1205 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1026,6 +1026,8 @@ AD52A21C2804851600D0157E /* CommandDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD52A21B2804851600D0157E /* CommandDTO.swift */; }; AD52A21D2804851600D0157E /* CommandDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD52A21B2804851600D0157E /* CommandDTO.swift */; }; AD540AE2260CECA10082D802 /* QuotedChatMessageView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD540AE1260CECA10082D802 /* QuotedChatMessageView_Tests.swift */; }; + AD552E0128F46CE700199A6F /* ImageLoaderOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD552E0028F46CE700199A6F /* ImageLoaderOptions.swift */; }; + AD552E0228F46CE700199A6F /* ImageLoaderOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD552E0028F46CE700199A6F /* ImageLoaderOptions.swift */; }; AD6A248A280DA890003BA1E4 /* PushDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6A2489280DA88F003BA1E4 /* PushDevice.swift */; }; AD6A248B280DA890003BA1E4 /* PushDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6A2489280DA88F003BA1E4 /* PushDevice.swift */; }; AD6BEFF02786070800E184B4 /* SwitchButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6BEFEF2786070800E184B4 /* SwitchButton.swift */; }; @@ -1056,6 +1058,9 @@ AD7AC99B260A9572004AADA5 /* MessagePinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7AC98B260A94C6004AADA5 /* MessagePinning.swift */; }; AD7B51D327EDECA80068CBD1 /* MixedAttachmentViewInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7B51D227EDECA80068CBD1 /* MixedAttachmentViewInjector.swift */; }; AD7B51D427EDECA80068CBD1 /* MixedAttachmentViewInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7B51D227EDECA80068CBD1 /* MixedAttachmentViewInjector.swift */; }; + AD7BBFCB2901AF3F004E8B76 /* ImageResultsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7BBFCA2901AF3F004E8B76 /* ImageResultsMapper.swift */; }; + AD7BBFCC2901AF3F004E8B76 /* ImageResultsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7BBFCA2901AF3F004E8B76 /* ImageResultsMapper.swift */; }; + AD7BBFD02901B1B7004E8B76 /* ImageResultsMapper_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7BBFCD2901B1AE004E8B76 /* ImageResultsMapper_Tests.swift */; }; AD7CF1712694ABCE00F3101D /* ComposerVC_Documentation_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */; }; AD7DFC3625D2FA8100DD9DA3 /* CurrentUserUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7DFC3525D2FA8100DD9DA3 /* CurrentUserUpdater.swift */; }; AD81AF0525ED141800F17F8F /* CellSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD81AEEC25ED135100F17F8F /* CellSeparatorView.swift */; }; @@ -1065,11 +1070,17 @@ AD87D0A1263C7823008B466C /* AttachmentButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD87D0A0263C7823008B466C /* AttachmentButton.swift */; }; AD87D0AB263C7A7E008B466C /* ShrinkInputButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD87D0AA263C7A7E008B466C /* ShrinkInputButton.swift */; }; AD87D0BD263C7C09008B466C /* CircularCloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD87D0BC263C7C09008B466C /* CircularCloseButton.swift */; }; + AD8B72752908016400921C31 /* ImageDownloadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8B72742908016400921C31 /* ImageDownloadRequest.swift */; }; + AD8B72762908016400921C31 /* ImageDownloadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8B72742908016400921C31 /* ImageDownloadRequest.swift */; }; AD8D1809268F7290004E3A5C /* TypingSuggester.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8D1808268F7290004E3A5C /* TypingSuggester.swift */; }; AD8D180B268F8ED4004E3A5C /* SlackComposerVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8D180A268F8ED4004E3A5C /* SlackComposerVC.swift */; }; AD90D18525D56196001D03BB /* CurrentUserUpdater_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD90D18425D56196001D03BB /* CurrentUserUpdater_Tests.swift */; }; AD91C35428A5550C004D1E45 /* ChatMessageListVC+DiffKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD91C35328A5550C004D1E45 /* ChatMessageListVC+DiffKit.swift */; }; AD91C35528A5550C004D1E45 /* ChatMessageListVC+DiffKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD91C35328A5550C004D1E45 /* ChatMessageListVC+DiffKit.swift */; }; + AD95FD0D28F991ED00DBDF41 /* ImageResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD95FD0C28F991ED00DBDF41 /* ImageResize.swift */; }; + AD95FD0E28F991ED00DBDF41 /* ImageResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD95FD0C28F991ED00DBDF41 /* ImageResize.swift */; }; + AD95FD1128FA038900DBDF41 /* ImageDownloadOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD95FD1028FA038900DBDF41 /* ImageDownloadOptions.swift */; }; + AD95FD1228FA038900DBDF41 /* ImageDownloadOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD95FD1028FA038900DBDF41 /* ImageDownloadOptions.swift */; }; AD99A7CE28EF17CA005185DF /* SlackReactonsItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99A7CD28EF17CA005185DF /* SlackReactonsItemView.swift */; }; AD99A7D028EF17ED005185DF /* SlackReactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99A7CF28EF17ED005185DF /* SlackReactionsView.swift */; }; AD99A7D228EF180C005185DF /* SlackReactionsMessagePopupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99A7D128EF180C005185DF /* SlackReactionsMessagePopupVC.swift */; }; @@ -1132,6 +1143,11 @@ ADCD5E4427987EFE00E66911 /* StreamModalTransitioningDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCD5E4227987EFE00E66911 /* StreamModalTransitioningDelegate.swift */; }; ADCDDD0025AE2784004E15FB /* UserUpdateViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCDDCEC25AE2214004E15FB /* UserUpdateViewController.swift */; }; ADCDDD0825AE2F4A004E15FB /* InputViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCDDD0725AE2F4A004E15FB /* InputViewController.swift */; }; + ADD2A99028FF0CD300A83305 /* ImageSizeCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD2A98F28FF0CD300A83305 /* ImageSizeCalculator.swift */; }; + ADD2A99128FF0CD300A83305 /* ImageSizeCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD2A98F28FF0CD300A83305 /* ImageSizeCalculator.swift */; }; + ADD2A99828FF227D00A83305 /* ImageSizeCalculator_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD2A99528FF227800A83305 /* ImageSizeCalculator_Tests.swift */; }; + ADD2A99A28FF4F4B00A83305 /* StreamCDN.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD2A99928FF4F4B00A83305 /* StreamCDN.swift */; }; + ADD2A99B28FF4F4B00A83305 /* StreamCDN.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD2A99928FF4F4B00A83305 /* StreamCDN.swift */; }; ADDFDE2B2779EC8A003B3B07 /* Atlantis in Frameworks */ = {isa = PBXBuildFile; productRef = ADDFDE2A2779EC8A003B3B07 /* Atlantis */; }; ADEE888D289C3CC0007DF3F8 /* ChatMessageListView+DiffKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEE888C289C3CC0007DF3F8 /* ChatMessageListView+DiffKit.swift */; }; ADEE888E289C3CC0007DF3F8 /* ChatMessageListView+DiffKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEE888C289C3CC0007DF3F8 /* ChatMessageListView+DiffKit.swift */; }; @@ -1153,7 +1169,7 @@ BCE48639FD7B6B05CD63A6AF /* FilterDecoding_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE4862E2C4943998F0DCBD9 /* FilterDecoding_Tests.swift */; }; BCE486580F913CFFDB3B5ECD /* JSONEncoder_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE489A4B136D48249DD6969 /* JSONEncoder_Tests.swift */; }; BD4016362638411D00F09774 /* Deprecations.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4016352638411D00F09774 /* Deprecations.swift */; }; - BD40C1F5265FA80D004392CE /* ImageCDN_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD40C1F4265FA80D004392CE /* ImageCDN_Tests.swift */; }; + BD40C1F5265FA80D004392CE /* StreamImageCDN_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD40C1F4265FA80D004392CE /* StreamImageCDN_Tests.swift */; }; BD69F5D52669392E00E9E3FA /* ScrollToLatestMessageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD69F5D42669392E00E9E3FA /* ScrollToLatestMessageButton.swift */; }; BD837AF02652D23600A99AB5 /* AttachmentPreviewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD837AEF2652D23600A99AB5 /* AttachmentPreviewContainer.swift */; }; BD8EBC3A26442E090052199F /* AttachmentsPreviewVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8EBC3926442E090052199F /* AttachmentsPreviewVC.swift */; }; @@ -3243,6 +3259,7 @@ AD52A21B2804851600D0157E /* CommandDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandDTO.swift; sourceTree = ""; }; AD53DCDE27271D850019290C /* MessageReactionsPayload.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MessageReactionsPayload.json; sourceTree = ""; }; AD540AE1260CECA10082D802 /* QuotedChatMessageView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedChatMessageView_Tests.swift; sourceTree = ""; }; + AD552E0028F46CE700199A6F /* ImageLoaderOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoaderOptions.swift; sourceTree = ""; }; AD6A2489280DA88F003BA1E4 /* PushDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushDevice.swift; sourceTree = ""; }; AD6BEFEF2786070800E184B4 /* SwitchButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchButton.swift; sourceTree = ""; }; AD6BEFF127862F9300E184B4 /* AppConfigViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigViewController.swift; sourceTree = ""; }; @@ -3255,6 +3272,8 @@ AD793F4A270B769E00B05456 /* ChatMessageReactionAuthorViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReactionAuthorViewCell.swift; sourceTree = ""; }; AD7AC98B260A94C6004AADA5 /* MessagePinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagePinning.swift; sourceTree = ""; }; AD7B51D227EDECA80068CBD1 /* MixedAttachmentViewInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixedAttachmentViewInjector.swift; sourceTree = ""; }; + AD7BBFCA2901AF3F004E8B76 /* ImageResultsMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageResultsMapper.swift; sourceTree = ""; }; + AD7BBFCD2901B1AE004E8B76 /* ImageResultsMapper_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageResultsMapper_Tests.swift; sourceTree = ""; }; AD7CF16F2694ABC500F3101D /* ComposerVC_Documentation_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerVC_Documentation_Tests.swift; sourceTree = ""; }; AD7D633225AF577E0051219B /* UserUpdateResponse+MissingUser.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "UserUpdateResponse+MissingUser.json"; sourceTree = ""; }; AD7DFBEB25D2AE7400DD9DA3 /* TestDataModel2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = TestDataModel2.xcdatamodel; sourceTree = ""; }; @@ -3266,11 +3285,14 @@ AD87D0A0263C7823008B466C /* AttachmentButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentButton.swift; sourceTree = ""; }; AD87D0AA263C7A7E008B466C /* ShrinkInputButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShrinkInputButton.swift; sourceTree = ""; }; AD87D0BC263C7C09008B466C /* CircularCloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularCloseButton.swift; sourceTree = ""; }; + AD8B72742908016400921C31 /* ImageDownloadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDownloadRequest.swift; sourceTree = ""; }; AD8D1808268F7290004E3A5C /* TypingSuggester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingSuggester.swift; sourceTree = ""; }; AD8D180A268F8ED4004E3A5C /* SlackComposerVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlackComposerVC.swift; sourceTree = ""; }; AD90D18425D56196001D03BB /* CurrentUserUpdater_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrentUserUpdater_Tests.swift; sourceTree = ""; }; AD90D18C25D5619C001D03BB /* CurrentUserUpdater_Mock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrentUserUpdater_Mock.swift; sourceTree = ""; }; AD91C35328A5550C004D1E45 /* ChatMessageListVC+DiffKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatMessageListVC+DiffKit.swift"; sourceTree = ""; }; + AD95FD0C28F991ED00DBDF41 /* ImageResize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageResize.swift; sourceTree = ""; }; + AD95FD1028FA038900DBDF41 /* ImageDownloadOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDownloadOptions.swift; sourceTree = ""; }; AD99A7CD28EF17CA005185DF /* SlackReactonsItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlackReactonsItemView.swift; sourceTree = ""; }; AD99A7CF28EF17ED005185DF /* SlackReactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlackReactionsView.swift; sourceTree = ""; }; AD99A7D128EF180C005185DF /* SlackReactionsMessagePopupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlackReactionsMessagePopupVC.swift; sourceTree = ""; }; @@ -3314,6 +3336,9 @@ ADCDDCC425AE1293004E15FB /* UserUpdateResponse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = UserUpdateResponse.json; sourceTree = ""; }; ADCDDCEC25AE2214004E15FB /* UserUpdateViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserUpdateViewController.swift; sourceTree = ""; }; ADCDDD0725AE2F4A004E15FB /* InputViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputViewController.swift; sourceTree = ""; }; + ADD2A98F28FF0CD300A83305 /* ImageSizeCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSizeCalculator.swift; sourceTree = ""; }; + ADD2A99528FF227800A83305 /* ImageSizeCalculator_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSizeCalculator_Tests.swift; sourceTree = ""; }; + ADD2A99928FF4F4B00A83305 /* StreamCDN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamCDN.swift; sourceTree = ""; }; ADD5A9E725DE8AF6006DC88A /* ChatSuggestionsVC_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSuggestionsVC_Tests.swift; sourceTree = ""; }; ADEA7F21261D2F8C00CA2289 /* chewbacca.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = chewbacca.jpg; sourceTree = ""; }; ADEA7F22261D2F8C00CA2289 /* r2.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = r2.jpg; sourceTree = ""; }; @@ -3332,7 +3357,7 @@ BCE489A4B136D48249DD6969 /* JSONEncoder_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONEncoder_Tests.swift; sourceTree = ""; }; BCE48E6828D1A30622C243F0 /* FilterTestScope.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterTestScope.swift; sourceTree = ""; }; BD4016352638411D00F09774 /* Deprecations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Deprecations.swift; sourceTree = ""; }; - BD40C1F4265FA80D004392CE /* ImageCDN_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCDN_Tests.swift; sourceTree = ""; }; + BD40C1F4265FA80D004392CE /* StreamImageCDN_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamImageCDN_Tests.swift; sourceTree = ""; }; BD69F5D42669392E00E9E3FA /* ScrollToLatestMessageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToLatestMessageButton.swift; sourceTree = ""; }; BD837AEF2652D23600A99AB5 /* AttachmentPreviewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewContainer.swift; sourceTree = ""; }; BD8EBC3926442E090052199F /* AttachmentsPreviewVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsPreviewVC.swift; sourceTree = ""; }; @@ -3919,6 +3944,8 @@ E7166CB925BED29200B03B07 /* Appearance+Fonts.swift */, E7166CE125BEE20600B03B07 /* Appearance+Images.swift */, BDDD1EA92632CE3C00BA007B /* Appearance+SwiftUI.swift */, + BDDD1EA52632C6D600BA007B /* AppearanceProvider.swift */, + 792DD9FA256E67C6001DB91B /* ComponentsProvider.swift */, 8850B929255C286B003AED69 /* Components.swift */, BDDD1E982632C4C900BA007B /* Components+SwiftUI.swift */, BD4016352638411D00F09774 /* Deprecations.swift */, @@ -3931,15 +3958,15 @@ 888123E5255D51BD00070D5A /* CommonViews */, AD4EA229264ADE0100DF8EE2 /* Composer */, F833D64326393E4800651D14 /* Gallery */, - 88F0D6EA257E409E00F4B050 /* Generated */, 88F8364E2578D1590039AEC8 /* MessageActionsPopup */, 7973CE48265413B4004C7CE5 /* Navigation */, + 888123D0255D42F000070D5A /* Utils */, + 88F0D6EA257E409E00F4B050 /* Generated */, 88F0D6ED257E446800F4B050 /* Resources */, C1BE72742732CB7B006EB51E /* StreamNuke */, ADCB576728A42D7700B81AE8 /* StreamDifferenceKit */, A3C50218284F9CF70048753E /* StreamSwiftyMarkdown */, C13C74D1273932D200F93B34 /* StreamSwiftyGif */, - 888123D0255D42F000070D5A /* Utils */, ); path = StreamChatUI; sourceTree = ""; @@ -4308,6 +4335,7 @@ DB9A3D552582689A00555D36 /* ChatMessageListRouter.swift */, 8825334B258CE82500B77352 /* AlertsRouter.swift */, 8850FE90256558B200C8D534 /* NavigationRouter.swift */, + 88410ED026556B6F00525AA3 /* NavigationVC.swift */, ); path = Navigation; sourceTree = ""; @@ -4793,43 +4821,18 @@ 888123D0255D42F000070D5A /* Utils */ = { isa = PBXGroup; children = ( - ACCA772826C40C7A007AE2ED /* ImageLoading */, - ADFB7E92283E701C00ABB6CF /* ListChangeUpdater */, - 843F0BC426775D2D00B342CB /* VideoLoading.swift */, - 22C23599259CA87B00DC805A /* Animation.swift */, - E386432C2857299E00DB3FBE /* Reusable+Extensions.swift */, - BDDD1EA52632C6D600BA007B /* AppearanceProvider.swift */, - 88EF29FE2571288600B06EF1 /* Array+Extensions.swift */, - C171041D2768C34E008FB3F2 /* Array+SafeSubscript.swift */, - 224FF6962562F5AE00725DD1 /* Bundle+Extensions.swift */, 843F0BC226775CDB00B342CB /* Cache.swift */, - 883051732630366E0069D731 /* CACornerMask+Extensions.swift */, - E70120152583EBC90036DACD /* CALayer+Extensions.swift */, ACF73D7726CFE07900372DC0 /* Cancellable.swift */, - F64DFA8B26282F8B00F7F6F9 /* CGPoint+Extensions.swift */, - F6E5E3462627A372007FA51F /* CGRect+Extensions.swift */, - 79CCB66D259CBC4F0082F172 /* ChatChannelNamer.swift */, - 88A11B092590AFBB0000AC24 /* ChatMessage+Extensions.swift */, - 792DD9FA256E67C6001DB91B /* ComponentsProvider.swift */, ACA3C98526CA23F300EB8B07 /* DateUtils.swift */, - BDC80CB4265CF4B800F62CE2 /* ImageCDN.swift */, - ACD502A826BC0C670029FB7D /* ImageMerger.swift */, - CFBF8D502847C57700EEB7D3 /* MarkdownFormatter.swift */, - 88410ED026556B6F00525AA3 /* NavigationVC.swift */, - 88D88F85257F9AA700AFE2A2 /* NSLayoutConstraint+Extensions.swift */, - ACCA772D26C568D8007AE2ED /* NukeImageProcessor.swift */, - 2241167F258A91280034184D /* String+Extensions.swift */, + 22C23599259CA87B00DC805A /* Animation.swift */, 79F691B12604C10A000AE89B /* SystemEnvironment.swift */, + 88A11B092590AFBB0000AC24 /* ChatMessage+Extensions.swift */, CF7B2A2528BEAA93006BE124 /* TextViewMentionedUsersHandler.swift */, - 224FF6902562F58F00725DD1 /* UIColor+Extensions.swift */, - 228190EA256733420048D7C6 /* UIFont+Extensions.swift */, - 224FF69C2562F5D100725DD1 /* UIImage+Extensions.swift */, - E7073A6225DD67B3003896B9 /* UILabel+Extensions.swift */, - 849980F0277246DB00ABA58B /* UIScrollView+Extensions.swift */, - 796CBD1B25FF9552003299B0 /* UIStackView+Extensions.swift */, - 228C7EE42583AF4800AAE9E3 /* UITextView+Extensions.swift */, - 888123D1255D430B00070D5A /* UIView+Extensions.swift */, - 882AE123257A7FFE004095B3 /* UIViewController+Extensions.swift */, + ACCA772826C40C7A007AE2ED /* ImageLoading */, + AD95FD0B28F98C7A00DBDF41 /* ImageProcessor */, + AD95FD0A28F98C1E00DBDF41 /* VideoLoading */, + ADFB7E92283E701C00ABB6CF /* ListChangeUpdater */, + AD95FD0F28F9B72200DBDF41 /* Extensions */, ); path = Utils; sourceTree = ""; @@ -6322,10 +6325,12 @@ E73262D725ED6432008CB152 /* ChatChannelNamer_Tests.swift */, 795296C02582494000435B2E /* ComponentsProvider_Tests.swift */, ACA3C98426CA23F300EB8B07 /* DateUtils_Tests.swift */, - BD40C1F4265FA80D004392CE /* ImageCDN_Tests.swift */, + BD40C1F4265FA80D004392CE /* StreamImageCDN_Tests.swift */, ACDB5412269C6F2A007CD465 /* String+Extensions_Tests.swift */, 7865705725FB6DF300974045 /* UIViewController+Extensions_Tests.swift */, CF24AAB2284A5659005AD3B8 /* DefaultMarkdownFormatter_Tests.swift */, + ADD2A99528FF227800A83305 /* ImageSizeCalculator_Tests.swift */, + AD7BBFCD2901B1AE004E8B76 /* ImageResultsMapper_Tests.swift */, ); path = Utils; sourceTree = ""; @@ -6752,8 +6757,16 @@ ACCA772826C40C7A007AE2ED /* ImageLoading */ = { isa = PBXGroup; children = ( + AD8B7277290801B800921C31 /* ImageCDN */, ACCA772926C40C96007AE2ED /* ImageLoading.swift */, ACCA772B26C40D43007AE2ED /* NukeImageLoader.swift */, + AD552E0028F46CE700199A6F /* ImageLoaderOptions.swift */, + AD95FD1028FA038900DBDF41 /* ImageDownloadOptions.swift */, + AD8B72742908016400921C31 /* ImageDownloadRequest.swift */, + AD95FD0C28F991ED00DBDF41 /* ImageResize.swift */, + ACD502A826BC0C670029FB7D /* ImageMerger.swift */, + ADD2A98F28FF0CD300A83305 /* ImageSizeCalculator.swift */, + AD7BBFCA2901AF3F004E8B76 /* ImageResultsMapper.swift */, ); path = ImageLoading; sourceTree = ""; @@ -6952,6 +6965,15 @@ path = ShrinkInputButton; sourceTree = ""; }; + AD8B7277290801B800921C31 /* ImageCDN */ = { + isa = PBXGroup; + children = ( + ADD2A99928FF4F4B00A83305 /* StreamCDN.swift */, + BDC80CB4265CF4B800F62CE2 /* ImageCDN.swift */, + ); + path = ImageCDN; + sourceTree = ""; + }; AD8E6BBD2642DB520013E01E /* CommandLabelView */ = { isa = PBXGroup; children = ( @@ -6960,6 +6982,49 @@ path = CommandLabelView; sourceTree = ""; }; + AD95FD0A28F98C1E00DBDF41 /* VideoLoading */ = { + isa = PBXGroup; + children = ( + 843F0BC426775D2D00B342CB /* VideoLoading.swift */, + ); + path = VideoLoading; + sourceTree = ""; + }; + AD95FD0B28F98C7A00DBDF41 /* ImageProcessor */ = { + isa = PBXGroup; + children = ( + ACCA772D26C568D8007AE2ED /* NukeImageProcessor.swift */, + ); + path = ImageProcessor; + sourceTree = ""; + }; + AD95FD0F28F9B72200DBDF41 /* Extensions */ = { + isa = PBXGroup; + children = ( + C171041D2768C34E008FB3F2 /* Array+SafeSubscript.swift */, + 88EF29FE2571288600B06EF1 /* Array+Extensions.swift */, + E386432C2857299E00DB3FBE /* Reusable+Extensions.swift */, + 224FF6962562F5AE00725DD1 /* Bundle+Extensions.swift */, + 883051732630366E0069D731 /* CACornerMask+Extensions.swift */, + E70120152583EBC90036DACD /* CALayer+Extensions.swift */, + F64DFA8B26282F8B00F7F6F9 /* CGPoint+Extensions.swift */, + F6E5E3462627A372007FA51F /* CGRect+Extensions.swift */, + 79CCB66D259CBC4F0082F172 /* ChatChannelNamer.swift */, + 88D88F85257F9AA700AFE2A2 /* NSLayoutConstraint+Extensions.swift */, + 2241167F258A91280034184D /* String+Extensions.swift */, + 224FF6902562F58F00725DD1 /* UIColor+Extensions.swift */, + 228190EA256733420048D7C6 /* UIFont+Extensions.swift */, + 224FF69C2562F5D100725DD1 /* UIImage+Extensions.swift */, + E7073A6225DD67B3003896B9 /* UILabel+Extensions.swift */, + 849980F0277246DB00ABA58B /* UIScrollView+Extensions.swift */, + 796CBD1B25FF9552003299B0 /* UIStackView+Extensions.swift */, + 228C7EE42583AF4800AAE9E3 /* UITextView+Extensions.swift */, + 888123D1255D430B00070D5A /* UIView+Extensions.swift */, + 882AE123257A7FFE004095B3 /* UIViewController+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; AD99C901279B06E9009DD9C5 /* Appearance+Formatters */ = { isa = PBXGroup; children = ( @@ -6970,6 +7035,7 @@ ADBBDA21279F0CFA00E47B1C /* UploadingProgressFormatter.swift */, AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */, ADBBDA1E279F0CEA00E47B1C /* VideoDurationFormatter.swift */, + CFBF8D502847C57700EEB7D3 /* MarkdownFormatter.swift */, ); path = "Appearance+Formatters"; sourceTree = ""; @@ -8617,6 +8683,7 @@ C1FC2F8127416E150062530F /* RateLimiter.swift in Sources */, 224165A825910A2C00ED7F78 /* CheckboxControl.swift in Sources */, 79FA4A84263BFD1100EC33DA /* GalleryAttachmentViewInjector.swift in Sources */, + AD552E0128F46CE700199A6F /* ImageLoaderOptions.swift in Sources */, 88D88F86257F9AA700AFE2A2 /* NSLayoutConstraint+Extensions.swift in Sources */, ADCB577928A42D7700B81AE8 /* Algorithm.swift in Sources */, 796CBD1C25FF9552003299B0 /* UIStackView+Extensions.swift in Sources */, @@ -8635,7 +8702,9 @@ 88BD82B02549D18F00369074 /* ChatChannelListItemView.swift in Sources */, A3C5022B284F9CF70048753E /* Token.swift in Sources */, A39A8AE7263825F4003453D9 /* ChatMessageLayoutOptionsResolver.swift in Sources */, + AD8B72752908016400921C31 /* ImageDownloadRequest.swift in Sources */, 8825334C258CE82500B77352 /* AlertsRouter.swift in Sources */, + AD95FD0D28F991ED00DBDF41 /* ImageResize.swift in Sources */, AD4474FD263B19F90030E583 /* ImageAttachmentComposerPreview.swift in Sources */, 88BA7F87258B97C9006CE0C5 /* ChatMessageImageGallery+UploadingOverlay.swift in Sources */, 8806570D259A51C200E31D23 /* ChatMessageInteractiveAttachmentView+ActionButton.swift in Sources */, @@ -8651,6 +8720,7 @@ 792DD9D9256BC542001DB91B /* BaseViews.swift in Sources */, AD7B51D327EDECA80068CBD1 /* MixedAttachmentViewInjector.swift in Sources */, ADA3572F269C807A004AD8E9 /* ChatChannelHeaderView.swift in Sources */, + ADD2A99A28FF4F4B00A83305 /* StreamCDN.swift in Sources */, ADCD5E4327987EFE00E66911 /* StreamModalTransitioningDelegate.swift in Sources */, F80BCA1426304F7800F2107B /* ShareButton.swift in Sources */, CF38F5AF287DB53E00E24D10 /* ChatChannelListErrorView.swift in Sources */, @@ -8686,6 +8756,7 @@ 790882FD25486BFD00896F03 /* ChatChannelListCollectionViewCell.swift in Sources */, ADFB7E94283E703A00ABB6CF /* TableViewListChangeUpdater.swift in Sources */, 88CABC4525933EE70061BB67 /* ChatMessageReactionsView.swift in Sources */, + ADD2A99028FF0CD300A83305 /* ImageSizeCalculator.swift in Sources */, A3C5022A284F9CF70048753E /* CharacterRule.swift in Sources */, E7166CE225BEE20600B03B07 /* Appearance+Images.swift in Sources */, 88CABC6625934CF60061BB67 /* ChatMessageReactions+Types.swift in Sources */, @@ -8729,6 +8800,7 @@ 843F0BC72677640000B342CB /* VideoAttachmentGalleryPreview.swift in Sources */, ADB22F7D25F1626200853C92 /* ChatPresenceAvatarView.swift in Sources */, ADBBDA1F279F0CEA00E47B1C /* VideoDurationFormatter.swift in Sources */, + AD95FD1128FA038900DBDF41 /* ImageDownloadOptions.swift in Sources */, A3C50226284F9CF70048753E /* SwiftyScanner.swift in Sources */, AD99C904279B073B009DD9C5 /* MessageTimestampFormatter.swift in Sources */, 2245B2B625602465006A612D /* ChatChannelAvatarView.swift in Sources */, @@ -8789,6 +8861,7 @@ 22C2359A259CA87B00DC805A /* Animation.swift in Sources */, 79088339254876F200896F03 /* ChatMessageListView.swift in Sources */, C1FC2F7627416E150062530F /* Extensions.swift in Sources */, + AD7BBFCB2901AF3F004E8B76 /* ImageResultsMapper.swift in Sources */, ACD502A926BC0C670029FB7D /* ImageMerger.swift in Sources */, E3C7A0E02858BA9B006133C3 /* Reusable+Extensions.swift in Sources */, AD447398263ABD530030E583 /* ChatCommandSuggestionCollectionViewCell.swift in Sources */, @@ -8884,7 +8957,7 @@ E73BD9EE264D9B3A00E208B7 /* ChatMessageFileAttachmentListView_Tests.swift in Sources */, BDDD1EAE2632E6C200BA007B /* Appearance+SwiftUI_Tests.swift in Sources */, CFAA6CBE2834AEA900EBF57A /* CooldownTracker_Mock.swift in Sources */, - BD40C1F5265FA80D004392CE /* ImageCDN_Tests.swift in Sources */, + BD40C1F5265FA80D004392CE /* StreamImageCDN_Tests.swift in Sources */, AD447455263AC6A60030E583 /* ChatMentionSuggestionView_Tests.swift in Sources */, 225504C725DEA03700A5A65A /* ChatChannelListItemView_Tests.swift in Sources */, 79B4F0E625D305D40063FFB5 /* CurrentChatUserAvatarView_Tests.swift in Sources */, @@ -8900,10 +8973,12 @@ ADA8EBEB28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift in Sources */, 8897305E265D046D00F83739 /* ChatMessageLayoutOptionsResolver_Tests.swift in Sources */, ADA8EBE928CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift in Sources */, + ADD2A99828FF227D00A83305 /* ImageSizeCalculator_Tests.swift in Sources */, C1320E0A276B2E0F00A06B35 /* Array+SafeSubscript_Tests.swift in Sources */, F86615D9264940A80026814A /* ChatMessageGalleryView_Tests.swift in Sources */, A3D9D69827EDE88300725066 /* ChatChannelListRouter_Mock.swift in Sources */, CFE5F8602874F8BE0099A6A1 /* ChatChannelListEmptyView_Tests.swift in Sources */, + AD7BBFD02901B1B7004E8B76 /* ImageResultsMapper_Tests.swift in Sources */, 8893FF16265FC60B00DD62BE /* ChatMessageErrorIndicator_Tests.swift in Sources */, E76B2F0425F23EE200E57112 /* CustomUIViewSubclasses.swift in Sources */, F8700108264D144400898FDF /* ChatMessageLinkPreviewView_Tests.swift in Sources */, @@ -10113,6 +10188,7 @@ C121EB612746A1E600023E4C /* Appearance+ColorPalette.swift in Sources */, C121EB622746A1E600023E4C /* Appearance+Images.swift in Sources */, C121EB632746A1E600023E4C /* Appearance+Fonts.swift in Sources */, + AD95FD0E28F991ED00DBDF41 /* ImageResize.swift in Sources */, C121EB642746A1E600023E4C /* Appearance+SwiftUI.swift in Sources */, ADBBDA23279F0CFA00E47B1C /* UploadingProgressFormatter.swift in Sources */, AD78F9F428EC72D700BC0FCE /* UIScrollView+Extensions.swift in Sources */, @@ -10187,9 +10263,11 @@ C121EBA32746A1E800023E4C /* QuotedChatMessageView.swift in Sources */, C121EBA42746A1E800023E4C /* QuotedChatMessageView+SwiftUI.swift in Sources */, C121EBA52746A1E800023E4C /* OnlineIndicatorView.swift in Sources */, + AD8B72762908016400921C31 /* ImageDownloadRequest.swift in Sources */, AD78F9FE28EC735700BC0FCE /* Token.swift in Sources */, C121EBA62746A1E800023E4C /* ChatPresenceAvatarView.swift in Sources */, C121EBA72746A1E800023E4C /* ChatAvatarView.swift in Sources */, + AD552E0228F46CE700199A6F /* ImageLoaderOptions.swift in Sources */, C121EBA82746A1E800023E4C /* ChatChannelAvatarView.swift in Sources */, C121EBA92746A1E800023E4C /* ChatChannelAvatarView+SwiftUI.swift in Sources */, C121EBAA2746A1E800023E4C /* ChatUserAvatarView.swift in Sources */, @@ -10216,7 +10294,9 @@ C121EBBB2746A1E900023E4C /* ImageAttachmentGalleryCell.swift in Sources */, C121EBBC2746A1E900023E4C /* VideoAttachmentGalleryCell.swift in Sources */, C121EBBD2746A1E900023E4C /* GalleryVC.swift in Sources */, + ADD2A99128FF0CD300A83305 /* ImageSizeCalculator.swift in Sources */, C121EBBE2746A1E900023E4C /* ZoomDismissalInteractionController.swift in Sources */, + AD95FD1228FA038900DBDF41 /* ImageDownloadOptions.swift in Sources */, C121EBBF2746A1E900023E4C /* ZoomTransitionController.swift in Sources */, C121EBC02746A1E900023E4C /* ZoomAnimator.swift in Sources */, AD78F9FD28EC735700BC0FCE /* SwiftyScanner.swift in Sources */, @@ -10243,6 +10323,7 @@ C121EBD12746A1EA00023E4C /* ChatThreadVC+SwiftUI.swift in Sources */, C121EBD22746A1EA00023E4C /* ChatThreadHeaderView.swift in Sources */, C121EBD32746A1EA00023E4C /* ChatMessageReactionAuthorsVC.swift in Sources */, + ADD2A99B28FF4F4B00A83305 /* StreamCDN.swift in Sources */, ADCB578A28A42D7700B81AE8 /* Changeset.swift in Sources */, C121EBD42746A1EA00023E4C /* ChatMessageReactionAuthorViewCell.swift in Sources */, AD78F9F728EC735700BC0FCE /* SwiftyMarkdown.swift in Sources */, @@ -10341,6 +10422,7 @@ ADBBDA29279F0E9B00E47B1C /* ChannelNameFormatter.swift in Sources */, C121EC192746A1EC00023E4C /* ImageRequest.swift in Sources */, ADCD5E4427987EFE00E66911 /* StreamModalTransitioningDelegate.swift in Sources */, + AD7BBFCC2901AF3F004E8B76 /* ImageResultsMapper.swift in Sources */, C121EC1A2746A1EC00023E4C /* DataCache.swift in Sources */, CF14397E2886374900898ECA /* ChatChannelListLoadingViewCell.swift in Sources */, C121EC1B2746A1EC00023E4C /* ImageDecoding.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/Attachments/AnyAttachmentPayload_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/Attachments/AnyAttachmentPayload_Mock.swift index 6fac1ffb45b..38426ae2d72 100644 --- a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/Attachments/AnyAttachmentPayload_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/Attachments/AnyAttachmentPayload_Mock.swift @@ -6,10 +6,10 @@ import Foundation @testable import StreamChat public extension AnyAttachmentPayload { - static let mockFile = try! Self(localFileURL: .localYodaQuote, attachmentType: .file) - static let mockFileWithLongName = try! Self(localFileURL: .localYodaQuoteLongFileName, attachmentType: .file) - static let mockImage = try! Self(localFileURL: .localYodaImage, attachmentType: .image) - static let mockVideo = try! Self(localFileURL: .localYodaQuote, attachmentType: .video) + static let mockFile = try! Self(attachmentType: .file, localFileURL: .localYodaQuote) + static let mockFileWithLongName = try! Self(attachmentType: .file, localFileURL: .localYodaQuoteLongFileName) + static let mockImage = try! Self(attachmentType: .image, localFileURL: .localYodaImage) + static let mockVideo = try! Self(attachmentType: .video, localFileURL: .localYodaQuote) } public extension AnyAttachmentPayload { diff --git a/Tests/StreamChatTests/Models/Attachments/AnyAttachmentPayload_Tests.swift b/Tests/StreamChatTests/Models/Attachments/AnyAttachmentPayload_Tests.swift index ee90e00daff..523b285977e 100644 --- a/Tests/StreamChatTests/Models/Attachments/AnyAttachmentPayload_Tests.swift +++ b/Tests/StreamChatTests/Models/Attachments/AnyAttachmentPayload_Tests.swift @@ -12,7 +12,11 @@ final class AnyAttachmentPayload_Tests: XCTestCase { let url: URL = .localYodaImage let type: AttachmentType = .image let extraData = PhotoMetadata.random - let anyPayload = try AnyAttachmentPayload(localFileURL: url, attachmentType: type, extraData: extraData) + let anyPayload = try AnyAttachmentPayload( + attachmentType: type, + localFileURL: url, + extraData: extraData + ) // Assert any payload fields are correct. let payload = try XCTUnwrap(anyPayload.payload as? ImageAttachmentPayload) @@ -29,7 +33,11 @@ final class AnyAttachmentPayload_Tests: XCTestCase { let url: URL = .localYodaImage let type: AttachmentType = .video let extraData = PhotoMetadata.random - let anyPayload = try AnyAttachmentPayload(localFileURL: url, attachmentType: type, extraData: extraData) + let anyPayload = try AnyAttachmentPayload( + attachmentType: type, + localFileURL: url, + extraData: extraData + ) // Assert any payload fields are correct. let payload = try XCTUnwrap(anyPayload.payload as? VideoAttachmentPayload) @@ -46,7 +54,11 @@ final class AnyAttachmentPayload_Tests: XCTestCase { let url: URL = .localYodaQuote let type: AttachmentType = .file let extraData = PhotoMetadata.random - let anyPayload = try AnyAttachmentPayload(localFileURL: url, attachmentType: type, extraData: extraData) + let anyPayload = try AnyAttachmentPayload( + attachmentType: type, + localFileURL: url, + extraData: extraData + ) // Assert any payload fields are correct. let payload = try XCTUnwrap(anyPayload.payload as? FileAttachmentPayload) @@ -62,8 +74,8 @@ final class AnyAttachmentPayload_Tests: XCTestCase { XCTAssertThrowsError( // Try to create uploadable attachment with custom type try AnyAttachmentPayload( - localFileURL: .localYodaQuote, - attachmentType: .init(rawValue: .unique) + attachmentType: .init(rawValue: .unique), + localFileURL: .localYodaQuote ) ) { error in XCTAssertTrue(error is ClientError.UnsupportedUploadableAttachmentType) @@ -76,8 +88,8 @@ final class AnyAttachmentPayload_Tests: XCTestCase { XCTAssertThrowsError( // Try to create uploadable attachment with invalid extra data try AnyAttachmentPayload( - localFileURL: .localYodaQuote, attachmentType: .init(rawValue: .unique), + localFileURL: .localYodaQuote, extraData: String.unique ) ) diff --git a/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift b/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift index 0870950a892..6fba9d2084c 100644 --- a/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift +++ b/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift @@ -12,13 +12,17 @@ final class ImageAttachmentPayload_Tests: XCTestCase { let title: String = .unique let imageURL: URL = .unique() let thumbURL: URL = .unique() + let originalWidth: Double = 3200 + let originalHeight: Double = 2600 // Create JSON with the given values. let json = """ { "title": "\(title)", "image_url": "\(imageURL.absoluteString)", - "thumb_url": "\(thumbURL.absoluteString)" + "thumb_url": "\(thumbURL.absoluteString)", + "original_width": \(originalWidth), + "original_height": \(originalHeight) } """.data(using: .utf8)! @@ -29,6 +33,8 @@ final class ImageAttachmentPayload_Tests: XCTestCase { XCTAssertEqual(payload.title, title) XCTAssertEqual(payload.imageURL, imageURL) XCTAssertEqual(payload.imagePreviewURL, thumbURL) + XCTAssertEqual(payload.originalWidth, originalWidth) + XCTAssertEqual(payload.originalHeight, originalHeight) XCTAssertNil(payload.extraData) } @@ -65,4 +71,26 @@ final class ImageAttachmentPayload_Tests: XCTestCase { let extraData = try XCTUnwrap(payload.extraData(ofType: ExtraData.self)) XCTAssertEqual(extraData.comment, comment) } + + func test_encoding() throws { + let payload = ImageAttachmentPayload( + title: "Image1.png", + imageRemoteURL: URL(string: "dummyURL")!, + imagePreviewRemoteURL: URL(string: "dummyPreviewURL"), + originalWidth: 100, + originalHeight: 50, + extraData: ["isVerified": true] + ) + let json = try JSONEncoder.stream.encode(payload) + + let expectedJsonObject: [String: Any] = [ + "title": "Image1.png", + "image_url": "dummyURL", + "original_width": 100, + "original_height": 50, + "isVerified": true + ] + + AssertJSONEqual(json, expectedJsonObject) + } } diff --git a/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift b/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift index df8ea16b084..060dadcd384 100644 --- a/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift +++ b/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift @@ -7,45 +7,34 @@ import UIKit /// A mock implementation of the image loader which loads images synchronusly final class ImageLoader_Mock: ImageLoading { - func loadImage( - using urlRequest: URLRequest, - cachingKey: String?, - completion: @escaping ((Result) -> Void) - ) -> Cancellable? { - let image = UIImage(data: try! Data(contentsOf: urlRequest.url!))! - completion(.success(image)) - return nil - } - - func loadImage( - into imageView: UIImageView, - url: URL?, - imageCDN: ImageCDN, - placeholder: UIImage?, - resize: Bool, - preferredSize: CGSize?, - completion: ((Result) -> Void)? - ) -> Cancellable? { + func loadImage(into imageView: UIImageView, from url: URL?, with options: ImageLoaderOptions, completion: ((Result) -> Void)?) -> Cancellable? { if let url = url { let image = UIImage(data: try! Data(contentsOf: url))! imageView.image = image completion?(.success(image)) } else { - imageView.image = placeholder + imageView.image = options.placeholder } - + return nil } - func loadImages( - from urls: [URL], - placeholders: [UIImage], - loadThumbnails: Bool, - thumbnailSize: CGSize, - imageCDN: ImageCDN, - completion: @escaping (([UIImage]) -> Void) + func downloadImage( + with request: ImageDownloadRequest, + completion: @escaping ((Result) -> Void) + ) -> Cancellable? { + let image = UIImage(data: try! Data(contentsOf: request.url))! + completion(.success(image)) + return nil + } + + func downloadMultipleImages( + with requests: [ImageDownloadRequest], + completion: @escaping (([Result]) -> Void) ) { - let images = urls.map { UIImage(data: try! Data(contentsOf: $0))! } - completion(images) + let results = requests.map(\.url).map { + Result.success(UIImage(data: try! Data(contentsOf: $0))!) + } + completion(results) } } diff --git a/Tests/StreamChatUITests/Utils/ImageCDN_Tests.swift b/Tests/StreamChatUITests/Utils/ImageCDN_Tests.swift deleted file mode 100644 index edf163d4ddf..00000000000 --- a/Tests/StreamChatUITests/Utils/ImageCDN_Tests.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// Copyright © 2022 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamChat -@testable import StreamChatUI -import XCTest - -final class ImageCDN_Tests: XCTestCase { - func test_cache_validStreamURL_filtered() { - let provider = StreamImageCDN() - - let url = URL(string: "https://wwww.stream-io-cdn.com/image.jpg?name=Luke&father=Anakin")! - let filteredUrl = "https://wwww.stream-io-cdn.com/image.jpg" - let key = provider.cachingKey(forImage: url) - - XCTAssertEqual(key, filteredUrl) - } - - func test_cache_validStreamUrl_withSizeParameters() { - let provider = StreamImageCDN() - - let url = URL(string: "https://wwww.stream-io-cdn.com/image.jpg?name=Luke&w=128&h=128&crop=center&resize=fill&ro=0")! - let filteredUrl = "https://wwww.stream-io-cdn.com/image.jpg?w=128&h=128" - let key = provider.cachingKey(forImage: url) - - XCTAssertEqual(key, filteredUrl) - } - - func test_cache_validStreamURL_unchanged() { - let provider = StreamImageCDN() - - let url = URL(string: "https://wwww.stream-io-cdn.com/image.jpg")! - let key = provider.cachingKey(forImage: url) - - XCTAssertEqual(key, url.absoluteString) - } - - func test_cache_validURL_unchanged() { - let provider = StreamImageCDN() - - let url = URL(string: "https://wwww.stream.io")! - let key = provider.cachingKey(forImage: url) - - XCTAssertEqual(key, url.absoluteString) - } - - func test_cache_invalidURL_unchanged() { - let provider = StreamImageCDN() - - let url1 = URL(string: "https://abc")! - let key1 = provider.cachingKey(forImage: url1) - - let url2 = URL(string: "abc.def")! - let key2 = provider.cachingKey(forImage: url2) - - XCTAssertEqual(key1, url1.absoluteString) - XCTAssertEqual(key2, url2.absoluteString) - } - - func test_thumbnail_validStreamUrl_withoutParameters() { - let provider = StreamImageCDN() - - let url = URL(string: "https://wwww.stream-io-cdn.com/image.jpg")! - let size = Int(40 * UIScreen.main.scale) - let thumbnailUrl = URL(string: "https://wwww.stream-io-cdn.com/image.jpg?w=\(size)&h=\(size)&crop=center&resize=fill&ro=0")! - let processedURL = provider.thumbnailURL( - originalURL: url, - preferredSize: CGSize(width: 40, height: 40) - ) - - assertEqualURL(processedURL, thumbnailUrl) - } - - func test_thumbnail_validStreamUrl_withParameters() { - let provider = StreamImageCDN() - - let url = URL(string: "https://wwww.stream-io-cdn.com/image.jpg?name=Luke")! - let size = Int(40 * UIScreen.main.scale) - let thumbnailUrl = - URL(string: "https://wwww.stream-io-cdn.com/image.jpg?name=Luke&w=\(size)&h=\(size)&crop=center&resize=fill&ro=0")! - let processedURL = provider.thumbnailURL( - originalURL: url, - preferredSize: CGSize(width: 40, height: 40) - ) - - assertEqualURL(processedURL, thumbnailUrl) - } - - func test_thumbnail_validURL_unchanged() { - let provider = StreamImageCDN() - - let url = URL(string: "https://wwww.stream.io")! - let processedURL = provider.thumbnailURL( - originalURL: url, - preferredSize: CGSize(width: 40, height: 40) - ) - - XCTAssertEqual(processedURL, url) - } - - private func assertEqualURL(_ lhs: URL, _ rhs: URL) { - guard var lhsComponents = URLComponents(url: lhs, resolvingAgainstBaseURL: true), - var rhsComponents = URLComponents(url: rhs, resolvingAgainstBaseURL: true) else { - XCTFail("Unexpected url") - return - } - - // Because query paramters can be placed in a different order, we need to check it key by key. - var lhsParameters: [String: String] = [:] - lhsComponents.queryItems?.forEach { - lhsParameters[$0.name] = $0.value - } - - var rhsParameters: [String: String] = [:] - rhsComponents.queryItems?.forEach { - rhsParameters[$0.name] = $0.value - } - - XCTAssertEqual(lhsParameters.count, rhsParameters.count) - - lhsParameters.forEach { key, lValue in - XCTAssertEqual(lValue, rhsParameters[key]) - } - - lhsComponents.query = nil - rhsComponents.query = nil - XCTAssertEqual(lhsComponents.url, rhsComponents.url) - } -} diff --git a/Tests/StreamChatUITests/Utils/ImageResultsMapper_Tests.swift b/Tests/StreamChatUITests/Utils/ImageResultsMapper_Tests.swift new file mode 100644 index 00000000000..9fb0eb90609 --- /dev/null +++ b/Tests/StreamChatUITests/Utils/ImageResultsMapper_Tests.swift @@ -0,0 +1,131 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +@testable import StreamChatUI +import XCTest + +@available(iOS 13.0, *) +final class ImageResultsMapper_Tests: XCTestCase { + lazy var fakePlaceholderImage1 = UIImage(systemName: "square.and.arrow.up.circle")! + lazy var fakePlaceholderImage2 = UIImage(systemName: "square.and.arrow.up.circle.fill")! + lazy var fakePlaceholderImage3 = UIImage(systemName: "square.and.arrow.down.fill")! + lazy var fakePlaceholderImage4 = UIImage(systemName: "square.and.arrow.down")! + + struct MockError: Error {} + + func test_mapErrorsWithPlaceholders_whenWithoutErrors() { + let results: [Result] = [ + .success(TestImages.chewbacca.image), + .success(TestImages.r2.image), + .success(TestImages.vader.image), + .success(TestImages.yoda.image) + ] + + let mapper = ImageResultsMapper(results: results) + let images = mapper.mapErrors(with: [fakePlaceholderImage1]) + + XCTAssertEqual(images, [ + TestImages.chewbacca.image, + TestImages.r2.image, + TestImages.vader.image, + TestImages.yoda.image + ]) + } + + func test_mapErrorsWithPlaceholders_whenThereIs1Errors() { + let results: [Result] = [ + .success(TestImages.chewbacca.image), + .success(TestImages.r2.image), + .failure(MockError()), + .success(TestImages.yoda.image) + ] + + let mapper = ImageResultsMapper(results: results) + let images = mapper.mapErrors(with: [ + fakePlaceholderImage1, + fakePlaceholderImage2, + fakePlaceholderImage3, + fakePlaceholderImage4 + ]) + + XCTAssertEqual(images, [ + TestImages.chewbacca.image, + TestImages.r2.image, + fakePlaceholderImage1, + TestImages.yoda.image + ]) + } + + func test_mapErrorsWithPlaceholders_whenThereIs2Errors() { + let results: [Result] = [ + .success(TestImages.chewbacca.image), + .failure(MockError()), + .failure(MockError()), + .success(TestImages.yoda.image) + ] + + let mapper = ImageResultsMapper(results: results) + let images = mapper.mapErrors(with: [ + fakePlaceholderImage1, + fakePlaceholderImage2, + fakePlaceholderImage3, + fakePlaceholderImage4 + ]) + + XCTAssertEqual(images, [ + TestImages.chewbacca.image, + fakePlaceholderImage1, + fakePlaceholderImage2, + TestImages.yoda.image + ]) + } + + func test_mapErrorsWithPlaceholders_whenThereIs3Errors() { + let results: [Result] = [ + .failure(MockError()), + .success(TestImages.r2.image), + .failure(MockError()), + .failure(MockError()) + ] + + let mapper = ImageResultsMapper(results: results) + let images = mapper.mapErrors(with: [ + fakePlaceholderImage1, + fakePlaceholderImage2, + fakePlaceholderImage3, + fakePlaceholderImage4 + ]) + + XCTAssertEqual(images, [ + fakePlaceholderImage1, + TestImages.r2.image, + fakePlaceholderImage2, + fakePlaceholderImage3 + ]) + } + + func test_mapErrorsWithPlaceholders_whenThereIs4Errors() { + let results: [Result] = [ + .failure(MockError()), + .failure(MockError()), + .failure(MockError()), + .failure(MockError()) + ] + + let mapper = ImageResultsMapper(results: results) + let images = mapper.mapErrors(with: [ + fakePlaceholderImage1, + fakePlaceholderImage2, + fakePlaceholderImage3, + fakePlaceholderImage4 + ]) + + XCTAssertEqual(images, [ + fakePlaceholderImage1, + fakePlaceholderImage2, + fakePlaceholderImage3, + fakePlaceholderImage4 + ]) + } +} diff --git a/Tests/StreamChatUITests/Utils/ImageSizeCalculator_Tests.swift b/Tests/StreamChatUITests/Utils/ImageSizeCalculator_Tests.swift new file mode 100644 index 00000000000..cdef64356b7 --- /dev/null +++ b/Tests/StreamChatUITests/Utils/ImageSizeCalculator_Tests.swift @@ -0,0 +1,61 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +@testable import StreamChatUI +import XCTest + +final class ImageSizeCalculator_Tests: XCTestCase { + func test_calculateSize_whenOriginalResolutionBiggerThanMax() { + let calculator = ImageSizeCalculator() + let size = calculator.calculateSize( + originalWidthInPixels: 2000, + originalHeightInPixels: 4000, + maxResolutionTotalPixels: 1_000_000 + ) + + let expectedSize = CGSize( + width: 235, + height: 471 + ) + + XCTAssertEqual(size.width, expectedSize.width, accuracy: 1) + XCTAssertEqual(size.height, expectedSize.height, accuracy: 1) + } + + func test_calculateSize_whenOriginalResolutionBelowThanMax() { + let calculator = ImageSizeCalculator() + let size = calculator.calculateSize( + originalWidthInPixels: 900, + originalHeightInPixels: 1600, + maxResolutionTotalPixels: 5_000_000 + ) + + // It will be the original size in points + let expectedSize = CGSize( + width: 300, + height: 533 + ) + + XCTAssertEqual(size.width, expectedSize.width, accuracy: 1) + XCTAssertEqual(size.height, expectedSize.height, accuracy: 1) + } + + func test_calculateSize_whenOriginalResolutionEqualMax() { + let calculator = ImageSizeCalculator() + let size = calculator.calculateSize( + originalWidthInPixels: 600, + originalHeightInPixels: 600, + maxResolutionTotalPixels: 360_000 + ) + + // It will be the original size in points + let expectedSize = CGSize( + width: 200, + height: 200 + ) + + XCTAssertEqual(size.width, expectedSize.width, accuracy: 1) + XCTAssertEqual(size.height, expectedSize.height, accuracy: 1) + } +} diff --git a/Tests/StreamChatUITests/Utils/StreamImageCDN_Tests.swift b/Tests/StreamChatUITests/Utils/StreamImageCDN_Tests.swift new file mode 100644 index 00000000000..b37a81aeddf --- /dev/null +++ b/Tests/StreamChatUITests/Utils/StreamImageCDN_Tests.swift @@ -0,0 +1,154 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamChat +@testable import StreamChatUI +import XCTest + +final class StreamImageCDN_Tests: XCTestCase { + let baseUrl = "https://www.\(StreamImageCDN.streamCDNURL)" + + func test_cachingKey_whenHostIsNotStreamCDN() { + let streamCDN = StreamImageCDN() + + let url = URL(string: "https://www.google.com/image.jpg?someStuff=20")! + let key = streamCDN.cachingKey(forImageUrl: url) + + XCTAssertEqual(key, "https://www.google.com/image.jpg?someStuff=20") + } + + func test_cachingKey_shouldRemoveUnwantedQueryParameters() { + let streamCDN = StreamImageCDN() + + let url = URL(string: "\(baseUrl)/image.jpg?name=Luke&father=Anakin")! + let filteredUrl = "\(baseUrl)/image.jpg" + let key = streamCDN.cachingKey(forImageUrl: url) + + XCTAssertEqual(key, filteredUrl) + } + + func test_cachingKey_shouldFilterWantedQueryParameters() { + let streamCDN = StreamImageCDN() + + let url = URL(string: "\(baseUrl)/image.jpg?name=Luke&w=128&h=128&crop=center&resize=crop&ro=0")! + let filteredUrl = "\(baseUrl)/image.jpg?w=128&h=128&crop=center&resize=crop" + let key = streamCDN.cachingKey(forImageUrl: url) + + XCTAssertEqual(key, filteredUrl) + } + + func test_cachingKey_whenThereIsNoQueryParameters() { + let streamCDN = StreamImageCDN() + + let url = URL(string: "\(baseUrl)/image.jpg")! + let key = streamCDN.cachingKey(forImageUrl: url) + + XCTAssertEqual(key, "\(baseUrl)/image.jpg") + } + + func test_urlRequest_whenHostIsNotStreamCDN() { + let streamCDN = StreamImageCDN() + + let url = URL(string: "https://www.google.com/image.jpg?someStuff=20")! + let processedURLRequest = streamCDN.urlRequest( + forImageUrl: url, + resize: .init(CGSize(width: 40, height: 40)) + ) + + XCTAssertEqual( + processedURLRequest.url, + URL(string: "https://www.google.com/image.jpg?someStuff=20")! + ) + } + + func test_urlRequest_whenThereAreNoResizeOptions() { + let streamCDN = StreamImageCDN() + + let url = URL(string: "\(baseUrl)/image.jpg")! + + let processedURLRequest = streamCDN.urlRequest( + forImageUrl: url, + resize: nil + ) + + XCTAssertEqual( + processedURLRequest.url, + URL(string: "\(baseUrl)/image.jpg")! + ) + } + + func test_urlRequest_whenThereAreResizeQueryParameters() { + let streamCDN = StreamImageCDN() + + let url = URL(string: "\(baseUrl)/image.jpg")! + + let processedURLRequest = streamCDN.urlRequest( + forImageUrl: url, + resize: ImageResize( + CGSize(width: 40, height: 60), + mode: .crop(.center) + ) + ) + + let w: Int = Int(40 * UIScreen.main.scale) + let h: Int = Int(60 * UIScreen.main.scale) + + AssertEqualURL( + processedURLRequest.url!, + URL(string: "\(baseUrl)/image.jpg?w=\(w)&h=\(h)&crop=center&resize=crop&ro=0")! + ) + } + + func test_urlRequest_whenResizeIsNotCrop_shouldNotIncludeCropKey() { + let streamCDN = StreamImageCDN() + + let url = URL(string: "\(baseUrl)/image.jpg")! + + let processedURLRequest = streamCDN.urlRequest( + forImageUrl: url, + resize: ImageResize( + CGSize(width: 40, height: 60), + mode: .fill + ) + ) + + let w: Int = Int(40 * UIScreen.main.scale) + let h: Int = Int(60 * UIScreen.main.scale) + + AssertEqualURL( + processedURLRequest.url!, + URL(string: "\(baseUrl)/image.jpg?w=\(w)&h=\(h)&resize=fill&ro=0")! + ) + } + + private func AssertEqualURL(_ lhs: URL, _ rhs: URL) { + guard var lhsComponents = URLComponents(url: lhs, resolvingAgainstBaseURL: true), + var rhsComponents = URLComponents(url: rhs, resolvingAgainstBaseURL: true) else { + XCTFail("Unexpected url") + return + } + + // Because query paramters can be placed in a different order, we need to check it key by key. + var lhsParameters: [String: String] = [:] + lhsComponents.queryItems?.forEach { + lhsParameters[$0.name] = $0.value + } + + var rhsParameters: [String: String] = [:] + rhsComponents.queryItems?.forEach { + rhsParameters[$0.name] = $0.value + } + + XCTAssertEqual(lhsParameters.count, rhsParameters.count) + + lhsParameters.forEach { key, lValue in + XCTAssertEqual(lValue, rhsParameters[key]) + } + + lhsComponents.query = nil + rhsComponents.query = nil + XCTAssertEqual(lhsComponents.url, rhsComponents.url) + } +}