From 8ef27d20ee645aea71a2ae8e653fd70db0e94cde Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 10 Oct 2022 16:09:35 +0100 Subject: [PATCH 01/29] Add `ImageLoaderOptions` to define image loading configuration --- .../ImageLoading/ImageLoaderOptions.swift | 77 +++++++++++++++++++ StreamChat.xcodeproj/project.pbxproj | 6 ++ 2 files changed, 83 insertions(+) create mode 100644 Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift new file mode 100644 index 00000000000..6761c615322 --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift @@ -0,0 +1,77 @@ +// +// Copyright © 2022 Stream.io Inc. All rights reserved. +// + +import UIKit + +/// The options for loading an image. +public struct ImageLoaderOptions { + // Ideally, the name would be `ImageLoadingOptions`, but this would conflict with Nuke. + + /// The placeholder to be used while the image is finishing loading. + public var placeholder: UIImage? + /// The resize information when loading an image. `Nil` if you want the full resolution of the image. + public var resize: Resize? + + public init(placeholder: UIImage?, resize: Resize?) { + self.placeholder = placeholder + self.resize = resize + } +} + +extension ImageLoaderOptions { + /// The resize information when loading an image. + public struct Resize { + public var width: CGFloat + public var height: CGFloat + public var mode: ResizeMode + + public init(width: CGFloat, height: CGFloat, mode: ResizeMode = .clip) { + self.width = width + self.height = height + self.mode = mode + } + } + + /// The way to resize the image. The default value is `clip`. + /// + /// The possible options: + /// - `clip` + /// - `crop` + /// - `fill` + /// - `scale` + public struct ResizeMode { + internal var modeValue: String + internal var cropValue: String? + + internal init(modeValue: String, cropValue: String? = nil) { + self.modeValue = modeValue + 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 = ResizeMode(modeValue: "crop") + + /// 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 { + ResizeMode(modeValue: "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 = ResizeMode(modeValue: "fill") + + /// Ignore aspect ratio, and resize the image to the given height and width. + public static var scale = ResizeMode(modeValue: "scale") + + /// The crop position of the image. + public enum Crop: String { + case top + case bottom + case right + case left + case center + } + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index cc5587d4ecb..61e49d9471e 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1025,6 +1025,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 */; }; @@ -3241,6 +3243,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 = ""; }; @@ -6750,6 +6753,7 @@ isa = PBXGroup; children = ( ACCA772926C40C96007AE2ED /* ImageLoading.swift */, + AD552E0028F46CE700199A6F /* ImageLoaderOptions.swift */, ACCA772B26C40D43007AE2ED /* NukeImageLoader.swift */, ); path = ImageLoading; @@ -8614,6 +8618,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 */, @@ -10186,6 +10191,7 @@ 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 */, From c43cb7b25b86543c63209036df3ef8f0071408b4 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 10 Oct 2022 16:52:54 +0100 Subject: [PATCH 02/29] Fix Image Loader request not using resize parameters --- Sources/StreamChatUI/Utils/ImageCDN.swift | 4 ++-- .../Utils/ImageLoading/NukeImageLoader.swift | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Sources/StreamChatUI/Utils/ImageCDN.swift b/Sources/StreamChatUI/Utils/ImageCDN.swift index 84540c3d86a..3b2a28c0af2 100644 --- a/Sources/StreamChatUI/Utils/ImageCDN.swift +++ b/Sources/StreamChatUI/Utils/ImageCDN.swift @@ -46,7 +46,7 @@ open class StreamImageCDN: ImageCDN { else { return key } // Keep these parameters in the cache key as they determine the image size. - let persistedParameters = ["w", "h"] + let persistedParameters = ["w", "h", "resize", "crop"] let newParameters = components.queryItems?.filter { persistedParameters.contains($0.name) } ?? [] components.queryItems = newParameters.isEmpty ? nil : newParameters @@ -71,7 +71,7 @@ open class StreamImageCDN: ImageCDN { "w": preferredSize.width == 0 ? "*" : String(format: "%.0f", preferredSize.width * scale), "h": preferredSize.height == 0 ? "*" : String(format: "%.0f", preferredSize.height * scale), "crop": "center", - "resize": "fill", + "resize": "clip", "ro": "0" // Required parameter. ] diff --git a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift index d6488444721..33f57545d42 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift @@ -98,9 +98,8 @@ open class NukeImageLoader: ImageLoading { imageView.image = placeholder return nil } - - let urlRequest = imageCDN.urlRequest(forImage: url) - let size = preferredSize ?? .zero + + let size = preferredSize ?? imageView.bounds.size let canResize = resize && size != .zero // If we don't have a valid size, we will not be able to resize using our CDN. @@ -119,7 +118,12 @@ open class NukeImageLoader: ImageLoading { ? [ImageProcessors.LateResize(id: cachingKey, sizeProvider: { imageView.bounds.size })] : [] - let request = ImageRequest(urlRequest: urlRequest, processors: processors, userInfo: [.imageIdKey: cachingKey]) + let urlRequest = imageCDN.urlRequest(forImage: url) + let request = ImageRequest( + urlRequest: urlRequest, + processors: processors, + userInfo: [.imageIdKey: cachingKey] + ) let options = ImageLoadingOptions(placeholder: placeholder) imageView.currentImageLoadingTask = StreamChatUI.loadImage( with: request, From f3f2c5ed11c71574c51899ed415ee316c762ec64 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 12 Oct 2022 16:39:13 +0100 Subject: [PATCH 03/29] Parse original size of image attachment payload --- .../Models/Attachments/AttachmentTypes.swift | 2 ++ .../ChatMessageImageAttachment.swift | 25 ++++++++++++++++--- .../ImageAttachmentPayload_Tests.swift | 8 +++++- 3 files changed, 31 insertions(+), 4 deletions(-) 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..be6e44aa0cd 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. + public var originalWidth: Double? + /// The original height of the image. + 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 } } @@ -75,11 +88,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/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift b/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift index 0870950a892..d73f5e6d506 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) } From 843c176826317c03d9a9e12a0cfa3422fbfce0fa Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 12 Oct 2022 19:54:57 +0100 Subject: [PATCH 04/29] Add resize mode to thumbnailURL --- .../StreamChat/Config/ChatClientConfig.swift | 1 - Sources/StreamChatUI/Utils/ImageCDN.swift | 26 ++++++++++++++++--- .../ImageLoading/ImageLoaderOptions.swift | 12 ++++----- 3 files changed, 29 insertions(+), 10 deletions(-) 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/StreamChatUI/Utils/ImageCDN.swift b/Sources/StreamChatUI/Utils/ImageCDN.swift index 3b2a28c0af2..eb217e4e1a1 100644 --- a/Sources/StreamChatUI/Utils/ImageCDN.swift +++ b/Sources/StreamChatUI/Utils/ImageCDN.swift @@ -22,6 +22,12 @@ public protocol ImageCDN { /// /// 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 + + func thumbnailURL( + originalURL: URL, + preferredSize: CGSize, + resizeMode: ImageLoaderOptions.ResizeMode + ) -> URL } extension ImageCDN { @@ -60,6 +66,18 @@ open class StreamImageCDN: ImageCDN { } open func thumbnailURL(originalURL: URL, preferredSize: CGSize) -> URL { + thumbnailURL( + originalURL: originalURL, + preferredSize: preferredSize, + resizeMode: .clip + ) + } + + public func thumbnailURL( + originalURL: URL, + preferredSize: CGSize, + resizeMode: ImageLoaderOptions.ResizeMode + ) -> URL { guard var components = URLComponents(url: originalURL, resolvingAgainstBaseURL: true), let host = components.host, @@ -67,13 +85,15 @@ open class StreamImageCDN: ImageCDN { else { return originalURL } let scale = UIScreen.main.scale - let queryItems: [String: String] = [ + var 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": "clip", + "resize": resizeMode.modeValue, "ro": "0" // Required parameter. ] + if let cropValue = resizeMode.cropValue { + queryItems["crop"] = cropValue + } var items = components.queryItems ?? [] diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift index 6761c615322..92909693c93 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift @@ -22,13 +22,13 @@ public struct ImageLoaderOptions { extension ImageLoaderOptions { /// The resize information when loading an image. public struct Resize { - public var width: CGFloat - public var height: CGFloat + /// The width and height of the image. + public var size: CGSize? + /// The resize content mode. public var mode: ResizeMode - public init(width: CGFloat, height: CGFloat, mode: ResizeMode = .clip) { - self.width = width - self.height = height + public init(size: CGSize?, mode: ResizeMode = .clip) { + self.size = size self.mode = mode } } @@ -51,7 +51,7 @@ extension ImageLoaderOptions { /// 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 = ResizeMode(modeValue: "crop") + public static var clip = ResizeMode(modeValue: "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 { From c8f208b92c9fca9d4737ceef258f8563a3dd3164 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 12 Oct 2022 19:56:42 +0100 Subject: [PATCH 05/29] Replace Nuke LateResize with Resize Processor LateResize was not doing any actual resize, loading the full bytes of the image. --- Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift index 33f57545d42..2564059f7d2 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift @@ -112,10 +112,8 @@ open class NukeImageLoader: ImageLoading { // 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 })] + ? [ImageProcessors.Resize(size: size)] : [] let urlRequest = imageCDN.urlRequest(forImage: url) From 51299b91218130e89001f7e8eeaad1e1b1e99217 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 13 Oct 2022 00:12:07 +0100 Subject: [PATCH 06/29] Fix Image Attachment Payload JSON Encoding --- .../ChatMessageImageAttachment.swift | 7 ++++++ .../ImageAttachmentPayload_Tests.swift | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift b/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift index be6e44aa0cd..ca66f6892cd 100644 --- a/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift @@ -67,6 +67,13 @@ extension ImageAttachmentPayload: Encodable { var values = extraData ?? [:] values[AttachmentCodingKeys.title.rawValue] = title.map { .string($0) } values[AttachmentCodingKeys.imageURL.rawValue] = .string(imageURL.absoluteString) + values[AttachmentCodingKeys.thumbURL.rawValue] = .string(imagePreviewURL.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) } } diff --git a/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift b/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift index d73f5e6d506..df4080772ba 100644 --- a/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift +++ b/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift @@ -71,4 +71,27 @@ 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", + "thumb_url": "dummyPreviewURL", + "original_width": 100, + "original_height": 50, + "isVerified": true + ] + + AssertJSONEqual(json, expectedJsonObject) + } } From 98fb13462c132e1f8b7e240183f9e80da4317be4 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 14 Oct 2022 16:28:19 +0100 Subject: [PATCH 07/29] Reorganize Utils Folder --- .../MarkdownFormatter.swift | 0 .../{Utils => }/AppearanceProvider.swift | 0 .../{Utils => }/ComponentsProvider.swift | 0 .../{Utils => Navigation}/NavigationVC.swift | 0 .../{ => Extensions}/Array+Extensions.swift | 0 .../Array+SafeSubscript.swift | 0 .../{ => Extensions}/Bundle+Extensions.swift | 0 .../CACornerMask+Extensions.swift | 0 .../{ => Extensions}/CALayer+Extensions.swift | 0 .../{ => Extensions}/CGPoint+Extensions.swift | 0 .../{ => Extensions}/CGRect+Extensions.swift | 0 .../{ => Extensions}/ChatChannelNamer.swift | 0 .../NSLayoutConstraint+Extensions.swift | 0 .../Reusable+Extensions.swift | 0 .../{ => Extensions}/String+Extensions.swift | 0 .../{ => Extensions}/UIColor+Extensions.swift | 0 .../{ => Extensions}/UIFont+Extensions.swift | 0 .../{ => Extensions}/UIImage+Extensions.swift | 0 .../{ => Extensions}/UILabel+Extensions.swift | 0 .../UIScrollView+Extensions.swift | 0 .../UIStackView+Extensions.swift | 0 .../UITextView+Extensions.swift | 0 .../{ => Extensions}/UIView+Extensions.swift | 0 .../UIViewController+Extensions.swift | 0 Sources/StreamChatUI/Utils/ImageCDN.swift | 119 ------------------ .../{ => ImageLoading}/ImageMerger.swift | 0 .../{ => VideoLoading}/VideoLoading.swift | 0 StreamChat.xcodeproj/project.pbxproj | 98 ++++++++++----- 28 files changed, 64 insertions(+), 153 deletions(-) rename Sources/StreamChatUI/{Utils => Appearance+Formatters}/MarkdownFormatter.swift (100%) rename Sources/StreamChatUI/{Utils => }/AppearanceProvider.swift (100%) rename Sources/StreamChatUI/{Utils => }/ComponentsProvider.swift (100%) rename Sources/StreamChatUI/{Utils => Navigation}/NavigationVC.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/Array+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/Array+SafeSubscript.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/Bundle+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/CACornerMask+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/CALayer+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/CGPoint+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/CGRect+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/ChatChannelNamer.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/NSLayoutConstraint+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/Reusable+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/String+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/UIColor+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/UIFont+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/UIImage+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/UILabel+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/UIScrollView+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/UIStackView+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/UITextView+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/UIView+Extensions.swift (100%) rename Sources/StreamChatUI/Utils/{ => Extensions}/UIViewController+Extensions.swift (100%) delete mode 100644 Sources/StreamChatUI/Utils/ImageCDN.swift rename Sources/StreamChatUI/Utils/{ => ImageLoading}/ImageMerger.swift (100%) rename Sources/StreamChatUI/Utils/{ => VideoLoading}/VideoLoading.swift (100%) 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/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/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 eb217e4e1a1..00000000000 --- a/Sources/StreamChatUI/Utils/ImageCDN.swift +++ /dev/null @@ -1,119 +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 - - func thumbnailURL( - originalURL: URL, - preferredSize: CGSize, - resizeMode: ImageLoaderOptions.ResizeMode - ) -> 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", "resize", "crop"] - - 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 { - thumbnailURL( - originalURL: originalURL, - preferredSize: preferredSize, - resizeMode: .clip - ) - } - - public func thumbnailURL( - originalURL: URL, - preferredSize: CGSize, - resizeMode: ImageLoaderOptions.ResizeMode - ) -> 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 - var queryItems: [String: String] = [ - "w": preferredSize.width == 0 ? "*" : String(format: "%.0f", preferredSize.width * scale), - "h": preferredSize.height == 0 ? "*" : String(format: "%.0f", preferredSize.height * scale), - "resize": resizeMode.modeValue, - "ro": "0" // Required parameter. - ] - if let cropValue = resizeMode.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 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/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/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 61e49d9471e..46ea227cd75 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1071,6 +1071,8 @@ 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 */; }; 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 */; }; @@ -3272,6 +3274,7 @@ 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 = ""; }; 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 = ""; }; @@ -3920,6 +3923,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 */, @@ -3932,15 +3937,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 = ""; @@ -4309,6 +4314,7 @@ DB9A3D552582689A00555D36 /* ChatMessageListRouter.swift */, 8825334B258CE82500B77352 /* AlertsRouter.swift */, 8850FE90256558B200C8D534 /* NavigationRouter.swift */, + 88410ED026556B6F00525AA3 /* NavigationVC.swift */, ); path = Navigation; sourceTree = ""; @@ -4793,43 +4799,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 = ""; @@ -6754,7 +6735,10 @@ children = ( ACCA772926C40C96007AE2ED /* ImageLoading.swift */, AD552E0028F46CE700199A6F /* ImageLoaderOptions.swift */, + AD95FD0C28F991ED00DBDF41 /* ImageResize.swift */, ACCA772B26C40D43007AE2ED /* NukeImageLoader.swift */, + BDC80CB4265CF4B800F62CE2 /* ImageCDN.swift */, + ACD502A826BC0C670029FB7D /* ImageMerger.swift */, ); path = ImageLoading; sourceTree = ""; @@ -6961,6 +6945,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 = ( @@ -6971,6 +6998,7 @@ ADBBDA21279F0CFA00E47B1C /* UploadingProgressFormatter.swift */, AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */, ADBBDA1E279F0CEA00E47B1C /* VideoDurationFormatter.swift */, + CFBF8D502847C57700EEB7D3 /* MarkdownFormatter.swift */, ); path = "Appearance+Formatters"; sourceTree = ""; @@ -8638,6 +8666,7 @@ A3C5022B284F9CF70048753E /* Token.swift in Sources */, A39A8AE7263825F4003453D9 /* ChatMessageLayoutOptionsResolver.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 */, @@ -10114,6 +10143,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 */, From e854e65f74e11bbae3d1599aedd4998ca8b355bd Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 14 Oct 2022 16:33:41 +0100 Subject: [PATCH 08/29] Refactor ImageCDN --- .../Utils/ImageLoading/ImageCDN.swift | 95 +++++++++++++++++++ .../ImageLoading/ImageLoaderOptions.swift | 61 +----------- .../Utils/ImageLoading/ImageResize.swift | 65 +++++++++++++ .../Utils/ImageLoading/NukeImageLoader.swift | 38 ++++---- .../NukeImageProcessor.swift | 0 5 files changed, 179 insertions(+), 80 deletions(-) create mode 100644 Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift create mode 100644 Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift rename Sources/StreamChatUI/Utils/{ => ImageProcessor}/NukeImageProcessor.swift (100%) diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift new file mode 100644 index 00000000000..8907bf04413 --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift @@ -0,0 +1,95 @@ +// +// 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 +} + +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 + } +} + +public extension CGSize { + static var avatarThumbnailSize: CGSize { CGSize(width: 40, height: 40) } +} diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift index 92909693c93..34ae2edd07b 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift @@ -11,67 +11,10 @@ public struct ImageLoaderOptions { /// The placeholder to be used while the image is finishing loading. public var placeholder: UIImage? /// The resize information when loading an image. `Nil` if you want the full resolution of the image. - public var resize: Resize? + public var resize: ImageResize? - public init(placeholder: UIImage?, resize: Resize?) { + public init(placeholder: UIImage?, resize: ImageResize?) { self.placeholder = placeholder self.resize = resize } } - -extension ImageLoaderOptions { - /// The resize information when loading an image. - public struct Resize { - /// The width and height of the image. - public var size: CGSize? - /// The resize content mode. - public var mode: ResizeMode - - public init(size: CGSize?, mode: ResizeMode = .clip) { - self.size = size - self.mode = mode - } - } - - /// The way to resize the image. The default value is `clip`. - /// - /// The possible options: - /// - `clip` - /// - `crop` - /// - `fill` - /// - `scale` - public struct ResizeMode { - internal var modeValue: String - internal var cropValue: String? - - internal init(modeValue: String, cropValue: String? = nil) { - self.modeValue = modeValue - 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 = ResizeMode(modeValue: "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 { - ResizeMode(modeValue: "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 = ResizeMode(modeValue: "fill") - - /// Ignore aspect ratio, and resize the image to the given height and width. - public static var scale = ResizeMode(modeValue: "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/ImageResize.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift new file mode 100644 index 00000000000..9710caeae4a --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift @@ -0,0 +1,65 @@ +// +// 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. + public var width: CGFloat + /// The new height of the image. + public var height: CGFloat + /// The resize content mode. + public var mode: 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/NukeImageLoader.swift b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift index 2564059f7d2..e58a77bf46d 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift @@ -6,11 +6,19 @@ 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() {} + open var avatarThumbnailSize: CGSize { + Components.default.avatarThumbnailSize + } + + open var imageCDN: ImageCDN { + Components.default.imageCDN + } + @discardableResult open func loadImage( using urlRequest: URLRequest, @@ -52,10 +60,9 @@ open class NukeImageLoader: ImageLoading { 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) + + let imageRequest = imageCDN.urlRequest(forImageUrl: avatarUrl, resize: .init(thumbnailSize)) + let cachingKey = imageCDN.cachingKey(forImageUrl: avatarUrl) group.enter() @@ -94,29 +101,18 @@ open class NukeImageLoader: ImageLoading { ) -> Cancellable? { imageView.currentImageLoadingTask?.cancel() - guard var url = url else { + guard let url = url else { imageView.image = placeholder return nil } - - let size = preferredSize ?? imageView.bounds.size - let canResize = resize && size != .zero - - // 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) - } - // 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) - - let processors: [ImageProcessing] = canResize + let size = preferredSize ?? imageView.bounds.size + let processors: [ImageProcessing] = size != .zero ? [ImageProcessors.Resize(size: size)] : [] - let urlRequest = imageCDN.urlRequest(forImage: url) + let cachingKey = imageCDN.cachingKey(forImageUrl: url) + let urlRequest = imageCDN.urlRequest(forImageUrl: url, resize: .init(size)) let request = ImageRequest( urlRequest: urlRequest, processors: processors, 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 From d44444ad44dc89334de765af8effe31db090543d Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 14 Oct 2022 16:31:53 +0100 Subject: [PATCH 09/29] Replace `CGSize.avatarThumbnailSize` with `Components.avatarThumbnailSize` --- .../YTChatMessageContentView.swift | 2 +- .../ChatMessage/ChatMessageContentView.swift | 4 ++-- .../AvatarView/ChatChannelAvatarView.swift | 24 +++++++++++++------ .../AvatarView/ChatUserAvatarView.swift | 2 +- .../CurrentChatUserAvatarView.swift | 2 +- .../QuotedChatMessageView.swift | 2 +- Sources/StreamChatUI/Components.swift | 3 +++ .../Utils/ImageLoading/ImageCDN.swift | 1 + .../Utils/ImageLoading/ImageLoading.swift | 2 +- 9 files changed, 28 insertions(+), 14 deletions(-) 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/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift index 7c7ed40b5a7..80447bee79a 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift @@ -538,7 +538,7 @@ open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate { url: imageURL, imageCDN: components.imageCDN, placeholder: placeholder, - preferredSize: .avatarThumbnailSize + preferredSize: components.avatarThumbnailSize ) } else { authorAvatarView?.imageView.image = placeholder @@ -598,7 +598,7 @@ open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate { url: threadAvatarUrl, imageCDN: components.imageCDN, placeholder: appearance.images.userAvatarPlaceholder4, - preferredSize: .avatarThumbnailSize + preferredSize: components.avatarThumbnailSize ) } diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift index bb7497b2b7a..5c3dbebf10c 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift @@ -162,11 +162,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 +196,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 +229,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 +246,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 ) @@ -268,7 +278,7 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { url: url, imageCDN: components.imageCDN, placeholder: placeholder, - preferredSize: .avatarThumbnailSize + preferredSize: components.avatarThumbnailSize ) } } diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift index e9b74cb7911..801c8fa1c0a 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift @@ -28,7 +28,7 @@ open class ChatUserAvatarView: _View, ThemeProvider { url: content?.imageURL, imageCDN: components.imageCDN, placeholder: appearance.images.userAvatarPlaceholder1, - preferredSize: .avatarThumbnailSize + preferredSize: components.avatarThumbnailSize ) presenceAvatarView.isOnlineIndicatorVisible = content?.isOnline ?? false diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift index 2899681cdd2..65314a04674 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift @@ -70,7 +70,7 @@ open class CurrentChatUserAvatarView: _Control, ThemeProvider { url: currentUserImageUrl, imageCDN: components.imageCDN, placeholder: placeholderImage, - preferredSize: .avatarThumbnailSize + preferredSize: components.avatarThumbnailSize ) alpha = state == .normal ? 1 : 0.5 diff --git a/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift b/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift index 9dcc6e8dd9e..b29c68ce70c 100644 --- a/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift +++ b/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift @@ -180,7 +180,7 @@ open class QuotedChatMessageView: _View, ThemeProvider, SwiftUIRepresentable { url: imageUrl, imageCDN: components.imageCDN, placeholder: placeholder, - preferredSize: .avatarThumbnailSize + preferredSize: components.avatarThumbnailSize ) } diff --git a/Sources/StreamChatUI/Components.swift b/Sources/StreamChatUI/Components.swift index c54cc47edf1..72863b6daa9 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 diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift index 8907bf04413..a548209075f 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift @@ -91,5 +91,6 @@ open class StreamImageCDN: ImageCDN { } public extension CGSize { + @available(*, deprecated, message: "use Components.avatarThumbnailSize instead.") static var avatarThumbnailSize: CGSize { CGSize(width: 40, height: 40) } } diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift index fdfbe366670..ae39b031ceb 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift @@ -82,7 +82,7 @@ public extension ImageLoading { from urls: [URL], placeholders: [UIImage], loadThumbnails: Bool = true, - thumbnailSize: CGSize = .avatarThumbnailSize, + thumbnailSize: CGSize = Components.default.avatarThumbnailSize, imageCDN: ImageCDN, completion: @escaping (([UIImage]) -> Void) ) { From 4b07acec62eb52b6e5e76fbd7d89ad90b1680f17 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 14 Oct 2022 19:22:28 +0100 Subject: [PATCH 10/29] Add code documentation on the unit of image width and height --- .../Models/Attachments/ChatMessageImageAttachment.swift | 4 ++-- .../StreamChatUI/Utils/ImageLoading/ImageResize.swift | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift b/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift index ca66f6892cd..03de513f87a 100644 --- a/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift @@ -22,9 +22,9 @@ public struct ImageAttachmentPayload: AttachmentPayload { public var imageURL: URL /// A link to the image preview. public var imagePreviewURL: URL - /// The original width of the image. + /// The original width of the image in pixels. public var originalWidth: Double? - /// The original height of the image. + /// The original height of the image in pixels. public var originalHeight: Double? /// An extra data. public var extraData: [String: RawJSON]? diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift index 9710caeae4a..9c8e0508967 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift @@ -6,13 +6,18 @@ import UIKit /// The resize information when loading an image. public struct ImageResize { - /// The new width of the image. + /// The new width of the image in points (not pixels). public var width: CGFloat - /// The new height of the image. + /// 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 From bd8a735d25bc983a8d5c6ceb160bcd31ec52390d Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 14 Oct 2022 22:39:22 +0100 Subject: [PATCH 11/29] Refactor `ImageLoading` and deprecate previous API --- .../Attachments/AnyAttachmentPayload.swift | 1 - .../ChatMessageImageAttachment.swift | 1 - .../ImageLoading/ImageDownloadOptions.swift | 15 ++ .../ImageLoading/ImageLoaderOptions.swift | 4 +- .../Utils/ImageLoading/ImageLoading.swift | 117 +++++++++++++-- .../Utils/ImageLoading/NukeImageLoader.swift | 140 +++++++++--------- StreamChat.xcodeproj/project.pbxproj | 6 + 7 files changed, 195 insertions(+), 89 deletions(-) create mode 100644 Sources/StreamChatUI/Utils/ImageLoading/ImageDownloadOptions.swift diff --git a/Sources/StreamChat/Models/Attachments/AnyAttachmentPayload.swift b/Sources/StreamChat/Models/Attachments/AnyAttachmentPayload.swift index 2e3040af0b6..dab9a52f852 100644 --- a/Sources/StreamChat/Models/Attachments/AnyAttachmentPayload.swift +++ b/Sources/StreamChat/Models/Attachments/AnyAttachmentPayload.swift @@ -79,7 +79,6 @@ public extension AnyAttachmentPayload { payload = ImageAttachmentPayload( title: localFileURL.lastPathComponent, imageRemoteURL: localFileURL, - imagePreviewRemoteURL: localFileURL, extraData: extraData ) case .video: diff --git a/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift b/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift index 03de513f87a..5cbb472c8bd 100644 --- a/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift +++ b/Sources/StreamChat/Models/Attachments/ChatMessageImageAttachment.swift @@ -67,7 +67,6 @@ extension ImageAttachmentPayload: Encodable { var values = extraData ?? [:] values[AttachmentCodingKeys.title.rawValue] = title.map { .string($0) } values[AttachmentCodingKeys.imageURL.rawValue] = .string(imageURL.absoluteString) - values[AttachmentCodingKeys.thumbURL.rawValue] = .string(imagePreviewURL.absoluteString) if let originalWidth = self.originalWidth, let originalHeight = self.originalHeight { values[AttachmentCodingKeys.originalWidth.rawValue] = .double(originalWidth) 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/ImageLoaderOptions.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift index 34ae2edd07b..821c05ccb9d 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift @@ -4,7 +4,7 @@ import UIKit -/// The options for loading an image. +/// The options for loading an image into a view. public struct ImageLoaderOptions { // Ideally, the name would be `ImageLoadingOptions`, but this would conflict with Nuke. @@ -13,7 +13,7 @@ public struct ImageLoaderOptions { /// The resize information when loading an image. `Nil` if you want the full resolution of the image. public var resize: ImageResize? - public init(placeholder: UIImage?, resize: ImageResize?) { + public init(placeholder: UIImage? = nil, resize: ImageResize? = nil) { self.placeholder = placeholder self.resize = resize } diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift index ae39b031ceb..ed0a9b22d0c 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift @@ -4,13 +4,53 @@ 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 { + /// Download an image from the given `URL`. + /// - Parameters: + /// - url: The `URL` of the image. + /// - options: The loading options on how to fetch the image. + /// - completion: The completion when the loading is finished. + /// - Returns: A cancellable task. + @discardableResult + func downloadImage( + from url: URL, + with options: ImageDownloadOptions, + completion: @escaping ((_ result: Result) -> Void) + ) -> Cancellable? + + /// Load an image into an imageView from the given `URL`. + /// - Parameters: + /// - 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? + + /// Load a batch of images and get notified when all of them complete loading. + /// - Parameters: + /// - urls: A tuple of urls and the options on how to fetch the image. + /// - completion: The completion when the loading is finished. + func loadMultipleImages( + from urls: [(URL, ImageLoaderOptions)], + completion: @escaping (([UIImage]) -> Void) + ) + + // MARK: - Deprecations + /// Load an image from using the given URL request /// - 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 + @available(*, deprecated, message: "use downloadImage() instead.") @discardableResult func loadImage( using urlRequest: URLRequest, @@ -27,6 +67,7 @@ public protocol ImageLoading: AnyObject { /// - 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, @@ -46,6 +87,7 @@ public protocol ImageLoading: AnyObject { /// - 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 +98,48 @@ public protocol ImageLoading: AnyObject { ) } +// MARK: - Default Parameters + public extension ImageLoading { + func downloadImage( + from url: URL, + with options: ImageDownloadOptions = .init(), + completion: @escaping ((_ result: Result) -> Void) + ) -> Cancellable? { + downloadImage(from: url, with: options, completion: completion) + } + + func loadImage( + into imageView: UIImageView, + from url: URL?, + with options: ImageLoaderOptions = .init(), + 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( + from: url, + with: ImageDownloadOptions(resize: nil), + completion: completion + ) + } + + @available(*, deprecated, message: "use loadImage(into:from:with:) instead.") @discardableResult func loadImage( into imageView: UIImageView, @@ -69,15 +152,16 @@ public extension ImageLoading { ) -> Cancellable? { loadImage( into: imageView, - url: url, - imageCDN: imageCDN, - placeholder: placeholder, - resize: resize, - preferredSize: preferredSize, + from: url, + with: ImageLoaderOptions( + placeholder: placeholder, + resize: preferredSize.map { ImageResize($0) } + ), completion: completion ) } - + + @available(*, deprecated, message: "use loadMultipleImages() instead.") func loadImages( from urls: [URL], placeholders: [UIImage], @@ -86,13 +170,16 @@ public extension ImageLoading { imageCDN: ImageCDN, completion: @escaping (([UIImage]) -> Void) ) { - loadImages( - from: urls, - placeholders: placeholders, - loadThumbnails: loadThumbnails, - thumbnailSize: thumbnailSize, - imageCDN: imageCDN, - completion: completion - ) + let options = placeholders.map { + ImageLoaderOptions( + placeholder: $0, + resize: .init(thumbnailSize) + ) + } + + let urls = zip(urls, options) + .map { ($0.0, $0.1) } + + loadMultipleImages(from: urls, completion: completion) } } diff --git a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift index e58a77bf46d..f6c518e4eca 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift @@ -20,21 +20,72 @@ open class NukeImageLoader: ImageLoading { } @discardableResult - open func loadImage( - using urlRequest: URLRequest, - cachingKey: String?, + public func loadImage( + into imageView: UIImageView, + from url: URL?, + with options: ImageLoaderOptions, + completion: ((Result) -> Void)? + ) -> Cancellable? { + imageView.currentImageLoadingTask?.cancel() + + guard let url = url else { + imageView.image = options.placeholder + return nil + } + + 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 options = ImageLoadingOptions(placeholder: options.placeholder) + imageView.currentImageLoadingTask = StreamChatUI.loadImage( + with: request, + options: options, + into: imageView + ) { result in + switch result { + case let .success(imageResponse): + completion?(.success(imageResponse.image)) + case let .failure(error): + completion?(.failure(error)) + } + } + + return imageView.currentImageLoadingTask + } + + @discardableResult + public func downloadImage( + from url: URL, + with options: ImageDownloadOptions, completion: @escaping ((Result) -> Void) ) -> Cancellable? { - var userInfo: [ImageRequest.UserInfoKey: Any]? - if let cachingKey = cachingKey { - userInfo = [.imageIdKey: cachingKey] + 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, - userInfo: userInfo + processors: processors, + userInfo: [.imageIdKey: cachingKey] ) - + let imageTask = ImagePipeline.shared.loadImage(with: request) { result in switch result { case let .success(imageResponse): @@ -43,34 +94,29 @@ open class NukeImageLoader: ImageLoading { completion(.failure(error)) } } - + return imageTask } - - open func loadImages( - from urls: [URL], - placeholders: [UIImage], - loadThumbnails: Bool, - thumbnailSize: CGSize, - imageCDN: ImageCDN, + + public func loadMultipleImages( + from urls: [(URL, ImageLoaderOptions)], completion: @escaping (([UIImage]) -> Void) ) { let group = DispatchGroup() var images: [UIImage] = [] - - for avatarUrl in urls { - var placeholderIndex = 0 - let imageRequest = imageCDN.urlRequest(forImageUrl: avatarUrl, resize: .init(thumbnailSize)) - let cachingKey = imageCDN.cachingKey(forImageUrl: avatarUrl) + for (url, loaderOptions) in urls { + var placeholderIndex = 0 group.enter() - loadImage(using: imageRequest, cachingKey: cachingKey) { result in + let downloadOptions = ImageDownloadOptions(resize: loaderOptions.resize) + downloadImage(from: url, with: downloadOptions) { result in switch result { case let .success(image): images.append(image) case .failure: + let placeholders = urls.map(\.1).compactMap(\.placeholder) if !placeholders.isEmpty { // Rotationally use the placeholders images.append(placeholders[placeholderIndex]) @@ -83,57 +129,11 @@ open class NukeImageLoader: ImageLoading { group.leave() } } - + group.notify(queue: .main) { completion(images) } } - - @discardableResult - open func loadImage( - into imageView: UIImageView, - url: URL?, - imageCDN: ImageCDN, - placeholder: UIImage?, - resize: Bool = true, - preferredSize: CGSize? = nil, - completion: ((_ result: Result) -> Void)? = nil - ) -> Cancellable? { - imageView.currentImageLoadingTask?.cancel() - - guard let url = url else { - imageView.image = placeholder - return nil - } - - let size = preferredSize ?? imageView.bounds.size - let processors: [ImageProcessing] = size != .zero - ? [ImageProcessors.Resize(size: size)] - : [] - - let cachingKey = imageCDN.cachingKey(forImageUrl: url) - let urlRequest = imageCDN.urlRequest(forImageUrl: url, resize: .init(size)) - let request = ImageRequest( - urlRequest: urlRequest, - processors: processors, - userInfo: [.imageIdKey: cachingKey] - ) - let options = ImageLoadingOptions(placeholder: placeholder) - imageView.currentImageLoadingTask = StreamChatUI.loadImage( - with: request, - options: options, - into: imageView - ) { result in - switch result { - case let .success(imageResponse): - completion?(.success(imageResponse.image)) - case let .failure(error): - completion?(.failure(error)) - } - } - - return imageView.currentImageLoadingTask - } } private extension UIImageView { diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 46ea227cd75..af05fd0288b 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1073,6 +1073,8 @@ 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 */; }; @@ -3275,6 +3277,7 @@ 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 = ""; }; @@ -6735,6 +6738,7 @@ children = ( ACCA772926C40C96007AE2ED /* ImageLoading.swift */, AD552E0028F46CE700199A6F /* ImageLoaderOptions.swift */, + AD95FD1028FA038900DBDF41 /* ImageDownloadOptions.swift */, AD95FD0C28F991ED00DBDF41 /* ImageResize.swift */, ACCA772B26C40D43007AE2ED /* NukeImageLoader.swift */, BDC80CB4265CF4B800F62CE2 /* ImageCDN.swift */, @@ -8760,6 +8764,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 */, @@ -10249,6 +10254,7 @@ C121EBBC2746A1E900023E4C /* VideoAttachmentGalleryCell.swift in Sources */, C121EBBD2746A1E900023E4C /* GalleryVC.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 */, From 2337994814b5c7a6a63aa78021b310867f74f68d Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 17 Oct 2022 18:13:18 +0100 Subject: [PATCH 12/29] Replace the deprecated ImageLoader API in the UI Components --- ...ChatMessageImageGallery+ImagePreview.swift | 14 ++++++------- .../ChatMessageLinkPreviewView.swift | 6 +----- .../ChatMessage/ChatMessageContentView.swift | 18 +++++++++-------- .../ChatMessageReactionAuthorViewCell.swift | 9 +++++---- .../ImageAttachmentComposerPreview.swift | 6 ++++-- .../AvatarView/ChatChannelAvatarView.swift | 20 +++++++++---------- .../AvatarView/ChatUserAvatarView.swift | 9 +++++---- .../CurrentChatUserAvatarView.swift | 10 ++++++---- .../QuotedChatMessageView.swift | 14 ++++++------- .../Cells/ImageAttachmentGalleryCell.swift | 8 ++------ .../ImageLoading/ImageLoaderOptions.swift | 7 ++++--- .../Utils/ImageLoading/ImageLoading.swift | 10 ++++++---- 12 files changed, 66 insertions(+), 65 deletions(-) diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift index 7185a624220..c1621c9bde5 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift @@ -90,15 +90,13 @@ extension ChatMessageGalleryView { let attachment = content loadingIndicator.isVisible = true - imageTask = components.imageLoader.loadImage( + 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.imagePreviewURL + ) { [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..10b01f7dbea 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageLinkPreviewView.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageLinkPreviewView.swift @@ -122,11 +122,7 @@ open class ChatMessageLinkPreviewView: _Control, ThemeProvider { authorLabel.textColor = tintColor - components.imageLoader.loadImage( - into: imagePreview, - url: payload?.previewURL, - imageCDN: components.imageCDN - ) + components.imageLoader.loadImage(into: imagePreview, from: payload?.previewURL) imagePreview.isHidden = isImageHidden authorLabel.text = payload?.author diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift index 80447bee79a..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: components.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: components.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 5c3dbebf10c..edb78198d87 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift @@ -139,12 +139,11 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { placeholderAvatars.append(placeholderImages.removeFirst()) } } - - components.imageLoader.loadImages( - from: avatarUrls, - placeholders: placeholderImages, - imageCDN: components.imageCDN - ) { images in + + let options = placeholderImages.map { ImageLoaderOptions(placeholder: $0) } + let urls = Array(zip(avatarUrls, options)) + + components.imageLoader.loadMultipleImages(from: urls) { images in completion(images, channelId) } } @@ -275,10 +274,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: components.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 801c8fa1c0a..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: components.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 65314a04674..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: components.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 b29c68ce70c..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: components.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/Gallery/Cells/ImageAttachmentGalleryCell.swift b/Sources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swift index fc7454ecd59..a87b9b3e217 100644 --- a/Sources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swift +++ b/Sources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swift @@ -31,13 +31,9 @@ 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 - ) + components.imageLoader.loadImage(into: imageView, from: url) } else { imageView.image = nil } diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift index 821c05ccb9d..9bd2ec6f403 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift @@ -8,12 +8,13 @@ import UIKit public struct ImageLoaderOptions { // Ideally, the name would be `ImageLoadingOptions`, but this would conflict with Nuke. - /// The placeholder to be used while the image is finishing loading. - public var placeholder: UIImage? /// The resize information when loading an image. `Nil` if you want the full resolution of the image. public var resize: ImageResize? - public init(placeholder: UIImage? = nil, resize: ImageResize? = nil) { + /// 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 ed0a9b22d0c..d72996fe7f0 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift @@ -101,6 +101,7 @@ public protocol ImageLoading: AnyObject { // MARK: - Default Parameters public extension ImageLoading { + @discardableResult func downloadImage( from url: URL, with options: ImageDownloadOptions = .init(), @@ -109,6 +110,7 @@ public extension ImageLoading { downloadImage(from: url, with: options, completion: completion) } + @discardableResult func loadImage( into imageView: UIImageView, from url: URL?, @@ -154,8 +156,8 @@ public extension ImageLoading { into: imageView, from: url, with: ImageLoaderOptions( - placeholder: placeholder, - resize: preferredSize.map { ImageResize($0) } + resize: preferredSize.map { ImageResize($0) }, + placeholder: placeholder ), completion: completion ) @@ -172,8 +174,8 @@ public extension ImageLoading { ) { let options = placeholders.map { ImageLoaderOptions( - placeholder: $0, - resize: .init(thumbnailSize) + resize: .init(thumbnailSize), + placeholder: $0 ) } From 1516889116886bcd677fd534f8aa8fb5b9d219da Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 18 Oct 2022 18:41:40 +0100 Subject: [PATCH 13/29] Add original resolution when uploading local file --- .../Attachments/AnyAttachmentPayload.swift | 14 ++++- .../StreamChatUI/Composer/ComposerVC.swift | 52 ++++++++++++++++--- .../Utils/ImageLoading/NukeImageLoader.swift | 4 +- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/Sources/StreamChat/Models/Attachments/AnyAttachmentPayload.swift b/Sources/StreamChat/Models/Attachments/AnyAttachmentPayload.swift index dab9a52f852..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,6 +89,8 @@ public extension AnyAttachmentPayload { payload = ImageAttachmentPayload( title: localFileURL.lastPathComponent, imageRemoteURL: localFileURL, + originalWidth: localMetadata?.originalResolution?.width, + originalHeight: localMetadata?.originalResolution?.height, extraData: extraData ) case .video: diff --git a/Sources/StreamChatUI/Composer/ComposerVC.swift b/Sources/StreamChatUI/Composer/ComposerVC.swift index c2ffebb2162..cd37c5bbb10 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 { @@ -830,7 +840,12 @@ open class ComposerVC: _ViewController, /// - 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 +862,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 +959,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 +992,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 +1024,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/Utils/ImageLoading/NukeImageLoader.swift b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift index f6c518e4eca..7e41c948fd4 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift @@ -48,10 +48,10 @@ open class NukeImageLoader: ImageLoading { userInfo: [.imageIdKey: cachingKey] ) - let options = ImageLoadingOptions(placeholder: options.placeholder) + let nukeOptions = ImageLoadingOptions(placeholder: options.placeholder) imageView.currentImageLoadingTask = StreamChatUI.loadImage( with: request, - options: options, + options: nukeOptions, into: imageView ) { result in switch result { From 845230ee70d883cbf5e26ee4648017472e5faa9f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 18 Oct 2022 18:44:30 +0100 Subject: [PATCH 14/29] Add ability to load an image attachment and limit the resolution in `ImageLoading` --- .../Utils/ImageLoading/ImageLoading.swift | 36 +++++++++++++++ .../ImageLoading/ImageSizeCalculator.swift | 44 +++++++++++++++++++ StreamChat.xcodeproj/project.pbxproj | 8 +++- 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift index d72996fe7f0..a55af920a87 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift @@ -2,6 +2,7 @@ // Copyright © 2022 Stream.io Inc. All rights reserved. // +import StreamChat import UIKit /// A protocol that provides a set of functions for loading images. @@ -98,6 +99,41 @@ 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, + 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 { diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift new file mode 100644 index 00000000000..daddb132369 --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift @@ -0,0 +1,44 @@ +// +// 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 + } + + 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/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index af05fd0288b..e49bf7f4724 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1137,6 +1137,8 @@ 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 */; }; 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 */; }; @@ -3321,6 +3323,7 @@ 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 = ""; }; 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 = ""; }; @@ -6737,12 +6740,13 @@ isa = PBXGroup; children = ( ACCA772926C40C96007AE2ED /* ImageLoading.swift */, + ACCA772B26C40D43007AE2ED /* NukeImageLoader.swift */, AD552E0028F46CE700199A6F /* ImageLoaderOptions.swift */, AD95FD1028FA038900DBDF41 /* ImageDownloadOptions.swift */, AD95FD0C28F991ED00DBDF41 /* ImageResize.swift */, - ACCA772B26C40D43007AE2ED /* NukeImageLoader.swift */, BDC80CB4265CF4B800F62CE2 /* ImageCDN.swift */, ACD502A826BC0C670029FB7D /* ImageMerger.swift */, + ADD2A98F28FF0CD300A83305 /* ImageSizeCalculator.swift */, ); path = ImageLoading; sourceTree = ""; @@ -8721,6 +8725,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 */, @@ -10253,6 +10258,7 @@ 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 */, From d7adb405b70c5a4ba43266be24c51242d40c7107 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 18 Oct 2022 18:44:51 +0100 Subject: [PATCH 15/29] Make the max resolution of image attachments configurable --- .../ChatMessageImageGallery+ImagePreview.swift | 3 ++- Sources/StreamChatUI/Components.swift | 6 ++++++ .../Gallery/Cells/ImageAttachmentGalleryCell.swift | 10 +++++----- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift index c1621c9bde5..8decb52901d 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift @@ -92,7 +92,8 @@ extension ChatMessageGalleryView { loadingIndicator.isVisible = true components.imageLoader.loadImage( into: imageView, - from: attachment?.payload.imagePreviewURL + from: attachment?.payload, + maxResolutionInPixels: components.imageAttachmentMaxPixels ) { [weak self] _ in self?.loadingIndicator.isVisible = false self?.imageTask = nil diff --git a/Sources/StreamChatUI/Components.swift b/Sources/StreamChatUI/Components.swift index 72863b6daa9..1b58bfcc42c 100644 --- a/Sources/StreamChatUI/Components.swift +++ b/Sources/StreamChatUI/Components.swift @@ -144,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/Gallery/Cells/ImageAttachmentGalleryCell.swift b/Sources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swift index a87b9b3e217..b63130b2ed9 100644 --- a/Sources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swift +++ b/Sources/StreamChatUI/Gallery/Cells/ImageAttachmentGalleryCell.swift @@ -32,11 +32,11 @@ open class ImageAttachmentGalleryCell: GalleryCollectionViewCell { let imageAttachment = content?.attachment(payloadType: ImageAttachmentPayload.self) - if let url = imageAttachment?.imageURL { - components.imageLoader.loadImage(into: imageView, from: url) - } else { - imageView.image = nil - } + components.imageLoader.loadImage( + into: imageView, + from: imageAttachment?.payload, + maxResolutionInPixels: components.imageAttachmentMaxPixels + ) } override open func viewForZooming(in scrollView: UIScrollView) -> UIView? { From 47ddb75b000b1a8fbaa61ad365092a2e9ed44278 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 18 Oct 2022 22:42:36 +0100 Subject: [PATCH 16/29] Add test coverage to ImageSizeCalculator --- StreamChat.xcodeproj/project.pbxproj | 4 ++ .../Utils/ImageSizeCalculator_Tests.swift | 61 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 Tests/StreamChatUITests/Utils/ImageSizeCalculator_Tests.swift diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index e49bf7f4724..101db00046f 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1139,6 +1139,7 @@ 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 */; }; 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 */; }; @@ -3324,6 +3325,7 @@ 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 = ""; }; 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 = ""; }; @@ -6313,6 +6315,7 @@ ACDB5412269C6F2A007CD465 /* String+Extensions_Tests.swift */, 7865705725FB6DF300974045 /* UIViewController+Extensions_Tests.swift */, CF24AAB2284A5659005AD3B8 /* DefaultMarkdownFormatter_Tests.swift */, + ADD2A99528FF227800A83305 /* ImageSizeCalculator_Tests.swift */, ); path = Utils; sourceTree = ""; @@ -8941,6 +8944,7 @@ 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 */, 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) + } +} From 2f6be7c1c3c2cf1a0daebe65fd3a3e5d18f23982 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 18 Oct 2022 23:36:30 +0100 Subject: [PATCH 17/29] Add test coverage to StreamImageCDN --- Sources/StreamChatUI/Deprecations.swift | 5 + .../Utils/ImageLoading/ImageCDN.swift | 65 -------- .../Utils/ImageLoading/ImageLoading.swift | 26 +-- .../Utils/ImageLoading/NukeImageLoader.swift | 2 +- .../Utils/ImageLoading/StreamCDN.swift | 65 ++++++++ StreamChat.xcodeproj/project.pbxproj | 14 +- .../AnyAttachmentPayload_Mock.swift | 8 +- .../Mocks/ImageLoader_Mock.swift | 37 +---- .../Utils/ImageCDN_Tests.swift | 131 --------------- .../Utils/StreamImageCDN_Tests.swift | 154 ++++++++++++++++++ 10 files changed, 250 insertions(+), 257 deletions(-) create mode 100644 Sources/StreamChatUI/Utils/ImageLoading/StreamCDN.swift delete mode 100644 Tests/StreamChatUITests/Utils/ImageCDN_Tests.swift create mode 100644 Tests/StreamChatUITests/Utils/StreamImageCDN_Tests.swift 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/Utils/ImageLoading/ImageCDN.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift index a548209075f..e6aa68a3861 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift @@ -29,68 +29,3 @@ public protocol ImageCDN { /// - Returns: A String to be used as an image cache key. func cachingKey(forImageUrl url: URL) -> String } - -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 - } -} - -public extension CGSize { - @available(*, deprecated, message: "use Components.avatarThumbnailSize instead.") - static var avatarThumbnailSize: CGSize { CGSize(width: 40, height: 40) } -} diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift index a55af920a87..f06cbe2d930 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift @@ -46,11 +46,6 @@ public protocol ImageLoading: AnyObject { // MARK: - Deprecations - /// Load an image from using the given URL request - /// - 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 @available(*, deprecated, message: "use downloadImage() instead.") @discardableResult func loadImage( @@ -58,16 +53,7 @@ public protocol ImageLoading: AnyObject { 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( @@ -79,15 +65,7 @@ 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], diff --git a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift index 7e41c948fd4..b9ae42f6c4a 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift @@ -68,7 +68,7 @@ open class NukeImageLoader: ImageLoading { @discardableResult public func downloadImage( from url: URL, - with options: ImageDownloadOptions, + with options: ImageDownloadOptions = ImageDownloadOptions(), completion: @escaping ((Result) -> Void) ) -> Cancellable? { let urlRequest = imageCDN.urlRequest(forImageUrl: url, resize: options.resize) diff --git a/Sources/StreamChatUI/Utils/ImageLoading/StreamCDN.swift b/Sources/StreamChatUI/Utils/ImageLoading/StreamCDN.swift new file mode 100644 index 00000000000..69ee8db04f0 --- /dev/null +++ b/Sources/StreamChatUI/Utils/ImageLoading/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/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 101db00046f..7f6079ce03d 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1140,6 +1140,8 @@ 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 */; }; @@ -1161,7 +1163,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 */; }; @@ -3326,6 +3328,7 @@ 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 = ""; }; @@ -3344,7 +3347,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 = ""; }; @@ -6311,7 +6314,7 @@ 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 */, @@ -6748,6 +6751,7 @@ AD95FD1028FA038900DBDF41 /* ImageDownloadOptions.swift */, AD95FD0C28F991ED00DBDF41 /* ImageResize.swift */, BDC80CB4265CF4B800F62CE2 /* ImageCDN.swift */, + ADD2A99928FF4F4B00A83305 /* StreamCDN.swift */, ACD502A826BC0C670029FB7D /* ImageMerger.swift */, ADD2A98F28FF0CD300A83305 /* ImageSizeCalculator.swift */, ); @@ -8693,6 +8697,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 */, @@ -8928,7 +8933,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 */, @@ -10291,6 +10296,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 */, 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/StreamChatUITests/Mocks/ImageLoader_Mock.swift b/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift index df8ea16b084..0867a252f3d 100644 --- a/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift +++ b/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift @@ -7,45 +7,26 @@ 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!))! + func downloadImage(from url: URL, with options: ImageDownloadOptions, completion: @escaping ((Result) -> Void)) -> Cancellable? { + let image = UIImage(data: try! Data(contentsOf: 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) - ) { - let images = urls.map { UIImage(data: try! Data(contentsOf: $0))! } + + func loadMultipleImages(from urls: [(URL, ImageLoaderOptions)], completion: @escaping (([UIImage]) -> Void)) { + let images = urls.map(\.0).map { UIImage(data: try! Data(contentsOf: $0))! } completion(images) } } 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/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) + } +} From 90b67e186339a2d13034a42979bf66d05f167a0b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 19 Oct 2022 01:23:06 +0100 Subject: [PATCH 18/29] Rename loadMultipleImages -> downloadMultipleImages + Reduce responsibility to just download --- .../AvatarView/ChatChannelAvatarView.swift | 28 +++++++- .../Utils/ImageLoading/ImageLoading.swift | 69 ++++++++++++------- .../Utils/ImageLoading/NukeImageLoader.swift | 31 +++------ .../Mocks/ImageLoader_Mock.swift | 11 ++- 4 files changed, 84 insertions(+), 55 deletions(-) diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift index edb78198d87..11f02860305 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift @@ -140,10 +140,32 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { } } - let options = placeholderImages.map { ImageLoaderOptions(placeholder: $0) } - let urls = Array(zip(avatarUrls, options)) + let avatarSize = components.avatarThumbnailSize + let urlsAndOptions = avatarUrls.map { + (url: $0, options: ImageDownloadOptions(resize: .init(avatarSize))) + } + + components.imageLoader.downloadMultipleImages(from: urlsAndOptions) { results in + var images: [UIImage] = [] + + for result in results { + var placeholderIndex = 0 + + switch result { + case let .success(image): + images.append(image) + case .failure: + if !placeholderImages.isEmpty { + // Rotationally use the placeholders + images.append(placeholderImages[placeholderIndex]) + placeholderIndex += 1 + if placeholderIndex == placeholderImages.count { + placeholderIndex = 0 + } + } + } + } - components.imageLoader.loadMultipleImages(from: urls) { images in completion(images, channelId) } } diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift index f06cbe2d930..956123cdf0d 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift @@ -7,19 +7,6 @@ import UIKit /// A protocol that provides a set of functions for loading images. public protocol ImageLoading: AnyObject { - /// Download an image from the given `URL`. - /// - Parameters: - /// - url: The `URL` of the image. - /// - options: The loading options on how to fetch the image. - /// - completion: The completion when the loading is finished. - /// - Returns: A cancellable task. - @discardableResult - func downloadImage( - from url: URL, - with options: ImageDownloadOptions, - completion: @escaping ((_ result: Result) -> Void) - ) -> Cancellable? - /// Load an image into an imageView from the given `URL`. /// - Parameters: /// - imageView: The image view where the image will be loaded. @@ -35,13 +22,27 @@ public protocol ImageLoading: AnyObject { completion: ((_ result: Result) -> Void)? ) -> Cancellable? + /// Download an image from the given `URL`. + /// - Parameters: + /// - url: The `URL` of the image. + /// - options: The loading options on how to fetch the image. + /// - completion: The completion when the loading is finished. + /// - Returns: A cancellable task. + @discardableResult + func downloadImage( + from url: URL, + with options: ImageDownloadOptions, + completion: @escaping ((_ result: Result) -> Void) + ) -> Cancellable? + /// Load a batch of images and get notified when all of them complete loading. /// - Parameters: - /// - urls: A tuple of urls and the options on how to fetch the image. + /// - urlsAndOptions: A tuple of urls and the options on how to fetch the image. /// - completion: The completion when the loading is finished. - func loadMultipleImages( - from urls: [(URL, ImageLoaderOptions)], - completion: @escaping (([UIImage]) -> Void) + /// It returns an array of image and errors in case the image failed to load. + func downloadMultipleImages( + from urlsAndOptions: [(url: URL, options: ImageDownloadOptions)], + completion: @escaping (([Result]) -> Void) ) // MARK: - Deprecations @@ -186,16 +187,32 @@ public extension ImageLoading { imageCDN: ImageCDN, completion: @escaping (([UIImage]) -> Void) ) { - let options = placeholders.map { - ImageLoaderOptions( - resize: .init(thumbnailSize), - placeholder: $0 - ) + let urlsAndOptions = urls.map { url in + (url: url, options: ImageDownloadOptions(resize: .init(thumbnailSize))) } - let urls = zip(urls, options) - .map { ($0.0, $0.1) } - - loadMultipleImages(from: urls, completion: completion) + downloadMultipleImages(from: urlsAndOptions) { results in + var images: [UIImage] = [] + + for result in results { + var placeholderIndex = 0 + + 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 + } + } + } + } + + completion(images) + } } } diff --git a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift index b9ae42f6c4a..a15eedd1da1 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift @@ -98,40 +98,25 @@ open class NukeImageLoader: ImageLoading { return imageTask } - public func loadMultipleImages( - from urls: [(URL, ImageLoaderOptions)], - completion: @escaping (([UIImage]) -> Void) + public func downloadMultipleImages( + from urlsAndOptions: [(url: URL, options: ImageDownloadOptions)], + completion: @escaping (([Result]) -> Void) ) { let group = DispatchGroup() - var images: [UIImage] = [] - - for (url, loaderOptions) in urls { - var placeholderIndex = 0 + var results: [Result] = [] + for (url, downloadOptions) in urlsAndOptions { group.enter() - let downloadOptions = ImageDownloadOptions(resize: loaderOptions.resize) downloadImage(from: url, with: downloadOptions) { result in - switch result { - case let .success(image): - images.append(image) - case .failure: - let placeholders = urls.map(\.1).compactMap(\.placeholder) - if !placeholders.isEmpty { - // Rotationally use the placeholders - images.append(placeholders[placeholderIndex]) - placeholderIndex += 1 - if placeholderIndex == placeholders.count { - placeholderIndex = 0 - } - } - } + results.append(result) + group.leave() } } group.notify(queue: .main) { - completion(images) + completion(results) } } } diff --git a/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift b/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift index 0867a252f3d..cd3963c884a 100644 --- a/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift +++ b/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift @@ -25,8 +25,13 @@ final class ImageLoader_Mock: ImageLoading { return nil } - func loadMultipleImages(from urls: [(URL, ImageLoaderOptions)], completion: @escaping (([UIImage]) -> Void)) { - let images = urls.map(\.0).map { UIImage(data: try! Data(contentsOf: $0))! } - completion(images) + func downloadMultipleImages( + from urlsAndOptions: [(url: URL, options: ImageDownloadOptions)], + completion: @escaping (([Result]) -> Void) + ) { + let results = urlsAndOptions.map(\.0).map { + Result.success(UIImage(data: try! Data(contentsOf: $0))!) + } + completion(results) } } From 53a0a7d22f54b217d7fbae52c9bd3806fc6816ef Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 19 Oct 2022 12:31:41 +0100 Subject: [PATCH 19/29] Update CHANGELOG.md --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a63f2f156..486baf083f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,22 @@ 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) + ### 🐞 Fixed - Fix message text not dynamically scalable with content size category changes [#2328](https://github.com/GetStream/stream-chat-swift/pull/2328) +### 🚨 Breaking Changes +- The `ImageCDN` protocol has some minor breaking changes that were needed to support the new Stream CDN v2 and to make it more scalabe in the future. If you have your own implementation of the `ImageCDN`, the old `urlRequest(forImage)` is now the `urlRequest(forImageUrl:resize:)`, and the `cachingKey(fromImage)` is now `cachingKey(fromImageUrl:)`, the `thumbnail(originalURL:preferreSize:)` was removed since it is not needed and it is handled by `urlRequest(forImageUrl:resize:)`. So mostly it was naming changes. For the `urlRequest(forImageUrl:resize:)`, if your CDN does not support resizing, you can just ignore it, and copy the exact some implementation of the old `urlRequest(forImage)`. + # [4.22.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.22.0) _September 26, 2022_ ## StreamChat From 95f999ce4d901face0ebf3e9aa5436ca95429f5b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 19 Oct 2022 12:48:38 +0100 Subject: [PATCH 20/29] Update CHANGELOG.md --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 486baf083f2..129f005e328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 🐞 Fixed - Fix message text not dynamically scalable with content size category changes [#2328](https://github.com/GetStream/stream-chat-swift/pull/2328) -### 🚨 Breaking Changes +### 🚨 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. + +- `ComposerVC.addAttachmentToContent()` has a new `info` property. - The `ImageCDN` protocol has some minor breaking changes that were needed to support the new Stream CDN v2 and to make it more scalabe in the future. If you have your own implementation of the `ImageCDN`, the old `urlRequest(forImage)` is now the `urlRequest(forImageUrl:resize:)`, and the `cachingKey(fromImage)` is now `cachingKey(fromImageUrl:)`, the `thumbnail(originalURL:preferreSize:)` was removed since it is not needed and it is handled by `urlRequest(forImageUrl:resize:)`. So mostly it was naming changes. For the `urlRequest(forImageUrl:resize:)`, if your CDN does not support resizing, you can just ignore it, and copy the exact some implementation of the old `urlRequest(forImage)`. # [4.22.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.22.0) From 3ce95f9923860c153a024cea136d13716880a1ca Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 20 Oct 2022 14:53:23 +0100 Subject: [PATCH 21/29] Fix not saving the image task when loading attachments --- .../Attachments/ChatMessageImageGallery+ImagePreview.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift index 8decb52901d..6666a269352 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageImageGallery+ImagePreview.swift @@ -90,7 +90,7 @@ extension ChatMessageGalleryView { let attachment = content loadingIndicator.isVisible = true - components.imageLoader.loadImage( + imageTask = components.imageLoader.loadImage( into: imageView, from: attachment?.payload, maxResolutionInPixels: components.imageAttachmentMaxPixels From 7b55128c80d31be46f10c03b2891310620a55ab3 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 20 Oct 2022 14:57:38 +0100 Subject: [PATCH 22/29] Fix the LLC Tests --- .../AnyAttachmentPayload_Tests.swift | 24 ++++++++++++++----- .../ImageAttachmentPayload_Tests.swift | 1 - 2 files changed, 18 insertions(+), 7 deletions(-) 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 df4080772ba..6fba9d2084c 100644 --- a/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift +++ b/Tests/StreamChatTests/Models/Attachments/ImageAttachmentPayload_Tests.swift @@ -86,7 +86,6 @@ final class ImageAttachmentPayload_Tests: XCTestCase { let expectedJsonObject: [String: Any] = [ "title": "Image1.png", "image_url": "dummyURL", - "thumb_url": "dummyPreviewURL", "original_width": 100, "original_height": 50, "isVerified": true From f813005464320f95a2e7366b5d12df3d9d316847 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 20 Oct 2022 15:01:19 +0100 Subject: [PATCH 23/29] Make sure the NukeImageLoader API is open --- .../StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift index a15eedd1da1..32db4296bc0 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift @@ -20,7 +20,7 @@ open class NukeImageLoader: ImageLoading { } @discardableResult - public func loadImage( + open func loadImage( into imageView: UIImageView, from url: URL?, with options: ImageLoaderOptions, @@ -66,7 +66,7 @@ open class NukeImageLoader: ImageLoading { } @discardableResult - public func downloadImage( + open func downloadImage( from url: URL, with options: ImageDownloadOptions = ImageDownloadOptions(), completion: @escaping ((Result) -> Void) @@ -98,7 +98,7 @@ open class NukeImageLoader: ImageLoading { return imageTask } - public func downloadMultipleImages( + open func downloadMultipleImages( from urlsAndOptions: [(url: URL, options: ImageDownloadOptions)], completion: @escaping (([Result]) -> Void) ) { From 1070c48c7dc766822003b803a3426dd9d7215dc8 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 20 Oct 2022 15:27:22 +0100 Subject: [PATCH 24/29] Add back the addAttachmentToContent(from:type:) to avoid breaking changes --- Sources/StreamChatUI/Composer/ComposerVC.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/StreamChatUI/Composer/ComposerVC.swift b/Sources/StreamChatUI/Composer/ComposerVC.swift index cd37c5bbb10..8f60494a018 100644 --- a/Sources/StreamChatUI/Composer/ComposerVC.swift +++ b/Sources/StreamChatUI/Composer/ComposerVC.swift @@ -835,6 +835,17 @@ 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: From 275b872c845a1295fff7b3f9aac0dbdf435f9de6 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 20 Oct 2022 15:41:41 +0100 Subject: [PATCH 25/29] Update CHANGELOG.md --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 129f005e328..64ba960e7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 🚨 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. -- `ComposerVC.addAttachmentToContent()` has a new `info` property. -- The `ImageCDN` protocol has some minor breaking changes that were needed to support the new Stream CDN v2 and to make it more scalabe in the future. If you have your own implementation of the `ImageCDN`, the old `urlRequest(forImage)` is now the `urlRequest(forImageUrl:resize:)`, and the `cachingKey(fromImage)` is now `cachingKey(fromImageUrl:)`, the `thumbnail(originalURL:preferreSize:)` was removed since it is not needed and it is handled by `urlRequest(forImageUrl:resize:)`. So mostly it was naming changes. For the `urlRequest(forImageUrl:resize:)`, if your CDN does not support resizing, you can just ignore it, and copy the exact some implementation of the old `urlRequest(forImage)`. +- 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_ From 71834200b8fcaf6e400653bb5e74e097c8ed383a Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 20 Oct 2022 18:10:05 +0100 Subject: [PATCH 26/29] Reuse image placeholder replacing logic --- .../AvatarView/ChatChannelAvatarView.swift | 22 +-- .../Utils/ImageLoading/ImageLoading.swift | 22 +-- .../ImageLoading/ImageResultsMapper.swift | 39 ++++++ StreamChat.xcodeproj/project.pbxproj | 10 ++ .../Utils/ImageResultsMapper_Tests.swift | 131 ++++++++++++++++++ 5 files changed, 184 insertions(+), 40 deletions(-) create mode 100644 Sources/StreamChatUI/Utils/ImageLoading/ImageResultsMapper.swift create mode 100644 Tests/StreamChatUITests/Utils/ImageResultsMapper_Tests.swift diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift index 11f02860305..8fbe3206939 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift @@ -146,26 +146,8 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { } components.imageLoader.downloadMultipleImages(from: urlsAndOptions) { results in - var images: [UIImage] = [] - - for result in results { - var placeholderIndex = 0 - - switch result { - case let .success(image): - images.append(image) - case .failure: - if !placeholderImages.isEmpty { - // Rotationally use the placeholders - images.append(placeholderImages[placeholderIndex]) - placeholderIndex += 1 - if placeholderIndex == placeholderImages.count { - placeholderIndex = 0 - } - } - } - } - + let imagesMapper = ImageResultsMapper(results: results) + let images = imagesMapper.mapErrors(with: placeholderImages) completion(images, channelId) } } diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift index 956123cdf0d..88fc0319e0b 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift @@ -192,26 +192,8 @@ public extension ImageLoading { } downloadMultipleImages(from: urlsAndOptions) { results in - var images: [UIImage] = [] - - for result in results { - var placeholderIndex = 0 - - 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 - } - } - } - } - + let imagesMapper = ImageResultsMapper(results: results) + let images = imagesMapper.mapErrors(with: placeholders) completion(images) } } 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/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 7f6079ce03d..0f9ff2cefc2 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1057,6 +1057,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 */; }; @@ -3265,6 +3268,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 = ""; }; @@ -6319,6 +6324,7 @@ 7865705725FB6DF300974045 /* UIViewController+Extensions_Tests.swift */, CF24AAB2284A5659005AD3B8 /* DefaultMarkdownFormatter_Tests.swift */, ADD2A99528FF227800A83305 /* ImageSizeCalculator_Tests.swift */, + AD7BBFCD2901B1AE004E8B76 /* ImageResultsMapper_Tests.swift */, ); path = Utils; sourceTree = ""; @@ -6754,6 +6760,7 @@ ADD2A99928FF4F4B00A83305 /* StreamCDN.swift */, ACD502A826BC0C670029FB7D /* ImageMerger.swift */, ADD2A98F28FF0CD300A83305 /* ImageSizeCalculator.swift */, + AD7BBFCA2901AF3F004E8B76 /* ImageResultsMapper.swift */, ); path = ImageLoading; sourceTree = ""; @@ -8838,6 +8845,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 */, @@ -8954,6 +8962,7 @@ 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 */, @@ -10395,6 +10404,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/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 + ]) + } +} From 70103c07dcf48cae58b216864d314c092095a6f4 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 21 Oct 2022 16:06:10 +0100 Subject: [PATCH 27/29] Add formula documentation on how to calculate new resolution --- .../ImageLoading/ImageSizeCalculator.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift index daddb132369..6dc870102e6 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift @@ -33,6 +33,24 @@ struct ImageSizeCalculator { 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 From 1802b0bdf3ec6f59ee42c074d7e0e6119c2b1c60 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 25 Oct 2022 13:05:08 +0100 Subject: [PATCH 28/29] Improve `downloadImage()` API with `ImageDownloadRequest` structure --- .../ChatMessageLinkPreviewView.swift | 6 +++- .../AvatarView/ChatChannelAvatarView.swift | 6 ++-- .../{ => ImageCDN}/ImageCDN.swift | 0 .../{ => ImageCDN}/StreamCDN.swift | 0 .../ImageLoading/ImageDownloadRequest.swift | 16 ++++++++++ .../Utils/ImageLoading/ImageLoading.swift | 30 +++++++------------ .../Utils/ImageLoading/NukeImageLoader.swift | 15 ++++++---- StreamChat.xcodeproj/project.pbxproj | 18 +++++++++-- .../Mocks/ImageLoader_Mock.swift | 19 +++++++----- 9 files changed, 71 insertions(+), 39 deletions(-) rename Sources/StreamChatUI/Utils/ImageLoading/{ => ImageCDN}/ImageCDN.swift (100%) rename Sources/StreamChatUI/Utils/ImageLoading/{ => ImageCDN}/StreamCDN.swift (100%) create mode 100644 Sources/StreamChatUI/Utils/ImageLoading/ImageDownloadRequest.swift diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageLinkPreviewView.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageLinkPreviewView.swift index 10b01f7dbea..6422d376c1a 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageLinkPreviewView.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageLinkPreviewView.swift @@ -122,7 +122,11 @@ open class ChatMessageLinkPreviewView: _Control, ThemeProvider { authorLabel.textColor = tintColor - components.imageLoader.loadImage(into: imagePreview, from: payload?.previewURL) + components.imageLoader.loadImage( + into: imagePreview, + from: payload?.previewURL, + with: ImageLoaderOptions() + ) imagePreview.isHidden = isImageHidden authorLabel.text = payload?.author diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift index 8fbe3206939..2ff510b3685 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift @@ -141,11 +141,11 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { } let avatarSize = components.avatarThumbnailSize - let urlsAndOptions = avatarUrls.map { - (url: $0, options: ImageDownloadOptions(resize: .init(avatarSize))) + let requests = avatarUrls.map { + ImageDownloadRequest(url: $0, options: ImageDownloadOptions(resize: .init(avatarSize))) } - components.imageLoader.downloadMultipleImages(from: urlsAndOptions) { results in + components.imageLoader.downloadMultipleImages(with: requests) { results in let imagesMapper = ImageResultsMapper(results: results) let images = imagesMapper.mapErrors(with: placeholderImages) completion(images, channelId) diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/ImageCDN.swift similarity index 100% rename from Sources/StreamChatUI/Utils/ImageLoading/ImageCDN.swift rename to Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/ImageCDN.swift diff --git a/Sources/StreamChatUI/Utils/ImageLoading/StreamCDN.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/StreamCDN.swift similarity index 100% rename from Sources/StreamChatUI/Utils/ImageLoading/StreamCDN.swift rename to Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/StreamCDN.swift 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/ImageLoading.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift index 88fc0319e0b..98f09f58979 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift @@ -24,24 +24,22 @@ public protocol ImageLoading: AnyObject { /// Download an image from the given `URL`. /// - Parameters: - /// - url: The `URL` of the image. - /// - options: The loading options on how to fetch the image. + /// - 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( - from url: URL, - with options: ImageDownloadOptions, + with request: ImageDownloadRequest, completion: @escaping ((_ result: Result) -> Void) ) -> Cancellable? /// Load a batch of images and get notified when all of them complete loading. /// - Parameters: - /// - urlsAndOptions: A tuple of urls and the options on how to fetch the image. + /// - 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( - from urlsAndOptions: [(url: URL, options: ImageDownloadOptions)], + with requests: [ImageDownloadRequest], completion: @escaping (([Result]) -> Void) ) @@ -93,6 +91,7 @@ public extension ImageLoading { return loadImage( into: imageView, from: attachmentPayload?.imageURL, + with: ImageLoaderOptions(), completion: completion ) } @@ -116,20 +115,12 @@ public extension ImageLoading { // MARK: - Default Parameters public extension ImageLoading { - @discardableResult - func downloadImage( - from url: URL, - with options: ImageDownloadOptions = .init(), - completion: @escaping ((_ result: Result) -> Void) - ) -> Cancellable? { - downloadImage(from: url, with: options, completion: completion) - } - + // Default empty completion block. @discardableResult func loadImage( into imageView: UIImageView, from url: URL?, - with options: ImageLoaderOptions = .init(), + with options: ImageLoaderOptions, completion: ((_ result: Result) -> Void)? = nil ) -> Cancellable? { loadImage(into: imageView, from: url, with: options, completion: completion) @@ -150,8 +141,7 @@ public extension ImageLoading { } return downloadImage( - from: url, - with: ImageDownloadOptions(resize: nil), + with: ImageDownloadRequest(url: url, options: ImageDownloadOptions()), completion: completion ) } @@ -188,10 +178,10 @@ public extension ImageLoading { completion: @escaping (([UIImage]) -> Void) ) { let urlsAndOptions = urls.map { url in - (url: url, options: ImageDownloadOptions(resize: .init(thumbnailSize))) + ImageDownloadRequest(url: url, options: .init(resize: .init(thumbnailSize))) } - downloadMultipleImages(from: urlsAndOptions) { results in + downloadMultipleImages(with: urlsAndOptions) { results in let imagesMapper = ImageResultsMapper(results: results) let images = imagesMapper.mapErrors(with: placeholders) completion(images) diff --git a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift index 32db4296bc0..83d0bac6f03 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift @@ -67,10 +67,11 @@ open class NukeImageLoader: ImageLoading { @discardableResult open func downloadImage( - from url: URL, - with options: ImageDownloadOptions = ImageDownloadOptions(), + 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) @@ -99,16 +100,20 @@ open class NukeImageLoader: ImageLoading { } open func downloadMultipleImages( - from urlsAndOptions: [(url: URL, options: ImageDownloadOptions)], + with requests: [ImageDownloadRequest], completion: @escaping (([Result]) -> Void) ) { let group = DispatchGroup() var results: [Result] = [] - for (url, downloadOptions) in urlsAndOptions { + for request in requests { + let url = request.url + let downloadOptions = request.options + group.enter() - downloadImage(from: url, with: downloadOptions) { result in + let request = ImageDownloadRequest(url: url, options: downloadOptions) + downloadImage(with: request) { result in results.append(result) group.leave() diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 0f9ff2cefc2..8339e482fbe 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1069,6 +1069,8 @@ 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 */; }; @@ -3281,6 +3283,7 @@ 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 = ""; }; @@ -6751,13 +6754,13 @@ 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 */, - BDC80CB4265CF4B800F62CE2 /* ImageCDN.swift */, - ADD2A99928FF4F4B00A83305 /* StreamCDN.swift */, ACD502A826BC0C670029FB7D /* ImageMerger.swift */, ADD2A98F28FF0CD300A83305 /* ImageSizeCalculator.swift */, AD7BBFCA2901AF3F004E8B76 /* ImageResultsMapper.swift */, @@ -6959,6 +6962,15 @@ path = ShrinkInputButton; sourceTree = ""; }; + AD8B7277290801B800921C31 /* ImageCDN */ = { + isa = PBXGroup; + children = ( + ADD2A99928FF4F4B00A83305 /* StreamCDN.swift */, + BDC80CB4265CF4B800F62CE2 /* ImageCDN.swift */, + ); + path = ImageCDN; + sourceTree = ""; + }; AD8E6BBD2642DB520013E01E /* CommandLabelView */ = { isa = PBXGroup; children = ( @@ -8687,6 +8699,7 @@ 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 */, @@ -10246,6 +10259,7 @@ 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 */, diff --git a/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift b/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift index cd3963c884a..060dadcd384 100644 --- a/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift +++ b/Tests/StreamChatUITests/Mocks/ImageLoader_Mock.swift @@ -7,12 +7,6 @@ import UIKit /// A mock implementation of the image loader which loads images synchronusly final class ImageLoader_Mock: ImageLoading { - func downloadImage(from url: URL, with options: ImageDownloadOptions, completion: @escaping ((Result) -> Void)) -> Cancellable? { - let image = UIImage(data: try! Data(contentsOf: url))! - completion(.success(image)) - return nil - } - 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))! @@ -24,12 +18,21 @@ final class ImageLoader_Mock: ImageLoading { return nil } + + 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( - from urlsAndOptions: [(url: URL, options: ImageDownloadOptions)], + with requests: [ImageDownloadRequest], completion: @escaping (([Result]) -> Void) ) { - let results = urlsAndOptions.map(\.0).map { + let results = requests.map(\.url).map { Result.success(UIImage(data: try! Data(contentsOf: $0))!) } completion(results) From 2459d1193ac38e2fe225533b885a0c485c20446b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 25 Oct 2022 17:35:23 +0100 Subject: [PATCH 29/29] Fix small typo --- Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift index 98f09f58979..885280e7bdd 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift @@ -177,11 +177,11 @@ public extension ImageLoading { imageCDN: ImageCDN, completion: @escaping (([UIImage]) -> Void) ) { - let urlsAndOptions = urls.map { url in + let requests = urls.map { url in ImageDownloadRequest(url: url, options: .init(resize: .init(thumbnailSize))) } - downloadMultipleImages(with: urlsAndOptions) { results in + downloadMultipleImages(with: requests) { results in let imagesMapper = ImageResultsMapper(results: results) let images = imagesMapper.mapErrors(with: placeholders) completion(images)