diff --git a/Sources/StreamChatUI/Appearance+Formatters/ChannelNameFormatter.swift b/Sources/StreamChatUI/Appearance+Formatters/ChannelNameFormatter.swift index 4d95a6ef1d5..949be78566c 100644 --- a/Sources/StreamChatUI/Appearance+Formatters/ChannelNameFormatter.swift +++ b/Sources/StreamChatUI/Appearance+Formatters/ChannelNameFormatter.swift @@ -7,7 +7,7 @@ import StreamChat /// A formatter that generates a name for the given channel. public protocol ChannelNameFormatter { - func format(channel: ChatChannel, forCurrentUserId currentUserId: UserId?) -> String? + @preconcurrency @MainActor func format(channel: ChatChannel, forCurrentUserId currentUserId: UserId?) -> String? } /// The default channel name formatter. @@ -15,7 +15,7 @@ open class DefaultChannelNameFormatter: ChannelNameFormatter { public init() {} /// Internal static property to add backwards compatibility to `Components.channelNamer` - internal static var channelNamer: ( + @MainActor static var channelNamer: ( _ channel: ChatChannel, _ currentUserId: UserId? ) -> String? = DefaultChatChannelNamer() diff --git a/Sources/StreamChatUI/Appearance+Formatters/MarkdownFormatter.swift b/Sources/StreamChatUI/Appearance+Formatters/MarkdownFormatter.swift index 3f5db875990..e6d57f0d56d 100644 --- a/Sources/StreamChatUI/Appearance+Formatters/MarkdownFormatter.swift +++ b/Sources/StreamChatUI/Appearance+Formatters/MarkdownFormatter.swift @@ -187,7 +187,7 @@ private extension UIFont { @available(iOS 15.0, *) private extension InlinePresentationIntent { /// An intent that represents bold with italic presentation. - static var extremelyStronglyEmphasized = InlinePresentationIntent(rawValue: 3) + static var extremelyStronglyEmphasized: InlinePresentationIntent { InlinePresentationIntent(rawValue: 3) } } /// Configures the font style properties for base Markdown elements diff --git a/Sources/StreamChatUI/Appearance.swift b/Sources/StreamChatUI/Appearance.swift index 54fd0037f1d..14d8388237e 100644 --- a/Sources/StreamChatUI/Appearance.swift +++ b/Sources/StreamChatUI/Appearance.swift @@ -6,7 +6,7 @@ import Foundation import StreamChat /// An object containing visual configuration for whole application. -public struct Appearance { +public struct Appearance: @unchecked Sendable { /// A color pallete to provide basic set of colors for the Views. /// /// By providing different object or changing individual colors, you can change the look of the views. @@ -29,7 +29,7 @@ public struct Appearance { public var formatters = Formatters() /// Provider for custom localization which is dependent on App Bundle. - public var localizationProvider: (_ key: String, _ table: String) -> String = { key, table in + public var localizationProvider: @Sendable(_ key: String, _ table: String) -> String = { key, table in Bundle.streamChatUI.localizedString(forKey: key, value: nil, table: table) } @@ -39,5 +39,16 @@ public struct Appearance { // MARK: - Appearance + Default public extension Appearance { - static var `default`: Appearance = .init() + static var `default`: Appearance { + get { + StreamConcurrency.onMain { _default } + } + set { + StreamConcurrency.onMain { _default = newValue } + } + } + + // Shared instance is mutated only on the main thread without explicit + // main actor annotation for easier SDK setup. + @MainActor private static var _default: Appearance = .init() } diff --git a/Sources/StreamChatUI/AppearanceProvider.swift b/Sources/StreamChatUI/AppearanceProvider.swift index a7c2a5b7f77..1b1c3ca251b 100644 --- a/Sources/StreamChatUI/AppearanceProvider.swift +++ b/Sources/StreamChatUI/AppearanceProvider.swift @@ -7,7 +7,7 @@ import UIKit // MARK: - Protocol -public protocol AppearanceProvider: AnyObject { +@preconcurrency @MainActor public protocol AppearanceProvider: AnyObject { /// Appearance object to change appearance of the existing views or to use default appearance of the SDK by custom components. var appearance: Appearance { get set } diff --git a/Sources/StreamChatUI/ChatChannel/ChatChannelHeaderView.swift b/Sources/StreamChatUI/ChatChannel/ChatChannelHeaderView.swift index f05f3ddc2ff..d4ddd680a28 100644 --- a/Sources/StreamChatUI/ChatChannel/ChatChannelHeaderView.swift +++ b/Sources/StreamChatUI/ChatChannel/ChatChannelHeaderView.swift @@ -104,25 +104,29 @@ open class ChatChannelHeaderView: _View, withTimeInterval: statusUpdateInterval, repeats: true ) { [weak self] _ in - self?.updateContentIfNeeded() + StreamConcurrency.onMain { [weak self] in + self?.updateContentIfNeeded() + } } } // MARK: - ChatChannelControllerDelegate Implementation - open func channelController( + nonisolated open func channelController( _ channelController: ChatChannelController, didUpdateChannel channel: EntityChange ) { - switch channel { - case .update, .create: - updateContent() - default: - break + StreamConcurrency.onMain { + switch channel { + case .update, .create: + updateContent() + default: + break + } } } - open func channelController( + nonisolated open func channelController( _ channelController: ChatChannelController, didChangeTypingUsers typingUsers: Set ) { @@ -130,7 +134,7 @@ open class ChatChannelHeaderView: _View, // but this can be overridden by subclassing this component. } - open func channelController( + nonisolated open func channelController( _ channelController: ChatChannelController, didReceiveMemberEvent: MemberEvent ) { @@ -138,7 +142,7 @@ open class ChatChannelHeaderView: _View, // but this can be overridden by subclassing this component. } - open func channelController( + nonisolated open func channelController( _ channelController: ChatChannelController, didUpdateMessages changes: [ListChange] ) { @@ -147,6 +151,8 @@ open class ChatChannelHeaderView: _View, } deinit { - timer?.invalidate() + StreamConcurrency.onMain { + timer?.invalidate() + } } } diff --git a/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift b/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift index 35c34f74810..675401a58e1 100644 --- a/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift +++ b/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift @@ -149,7 +149,9 @@ open class ChatChannelVC: _ViewController, channelController.delegate = self channelController.synchronize { [weak self] error in - self?.didFinishSynchronizing(with: error) + StreamConcurrency.onMain { [weak self] in + self?.didFinishSynchronizing(with: error) + } } if channelController.channelQuery.pagination?.parameter == nil { @@ -309,10 +311,12 @@ open class ChatChannelVC: _ViewController, // MARK: - Loading previous and next messages state handling. /// Called when the channel will load previous (older) messages. - open func loadPreviousMessages(completion: @escaping (Error?) -> Void) { + open func loadPreviousMessages(completion: @escaping @Sendable(Error?) -> Void) { channelController.loadPreviousMessages { [weak self] error in completion(error) - self?.didFinishLoadingPreviousMessages(with: error) + StreamConcurrency.onMain { [weak self] in + self?.didFinishLoadingPreviousMessages(with: error) + } } } @@ -323,10 +327,12 @@ open class ChatChannelVC: _ViewController, } /// Called when the channel will load next (newer) messages. - open func loadNextMessages(completion: @escaping (Error?) -> Void) { + open func loadNextMessages(completion: @escaping @Sendable(Error?) -> Void) { channelController.loadNextMessages { [weak self] error in - completion(error) - self?.didFinishLoadingNextMessages(with: error) + StreamConcurrency.onMain { [weak self] in + completion(error) + self?.didFinishLoadingNextMessages(with: error) + } } } @@ -376,7 +382,7 @@ open class ChatChannelVC: _ViewController, public func chatMessageListVC( _ vc: ChatMessageListVC, shouldLoadPageAroundMessageId messageId: MessageId, - _ completion: @escaping ((Error?) -> Void) + _ completion: @escaping @Sendable(Error?) -> Void ) { if let message = channelController.dataStore.message(id: messageId), let parentMessageId = getParentMessageId(forMessageInsideThread: message) { @@ -386,8 +392,10 @@ open class ChatChannelVC: _ViewController, } channelController.loadPageAroundMessageId(messageId) { [weak self] error in - self?.updateJumpToUnreadRelatedComponents() - completion(error) + StreamConcurrency.onMain { [weak self] in + self?.updateJumpToUnreadRelatedComponents() + completion(error) + } } } @@ -433,8 +441,10 @@ open class ChatChannelVC: _ViewController, case is MarkUnreadActionItem: dismiss(animated: true) { [weak self] in self?.channelController.markUnread(from: message.id) { result in - if case let .success(channel) = result { - self?.updateAllUnreadMessagesRelatedComponents(channel: channel) + StreamConcurrency.onMain { + if case let .success(channel) = result { + self?.updateAllUnreadMessagesRelatedComponents(channel: channel) + } } } } @@ -506,7 +516,16 @@ open class ChatChannelVC: _ViewController, // MARK: - ChatChannelControllerDelegate - open func channelController( + nonisolated open func channelController( + _ channelController: ChatChannelController, + didUpdateMessages changes: [ListChange] + ) { + StreamConcurrency.onMain { + _channelController(channelController, didUpdateMessages: changes) + } + } + + private func _channelController( _ channelController: ChatChannelController, didUpdateMessages changes: [ListChange] ) { @@ -531,7 +550,16 @@ open class ChatChannelVC: _ViewController, viewPaginationHandler.updateElementsCount(with: channelController.messages.count) } - open func channelController( + nonisolated open func channelController( + _ channelController: ChatChannelController, + didUpdateChannel channel: EntityChange + ) { + StreamConcurrency.onMain { + _channelController(channelController, didUpdateChannel: channel) + } + } + + private func _channelController( _ channelController: ChatChannelController, didUpdateChannel channel: EntityChange ) { @@ -545,7 +573,16 @@ open class ChatChannelVC: _ViewController, channelAvatarView.content = (channelController.channel, client.currentUserId) } - open func channelController( + nonisolated open func channelController( + _ channelController: ChatChannelController, + didChangeTypingUsers typingUsers: Set + ) { + StreamConcurrency.onMain { + _channelController(channelController, didChangeTypingUsers: typingUsers) + } + } + + private func _channelController( _ channelController: ChatChannelController, didChangeTypingUsers typingUsers: Set ) { @@ -564,7 +601,13 @@ open class ChatChannelVC: _ViewController, // MARK: - EventsControllerDelegate - open func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { + nonisolated open func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { + StreamConcurrency.onMain { + _eventsController(controller, didReceiveEvent: event) + } + } + + private func _eventsController(_ controller: EventsController, didReceiveEvent event: Event) { if let newMessagePendingEvent = event as? NewMessagePendingEvent { let newMessage = newMessagePendingEvent.message if !isFirstPageLoaded && newMessage.isSentByCurrentUser && !newMessage.isPartOfThread { @@ -596,15 +639,17 @@ open class ChatChannelVC: _ViewController, // MARK: - AudioQueuePlayerDatasource - open func audioQueuePlayerNextAssetURL( + nonisolated open func audioQueuePlayerNextAssetURL( _ audioPlayer: AudioPlaying, currentAssetURL: URL? ) -> URL? { - audioQueuePlayerNextItemProvider.findNextItem( - in: messages, - currentVoiceRecordingURL: currentAssetURL, - lookUpScope: .subsequentMessagesFromUser - ) + StreamConcurrency.onMain { + audioQueuePlayerNextItemProvider.findNextItem( + in: messages, + currentVoiceRecordingURL: currentAssetURL, + lookUpScope: .subsequentMessagesFromUser + ) + } } } diff --git a/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView+SwiftUI.swift b/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView+SwiftUI.swift index 2ea4f1fd575..3b71df01fd0 100644 --- a/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView+SwiftUI.swift +++ b/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView+SwiftUI.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// Protocol of `ChatChannelListItemView` wrapper for use in SwiftUI. -public protocol ChatChannelListItemViewSwiftUIView: View { +@preconcurrency @MainActor public protocol ChatChannelListItemViewSwiftUIView: View { init(dataSource: ChatChannelListItemView.ObservedObject) } diff --git a/Sources/StreamChatUI/ChatChannelList/ChatChannelListVC.swift b/Sources/StreamChatUI/ChatChannelList/ChatChannelListVC.swift index e7da1ab6cf3..ecf202de5b8 100644 --- a/Sources/StreamChatUI/ChatChannelList/ChatChannelListVC.swift +++ b/Sources/StreamChatUI/ChatChannelList/ChatChannelListVC.swift @@ -276,7 +276,9 @@ open class ChatChannelListVC: _ViewController, isPaginatingChannels = true controller.loadNextChannels { [weak self] _ in - self?.isPaginatingChannels = false + StreamConcurrency.onMain { [weak self] in + self?.isPaginatingChannels = false + } } } @@ -403,28 +405,34 @@ open class ChatChannelListVC: _ViewController, // MARK: - ChatChannelListControllerDelegate - open func controllerWillChangeChannels(_ controller: ChatChannelListController) { - collectionView.layoutIfNeeded() + nonisolated open func controllerWillChangeChannels(_ controller: ChatChannelListController) { + StreamConcurrency.onMain { + collectionView.layoutIfNeeded() + } } - open func controller( + nonisolated open func controller( _ controller: ChatChannelListController, didChangeChannels changes: [ListChange] ) { - handleStateChanges(controller.state) - - if skipChannelUpdates { - skippedRendering = true - return + StreamConcurrency.onMain { + handleStateChanges(controller.state) + + if skipChannelUpdates { + skippedRendering = true + return + } + + reloadChannels() } - - reloadChannels() } // MARK: - DataControllerStateDelegate - open func controller(_ controller: DataController, didChangeState state: DataController.State) { - handleStateChanges(state) + nonisolated open func controller(_ controller: DataController, didChangeState state: DataController.State) { + StreamConcurrency.onMain { + handleStateChanges(state) + } } /// Called whenever the channels data changes or the controller.state changes. diff --git a/Sources/StreamChatUI/ChatChannelList/ChatChannelUnreadCountView+SwiftUI.swift b/Sources/StreamChatUI/ChatChannelList/ChatChannelUnreadCountView+SwiftUI.swift index 99031c1baab..67c9a5e03d4 100644 --- a/Sources/StreamChatUI/ChatChannelList/ChatChannelUnreadCountView+SwiftUI.swift +++ b/Sources/StreamChatUI/ChatChannelList/ChatChannelUnreadCountView+SwiftUI.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// Protocol of `ChatChannelUnreadCountView` wrapper for use in SwiftUI. -public protocol ChatChannelUnreadCountViewSwiftUIView: View { +@preconcurrency @MainActor public protocol ChatChannelUnreadCountViewSwiftUIView: View { init(dataSource: ChatChannelUnreadCountView.ObservedObject) } diff --git a/Sources/StreamChatUI/ChatChannelList/Search/ChannelListSearchStrategy.swift b/Sources/StreamChatUI/ChatChannelList/Search/ChannelListSearchStrategy.swift index 9c7815e1995..993c3f65d1d 100644 --- a/Sources/StreamChatUI/ChatChannelList/Search/ChannelListSearchStrategy.swift +++ b/Sources/StreamChatUI/ChatChannelList/Search/ChannelListSearchStrategy.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// The channel list search strategy. It is possible to search by messages or channels. -public struct ChannelListSearchStrategy { +public struct ChannelListSearchStrategy: Sendable { /// The name of the strategy. public var name: String /// The type of search UI component. @@ -34,7 +34,7 @@ public struct ChannelListSearchStrategy { } /// Creates the `UISearchController` for the Channel List depending on the current search strategy. - public func makeSearchController( + @MainActor public func makeSearchController( with channelListVC: ChatChannelListVC ) -> UISearchController? { if let messageSearchVC = searchVC.init() as? ChatMessageSearchVC { diff --git a/Sources/StreamChatUI/ChatChannelList/Search/ChatChannelListSearchVC.swift b/Sources/StreamChatUI/ChatChannelList/Search/ChatChannelListSearchVC.swift index 04ab805295d..04640e46b52 100644 --- a/Sources/StreamChatUI/ChatChannelList/Search/ChatChannelListSearchVC.swift +++ b/Sources/StreamChatUI/ChatChannelList/Search/ChatChannelListSearchVC.swift @@ -120,6 +120,8 @@ open class ChatChannelListSearchVC: ChatChannelListVC, UISearchResultsUpdating { // MARK: - Deinit deinit { - debouncer.invalidate() + StreamConcurrency.onMain { + debouncer.invalidate() + } } } diff --git a/Sources/StreamChatUI/ChatChannelList/Search/ChatChannelSearchVC.swift b/Sources/StreamChatUI/ChatChannelList/Search/ChatChannelSearchVC.swift index af57147adc6..2819171e994 100644 --- a/Sources/StreamChatUI/ChatChannelList/Search/ChatChannelSearchVC.swift +++ b/Sources/StreamChatUI/ChatChannelList/Search/ChatChannelSearchVC.swift @@ -10,7 +10,7 @@ import UIKit @available(iOSApplicationExtension, unavailable) open class ChatChannelSearchVC: ChatChannelListSearchVC { /// The closure that is triggered whenever a channel is selected from the search result. - public var didSelectChannel: ((ChatChannel) -> Void)? + public var didSelectChannel: (@MainActor(ChatChannel) -> Void)? // MARK: - ChatChannelListSearchVC Abstract Implementations diff --git a/Sources/StreamChatUI/ChatChannelList/Search/ChatMessageSearchVC.swift b/Sources/StreamChatUI/ChatChannelList/Search/ChatMessageSearchVC.swift index f89d1311bc9..8bc888fd30a 100644 --- a/Sources/StreamChatUI/ChatChannelList/Search/ChatMessageSearchVC.swift +++ b/Sources/StreamChatUI/ChatChannelList/Search/ChatMessageSearchVC.swift @@ -16,7 +16,7 @@ open class ChatMessageSearchVC: ChatChannelListSearchVC, ChatMessageSearchContro public var messageSearchController: ChatMessageSearchController! /// The closure that is triggered whenever a message is selected from the search result. - public var didSelectMessage: ((ChatChannel, ChatMessage) -> Void)? + public var didSelectMessage: (@MainActor(ChatChannel, ChatMessage) -> Void)? private var isPaginatingMessages: Bool = false @@ -61,7 +61,9 @@ open class ChatMessageSearchVC: ChatChannelListSearchVC, ChatMessageSearchContro isPaginatingMessages = true messageSearchController.loadNextMessages { [weak self] _ in - self?.isPaginatingMessages = false + StreamConcurrency.onMain { [weak self] in + self?.isPaginatingMessages = false + } } } @@ -105,7 +107,9 @@ open class ChatMessageSearchVC: ChatChannelListSearchVC, ChatMessageSearchContro // MARK: - ChatMessageSearchControllerDelegate - open func controller(_ controller: ChatMessageSearchController, didChangeMessages changes: [ListChange]) { - reloadMessages() + nonisolated open func controller(_ controller: ChatMessageSearchController, didChangeMessages changes: [ListChange]) { + StreamConcurrency.onMain { + reloadMessages() + } } } diff --git a/Sources/StreamChatUI/ChatChannelList/SwipeableView.swift b/Sources/StreamChatUI/ChatChannelList/SwipeableView.swift index 040b014ac24..4b69df50199 100644 --- a/Sources/StreamChatUI/ChatChannelList/SwipeableView.swift +++ b/Sources/StreamChatUI/ChatChannelList/SwipeableView.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// Delegate responsible for easily assigning swipe action buttons to collectionView cells. -public protocol SwipeableViewDelegate: AnyObject { +@preconcurrency @MainActor public protocol SwipeableViewDelegate: AnyObject { /// Prepares the receiver that showing of actionViews will ocur. /// use this method to for example close other actionViews in your collectionView/tableView. /// - Parameter indexPath: IndexPath of `collectionViewCell` which asks for action buttons. diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/AttachmentViewInjector.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/AttachmentViewInjector.swift index 0679b947864..6ae0593d050 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/AttachmentViewInjector.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/AttachmentViewInjector.swift @@ -10,7 +10,7 @@ import StreamChat /// /// - Important: This is an abstract superclass meant to be subclassed. /// -open class AttachmentViewInjector { +@preconcurrency @MainActor open class AttachmentViewInjector { /// Says whether a message content should start filling all available width. /// Is `true` by default. open var fillAllAvailableWidth: Bool = true diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift index cf7cd9b69fa..4b5b08259a7 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift @@ -102,20 +102,24 @@ open class ChatMessageAttachmentPreviewVC: _ViewController, WKNavigationDelegate // MARK: - WKNavigationDelegate - public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - activityIndicatorView.startAnimating() + nonisolated public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + StreamConcurrency.onMain { + activityIndicatorView.startAnimating() + } } - public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - activityIndicatorView.stopAnimating() - - webView.evaluateJavaScript("document.title") { data, _ in - if let title = data as? String, !title.isEmpty { - self.title = title + nonisolated public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + StreamConcurrency.onMain { + activityIndicatorView.stopAnimating() + + webView.evaluateJavaScript("document.title") { data, _ in + if let title = data as? String, !title.isEmpty { + self.title = title + } } + + goBackButton.isEnabled = webView.canGoBack + goForwardButton.isEnabled = webView.canGoForward } - - goBackButton.isEnabled = webView.canGoBack - goForwardButton.isEnabled = webView.canGoForward } } diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/File/FileAttachmentViewInjector.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/File/FileAttachmentViewInjector.swift index b51231fc9a6..4cf335a293d 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/File/FileAttachmentViewInjector.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/File/FileAttachmentViewInjector.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// The delegate used `FileAttachmentViewInjector` to communicate user interactions. -public protocol FileActionContentViewDelegate: ChatMessageContentViewDelegate { +@preconcurrency @MainActor public protocol FileActionContentViewDelegate: ChatMessageContentViewDelegate { /// Called when the user taps on the attachment. func didTapOnAttachment(_ attachment: ChatMessageFileAttachment, at indexPath: IndexPath?) diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/ChatMessageImageGallery+ImagePreview.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/ChatMessageImageGallery+ImagePreview.swift index 6168bbb01fb..1b3c5d752cb 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/ChatMessageImageGallery+ImagePreview.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/ChatMessageImageGallery+ImagePreview.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// The type preview should conform to in order the gallery can be shown from it. -public protocol GalleryItemPreview { +@preconcurrency @MainActor public protocol GalleryItemPreview { /// Attachment identifier. var attachmentId: AttachmentId? { get } @@ -95,8 +95,10 @@ extension ChatMessageGalleryView { from: attachment?.payload, maxResolutionInPixels: components.imageAttachmentMaxPixels ) { [weak self] _ in - self?.loadingIndicator.isVisible = false - self?.imageTask = nil + StreamConcurrency.onMain { [weak self] in + self?.loadingIndicator.isVisible = false + self?.imageTask = nil + } } uploadingOverlay.content = content?.uploadingState @@ -113,7 +115,9 @@ extension ChatMessageGalleryView { // MARK: - Init & Deinit deinit { - imageTask?.cancel() + StreamConcurrency.onMain { + imageTask?.cancel() + } } } } diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/GalleryAttachmentViewInjector.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/GalleryAttachmentViewInjector.swift index 1c1c89f4b41..9cba81b5a7b 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/GalleryAttachmentViewInjector.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/GalleryAttachmentViewInjector.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// The delegate used `GalleryAttachmentViewInjector` to communicate user interactions. -public protocol GalleryContentViewDelegate: ChatMessageContentViewDelegate { +@preconcurrency @MainActor public protocol GalleryContentViewDelegate: ChatMessageContentViewDelegate { /// Called when the user taps on one of the attachment previews. func galleryMessageContentView( at indexPath: IndexPath?, diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/VideoAttachmentGalleryPreview.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/VideoAttachmentGalleryPreview.swift index 8c1bb70e20a..f5915ef3262 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/VideoAttachmentGalleryPreview.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Gallery/VideoAttachmentGalleryPreview.swift @@ -88,13 +88,15 @@ open class VideoAttachmentGalleryPreview: _View, ThemeProvider { if let thumbnailURL = content?.thumbnailURL { showPreview(using: thumbnailURL) } else if let url = content?.videoURL { - components.videoLoader.loadPreviewForVideo(at: url) { [weak self] in - self?.loadingIndicator.isHidden = true - switch $0 { - case let .success(preview): - self?.showPreview(using: preview) - case .failure: - break + components.videoLoader.loadPreviewForVideo(at: url) { [weak self] result in + StreamConcurrency.onMain { [weak self] in + self?.loadingIndicator.isHidden = true + switch result { + case let .success(preview): + self?.showPreview(using: preview) + case .failure: + break + } } } } @@ -105,9 +107,11 @@ open class VideoAttachmentGalleryPreview: _View, ThemeProvider { private func showPreview(using thumbnailURL: URL) { components.imageLoader.downloadImage(with: .init(url: thumbnailURL, options: ImageDownloadOptions())) { [weak self] result in - self?.loadingIndicator.isHidden = true - guard case let .success(image) = result else { return } - self?.showPreview(using: image) + StreamConcurrency.onMain { [weak self] in + self?.loadingIndicator.isHidden = true + guard case let .success(image) = result else { return } + self?.showPreview(using: image) + } } } diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Giphy/ChatMessageInteractiveAttachmentView.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Giphy/ChatMessageInteractiveAttachmentView.swift index e379871d7a5..3e79da8e68b 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Giphy/ChatMessageInteractiveAttachmentView.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Giphy/ChatMessageInteractiveAttachmentView.swift @@ -72,9 +72,11 @@ open class ChatMessageInteractiveAttachmentView: _View, ThemeProvider { actionsStackView.removeAllArrangedSubviews() - (content?.actions ?? []) - .map(createActionButton) - .forEach(actionsStackView.addArrangedSubview) + let actions = content?.actions ?? [] + for action in actions { + let button = createActionButton(for: action) + actionsStackView.addArrangedSubview(button) + } } // MARK: - Private diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Giphy/GiphyAttachmentViewInjector.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Giphy/GiphyAttachmentViewInjector.swift index 51118a6080a..b599e32c817 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Giphy/GiphyAttachmentViewInjector.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Giphy/GiphyAttachmentViewInjector.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// The delegate used `GiphyAttachmentViewInjector` to communicate user interactions. -public protocol GiphyActionContentViewDelegate: ChatMessageContentViewDelegate { +@preconcurrency @MainActor public protocol GiphyActionContentViewDelegate: ChatMessageContentViewDelegate { /// Called when the user taps on attachment action func didTapOnAttachmentAction(_ action: AttachmentAction, at indexPath: IndexPath) } diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Link/LinkAttachmentViewInjector.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Link/LinkAttachmentViewInjector.swift index 11bd502e75a..3d5984ccc9f 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Link/LinkAttachmentViewInjector.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Link/LinkAttachmentViewInjector.swift @@ -7,7 +7,7 @@ import UIKit /// The delegate used in `LinkAttachmentViewInjector` to communicate user interactions. @available(iOSApplicationExtension, unavailable) -public protocol LinkPreviewViewDelegate: ChatMessageContentViewDelegate { +@preconcurrency @MainActor public protocol LinkPreviewViewDelegate: ChatMessageContentViewDelegate { /// Called when the user taps the link preview. func didTapOnLinkAttachment( _ attachment: ChatMessageLinkAttachment, diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAllOptionsListVC/PollAllOptionsListVC.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAllOptionsListVC/PollAllOptionsListVC.swift index fe346dc2f56..76b90d5b7d4 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAllOptionsListVC/PollAllOptionsListVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAllOptionsListVC/PollAllOptionsListVC.swift @@ -35,7 +35,7 @@ open class PollAllOptionsListVC: /// You can disable the feedback generator by overriding to `nil`. open private(set) lazy var notificationFeedbackGenerator: UINotificationFeedbackGenerator? = UINotificationFeedbackGenerator() - public struct Section: RawRepresentable, Equatable { + public struct Section: RawRepresentable, Equatable, Sendable { public var rawValue: String public init(rawValue: String) { @@ -43,10 +43,10 @@ open class PollAllOptionsListVC: } /// The section that displays the poll's name. - public static var name = Self(rawValue: "name") + public static let name = Self(rawValue: "name") /// The section that displays the options of the poll. - public static var options = Self(rawValue: "options") + public static let options = Self(rawValue: "options") } /// The sections of the view. @@ -162,13 +162,17 @@ open class PollAllOptionsListVC: if let currentUserVote = pollController.poll?.currentUserVote(for: option) { pollController.removePollVote(voteId: currentUserVote.id) { [weak self] error in if error != nil { - self?.notificationFeedbackGenerator?.notificationOccurred(.error) + StreamConcurrency.onMain { [weak self] in + self?.notificationFeedbackGenerator?.notificationOccurred(.error) + } } } } else { pollController.castPollVote(answerText: nil, optionId: option.id) { [weak self] error in if error != nil { - self?.notificationFeedbackGenerator?.notificationOccurred(.error) + StreamConcurrency.onMain { [weak self] in + self?.notificationFeedbackGenerator?.notificationOccurred(.error) + } } } } @@ -176,11 +180,13 @@ open class PollAllOptionsListVC: // MARK: - PollControllerDelegate - open func pollController(_ pollController: PollController, didUpdatePoll poll: EntityChange) { - tableView.reloadData() + nonisolated open func pollController(_ pollController: PollController, didUpdatePoll poll: EntityChange) { + StreamConcurrency.onMain { + tableView.reloadData() + } } - open func pollController( + nonisolated open func pollController( _ pollController: PollController, didUpdateCurrentUserVotes votes: [ListChange] ) { diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentViewInjector.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentViewInjector.swift index cfb3e659457..07afec8541f 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentViewInjector.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentViewInjector.swift @@ -6,7 +6,7 @@ import Foundation import StreamChat /// The delegate used to handle Polls interactions in the message list. -public protocol PollAttachmentViewInjectorDelegate: ChatMessageContentViewDelegate { +@preconcurrency @MainActor public protocol PollAttachmentViewInjectorDelegate: ChatMessageContentViewDelegate { /// Called when the user taps in an option of the poll. func pollAttachmentView( _ pollAttachmentView: PollAttachmentView, diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollCommentListVC/PollCommentListVC.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollCommentListVC/PollCommentListVC.swift index 158f39d5f3e..c92873cb3c7 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollCommentListVC/PollCommentListVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollCommentListVC/PollCommentListVC.swift @@ -173,17 +173,20 @@ open class PollCommentListVC: // MARK: - PollVoteListControllerDelegate - public func controller(_ controller: PollVoteListController, didChangeVotes changes: [ListChange]) { - var snapshot = NSDiffableDataSourceSnapshot() - let comments = Array(controller.votes) - snapshot.appendSections(comments) - comments.forEach { - snapshot.appendItems([$0], toSection: $0) - } - dataSource.apply(snapshot, animatingDifferences: true) - - if let poll = pollController.poll, let currentUserId = pollController.client.currentUserId { - footerView.content = .init(poll: poll, currentUserId: currentUserId) + nonisolated public func controller(_ controller: PollVoteListController, didChangeVotes changes: [ListChange]) { + StreamConcurrency.onMain { [weak self] in + guard let self else { return } + var snapshot = NSDiffableDataSourceSnapshot() + let comments = Array(controller.votes) + snapshot.appendSections(comments) + comments.forEach { + snapshot.appendItems([$0], toSection: $0) + } + dataSource.apply(snapshot, animatingDifferences: true) + + if let poll = pollController.poll, let currentUserId = pollController.client.currentUserId { + footerView.content = .init(poll: poll, currentUserId: currentUserId) + } } } @@ -197,7 +200,9 @@ open class PollCommentListVC: isPaginatingComments = true commentsController.loadMoreVotes { [weak self] error in - self?.didFinishLoadingMoreComments(with: error) + StreamConcurrency.onMain { [weak self] in + self?.didFinishLoadingMoreComments(with: error) + } } } @@ -211,8 +216,10 @@ open class PollCommentListVC: in: messageId, currentUserId: currentUserId ) { [weak self] comment in - self?.pollController.castPollVote(answerText: comment, optionId: nil) { _ in - self?.tableView.scrollToRow(at: IndexPath(item: 0, section: 0), at: .top, animated: true) + self?.pollController.castPollVote(answerText: comment, optionId: nil) { [weak self] _ in + StreamConcurrency.onMain { [weak self] in + self?.tableView.scrollToRow(at: IndexPath(item: 0, section: 0), at: .top, animated: true) + } } } } diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollCreationVC/PollCreationVC.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollCreationVC/PollCreationVC.swift index 9b3824e817c..43847ebebd5 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollCreationVC/PollCreationVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollCreationVC/PollCreationVC.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// The sections for the poll creation view. -public struct PollCreationSection: RawRepresentable, Equatable { +public struct PollCreationSection: RawRepresentable, Equatable, Sendable { public var rawValue: String public init(rawValue: String) { @@ -14,25 +14,25 @@ public struct PollCreationSection: RawRepresentable, Equatable { } /// The section to edit the name of the poll. - public static var name = Self(rawValue: "name") + public static let name = Self(rawValue: "name") /// The section to provide the options of the poll. - public static var options = Self(rawValue: "options") + public static let options = Self(rawValue: "options") /// THe section to enable or disable the poll features. - public static var features = Self(rawValue: "features") + public static let features = Self(rawValue: "features") } /// The sections for the poll creation view. -public struct PollFeatureType: Equatable { +public struct PollFeatureType: Equatable, Sendable { public var rawValue: String public init(rawValue: String) { self.rawValue = rawValue } - public static var multipleVotes = Self(rawValue: "multiple-votes") - public static var anonymous = Self(rawValue: "anonymous") - public static var suggestions = Self(rawValue: "suggestions") - public static var comments = Self(rawValue: "comments") + public static let multipleVotes = Self(rawValue: "multiple-votes") + public static let anonymous = Self(rawValue: "anonymous") + public static let suggestions = Self(rawValue: "suggestions") + public static let comments = Self(rawValue: "comments") } /// The view controller to create a poll in a channel. @@ -657,8 +657,10 @@ open class PollCreationVC: .map { PollOption(text: $0) }, extraData: extraData ) { [weak self] result in - self?.createPollButton.isEnabled = true - self?.handleCreatePollResponse(result: result) + StreamConcurrency.onMain { [weak self] in + self?.createPollButton.isEnabled = true + self?.handleCreatePollResponse(result: result) + } } } diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVC.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVC.swift index b2ede37c0d1..69630825963 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVC.swift @@ -206,11 +206,13 @@ open class PollResultsVC: // MARK: - PollControllerDelegate - open func pollController(_ pollController: PollController, didUpdatePoll poll: EntityChange) { - updateDataSource() + nonisolated open func pollController(_ pollController: PollController, didUpdatePoll poll: EntityChange) { + StreamConcurrency.onMain { + updateDataSource() + } } - open func pollController( + nonisolated open func pollController( _ pollController: PollController, didUpdateCurrentUserVotes votes: [ListChange] ) { diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift index d8363acf187..846e10b1f27 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift @@ -141,11 +141,13 @@ open class PollResultsVoteListVC: // MARK: - PollVoteListControllerDelegate - public func controller(_ controller: PollVoteListController, didChangeVotes changes: [ListChange]) { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([option]) - snapshot.appendItems(Array(controller.votes)) - dataSource.apply(snapshot, animatingDifferences: true) + nonisolated public func controller(_ controller: PollVoteListController, didChangeVotes changes: [ListChange]) { + StreamConcurrency.onMain { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([option]) + snapshot.appendItems(Array(controller.votes)) + dataSource.apply(snapshot, animatingDifferences: true) + } } // MARK: - Actions @@ -158,7 +160,9 @@ open class PollResultsVoteListVC: isPaginatingVotes = true pollVoteListController.loadMoreVotes { [weak self] error in - self?.didFinishLoadingMoreVotes(with: error) + StreamConcurrency.onMain { [weak self] in + self?.didFinishLoadingMoreVotes(with: error) + } } } diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollsConfig.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollsConfig.swift index c913d1e5ff7..d0b4f99b5a1 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollsConfig.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollsConfig.swift @@ -5,7 +5,7 @@ import Foundation /// The configuration for the various poll features. It determines if the user can or can not enable certain poll features. -public struct PollsConfig { +public struct PollsConfig: Sendable { /// Configuration for allowing multiple votes in a poll. public var multipleVotes: PollsEntryConfig /// Configuration for enabling anonymous polls. @@ -41,7 +41,7 @@ public struct PollsConfig { } /// Config for individual poll entry. -public struct PollsEntryConfig { +public struct PollsEntryConfig: Sendable { /// Indicates whether the poll entry is configurable. public var configurable: Bool /// Indicates the default value of the poll entry. diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/VoiceRecording/ChatMessageVoiceRecordingAttachmentListView+ItemViewPresenter.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/VoiceRecording/ChatMessageVoiceRecordingAttachmentListView+ItemViewPresenter.swift index b925181e603..35a2a7a9fc3 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/VoiceRecording/ChatMessageVoiceRecordingAttachmentListView+ItemViewPresenter.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/VoiceRecording/ChatMessageVoiceRecordingAttachmentListView+ItemViewPresenter.swift @@ -8,7 +8,7 @@ import UIKit extension ChatMessageVoiceRecordingAttachmentListView { /// The Presenter that drives interactions and events for - internal class ItemViewPresenter: AudioPlayingDelegate { + @MainActor internal class ItemViewPresenter: AudioPlayingDelegate { /// The delegate to which the Presenter will forward all audioPlayback events. internal weak var delegate: VoiceRecordingAttachmentPresentationViewDelegate? @@ -120,61 +120,72 @@ extension ChatMessageVoiceRecordingAttachmentListView { // MARK: - AudioPlayingDelegate - internal func audioPlayer( + nonisolated internal func audioPlayer( _ audioPlayer: AudioPlaying, didUpdateContext context: AudioPlaybackContext ) { - guard let view = view, let content = view.content else { - return + StreamConcurrency.onMain { + _audioPlayer(audioPlayer, didUpdateContext: context) } - - let isCurrentItemActive = context.assetLocation == content.voiceRecordingURL - let contextForViewUpdate = isCurrentItemActive ? context : .notLoaded - let contentDuration = content.duration ?? contextForViewUpdate.duration - - view.updatePlayPauseButton(for: contextForViewUpdate.state) - view.updateFileIconImageView(for: contextForViewUpdate.state) - view.updateWaveformView( - for: contextForViewUpdate.state, - duration: contentDuration, - currentTime: contextForViewUpdate.currentTime - ) - - let playbackRate: Float = { - switch contextForViewUpdate.state { - case .paused, .playing: - return contextForViewUpdate.rate.rawValue != 0 - ? contextForViewUpdate.rate.rawValue - : currentPlaybackRate.rawValue - default: - return contextForViewUpdate.rate.rawValue + } + + func _audioPlayer( + _ audioPlayer: AudioPlaying, + didUpdateContext context: AudioPlaybackContext + ) { + StreamConcurrency.onMain { + guard let view = view, let content = view.content else { + return } - }() - - view.updatePlaybackRateButton( - for: contextForViewUpdate.state, - value: playbackRate - ) - - if contextForViewUpdate.rate != .zero { - currentPlaybackRate = contextForViewUpdate.rate - } - - let loadingIndicatorAndDurationLabel = { [view] in - view.updatePlaybackLoadingIndicator(for: contextForViewUpdate.state) - view.updateDurationLabel( + + let isCurrentItemActive = context.assetLocation == content.voiceRecordingURL + let contextForViewUpdate = isCurrentItemActive ? context : .notLoaded + let contentDuration = content.duration ?? contextForViewUpdate.duration + + view.updatePlayPauseButton(for: contextForViewUpdate.state) + view.updateFileIconImageView(for: contextForViewUpdate.state) + view.updateWaveformView( for: contextForViewUpdate.state, duration: contentDuration, currentTime: contextForViewUpdate.currentTime ) - view.durationLabel.isHidden = view.durationLabel.isHidden || !view.playbackLoadingIndicator.isHidden - } - - debouncer.invalidate() - if contextForViewUpdate.state == .loading { - debouncer.execute { loadingIndicatorAndDurationLabel() } - } else { - loadingIndicatorAndDurationLabel() + + let playbackRate: Float = { + switch contextForViewUpdate.state { + case .paused, .playing: + return contextForViewUpdate.rate.rawValue != 0 + ? contextForViewUpdate.rate.rawValue + : currentPlaybackRate.rawValue + default: + return contextForViewUpdate.rate.rawValue + } + }() + + view.updatePlaybackRateButton( + for: contextForViewUpdate.state, + value: playbackRate + ) + + if contextForViewUpdate.rate != .zero { + currentPlaybackRate = contextForViewUpdate.rate + } + + let loadingIndicatorAndDurationLabel = { [view] in + view.updatePlaybackLoadingIndicator(for: contextForViewUpdate.state) + view.updateDurationLabel( + for: contextForViewUpdate.state, + duration: contentDuration, + currentTime: contextForViewUpdate.currentTime + ) + view.durationLabel.isHidden = view.durationLabel.isHidden || !view.playbackLoadingIndicator.isHidden + } + + debouncer.invalidate() + if contextForViewUpdate.state == .loading { + debouncer.execute { loadingIndicatorAndDurationLabel() } + } else { + loadingIndicatorAndDurationLabel() + } } } } diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/VoiceRecording/VoiceRecordingAttachmentViewInjector.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/VoiceRecording/VoiceRecordingAttachmentViewInjector.swift index 8d94dabbfa2..fae2a273c21 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/VoiceRecording/VoiceRecordingAttachmentViewInjector.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/VoiceRecording/VoiceRecordingAttachmentViewInjector.swift @@ -7,7 +7,7 @@ import UIKit /// The delegate that will be assigned on an AudioView and will be responsible to handle user interactions /// from the view. -public protocol VoiceRecordingAttachmentPresentationViewDelegate: ChatMessageContentViewDelegate { +@preconcurrency @MainActor public protocol VoiceRecordingAttachmentPresentationViewDelegate: ChatMessageContentViewDelegate { /// Called when the user taps on the play button. func voiceRecordingAttachmentPresentationViewConnect( delegate: AudioPlayingDelegate diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift index 8e21179d5fb..ece081ac1ea 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift @@ -9,7 +9,7 @@ import UIKit /// /// When custom message content view is created, the protocol that inherits from this one /// should be created if an action can be taken on the new content view. -public protocol ChatMessageContentViewDelegate: AnyObject { +@preconcurrency @MainActor public protocol ChatMessageContentViewDelegate: AnyObject { /// Gets called when error indicator is tapped. /// - Parameter indexPath: The index path of the cell displaying the content view. Equals to `nil` when /// the content view is displayed outside the collection/table view. @@ -659,7 +659,7 @@ open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate { // Bubble view bubbleView?.content = content.map { message in - var backgroundColor: UIColor { + let backgroundColor: UIColor = { if message.isSentByCurrentUser { if message.type == .ephemeral { return appearance.colorPalette.background8 @@ -669,7 +669,7 @@ open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate { } else { return appearance.colorPalette.background8 } - } + }() return .init( backgroundColor: backgroundColor, diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageLayoutOptions.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageLayoutOptions.swift index c04ccd309db..df4d2bdc749 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageLayoutOptions.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageLayoutOptions.swift @@ -30,7 +30,7 @@ public extension ChatMessageLayoutOptions { /// Each message layout option is used to define which views will be part of the message cell. /// A different combination of layout options will produce a different cell reuse identifier. -public struct ChatMessageLayoutOption: RawRepresentable, Hashable, ExpressibleByStringLiteral { +public struct ChatMessageLayoutOption: RawRepresentable, Hashable, ExpressibleByStringLiteral, Sendable { public let rawValue: String public init(rawValue: String) { diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListScrollOverlayView.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListScrollOverlayView.swift index 3b58fb5f4a2..2041f31aa6d 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListScrollOverlayView.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListScrollOverlayView.swift @@ -5,7 +5,7 @@ import UIKit /// A protocol for `ChatMessageListScrollOverlayView` data source. -public protocol ChatMessageListScrollOverlayDataSource: AnyObject { +@preconcurrency @MainActor public protocol ChatMessageListScrollOverlayDataSource: AnyObject { /// Get date for item at given index path /// - Parameters: /// - overlay: A view requesting date @@ -33,36 +33,38 @@ open class ChatMessageListScrollOverlayView: _View, ThemeProvider { didSet { listView?.accessibilityIdentifier = "listView" contentOffsetObservation = listView?.observe(\.contentOffset) { [weak self] tb, _ in - guard let self = self else { return } - - // To display correct date we use bottom edge of scroll overlay - let refPoint = CGPoint( - x: self.center.x, - y: self.frame.maxY - ) - - // If we cannot find any indexPath for `cell` we try to use max visible indexPath (we have bottom to top) layout - guard - let refPointInListView = self.superview?.convert(refPoint, to: tb), - let indexPath = tb.indexPathForRow(at: refPointInListView) ?? tb.indexPathsForVisibleRows?.max() - else { return } - - let overlayText = self.dataSource?.scrollOverlay(self, textForItemAt: indexPath) - - // If we have no date we have no reason to display `dateView` - self.isHidden = (overlayText ?? "").isEmpty - self.content = overlayText - - // Apple's naming is quite weird as actually this property should rather be named `isScrolling` - // as it stays true when user stops dragging and scrollView is decelerating and becomes false - // when scrollView stops decelerating - // - // But this case doesn't cover situation when user drags scrollView to a certain `contentOffset` - // leaves the finger there for a while and then just lifts it, it doesn't change `contentOffset` - // so this handler is not called, this is handled by `scrollStateChanged` - // that reacts on `panGestureRecognizer` states and can handle this case properly - if !tb.isDragging { - self.setAlpha(0) + StreamConcurrency.onMain { [weak self] in + guard let self = self else { return } + + // To display correct date we use bottom edge of scroll overlay + let refPoint = CGPoint( + x: self.center.x, + y: self.frame.maxY + ) + + // If we cannot find any indexPath for `cell` we try to use max visible indexPath (we have bottom to top) layout + guard + let refPointInListView = self.superview?.convert(refPoint, to: tb), + let indexPath = tb.indexPathForRow(at: refPointInListView) ?? tb.indexPathsForVisibleRows?.max() + else { return } + + let overlayText = self.dataSource?.scrollOverlay(self, textForItemAt: indexPath) + + // If we have no date we have no reason to display `dateView` + self.isHidden = (overlayText ?? "").isEmpty + self.content = overlayText + + // Apple's naming is quite weird as actually this property should rather be named `isScrolling` + // as it stays true when user stops dragging and scrollView is decelerating and becomes false + // when scrollView stops decelerating + // + // But this case doesn't cover situation when user drags scrollView to a certain `contentOffset` + // leaves the finger there for a while and then just lifts it, it doesn't change `contentOffset` + // so this handler is not called, this is handled by `scrollStateChanged` + // that reacts on `panGestureRecognizer` states and can handle this case properly + if !tb.isDragging { + self.setAlpha(0) + } } } diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift index 4ab27d17f42..0be3fc6d6ea 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift @@ -647,12 +647,12 @@ open class ChatMessageListVC: _ViewController, /// Jump to the current unread message if there is one. /// - Parameter animated: `true` if you want to animate the change in position; `false` if it should be immediate. /// - Parameter onHighlight: An optional closure to provide highlighting style when the message appears on screen. - open func jumpToUnreadMessage(animated: Bool = true, onHighlight: ((IndexPath) -> Void)? = nil) { + open func jumpToUnreadMessage(animated: Bool = true, onHighlight: (@Sendable(IndexPath) -> Void)? = nil) { getCurrentUnreadMessageId { [weak self] messageId in guard let jumpToUnreadMessageId = messageId else { return } // The delay helps having a smoother scrolling animation. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in self?.jumpToMessage(id: jumpToUnreadMessageId, animated: animated, onHighlight: onHighlight) } } @@ -680,13 +680,14 @@ open class ChatMessageListVC: _ViewController, log.error("Loading message around failed with error: \(error)") return } - - self?.updateScrollToBottomButtonVisibility() - - // When we load the mid-page, the UI is not yet updated, so we can't scroll here. - // So we need to wait when the updates messages are available in the UI, and only then - // we can scroll to it. - self?.messagePendingScrolling = (id, animated) + StreamConcurrency.onMain { [weak self] in + self?.updateScrollToBottomButtonVisibility() + + // When we load the mid-page, the UI is not yet updated, so we can't scroll here. + // So we need to wait when the updates messages are available in the UI, and only then + // we can scroll to it. + self?.messagePendingScrolling = (id, animated) + } } } @@ -750,7 +751,7 @@ open class ChatMessageListVC: _ViewController, /// /// Note: This is a current backend limitation. Ideally, in the future, /// we will get the `unreadMessageId` directly from the backend. - private func getCurrentUnreadMessageId(completion: @escaping (MessageId?) -> Void) { + private func getCurrentUnreadMessageId(completion: @escaping @Sendable(MessageId?) -> Void) { if let jumpToUnreadMessageId = self.jumpToUnreadMessageId { return completion(jumpToUnreadMessageId) } @@ -764,11 +765,13 @@ open class ChatMessageListVC: _ViewController, return completion(nil) } - guard let jumpToUnreadMessageId = self.jumpToUnreadMessageId else { - return completion(nil) + StreamConcurrency.onMain { + guard let jumpToUnreadMessageId = self.jumpToUnreadMessageId else { + return completion(nil) + } + + completion(jumpToUnreadMessageId) } - - completion(jumpToUnreadMessageId) } } @@ -1158,8 +1161,10 @@ open class ChatMessageListVC: _ViewController, currentUserId: currentUserId ) { [weak self] comment in let pollController = self?.client.pollController(messageId: message.id, pollId: poll.id) - pollController?.castPollVote(answerText: comment, optionId: nil) { error in - self?.notificationFeedbackGenerator?.notificationOccurred(error == nil ? .success : .error) + pollController?.castPollVote(answerText: comment, optionId: nil) { [weak self] error in + StreamConcurrency.onMain { [weak self] in + self?.notificationFeedbackGenerator?.notificationOccurred(error == nil ? .success : .error) + } } } } @@ -1178,8 +1183,10 @@ open class ChatMessageListVC: _ViewController, if isDuplicate { return } - pollController?.suggestPollOption(text: suggestion) { error in - self?.notificationFeedbackGenerator?.notificationOccurred(error == nil ? .success : .error) + pollController?.suggestPollOption(text: suggestion) { [weak self] error in + StreamConcurrency.onMain { [weak self] in + self?.notificationFeedbackGenerator?.notificationOccurred(error == nil ? .success : .error) + } } } } @@ -1192,7 +1199,9 @@ open class ChatMessageListVC: _ViewController, alertRouter.showPollEndVoteAlert(for: poll, in: message.id) { [weak self] in let pollController = self?.client.pollController(messageId: message.id, pollId: poll.id) pollController?.closePoll { [weak self] error in - self?.notificationFeedbackGenerator?.notificationOccurred(error == nil ? .success : .error) + StreamConcurrency.onMain { [weak self] in + self?.notificationFeedbackGenerator?.notificationOccurred(error == nil ? .success : .error) + } } } } @@ -1214,11 +1223,15 @@ open class ChatMessageListVC: _ViewController, let pollController = makePollController(for: poll, in: message) if let currentUserVote = poll.currentUserVote(for: option) { pollController.removePollVote(voteId: currentUserVote.id) { [weak self] error in - self?.didRemovePollVote(currentUserVote, for: option, in: message, error: error) + StreamConcurrency.onMain { [weak self] in + self?.didRemovePollVote(currentUserVote, for: option, in: message, error: error) + } } } else { pollController.castPollVote(answerText: nil, optionId: option.id) { [weak self] error in - self?.didCastPollVote(for: option, in: message, error: error) + StreamConcurrency.onMain { [weak self] in + self?.didCastPollVote(for: option, in: message, error: error) + } } } } diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVCDataSource.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVCDataSource.swift index 5b173fbbc3f..ddf9664572a 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVCDataSource.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVCDataSource.swift @@ -6,7 +6,7 @@ import Foundation import StreamChat /// The object that acts as the data source of the message list. -public protocol ChatMessageListVCDataSource: AnyObject { +@preconcurrency @MainActor public protocol ChatMessageListVCDataSource: AnyObject { /// Asks the data source if the first (newest) page is currently loaded. var isFirstPageLoaded: Bool { get } diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVCDelegate.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVCDelegate.swift index e468033e369..c71aa5f2cbc 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVCDelegate.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVCDelegate.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// The object that acts as the delegate of the message list. -public protocol ChatMessageListVCDelegate: AnyObject { +@preconcurrency @MainActor public protocol ChatMessageListVCDelegate: AnyObject { /// Tells the delegate the message list is about to draw a message for a particular row. /// - Parameters: /// - vc: The message list informing the delegate of this event. @@ -67,7 +67,7 @@ public protocol ChatMessageListVCDelegate: AnyObject { func chatMessageListVC( _ vc: ChatMessageListVC, shouldLoadPageAroundMessageId messageId: MessageId, - _ completion: @escaping ((Error?) -> Void) + _ completion: @escaping @Sendable(Error?) -> Void ) /// Tells the delegate that it should load the page around the given message id. @@ -81,7 +81,7 @@ public protocol ChatMessageListVCDelegate: AnyObject { func chatMessageListVC( _ vc: ChatMessageListVC, shouldLoadPageAroundMessage message: ChatMessage, - _ completion: @escaping ((Error?) -> Void) + _ completion: @escaping @Sendable(Error?) -> Void ) /// Tells the delegate that it should load the first page. @@ -145,7 +145,7 @@ public extension ChatMessageListVCDelegate { func chatMessageListVC( _ vc: ChatMessageListVC, shouldLoadPageAroundMessageId messageId: MessageId, - _ completion: @escaping ((Error?) -> Void) + _ completion: @escaping @Sendable(Error?) -> Void ) { completion(nil) } @@ -154,7 +154,7 @@ public extension ChatMessageListVCDelegate { func chatMessageListVC( _ vc: ChatMessageListVC, shouldLoadPageAroundMessage message: ChatMessage, - _ completion: @escaping ((Error?) -> Void) + _ completion: @escaping @Sendable(Error?) -> Void ) { chatMessageListVC(vc, shouldLoadPageAroundMessageId: message.id, completion) } diff --git a/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorsVC.swift b/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorsVC.swift index e165b9a3438..e5dc7036b87 100644 --- a/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorsVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorsVC.swift @@ -162,12 +162,14 @@ open class ChatMessageReactionAuthorsVC: _ViewController, // MARK: - ChatMessageControllerDelegate - open func messageController( + nonisolated open func messageController( _ controller: ChatMessageController, didChangeReactions reactions: [ChatMessageReaction] ) { - collectionView.reloadData() - updateContent() + StreamConcurrency.onMain { + collectionView.reloadData() + updateContent() + } } // MARK: - Public API @@ -183,7 +185,9 @@ open class ChatMessageReactionAuthorsVC: _ViewController, isLoadingReactions = true messageController.loadNextReactions { [weak self] _ in - self?.isLoadingReactions = false + StreamConcurrency.onMain { [weak self] in + self?.isLoadingReactions = false + } } } } diff --git a/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionsPickerVC.swift b/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionsPickerVC.swift index 8a9da8915c2..ffd0da38553 100644 --- a/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionsPickerVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Reactions/ChatMessageReactionsPickerVC.swift @@ -59,8 +59,10 @@ open class ChatMessageReactionsPickerVC: _ViewController, ThemeProvider, ChatMes open func toggleReaction(_ reaction: MessageReactionType) { guard let message = messageController.message else { return } - let completion: (Error?) -> Void = { [weak self] _ in - self?.dismiss(animated: true) + let completion: @Sendable(Error?) -> Void = { [weak self] _ in + StreamConcurrency.onMain { [weak self] in + self?.dismiss(animated: true) + } } let shouldRemove = message.currentUserReactions.contains { $0.type == reaction } @@ -71,13 +73,15 @@ open class ChatMessageReactionsPickerVC: _ViewController, ThemeProvider, ChatMes // MARK: - MessageControllerDelegate - open func messageController( + nonisolated open func messageController( _ controller: ChatMessageController, didChangeMessage change: EntityChange ) { - switch change { - case .create, .remove: break - case .update: updateContentIfNeeded() + StreamConcurrency.onMain { + switch change { + case .create, .remove: break + case .update: updateContentIfNeeded() + } } } } diff --git a/Sources/StreamChatUI/ChatMessageList/SwipeToReplyGestureHandler.swift b/Sources/StreamChatUI/ChatMessageList/SwipeToReplyGestureHandler.swift index ca880ef1cf8..92d5d1b41ab 100644 --- a/Sources/StreamChatUI/ChatMessageList/SwipeToReplyGestureHandler.swift +++ b/Sources/StreamChatUI/ChatMessageList/SwipeToReplyGestureHandler.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// A component responsible to manage the swipe to quote reply logic. -open class SwipeToReplyGestureHandler { +@preconcurrency @MainActor open class SwipeToReplyGestureHandler { /// A reference to the message list view. public private(set) weak var listView: ChatMessageListView? /// The current message cell which the gesture is being applied. @@ -60,10 +60,10 @@ open class SwipeToReplyGestureHandler { public init( listView: ChatMessageListView, - impactFeedbackGenerator: UIImpactFeedbackGenerator = .init(style: .medium) + impactFeedbackGenerator: UIImpactFeedbackGenerator? = nil ) { self.listView = listView - self.impactFeedbackGenerator = impactFeedbackGenerator + self.impactFeedbackGenerator = impactFeedbackGenerator ?? UIImpactFeedbackGenerator(style: .medium) } /// Handles the gesture state to determine if the reply should be triggered. diff --git a/Sources/StreamChatUI/ChatThread/ChatThreadHeaderView.swift b/Sources/StreamChatUI/ChatThread/ChatThreadHeaderView.swift index 661611e78d2..dabc9626e98 100644 --- a/Sources/StreamChatUI/ChatThread/ChatThreadHeaderView.swift +++ b/Sources/StreamChatUI/ChatThread/ChatThreadHeaderView.swift @@ -61,19 +61,21 @@ open class ChatThreadHeaderView: _View, // MARK: - ChatChannelControllerDelegate Implementation - open func channelController( + nonisolated open func channelController( _ channelController: ChatChannelController, didUpdateChannel channel: EntityChange ) { - switch channel { - case .update: - updateContentIfNeeded() - default: - break + StreamConcurrency.onMain { + switch channel { + case .update: + updateContentIfNeeded() + default: + break + } } } - open func channelController( + nonisolated open func channelController( _ channelController: ChatChannelController, didChangeTypingUsers typingUsers: Set ) { @@ -81,7 +83,7 @@ open class ChatThreadHeaderView: _View, // but this can be overridden by subclassing this component. } - open func channelController( + nonisolated open func channelController( _ channelController: ChatChannelController, didReceiveMemberEvent: MemberEvent ) { @@ -89,7 +91,7 @@ open class ChatThreadHeaderView: _View, // but this can be overridden by subclassing this component. } - open func channelController( + nonisolated open func channelController( _ channelController: ChatChannelController, didUpdateMessages changes: [ListChange] ) { diff --git a/Sources/StreamChatUI/ChatThread/ChatThreadVC.swift b/Sources/StreamChatUI/ChatThread/ChatThreadVC.swift index f3affa2d4bd..eb6ad437c65 100644 --- a/Sources/StreamChatUI/ChatThread/ChatThreadVC.swift +++ b/Sources/StreamChatUI/ChatThread/ChatThreadVC.swift @@ -149,7 +149,9 @@ open class ChatThreadVC: _ViewController, // Load data from server messageController.synchronize { [weak self] error in - self?.didFinishSynchronizing(with: error) + StreamConcurrency.onMain { [weak self] in + self?.didFinishSynchronizing(with: error) + } } } @@ -217,9 +219,10 @@ open class ChatThreadVC: _ViewController, guard error == nil else { return } - - let shouldAnimate = self?.components.shouldAnimateJumpToMessageWhenOpeningChannel == true - self?.jumpToMessage(id: initialReplyId, animated: shouldAnimate) + StreamConcurrency.onMain { [weak self] in + let shouldAnimate = self?.components.shouldAnimateJumpToMessageWhenOpeningChannel == true + self?.jumpToMessage(id: initialReplyId, animated: shouldAnimate) + } } return } @@ -227,7 +230,9 @@ open class ChatThreadVC: _ViewController, // When we tap on the parent message and start from oldest replies is enabled if shouldStartFromOldestReplies, let parentMessage = messageController.message { messageController.loadPageAroundReplyId(parentMessage.id) { [weak self] _ in - self?.messageListVC.scrollToTop(animated: false) + StreamConcurrency.onMain { [weak self] in + self?.messageListVC.scrollToTop(animated: false) + } } return } @@ -259,10 +264,12 @@ open class ChatThreadVC: _ViewController, // MARK: - Loading previous and next replies state handling /// Called when the thread will load previous (older) replies. - open func loadPreviousReplies(completion: @escaping (Error?) -> Void) { + open func loadPreviousReplies(completion: @escaping @Sendable(Error?) -> Void) { messageController.loadPreviousReplies { [weak self] error in - completion(error) - self?.didFinishLoadingPreviousReplies(with: error) + StreamConcurrency.onMain { [weak self] in + completion(error) + self?.didFinishLoadingPreviousReplies(with: error) + } } } @@ -273,10 +280,12 @@ open class ChatThreadVC: _ViewController, } /// Called when the thread will load next (newer) replies. - open func loadNextReplies(completion: @escaping (Error?) -> Void) { + open func loadNextReplies(completion: @escaping @Sendable(Error?) -> Void) { messageController.loadNextReplies { [weak self] error in - completion(error) - self?.didFinishLoadingNextReplies(with: error) + StreamConcurrency.onMain { [weak self] in + completion(error) + self?.didFinishLoadingNextReplies(with: error) + } } } @@ -381,7 +390,7 @@ open class ChatThreadVC: _ViewController, public func chatMessageListVC( _ vc: ChatMessageListVC, shouldLoadPageAroundMessageId messageId: MessageId, - _ completion: @escaping ((Error?) -> Void) + _ completion: @escaping @Sendable(Error?) -> Void ) { messageController.loadPageAroundReplyId(messageId, completion: completion) } @@ -437,39 +446,49 @@ open class ChatThreadVC: _ViewController, // MARK: - ChatMessageControllerDelegate - open func messageController( + nonisolated open func messageController( _ controller: ChatMessageController, didChangeMessage change: EntityChange ) { - guard shouldRenderParentMessage && !messages.isEmpty else { - return - } - - let indexPath = IndexPath(row: messages.count - 1, section: 0) - - let listChange: ListChange - switch change { - case let .create(item): - listChange = .insert(item, index: indexPath) - case let .update(item): - listChange = .update(item, index: indexPath) - case let .remove(item): - listChange = .remove(item, index: indexPath) + StreamConcurrency.onMain { + guard shouldRenderParentMessage && !messages.isEmpty else { + return + } + + let indexPath = IndexPath(row: messages.count - 1, section: 0) + + let listChange: ListChange + switch change { + case let .create(item): + listChange = .insert(item, index: indexPath) + case let .update(item): + listChange = .update(item, index: indexPath) + case let .remove(item): + listChange = .remove(item, index: indexPath) + } + + updateMessages(with: [listChange]) } - - updateMessages(with: [listChange]) } - open func messageController( + nonisolated open func messageController( _ controller: ChatMessageController, didChangeReplies changes: [ListChange] ) { - updateMessages(with: changes) + StreamConcurrency.onMain { + updateMessages(with: changes) + } } // MARK: - EventsControllerDelegate - open func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { + nonisolated open func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { + StreamConcurrency.onMain { + _eventsController(controller, didReceiveEvent: event) + } + } + + private func _eventsController(_ controller: EventsController, didReceiveEvent event: Event) { switch event { case let event as TypingEvent: guard event.parentId == messageController.messageId && event.user.id != client.currentUserId else { return } @@ -526,15 +545,17 @@ open class ChatThreadVC: _ViewController, // MARK: - AudioQueuePlayerDatasource - open func audioQueuePlayerNextAssetURL( + nonisolated open func audioQueuePlayerNextAssetURL( _ audioPlayer: AudioPlaying, currentAssetURL: URL? ) -> URL? { - audioQueuePlayerNextItemProvider.findNextItem( - in: messages, - currentVoiceRecordingURL: currentAssetURL, - lookUpScope: .subsequentMessagesFromUser - ) + StreamConcurrency.onMain { + audioQueuePlayerNextItemProvider.findNextItem( + in: messages, + currentVoiceRecordingURL: currentAssetURL, + lookUpScope: .subsequentMessagesFromUser + ) + } } // MARK: - Deprecations diff --git a/Sources/StreamChatUI/ChatThreadList/ChatThreadListVC.swift b/Sources/StreamChatUI/ChatThreadList/ChatThreadListVC.swift index 42aef6d52fe..55a392ac4e3 100644 --- a/Sources/StreamChatUI/ChatThreadList/ChatThreadListVC.swift +++ b/Sources/StreamChatUI/ChatThreadList/ChatThreadListVC.swift @@ -96,7 +96,9 @@ open class ChatThreadListVC: super.setUp() threadListController.synchronize { [weak self] error in - self?.didFinishSynchronizingThreads(with: error) + StreamConcurrency.onMain { [weak self] in + self?.didFinishSynchronizingThreads(with: error) + } } threadListController.delegate = self eventsController.delegate = self @@ -148,7 +150,9 @@ open class ChatThreadListVC: if !newAvailableThreadIds.isEmpty { showLoadingBannerView() threadListController.synchronize { [weak self] error in - self?.didFinishSynchronizingThreads(with: error) + StreamConcurrency.onMain { [weak self] in + self?.didFinishSynchronizingThreads(with: error) + } } } } @@ -161,7 +165,9 @@ open class ChatThreadListVC: isPaginatingThreads = true threadListController.loadMoreThreads { [weak self] result in - self?.didFinishLoadingMoreThreads(with: result) + StreamConcurrency.onMain { [weak self] in + self?.didFinishLoadingMoreThreads(with: result) + } } } @@ -170,7 +176,9 @@ open class ChatThreadListVC: hideHeaderBannerView() showLoadingBannerView() threadListController.synchronize { [weak self] error in - self?.didFinishSynchronizingThreads(with: error) + StreamConcurrency.onMain { [weak self] in + self?.didFinishSynchronizingThreads(with: error) + } } } @@ -188,7 +196,9 @@ open class ChatThreadListVC: } threadListController.synchronize { [weak self] error in - self?.didFinishSynchronizingThreads(with: error) + StreamConcurrency.onMain { [weak self] in + self?.didFinishSynchronizingThreads(with: error) + } } } @@ -273,25 +283,29 @@ open class ChatThreadListVC: // MARK: - ChatThreadListControllerDelegate - public func controller(_ controller: DataController, didChangeState state: DataController.State) { - handleStateChanges(state) + nonisolated public func controller(_ controller: DataController, didChangeState state: DataController.State) { + StreamConcurrency.onMain { + handleStateChanges(state) + } } - open func controller( + nonisolated open func controller( _ controller: ChatThreadListController, didChangeThreads changes: [ListChange] ) { - handleStateChanges(controller.state) - - let previousThreads = threads - let newThreads = Array(controller.threads) - let stagedChangeset = StagedChangeset(source: previousThreads, target: newThreads) - tableView.reload( - using: stagedChangeset, - with: .fade, - reconfigure: { _ in true } - ) { [weak self] newThreads in - self?.threads = newThreads + StreamConcurrency.onMain { + handleStateChanges(controller.state) + + let previousThreads = threads + let newThreads = Array(controller.threads) + let stagedChangeset = StagedChangeset(source: previousThreads, target: newThreads) + tableView.reload( + using: stagedChangeset, + with: .fade, + reconfigure: { _ in true } + ) { [weak self] newThreads in + self?.threads = newThreads + } } } @@ -346,19 +360,21 @@ open class ChatThreadListVC: // MARK: - EventsControllerDelegate - open func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { - switch event { - case let event as ThreadMessageNewEvent: - guard let parentId = event.message.parentMessageId else { break } - let isNewThread = threadListController.dataStore.thread(parentMessageId: parentId) == nil - if isNewThread { - newAvailableThreadIds.insert(parentId) - if isViewVisible { - showHeaderBannerView() + nonisolated open func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { + StreamConcurrency.onMain { + switch event { + case let event as ThreadMessageNewEvent: + guard let parentId = event.message.parentMessageId else { break } + let isNewThread = threadListController.dataStore.thread(parentMessageId: parentId) == nil + if isNewThread { + newAvailableThreadIds.insert(parentId) + if isViewVisible { + showHeaderBannerView() + } } + default: + break } - default: - break } } } diff --git a/Sources/StreamChatUI/CommonViews/Attachments/AttachmentPreviewProvider.swift b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentPreviewProvider.swift index fcf9da70bf6..e73fdceb95c 100644 --- a/Sources/StreamChatUI/CommonViews/Attachments/AttachmentPreviewProvider.swift +++ b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentPreviewProvider.swift @@ -7,7 +7,7 @@ import UIKit public protocol AttachmentPreviewProvider { /// The view representing the attachment. - func previewView(components: Components) -> UIView + @MainActor func previewView(components: Components) -> UIView /// The preferred axis to be used for attachment previews in attachments view. static var preferredAxis: NSLayoutConstraint.Axis { get } @@ -17,7 +17,7 @@ extension ImageAttachmentPayload: AttachmentPreviewProvider { public static var preferredAxis: NSLayoutConstraint.Axis { .horizontal } /// The view representing the attachment. - public func previewView(components: Components) -> UIView { + @MainActor public func previewView(components: Components) -> UIView { let view = components.imageAttachmentComposerPreview.init() view.content = imageURL return view @@ -28,7 +28,7 @@ extension FileAttachmentPayload: AttachmentPreviewProvider { public static var preferredAxis: NSLayoutConstraint.Axis { .vertical } /// The view representing the attachment. - public func previewView(components: Components) -> UIView { + @MainActor public func previewView(components: Components) -> UIView { let view = components.messageComposerFileAttachmentView.init() view.content = .init( title: title ?? "", @@ -43,7 +43,7 @@ extension VideoAttachmentPayload: AttachmentPreviewProvider { public static var preferredAxis: NSLayoutConstraint.Axis { .horizontal } /// The view representing the video attachment. - public func previewView(components: Components) -> UIView { + @MainActor public func previewView(components: Components) -> UIView { let preview = components.videoAttachmentComposerPreview.init() preview.content = videoURL return preview @@ -54,7 +54,7 @@ extension VoiceRecordingAttachmentPayload: AttachmentPreviewProvider { public static var preferredAxis: NSLayoutConstraint.Axis { .vertical } /// The view representing the voiceRecording attachment. - public func previewView(components: Components) -> UIView { + @MainActor public func previewView(components: Components) -> UIView { let preview = components.voiceRecordingAttachmentComposerPreview.init() preview.content = .init( title: title ?? "", diff --git a/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview.swift b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview.swift index 742ae08bc74..03487ba1306 100644 --- a/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview.swift +++ b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview.swift @@ -93,13 +93,15 @@ open class VideoAttachmentComposerPreview: _View, ThemeProvider { videoDurationLabel.text = nil if let url = content { - components.videoLoader.loadPreviewForVideo(at: url) { [weak self] in - self?.loadingIndicator.isHidden = true - switch $0 { - case let .success(preview): - self?.previewImageView.image = preview - case .failure: - self?.previewImageView.image = nil + components.videoLoader.loadPreviewForVideo(at: url) { [weak self] result in + StreamConcurrency.onMain { [weak self] in + self?.loadingIndicator.isHidden = true + switch result { + case let .success(preview): + self?.previewImageView.image = preview + case .failure: + self?.previewImageView.image = nil + } } } videoDurationLabel.text = appearance.formatters.videoDuration.format( diff --git a/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VoiceRecordingAttachmentComposerPreview.swift b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VoiceRecordingAttachmentComposerPreview.swift index 31616c019da..9fcd8cd0911 100644 --- a/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VoiceRecordingAttachmentComposerPreview.swift +++ b/Sources/StreamChatUI/CommonViews/Attachments/AttachmentViews/VoiceRecordingAttachmentComposerPreview.swift @@ -164,26 +164,28 @@ open class VoiceRecordingAttachmentComposerPreview: _View, AppearanceProvider, C // MARK: - AudioPlayingDelegate - open func audioPlayer( + nonisolated open func audioPlayer( _ audioPlayer: AudioPlaying, didUpdateContext context: AudioPlaybackContext ) { - guard - let content = content - else { return } - - // We check if the currentlyPlaying asset is the one we have in this view. - let isActive = context.assetLocation == content.audioAssetURL - - switch (isActive, context.state) { - case (true, .playing), (true, .paused): - playPauseButton.isSelected = context.state == .playing - durationLabel.text = appearance.formatters.videoDuration.format(context.currentTime) - case (true, .stopped), (false, _): - playPauseButton.isSelected = false - durationLabel.text = appearance.formatters.videoDuration.format(content.duration) - default: - break + StreamConcurrency.onMain { + guard + let content = content + else { return } + + // We check if the currentlyPlaying asset is the one we have in this view. + let isActive = context.assetLocation == content.audioAssetURL + + switch (isActive, context.state) { + case (true, .playing), (true, .paused): + playPauseButton.isSelected = context.state == .playing + durationLabel.text = appearance.formatters.videoDuration.format(context.currentTime) + case (true, .stopped), (false, _): + playPauseButton.isSelected = false + durationLabel.text = appearance.formatters.videoDuration.format(content.duration) + default: + break + } } } } diff --git a/Sources/StreamChatUI/CommonViews/Attachments/DefaultAttachmentPreviewProvider.swift b/Sources/StreamChatUI/CommonViews/Attachments/DefaultAttachmentPreviewProvider.swift index c18f5d3b324..dfcc0797356 100644 --- a/Sources/StreamChatUI/CommonViews/Attachments/DefaultAttachmentPreviewProvider.swift +++ b/Sources/StreamChatUI/CommonViews/Attachments/DefaultAttachmentPreviewProvider.swift @@ -8,7 +8,7 @@ import UIKit /// Default provider that is used when AttachmentPreviewProvider is not implemented for custom attachment payload. This /// provider always returns a new instance of `AttachmentPlaceholderView`. public struct DefaultAttachmentPreviewProvider: AttachmentPreviewProvider { - public func previewView(components: Components) -> UIView { + @MainActor public func previewView(components: Components) -> UIView { components.attachmentPreviewViewPlaceholder.init() } diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView+SwiftUI.swift b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView+SwiftUI.swift index 27242c018f1..57e26815eed 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView+SwiftUI.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView+SwiftUI.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// Protocol of `ChatChannelAvatarView` wrapper for use in SwiftUI. -public protocol ChatChannelAvatarViewSwiftUIView: View { +@preconcurrency @MainActor public protocol ChatChannelAvatarViewSwiftUIView: View { init(dataSource: ChatChannelAvatarView.ObservedObject) } diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift index d27c32b9b44..74bff2a7354 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatChannelAvatarView.swift @@ -103,10 +103,12 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { urls = Array(urls.prefix(maxNumberOfImagesInCombinedAvatar)) loadAvatarsFrom(urls: urls, channelId: channel.cid) { [weak self] avatars, channelId in - guard let self = self, channelId == self.content.channel?.cid else { return } - - let combinedImage = self.createMergedAvatar(from: avatars) ?? self.appearance.images.userAvatarPlaceholder2 - self.loadIntoAvatarImageView(from: nil, placeholder: combinedImage) + StreamConcurrency.onMain { [weak self] in + guard let self = self, channelId == self.content.channel?.cid else { return } + + let combinedImage = self.createMergedAvatar(from: avatars) ?? self.appearance.images.userAvatarPlaceholder2 + self.loadIntoAvatarImageView(from: nil, placeholder: combinedImage) + } } } @@ -118,10 +120,10 @@ open class ChatChannelAvatarView: _View, ThemeProvider, SwiftUIRepresentable { open func loadAvatarsFrom( urls: [URL?], channelId: ChannelId, - completion: @escaping ([UIImage], ChannelId) + completion: @escaping @Sendable([UIImage], ChannelId) -> Void ) { - var placeholderImages = [ + nonisolated(unsafe) var placeholderImages = [ appearance.images.userAvatarPlaceholder1, appearance.images.userAvatarPlaceholder2, appearance.images.userAvatarPlaceholder3, diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/ChatPresenceAvatarView/OnlineIndicatorView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/ChatPresenceAvatarView/OnlineIndicatorView.swift index c9c0804b71b..94f9e6176f3 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatPresenceAvatarView/OnlineIndicatorView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatPresenceAvatarView/OnlineIndicatorView.swift @@ -8,7 +8,7 @@ import UIKit /// Protocol used to get path to make a cutout in a parent view. /// /// This protocol is used to make a transparent "border" around online indicator in avatar view. -public protocol MaskProviding { +@preconcurrency @MainActor public protocol MaskProviding { /// Path used to mask space in super view. /// /// No mask is used when nil is returned diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift index a98e40df447..3477bb585c8 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/CurrentChatUserAvatarView.swift @@ -82,10 +82,12 @@ open class CurrentChatUserAvatarView: _Control, ThemeProvider { // MARK: - CurrentChatUserControllerDelegate extension CurrentChatUserAvatarView: CurrentChatUserControllerDelegate { - public func currentUserController( + nonisolated public func currentUserController( _ controller: CurrentChatUserController, didChangeCurrentUser: EntityChange ) { - updateContentIfNeeded() + StreamConcurrency.onMain { + updateContentIfNeeded() + } } } diff --git a/Sources/StreamChatUI/CommonViews/BaseViews.swift b/Sources/StreamChatUI/CommonViews/BaseViews.swift index 89c6b75024b..f18adeb6463 100644 --- a/Sources/StreamChatUI/CommonViews/BaseViews.swift +++ b/Sources/StreamChatUI/CommonViews/BaseViews.swift @@ -13,7 +13,7 @@ extension NSObject { } // Protocol that provides accessibility features -protocol AccessibilityView { +@MainActor protocol AccessibilityView { // Identifier for view var accessibilityViewIdentifier: String { get } @@ -32,7 +32,7 @@ extension AccessibilityView where Self: UIView { } // Just a protocol to formalize the methods required -public protocol Customizable { +@preconcurrency @MainActor public protocol Customizable { /// Main point of customization for the view functionality. /// /// **It's called zero or one time(s) during the view's lifetime.** Calling super implementation is required. @@ -416,7 +416,7 @@ open class _ViewController: UIViewController, Customizable { } } -protocol ViewVisibilityChecker { +@MainActor protocol ViewVisibilityChecker { func isViewVisible(for viewController: UIViewController) -> Bool } @@ -428,7 +428,7 @@ struct DefaultViewVisibilityChecker: ViewVisibilityChecker { } /// Closure stack, used to reverse order of appearance reloads on trait collection changes -private enum TraitCollectionReloadStack { +@MainActor private enum TraitCollectionReloadStack { private static var stack: [() -> Void] = [] static func executePendingUpdates() { diff --git a/Sources/StreamChatUI/CommonViews/ContainerStackView.swift b/Sources/StreamChatUI/CommonViews/ContainerStackView.swift index 710ce577515..64f78127840 100644 --- a/Sources/StreamChatUI/CommonViews/ContainerStackView.swift +++ b/Sources/StreamChatUI/CommonViews/ContainerStackView.swift @@ -9,7 +9,7 @@ import UIKit extension ContainerStackView { /// Describes the size distribution of the arranged subviews in a container stack view. - public struct Distribution: Equatable { + public struct Distribution: Equatable, Sendable { /// Makes the arranged subviews with their natural size. public static let natural = Distribution(rawValue: 0) /// Makes the arranged subviews all with the same size. @@ -19,7 +19,7 @@ extension ContainerStackView { } /// Describes the alignment of the arranged subviews in perpendicular to the container's axis. - public struct Alignment: Equatable { + public struct Alignment: Equatable, Sendable { /// Makes the arranged subviews so that they **fill** the available space perpendicular to the container’s axis. public static let fill = Alignment(rawValue: 0) /// Makes the arranged subviews align to the **leading edge** in a **vertical axis** container. @@ -37,7 +37,7 @@ extension ContainerStackView { } /// Describes the Spacing between the arranged subviews. - public struct Spacing: Equatable, ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral { + public struct Spacing: Equatable, ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral, Sendable { /// The actual value of the Spacing. public var rawValue: CGFloat @@ -202,10 +202,12 @@ public class ContainerStackView: UIView { hidingObserversByView[subview] = subview .observe(\.isHidden, options: [.new]) { [weak self] (view, isHiddenChange) in - if isHiddenChange.newValue == true { - self?.hideArrangedSubview(view) - } else { - self?.showArrangedSubview(view) + StreamConcurrency.onMain { [weak self] in + if isHiddenChange.newValue == true { + self?.hideArrangedSubview(view) + } else { + self?.showArrangedSubview(view) + } } } diff --git a/Sources/StreamChatUI/CommonViews/GroupedSectionListStyling.swift b/Sources/StreamChatUI/CommonViews/GroupedSectionListStyling.swift index ef4bc88f065..049223c87df 100644 --- a/Sources/StreamChatUI/CommonViews/GroupedSectionListStyling.swift +++ b/Sources/StreamChatUI/CommonViews/GroupedSectionListStyling.swift @@ -8,7 +8,7 @@ import UIKit /// /// The default `UITableViewStye.insetGrouped` is not enough because /// it does not group sections or footers. -protocol GroupedSectionListStyling { +@MainActor protocol GroupedSectionListStyling { /// Whether the grouped section styling is enabled. /// It is useful to easily disable the grouped section styling. var isGroupedSectionStylingEnabled: Bool { get } diff --git a/Sources/StreamChatUI/CommonViews/InputTextView/InputTextView.swift b/Sources/StreamChatUI/CommonViews/InputTextView/InputTextView.swift index c4070f21f6c..3ba8c270e48 100644 --- a/Sources/StreamChatUI/CommonViews/InputTextView/InputTextView.swift +++ b/Sources/StreamChatUI/CommonViews/InputTextView/InputTextView.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// The delegate of the `InputTextView` that notifies when an attachment is pasted in the text view. -public protocol InputTextViewClipboardAttachmentDelegate: AnyObject { +@preconcurrency @MainActor public protocol InputTextViewClipboardAttachmentDelegate: AnyObject { /// Notifies that an `UIImage` has been pasted into the text view /// - Parameters: /// - inputTextView: The `InputTextView` in which the image was pasted diff --git a/Sources/StreamChatUI/CommonViews/ListCollectionViewLayout/ListCollectionViewLayout.swift b/Sources/StreamChatUI/CommonViews/ListCollectionViewLayout/ListCollectionViewLayout.swift index 4fd5429a120..12d313f1620 100644 --- a/Sources/StreamChatUI/CommonViews/ListCollectionViewLayout/ListCollectionViewLayout.swift +++ b/Sources/StreamChatUI/CommonViews/ListCollectionViewLayout/ListCollectionViewLayout.swift @@ -89,8 +89,7 @@ open class ListCollectionViewLayout: UICollectionViewFlowLayout { private func separatorLayoutAttributes( forCellLayoutAttributes cellAttributes: [UICollectionViewLayoutAttributes] ) -> [UICollectionViewLayoutAttributes] { - guard let collectionView = collectionView else { return [] } - let delegate = collectionView.delegate as? ListCollectionViewLayoutDelegate + guard collectionView != nil else { return [] } return cellAttributes.compactMap { cellAttribute in guard cellAttribute.representedElementCategory == .cell else { return nil } return separatorLayoutAttributes(forCellFrame: cellAttribute.frame, indexPath: cellAttribute.indexPath) diff --git a/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView+SwiftUI.swift b/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView+SwiftUI.swift index 16d534893e2..aeeb638fdd6 100644 --- a/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView+SwiftUI.swift +++ b/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView+SwiftUI.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// Protocol of `QuotedChatMessageView` wrapper for use in SwiftUI. -public protocol QuotedChatMessageViewSwiftUIView: View { +@preconcurrency @MainActor public protocol QuotedChatMessageViewSwiftUIView: View { init(dataSource: QuotedChatMessageView.ObservedObject) } diff --git a/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift b/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift index 7a8d8975a7d..5baff2126d9 100644 --- a/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift +++ b/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift @@ -8,7 +8,7 @@ import UIKit /// The quoted author's avatar position in relation with the text message. /// New custom alignments can be added with extensions and by overriding the `QuotedChatMessageView.setAvatarAlignment()`. -public struct QuotedAvatarAlignment: RawRepresentable, Equatable { +public struct QuotedAvatarAlignment: RawRepresentable, Equatable, Sendable { /// The avatar will be aligned to the leading, and the message content on the trailing. public static let leading = QuotedAvatarAlignment(rawValue: 0) /// The avatar will be aligned to the trailing, and the message content on the leading. @@ -307,11 +307,13 @@ open class QuotedChatMessageView: _View, ThemeProvider, SwiftUIRepresentable { /// - Parameter url: The URL of the thumbnail open func setVideoAttachmentThumbnail(url: URL) { components.imageLoader.downloadImage(with: .init(url: url, options: ImageDownloadOptions())) { [weak self] result in - switch result { - case let .success(preview): - self?.attachmentPreviewView.image = preview - case .failure: - self?.attachmentPreviewView.image = nil + StreamConcurrency.onMain { [weak self] in + switch result { + case let .success(preview): + self?.attachmentPreviewView.image = preview + case .failure: + self?.attachmentPreviewView.image = nil + } } } } @@ -321,13 +323,15 @@ open class QuotedChatMessageView: _View, ThemeProvider, SwiftUIRepresentable { open func setVideoAttachmentPreviewImage(url: URL?) { guard let url = url else { return } - components.videoLoader.loadPreviewForVideo(at: url) { [weak self] in - switch $0 { - case let .success(preview): - self?.attachmentPreviewView.image = preview - case let .failure(error): - self?.attachmentPreviewView.image = nil - log.error("This \(error) received for processing Video Preview image.") + components.videoLoader.loadPreviewForVideo(at: url) { [weak self] result in + StreamConcurrency.onMain { [weak self] in + switch result { + case let .success(preview): + self?.attachmentPreviewView.image = preview + case let .failure(error): + self?.attachmentPreviewView.image = nil + log.error("This \(error) received for processing Video Preview image.") + } } } } diff --git a/Sources/StreamChatUI/CommonViews/SkeletonLoadable.swift b/Sources/StreamChatUI/CommonViews/SkeletonLoadable.swift index 3dea635ba0f..2ea9a18b55e 100644 --- a/Sources/StreamChatUI/CommonViews/SkeletonLoadable.swift +++ b/Sources/StreamChatUI/CommonViews/SkeletonLoadable.swift @@ -4,7 +4,7 @@ import UIKit -protocol SkeletonLoadable { +@MainActor protocol SkeletonLoadable { func makeAnimationGroup(previousGroup: CAAnimationGroup?) -> CAAnimationGroup } diff --git a/Sources/StreamChatUI/CommonViews/SwiftUIViewRepresentable.swift b/Sources/StreamChatUI/CommonViews/SwiftUIViewRepresentable.swift index 535a8b89ba7..6fc283fec29 100644 --- a/Sources/StreamChatUI/CommonViews/SwiftUIViewRepresentable.swift +++ b/Sources/StreamChatUI/CommonViews/SwiftUIViewRepresentable.swift @@ -6,7 +6,7 @@ import Combine import SwiftUI /// Protocol with necessary properties to make `SwiftUIRepresentable` instance -public protocol SwiftUIRepresentable: AnyObject { +@preconcurrency @MainActor public protocol SwiftUIRepresentable: AnyObject { /// Type used for `content` property associatedtype ViewContent /// Content of a given view diff --git a/Sources/StreamChatUI/Components.swift b/Sources/StreamChatUI/Components.swift index b8c75ef75ea..a4d1f33c244 100644 --- a/Sources/StreamChatUI/Components.swift +++ b/Sources/StreamChatUI/Components.swift @@ -6,7 +6,7 @@ import StreamChat import UIKit /// An object containing types of UI Components that are used through the UI SDK. -public struct Components { +public struct Components: @unchecked Sendable { /// A view that displays a title label and subtitle in a container stack view. public var titleContainerView: TitleContainerView.Type = TitleContainerView.self @@ -670,7 +670,18 @@ public struct Components { public init() {} - public static var `default` = Self() + public static var `default`: Components { + get { + StreamConcurrency.onMain { _default } + } + set { + StreamConcurrency.onMain { _default = newValue } + } + } + + // Shared instance is mutated only on the main thread without explicit + // main actor annotation for easier SDK setup. + nonisolated(unsafe) private static var _default = Self() // MARK: Deprecations @@ -688,7 +699,7 @@ public extension Components { deprecated, message: "Please use `Appearance.default.formatters.channelName` instead" ) - var channelNamer: ChatChannelNamer { + @preconcurrency @MainActor var channelNamer: ChatChannelNamer { get { DefaultChannelNameFormatter.channelNamer } diff --git a/Sources/StreamChatUI/ComponentsProvider.swift b/Sources/StreamChatUI/ComponentsProvider.swift index a2c7585e2ca..8b7788753a7 100644 --- a/Sources/StreamChatUI/ComponentsProvider.swift +++ b/Sources/StreamChatUI/ComponentsProvider.swift @@ -9,7 +9,7 @@ import UIKit public protocol ThemeProvider: ComponentsProvider, AppearanceProvider {} -public protocol ComponentsProvider: AnyObject { +@preconcurrency @MainActor public protocol ComponentsProvider: AnyObject { /// Appearance object to change components and component types from which the default SDK views are build /// or to use the default components in custom views. var components: Components { get set } diff --git a/Sources/StreamChatUI/Composer/ComposerVC.swift b/Sources/StreamChatUI/Composer/ComposerVC.swift index a046247dbe0..b2f05472fa1 100644 --- a/Sources/StreamChatUI/Composer/ComposerVC.swift +++ b/Sources/StreamChatUI/Composer/ComposerVC.swift @@ -14,10 +14,10 @@ public enum AttachmentValidationError: Error { /// The number of attachments reached the limit. case maxAttachmentsCountPerMessageExceeded(limit: Int) - internal static var fileSizeMaxLimitFallback: Int64 = 100 * 1024 * 1024 + internal static let fileSizeMaxLimitFallback: Int64 = 100 * 1024 * 1024 } -public struct LocalAttachmentInfoKey: Hashable, Equatable, RawRepresentable { +public struct LocalAttachmentInfoKey: Hashable, Equatable, RawRepresentable, Sendable { public let rawValue: String public init(rawValue: String) { @@ -31,7 +31,7 @@ public struct LocalAttachmentInfoKey: Hashable, Equatable, RawRepresentable { /// 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 { +public struct ComposerState: RawRepresentable, Equatable, Sendable { public let rawValue: String public var description: String { rawValue.uppercased() } @@ -39,11 +39,11 @@ public struct ComposerState: RawRepresentable, Equatable { self.rawValue = rawValue } - public static var new = ComposerState(rawValue: "new") - public static var edit = ComposerState(rawValue: "edit") - public static var quote = ComposerState(rawValue: "quote") - public static var recording = ComposerState(rawValue: "recording") - public static var recordingLocked = ComposerState(rawValue: "recordingLocked") + public static let new = ComposerState(rawValue: "new") + public static let edit = ComposerState(rawValue: "edit") + public static let quote = ComposerState(rawValue: "quote") + public static let recording = ComposerState(rawValue: "recording") + public static let recordingLocked = ComposerState(rawValue: "recordingLocked") } /// A view controller that manages the composer view. @@ -1361,24 +1361,26 @@ open class ComposerVC: _ViewController, enrichUrlDebouncer.execute { [weak self] in self?.channelController?.enrichUrl(link.url) { [weak self] result in - let enrichedUrlText = link.url.absoluteString - let currentLinks = self?.composerView.inputMessageView.textView.links ?? [] - guard let currentUrlText = currentLinks.first?.url.absoluteString else { - return - } - - // Only show/dismiss enrichment if the current url is still the one - // that should be shown. Since we currently do not support - // cancelling previous requests, this is the current optimal solution. - guard enrichedUrlText == currentUrlText else { - return - } - - switch result { - case let .success(linkPayload): - self?.showLinkPreview(for: linkPayload) - case .failure: - self?.dismissLinkPreview() + StreamConcurrency.onMain { [weak self] in + let enrichedUrlText = link.url.absoluteString + let currentLinks = self?.composerView.inputMessageView.textView.links ?? [] + guard let currentUrlText = currentLinks.first?.url.absoluteString else { + return + } + + // Only show/dismiss enrichment if the current url is still the one + // that should be shown. Since we currently do not support + // cancelling previous requests, this is the current optimal solution. + guard enrichedUrlText == currentUrlText else { + return + } + + switch result { + case let .success(linkPayload): + self?.showLinkPreview(for: linkPayload) + case .failure: + self?.dismissLinkPreview() + } } } } @@ -1436,7 +1438,7 @@ open class ComposerVC: _ViewController, from url: URL, type: AttachmentType, info: [LocalAttachmentInfoKey: Any], - extraData: Encodable? + extraData: (Encodable & Sendable)? ) throws { guard let chatConfig = channelController?.client.config else { log.assertionFailure("Channel controller must be set at this point") @@ -1762,7 +1764,7 @@ open class ComposerVC: _ViewController, ) { let alert = UIAlertController(title: title, message: message, preferredStyle: preferredStyle) alert.popoverPresentationController?.sourceView = sourceView - actions.forEach(alert.addAction) + actions.forEach { alert.addAction($0) } present(alert, animated: true) } @@ -1808,10 +1810,12 @@ func searchUsers(_ users: [ChatUser], by searchInput: String, excludingId: Strin } extension ComposerVC: ChatChannelControllerDelegate { - public func channelController( + nonisolated public func channelController( _ channelController: ChatChannelController, didUpdateMessages changes: [ListChange] ) { - cooldownTracker.start(with: channelController.currentCooldownTime()) + StreamConcurrency.onMain { + cooldownTracker.start(with: channelController.currentCooldownTime()) + } } } diff --git a/Sources/StreamChatUI/Composer/Suggestions/ChatSuggestionsVC.swift b/Sources/StreamChatUI/Composer/Suggestions/ChatSuggestionsVC.swift index 18a2cb78560..31f68d23d77 100644 --- a/Sources/StreamChatUI/Composer/Suggestions/ChatSuggestionsVC.swift +++ b/Sources/StreamChatUI/Composer/Suggestions/ChatSuggestionsVC.swift @@ -80,16 +80,18 @@ open class ChatSuggestionsVC: _ViewController, \.contentSize, options: [.new], changeHandler: { [weak self] collectionView, change in - guard let self = self, let newSize = change.newValue else { return } - guard !collectionView.isTrackingOrDecelerating else { return } - - // NOTE: The defaultRowHeight height value will be used only once to set visibleCells - // once again, not looping it to 0 value so this controller can resize again. - let cellHeight = collectionView.visibleCells.first?.bounds.height ?? self.defaultRowHeight - - let newHeight = min(newSize.height, cellHeight * self.numberOfVisibleRows) - guard self.heightConstraints.constant != newHeight else { return } - self.heightConstraints.constant = newHeight + StreamConcurrency.onMain { [weak self] in + guard let self = self, let newSize = change.newValue else { return } + guard !collectionView.isTrackingOrDecelerating else { return } + + // NOTE: The defaultRowHeight height value will be used only once to set visibleCells + // once again, not looping it to 0 value so this controller can resize again. + let cellHeight = collectionView.visibleCells.first?.bounds.height ?? self.defaultRowHeight + + let newHeight = min(newSize.height, cellHeight * self.numberOfVisibleRows) + guard self.heightConstraints.constant != newHeight else { return } + self.heightConstraints.constant = newHeight + } } ) } @@ -268,26 +270,32 @@ open class ChatMessageComposerSuggestionsMentionDataSource: NSObject, return cell } - public func controller( + nonisolated public func controller( _ controller: ChatUserSearchController, didChangeUsers changes: [ListChange] ) { - users = searchController.userArray - collectionView.reloadData() + StreamConcurrency.onMain { + users = searchController.userArray + collectionView.reloadData() + } } - public func memberListController( + nonisolated public func memberListController( _ controller: ChatChannelMemberListController, didChangeMembers changes: [ListChange] ) { - users = Array(controller.members) - collectionView.reloadData() + StreamConcurrency.onMain { + users = Array(controller.members) + collectionView.reloadData() + } } - public func controller(_ controller: DataController, didChangeState state: DataController.State) { - if let memberListController = controller as? ChatChannelMemberListController { - users = Array(memberListController.members) - collectionView.reloadData() + nonisolated public func controller(_ controller: DataController, didChangeState state: DataController.State) { + StreamConcurrency.onMain { + if let memberListController = controller as? ChatChannelMemberListController { + users = Array(memberListController.members) + collectionView.reloadData() + } } } } diff --git a/Sources/StreamChatUI/Composer/TypingSuggester.swift b/Sources/StreamChatUI/Composer/TypingSuggester.swift index df09d49f983..6b2c56a6a2c 100644 --- a/Sources/StreamChatUI/Composer/TypingSuggester.swift +++ b/Sources/StreamChatUI/Composer/TypingSuggester.swift @@ -62,7 +62,7 @@ public struct TypingSuggester { /// Checks if the user typed the recognising symbol and returns the typing suggestion. /// - Parameter textView: The `UITextView` the user is currently typing. /// - Returns: The typing suggestion if it was recognised, `nil` otherwise. - public func typingSuggestion(in textView: UITextView) -> TypingSuggestion? { + @MainActor public func typingSuggestion(in textView: UITextView) -> TypingSuggestion? { let text = textView.text as NSString let caretLocation = textView.selectedRange.location diff --git a/Sources/StreamChatUI/Gallery/Cells/VideoAttachmentGalleryCell.swift b/Sources/StreamChatUI/Gallery/Cells/VideoAttachmentGalleryCell.swift index 0c21eb2fb7d..094f3cabe74 100644 --- a/Sources/StreamChatUI/Gallery/Cells/VideoAttachmentGalleryCell.swift +++ b/Sources/StreamChatUI/Gallery/Cells/VideoAttachmentGalleryCell.swift @@ -60,12 +60,14 @@ open class VideoAttachmentGalleryCell: GalleryCollectionViewCell { if let thumbnailURL = videoAttachment?.thumbnailURL { showPreview(using: thumbnailURL) } else if let url = newAssetURL { - components.videoLoader.loadPreviewForVideo(at: url) { [weak self] in - switch $0 { - case let .success(preview): - self?.showPreview(using: preview) - case .failure: - self?.showPreview(using: nil) + components.videoLoader.loadPreviewForVideo(at: url) { [weak self] result in + StreamConcurrency.onMain { [weak self] in + switch result { + case let .success(preview): + self?.showPreview(using: preview) + case .failure: + self?.showPreview(using: nil) + } } } } @@ -74,11 +76,13 @@ open class VideoAttachmentGalleryCell: GalleryCollectionViewCell { private func showPreview(using thumbnailURL: URL) { components.imageLoader.downloadImage(with: .init(url: thumbnailURL, options: ImageDownloadOptions())) { [weak self] result in - switch result { - case let .success(preview): - self?.showPreview(using: preview) - case .failure: - self?.showPreview(using: nil) + StreamConcurrency.onMain { [weak self] in + switch result { + case let .success(preview): + self?.showPreview(using: preview) + case .failure: + self?.showPreview(using: nil) + } } } } diff --git a/Sources/StreamChatUI/Gallery/VideoPlaybackControlView.swift b/Sources/StreamChatUI/Gallery/VideoPlaybackControlView.swift index bc5e8b165c3..75a09971f71 100644 --- a/Sources/StreamChatUI/Gallery/VideoPlaybackControlView.swift +++ b/Sources/StreamChatUI/Gallery/VideoPlaybackControlView.swift @@ -229,42 +229,48 @@ open class VideoPlaybackControlView: _View, ThemeProvider { let interval = CMTime(seconds: 0.05, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) playerTimeChangesObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in - guard let currentItem = self?.player?.currentItem else { return } - - if time.isNumeric && currentItem.duration.isNumeric { - self?.content.playingProgress = time.seconds / currentItem.duration.seconds - } else { - self?.content.playingProgress = 0 + MainActor.assumeIsolated { + guard let currentItem = self?.player?.currentItem else { return } + + if time.isNumeric && currentItem.duration.isNumeric { + self?.content.playingProgress = time.seconds / currentItem.duration.seconds + } else { + self?.content.playingProgress = 0 + } } } playerStatusObserver = player.observe(\.timeControlStatus, options: [.new, .initial]) { [weak self] player, _ in guard let self = self else { return } - - switch player.timeControlStatus { - case .playing: - self.content.videoState = .playing - case .paused: - self.content.videoState = .paused - default: - self.content.videoState = .loading + StreamConcurrency.onMain { + switch player.timeControlStatus { + case .playing: + self.content.videoState = .playing + case .paused: + self.content.videoState = .paused + default: + self.content.videoState = .loading + } } } playerItemObserver = player.observe(\.currentItem, options: [.new, .initial]) { [weak self] player, _ in guard let self = self else { return } - - self.content.videoDuration = 0 - self.itemDurationObserver = player.currentItem?.observe(\.duration, options: [.new, .initial]) { [weak self] item, _ in - self?.content.videoDuration = item.duration.isNumeric ? item.duration.seconds : 0 + StreamConcurrency.onMain { + self.content.videoDuration = 0 + self.itemDurationObserver = player.currentItem?.observe(\.duration, options: [.new, .initial]) { [weak self] item, _ in + StreamConcurrency.onMain { [weak self] in + self?.content.videoDuration = item.duration.isNumeric ? item.duration.seconds : 0 + } + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleItemDidPlayToEndTime), + name: .AVPlayerItemDidPlayToEndTime, + object: player.currentItem + ) } - - NotificationCenter.default.addObserver( - self, - selector: #selector(self.handleItemDidPlayToEndTime), - name: .AVPlayerItemDidPlayToEndTime, - object: player.currentItem - ) } } @@ -273,6 +279,8 @@ open class VideoPlaybackControlView: _View, ThemeProvider { deinit { NotificationCenter.default.removeObserver(self) - unsubscribeFromPlayerNotifications(_currentPlayer) + StreamConcurrency.onMain { + unsubscribeFromPlayerNotifications(_currentPlayer) + } } } diff --git a/Sources/StreamChatUI/MessageActionsPopup/ChatMessageActionsVC.swift b/Sources/StreamChatUI/MessageActionsPopup/ChatMessageActionsVC.swift index 771015fab91..7cfa1de5a59 100644 --- a/Sources/StreamChatUI/MessageActionsPopup/ChatMessageActionsVC.swift +++ b/Sources/StreamChatUI/MessageActionsPopup/ChatMessageActionsVC.swift @@ -5,7 +5,7 @@ import StreamChat import UIKit -public protocol ChatMessageActionsVCDelegate: AnyObject { +@preconcurrency @MainActor public protocol ChatMessageActionsVCDelegate: AnyObject { func chatMessageActionsVC( _ vc: ChatMessageActionsVC, message: ChatMessage, @@ -204,7 +204,9 @@ open class ChatMessageActionsVC: _ViewController, ThemeProvider { guard confirmed else { return } self.messageController.deleteMessage { _ in - self.delegate?.chatMessageActionsVCDidFinish(self) + StreamConcurrency.onMain { + self.delegate?.chatMessageActionsVCDidFinish(self) + } } } }, @@ -218,7 +220,9 @@ open class ChatMessageActionsVC: _ViewController, ThemeProvider { action: { [weak self] _ in guard let self = self else { return } self.messageController.resendMessage { _ in - self.delegate?.chatMessageActionsVCDidFinish(self) + StreamConcurrency.onMain { + self.delegate?.chatMessageActionsVCDidFinish(self) + } } }, appearance: appearance @@ -236,7 +240,11 @@ open class ChatMessageActionsVC: _ViewController, ThemeProvider { self.messageController.client .userController(userId: author.id) - .mute { _ in self.delegate?.chatMessageActionsVCDidFinish(self) } + .mute { _ in + StreamConcurrency.onMain { + self.delegate?.chatMessageActionsVCDidFinish(self) + } + } }, appearance: appearance ) @@ -253,7 +261,11 @@ open class ChatMessageActionsVC: _ViewController, ThemeProvider { self.messageController.client .userController(userId: author.id) - .unmute { _ in self.delegate?.chatMessageActionsVCDidFinish(self) } + .unmute { _ in + StreamConcurrency.onMain { + self.delegate?.chatMessageActionsVCDidFinish(self) + } + } }, appearance: appearance ) @@ -270,7 +282,11 @@ open class ChatMessageActionsVC: _ViewController, ThemeProvider { self.messageController.client .userController(userId: author.id) - .block { _ in self.delegate?.chatMessageActionsVCDidFinish(self) } + .block { _ in + StreamConcurrency.onMain { + self.delegate?.chatMessageActionsVCDidFinish(self) + } + } }, appearance: appearance ) @@ -287,7 +303,11 @@ open class ChatMessageActionsVC: _ViewController, ThemeProvider { self.messageController.client .userController(userId: author.id) - .unblock { _ in self.delegate?.chatMessageActionsVCDidFinish(self) } + .unblock { _ in + StreamConcurrency.onMain { + self.delegate?.chatMessageActionsVCDidFinish(self) + } + } }, appearance: appearance ) @@ -347,7 +367,9 @@ open class ChatMessageActionsVC: _ViewController, ThemeProvider { guard confirmed else { return } self.messageController.flag { _ in - self.delegate?.chatMessageActionsVCDidFinish(self) + StreamConcurrency.onMain { + self.delegate?.chatMessageActionsVCDidFinish(self) + } } } }, diff --git a/Sources/StreamChatUI/MessageActionsPopup/MessageActionsTransitionController.swift b/Sources/StreamChatUI/MessageActionsPopup/MessageActionsTransitionController.swift index 5e77fe3044a..f54bf47b963 100644 --- a/Sources/StreamChatUI/MessageActionsPopup/MessageActionsTransitionController.swift +++ b/Sources/StreamChatUI/MessageActionsPopup/MessageActionsTransitionController.swift @@ -122,7 +122,7 @@ open class ChatMessageActionsTransitionController: NSObject, UIViewControllerTra messageView ].compactMap { $0 } - transitionSubviews.forEach(transitionContext.containerView.addSubview) + transitionSubviews.forEach { transitionContext.containerView.addSubview($0) } messageView.mainContainer.layoutMargins = originalMessageContentView.mainContainer.layoutMargins let duration = transitionDuration(using: transitionContext) @@ -205,7 +205,7 @@ open class ChatMessageActionsTransitionController: NSObject, UIViewControllerTra let transitionSubviews = [blurView, reactionsSnapshot, actionsSnapshot, reactionAuthorsSnapshot, messageView] .compactMap { $0 } - transitionSubviews.forEach(transitionContext.containerView.addSubview) + transitionSubviews.forEach { transitionContext.containerView.addSubview($0) } fromVC.view.isHidden = true diff --git a/Sources/StreamChatUI/StreamNuke/Core/DataLoader.swift b/Sources/StreamChatUI/StreamNuke/Core/DataLoader.swift deleted file mode 100644 index 7e66d284b04..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/DataLoader.swift +++ /dev/null @@ -1,221 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// Fetches original image data. -protocol DataLoading { - /// - parameter didReceiveData: Can be called multiple times if streaming - /// is supported. - /// - parameter completion: Must be called once after all (or none in case - /// of an error) `didReceiveData` closures have been called. - func loadData(with request: URLRequest, - didReceiveData: @escaping (Data, URLResponse) -> Void, - completion: @escaping (Error?) -> Void) -> Cancellable -} - -extension URLSessionTask: Cancellable {} - -/// Provides basic networking using `URLSession`. -final class DataLoader: DataLoading, _DataLoaderObserving { - let session: URLSession - private let impl = _DataLoader() - - var observer: DataLoaderObserving? - - deinit { - session.invalidateAndCancel() - - #if TRACK_ALLOCATIONS - Allocations.decrement("DataLoader") - #endif - } - - /// Initializes `DataLoader` with the given configuration. - /// - parameter configuration: `URLSessionConfiguration.default` with - /// `URLCache` with 0 MB memory capacity and 150 MB disk capacity. - init(configuration: URLSessionConfiguration = DataLoader.defaultConfiguration, - validate: @escaping (URLResponse) -> Swift.Error? = DataLoader.validate) { - let queue = OperationQueue() - queue.maxConcurrentOperationCount = 1 - self.session = URLSession(configuration: configuration, delegate: impl, delegateQueue: queue) - self.impl.validate = validate - self.impl.observer = self - - #if TRACK_ALLOCATIONS - Allocations.increment("DataLoader") - #endif - } - - /// Returns a default configuration which has a `sharedUrlCache` set - /// as a `urlCache`. - static var defaultConfiguration: URLSessionConfiguration { - let conf = URLSessionConfiguration.default - conf.urlCache = DataLoader.sharedUrlCache - return conf - } - - /// Validates `HTTP` responses by checking that the status code is 2xx. If - /// it's not returns `DataLoader.Error.statusCodeUnacceptable`. - static func validate(response: URLResponse) -> Swift.Error? { - guard let response = response as? HTTPURLResponse else { - return nil - } - return (200..<300).contains(response.statusCode) ? nil : Error.statusCodeUnacceptable(response.statusCode) - } - - #if !os(macOS) && !targetEnvironment(macCatalyst) - private static let cachePath = "com.github.kean.Nuke.Cache" - #else - private static let cachePath: String = { - let cachePaths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true) - if let cachePath = cachePaths.first, let identifier = Bundle.main.bundleIdentifier { - return cachePath.appending("/" + identifier) - } - - return "" - }() - #endif - - /// Shared url cached used by a default `DataLoader`. The cache is - /// initialized with 0 MB memory capacity and 150 MB disk capacity. - static let sharedUrlCache: URLCache = { - let diskCapacity = 150 * 1024 * 1024 // 150 MB - #if targetEnvironment(macCatalyst) - return URLCache(memoryCapacity: 0, diskCapacity: diskCapacity, directory: URL(fileURLWithPath: cachePath)) - #else - return URLCache(memoryCapacity: 0, diskCapacity: diskCapacity, diskPath: cachePath) - #endif - }() - - func loadData(with request: URLRequest, - didReceiveData: @escaping (Data, URLResponse) -> Void, - completion: @escaping (Swift.Error?) -> Void) -> Cancellable { - impl.loadData(with: request, session: session, didReceiveData: didReceiveData, completion: completion) - } - - /// Errors produced by `DataLoader`. - enum Error: Swift.Error, CustomStringConvertible { - /// Validation failed. - case statusCodeUnacceptable(Int) - - var description: String { - switch self { - case let .statusCodeUnacceptable(code): - return "Response status code was unacceptable: \(code.description)" - } - } - } - - // MARK: _DataLoaderObserving - - func dataTask(_ dataTask: URLSessionDataTask, didReceiveEvent event: DataTaskEvent) { - observer?.dataLoader(self, urlSession: session, dataTask: dataTask, didReceiveEvent: event) - } -} - -// Actual data loader implementation. Hide NSObject inheritance, hide -// URLSessionDataDelegate conformance, and break retain cycle between URLSession -// and URLSessionDataDelegate. -private final class _DataLoader: NSObject, URLSessionDataDelegate { - var validate: (URLResponse) -> Swift.Error? = DataLoader.validate - private var handlers = [URLSessionTask: _Handler]() - weak var observer: _DataLoaderObserving? - - /// Loads data with the given request. - func loadData(with request: URLRequest, - session: URLSession, - didReceiveData: @escaping (Data, URLResponse) -> Void, - completion: @escaping (Error?) -> Void) -> Cancellable { - let task = session.dataTask(with: request) - let handler = _Handler(didReceiveData: didReceiveData, completion: completion) - session.delegateQueue.addOperation { // `URLSession` is configured to use this same queue - self.handlers[task] = handler - } - task.resume() - send(task, .resumed) - return task - } - - // MARK: URLSessionDelegate - - func urlSession(_ session: URLSession, - dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - send(dataTask, .receivedResponse(response: response)) - - guard let handler = handlers[dataTask] else { - completionHandler(.cancel) - return - } - if let error = validate(response) { - handler.completion(error) - completionHandler(.cancel) - return - } - completionHandler(.allow) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - assert(task is URLSessionDataTask) - if let dataTask = task as? URLSessionDataTask { - send(dataTask, .completed(error: error)) - } - - guard let handler = handlers[task] else { - return - } - handlers[task] = nil - handler.completion(error) - } - - // MARK: URLSessionDataDelegate - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - send(dataTask, .receivedData(data: data)) - - guard let handler = handlers[dataTask], let response = dataTask.response else { - return - } - // Don't store data anywhere, just send it to the pipeline. - handler.didReceiveData(data, response) - } - - // MARK: Internal - - private func send(_ dataTask: URLSessionDataTask, _ event: DataTaskEvent) { - observer?.dataTask(dataTask, didReceiveEvent: event) - } - - private final class _Handler { - let didReceiveData: (Data, URLResponse) -> Void - let completion: (Error?) -> Void - - init(didReceiveData: @escaping (Data, URLResponse) -> Void, completion: @escaping (Error?) -> Void) { - self.didReceiveData = didReceiveData - self.completion = completion - } - } -} - -// MARK: - DataLoaderObserving - -/// An event send by the data loader. -enum DataTaskEvent { - case resumed - case receivedResponse(response: URLResponse) - case receivedData(data: Data) - case completed(error: Error?) -} - -/// Allows you to tap into internal events of the data loader. Events are -/// delivered on the internal serial operation queue. -protocol DataLoaderObserving { - func dataLoader(_ loader: DataLoader, urlSession: URLSession, dataTask: URLSessionDataTask, didReceiveEvent event: DataTaskEvent) -} - -protocol _DataLoaderObserving: AnyObject { - func dataTask(_ dataTask: URLSessionDataTask, didReceiveEvent event: DataTaskEvent) -} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImageCache.swift b/Sources/StreamChatUI/StreamNuke/Core/ImageCache.swift deleted file mode 100644 index 7efe0051842..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/ImageCache.swift +++ /dev/null @@ -1,351 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation -#if !os(macOS) -import UIKit -#else -import Cocoa -#endif - -/// In-memory image cache. -/// -/// The implementation must be thread safe. -protocol ImageCaching: AnyObject { - /// Access the image cached for the given request. - subscript(key: ImageCacheKey) -> ImageContainer? { get set } - - /// Removes all caches items. - func removeAll() -} - -/// An opaque container that acts as a cache key. -/// -/// In general, you don't construct it directly, and use `ImagePipeline` or `ImagePipeline.Cache` APIs. -struct ImageCacheKey: Hashable { - let key: Inner - - // This is faster than using AnyHashable (and it shows in performance tests). - enum Inner: Hashable { - case custom(String) - case `default`(CacheKey) - } - - init(key: String) { - self.key = .custom(key) - } - - init(request: ImageRequest) { - self.key = .default(request.makeImageCacheKey()) - } -} - -extension ImageCaching { - /// A convenience API for getting an image for the given request. - /// - /// - warning: If you provide a custom key using `ImagePipelineDelegate`, use - /// `ImagePipeline.Cache` instead. - subscript(request: ImageRequestConvertible) -> ImageContainer? { - get { self[ImageCacheKey(request: request.asImageRequest())] } - set { self[ImageCacheKey(request: request.asImageRequest())] = newValue } - } -} - -/// An LRU memory cache. -/// -/// The elements stored in cache are automatically discarded if either *cost* or -/// *count* limit is reached. The default cost limit represents a number of bytes -/// and is calculated based on the amount of physical memory available on the -/// device. The default count limit is set to `Int.max`. -/// -/// `ImageCache` automatically removes all stored elements when it receives a -/// memory warning. It also automatically removes *most* stored elements -/// when the app enters the background. -final class ImageCache: ImageCaching { - private let impl: NukeCache - - /// The maximum total cost that the cache can hold. - var costLimit: Int { - get { impl.costLimit } - set { impl.costLimit = newValue } - } - - /// The maximum number of items that the cache can hold. - var countLimit: Int { - get { impl.countLimit } - set { impl.countLimit = newValue } - } - - /// Default TTL (time to live) for each entry. Can be used to make sure that - /// the entries get validated at some point. `0` (never expire) by default. - var ttl: TimeInterval { - get { impl.ttl } - set { impl.ttl = newValue } - } - - /// The total cost of items in the cache. - var totalCost: Int { - return impl.totalCost - } - - /// The maximum cost of an entry in proportion to the `costLimit`. - /// By default, `0.1`. - var entryCostLimit: Double = 0.1 - - /// The total number of items in the cache. - var totalCount: Int { - return impl.totalCount - } - - /// Shared `Cache` instance. - static let shared = ImageCache() - - deinit { - #if TRACK_ALLOCATIONS - Allocations.decrement("ImageCache") - #endif - } - - /// Initializes `Cache`. - /// - parameter costLimit: Default value representes a number of bytes and is - /// calculated based on the amount of the phisical memory available on the device. - /// - parameter countLimit: `Int.max` by default. - init(costLimit: Int = ImageCache.defaultCostLimit(), countLimit: Int = Int.max) { - impl = NukeCache(costLimit: costLimit, countLimit: countLimit) - - #if TRACK_ALLOCATIONS - Allocations.increment("ImageCache") - #endif - } - - /// Returns a recommended cost limit which is computed based on the amount - /// of the phisical memory available on the device. - static func defaultCostLimit() -> Int { - let physicalMemory = ProcessInfo.processInfo.physicalMemory - let ratio = physicalMemory <= (536_870_912 /* 512 Mb */) ? 0.1 : 0.2 - let limit = physicalMemory / UInt64(1 / ratio) - return limit > UInt64(Int.max) ? Int.max : Int(limit) - } - - subscript(key: ImageCacheKey) -> ImageContainer? { - get { - return impl.value(forKey: key) - } - set { - if let image = newValue { - let cost = self.cost(for: image) - // Take care of overflow or cache size big enough to fit any - // resonable content (and also of costLimit = Int.max). - let sanitizedEntryLimit = max(0, min(entryCostLimit, 1)) - if costLimit > 2147483647 || cost < Int(sanitizedEntryLimit * Double(costLimit)) { - impl.set(image, forKey: key, cost: cost) - } - } else { - impl.removeValue(forKey: key) - } - } - } - - /// Removes all cached images. - func removeAll() { - impl.removeAll() - } - /// Removes least recently used items from the cache until the total cost - /// of the remaining items is less than the given cost limit. - func trim(toCost limit: Int) { - impl.trim(toCost: limit) - } - - /// Removes least recently used items from the cache until the total count - /// of the remaining items is less than the given count limit. - func trim(toCount limit: Int) { - impl.trim(toCount: limit) - } - - /// Returns cost for the given image by approximating its bitmap size in bytes in memory. - func cost(for container: ImageContainer) -> Int { - let dataCost: Int - if ImagePipeline.Configuration._isAnimatedImageDataEnabled { - dataCost = container.image._animatedImageData?.count ?? 0 - } else { - dataCost = container.data?.count ?? 0 - } - - // bytesPerRow * height gives a rough estimation of how much memory - // image uses in bytes. In practice this algorithm combined with a - // conservative default cost limit works OK. - guard let cgImage = container.image.cgImage else { - return 1 + dataCost - } - return cgImage.bytesPerRow * cgImage.height + dataCost - } -} - -final class NukeCache { - // Can't use `NSCache` because it is not LRU - - private var map = [Key: LinkedList.Node]() - private let list = LinkedList() - private let lock = NSLock() - private let memoryPressure: DispatchSourceMemoryPressure - - var costLimit: Int { - didSet { lock.sync(_trim) } - } - - var countLimit: Int { - didSet { lock.sync(_trim) } - } - - private(set) var totalCost = 0 - var ttl: TimeInterval = 0 - - var totalCount: Int { - map.count - } - - init(costLimit: Int, countLimit: Int) { - self.costLimit = costLimit - self.countLimit = countLimit - self.memoryPressure = DispatchSource.makeMemoryPressureSource(eventMask: [.warning, .critical], queue: .main) - self.memoryPressure.setEventHandler { [weak self] in - self?.removeAll() - } - self.memoryPressure.resume() - - #if os(iOS) || os(tvOS) - let center = NotificationCenter.default - center.addObserver(self, selector: #selector(didEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil) - #endif - - #if TRACK_ALLOCATIONS - Allocations.increment("Cache") - #endif - } - - deinit { - memoryPressure.cancel() - - #if TRACK_ALLOCATIONS - Allocations.decrement("Cache") - #endif - } - - func value(forKey key: Key) -> Value? { - lock.lock(); defer { lock.unlock() } - - guard let node = map[key] else { - return nil - } - - guard !node.value.isExpired else { - _remove(node: node) - return nil - } - - // bubble node up to make it last added (most recently used) - list.remove(node) - list.append(node) - - return node.value.value - } - - func set(_ value: Value, forKey key: Key, cost: Int = 0, ttl: TimeInterval? = nil) { - lock.lock(); defer { lock.unlock() } - - let ttl = ttl ?? self.ttl - let expiration = ttl == 0 ? nil : (Date() + ttl) - let entry = Entry(value: value, key: key, cost: cost, expiration: expiration) - _add(entry) - _trim() // _trim is extremely fast, it's OK to call it each time - } - - @discardableResult - func removeValue(forKey key: Key) -> Value? { - lock.lock(); defer { lock.unlock() } - - guard let node = map[key] else { - return nil - } - _remove(node: node) - return node.value.value - } - - private func _add(_ element: Entry) { - if let existingNode = map[element.key] { - _remove(node: existingNode) - } - map[element.key] = list.append(element) - totalCost += element.cost - } - - private func _remove(node: LinkedList.Node) { - list.remove(node) - map[node.value.key] = nil - totalCost -= node.value.cost - } - - @objc - dynamic func removeAll() { - lock.sync { - map.removeAll() - list.removeAll() - totalCost = 0 - } - } - - private func _trim() { - _trim(toCost: costLimit) - _trim(toCount: countLimit) - } - - @objc - private dynamic func didEnterBackground() { - // Remove most of the stored items when entering background. - // This behavior is similar to `NSCache` (which removes all - // items). This feature is not documented and may be subject - // to change in future Nuke versions. - lock.sync { - _trim(toCost: Int(Double(costLimit) * 0.1)) - _trim(toCount: Int(Double(countLimit) * 0.1)) - } - } - - func trim(toCost limit: Int) { - lock.sync { _trim(toCost: limit) } - } - - private func _trim(toCost limit: Int) { - _trim(while: { totalCost > limit }) - } - - func trim(toCount limit: Int) { - lock.sync { _trim(toCount: limit) } - } - - private func _trim(toCount limit: Int) { - _trim(while: { totalCount > limit }) - } - - private func _trim(while condition: () -> Bool) { - while condition(), let node = list.first { // least recently used - _remove(node: node) - } - } - - private struct Entry { - let value: Value - let key: Key - let cost: Int - let expiration: Date? - var isExpired: Bool { - guard let expiration = expiration else { - return false - } - return expiration.timeIntervalSinceNow < 0 - } - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImageDecoding.swift b/Sources/StreamChatUI/StreamNuke/Core/ImageDecoding.swift deleted file mode 100644 index ccc5a5b1d2b..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/ImageDecoding.swift +++ /dev/null @@ -1,471 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -#if !os(macOS) -import UIKit -#else -import Cocoa -#endif - -#if os(watchOS) -import WatchKit -#endif - -// MARK: - ImageDecoding - -/// An image decoder. -/// -/// A decoder is a one-shot object created for a single image decoding session. -/// -/// - note: If you need additional information in the decoder, you can pass -/// anything that you might need from the `ImageDecodingContext`. -protocol ImageDecoding { - /// Return `true` if you want the decoding to be performed on the decoding - /// queue (see `imageDecodingQueue`). If `false`, the decoding will be - /// performed synchronously on the pipeline operation queue. By default, `true`. - var isAsynchronous: Bool { get } - - /// Produces an image from the given image data. - func decode(_ data: Data) -> ImageContainer? - - /// Produces an image from the given partially dowloaded image data. - /// This method might be called multiple times during a single decoding - /// session. When the image download is complete, `decode(data:)` method is called. - /// - /// - returns: nil by default. - func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? -} - -extension ImageDecoding { - /// Returns `true` by default. - var isAsynchronous: Bool { - true - } - - /// The default implementation which simply returns `nil` (no progressive - /// decoding available). - func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { - nil - } -} - -extension ImageDecoding { - func decode(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool, cacheType: ImageResponse.CacheType?) -> ImageResponse? { - func _decode() -> ImageContainer? { - if isCompleted { - return decode(data) - } else { - return decodePartiallyDownloadedData(data) - } - } - guard let container = autoreleasepool(invoking: _decode) else { - return nil - } - #if !os(macOS) - ImageDecompression.setDecompressionNeeded(true, for: container.image) - #endif - return ImageResponse(container: container, urlResponse: urlResponse, cacheType: cacheType) - } -} - -// MARK: - ImageDecoders - -/// A namespace with all available decoders. -enum ImageDecoders {} - -// MARK: - ImageDecoders.Default - -extension ImageDecoders { - - /// A decoder that supports all of the formats natively supported by the system. - /// - /// - note: The decoder automatically sets the scale of the decoded images to - /// match the scale of the screen. - /// - /// - note: The default decoder supports progressive JPEG. It produces a new - /// preview every time it encounters a new full frame. - final class Default: ImageDecoding, ImageDecoderRegistering { - // Number of scans that the decoder has found so far. The last scan might be - // incomplete at this point. - var numberOfScans: Int { scanner.numberOfScans } - private var scanner = ProgressiveJPEGScanner() - - private var container: ImageContainer? - - private var isDecodingGIFProgressively = false - private var isPreviewForGIFGenerated = false - private var scale: CGFloat? - - init() { } - - var isAsynchronous: Bool { - false - } - - init?(data: Data, context: ImageDecodingContext) { - let scale = context.request.ref.userInfo?[.scaleKey] - self.scale = (scale as? NSNumber).map { CGFloat($0.floatValue) } - guard let container = _decode(data) else { - return nil - } - self.container = container - } - - init?(partiallyDownloadedData data: Data, context: ImageDecodingContext) { - let imageType = ImageType(data) - - self.scale = context.request.ref.userInfo?[.scaleKey] as? CGFloat - - // Determined whether the image supports progressive decoding or not - // (only proressive JPEG is allowed for now, but you can add support - // for other formats by implementing your own decoder). - if imageType == .jpeg, ImageProperties.JPEG(data)?.isProgressive == true { - return - } - - // Generate one preview for GIF. - if imageType == .gif { - self.isDecodingGIFProgressively = true - return - } - - return nil - } - - func decode(_ data: Data) -> ImageContainer? { - container ?? _decode(data) - } - - private func _decode(_ data: Data) -> ImageContainer? { - guard let image = ImageDecoders.Default._decode(data, scale: scale) else { - return nil - } - // Keep original data around in case of GIF - let type = ImageType(data) - if ImagePipeline.Configuration._isAnimatedImageDataEnabled, type == .gif { - image._animatedImageData = data - } - var container = ImageContainer(image: image, data: image._animatedImageData) - container.type = type - if type == .gif { - container.data = data - } - if numberOfScans > 0 { - container.userInfo[.scanNumberKey] = numberOfScans - } - return container - } - - func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { - if isDecodingGIFProgressively { // Special handling for GIF - if !isPreviewForGIFGenerated, let image = ImageDecoders.Default._decode(data, scale: scale) { - isPreviewForGIFGenerated = true - return ImageContainer(image: image, type: .gif, isPreview: true, data: nil, userInfo: [:]) - } - return nil - } - - guard let endOfScan = scanner.scan(data), endOfScan > 0 else { - return nil - } - guard let image = ImageDecoders.Default._decode(data[0...endOfScan], scale: scale) else { - return nil - } - return ImageContainer(image: image, type: .jpeg, isPreview: true, userInfo: [.scanNumberKey: numberOfScans]) - } - } -} - -private struct ProgressiveJPEGScanner { - // Number of scans that the decoder has found so far. The last scan might be - // incomplete at this point. - private(set) var numberOfScans = 0 - private var lastStartOfScan: Int = 0 // Index of the last found Start of Scan - private var scannedIndex: Int = -1 // Index at which previous scan was finished - - /// Scans the given data. If finds new scans, returns the last index of the - /// last available scan. - mutating func scan(_ data: Data) -> Int? { - // Check if there is more data to scan. - guard (scannedIndex + 1) < data.count else { - return nil - } - - // Start scaning from the where it left off previous time. - var index = (scannedIndex + 1) - var numberOfScans = self.numberOfScans - while index < (data.count - 1) { - scannedIndex = index - // 0xFF, 0xDA - Start Of Scan - if data[index] == 0xFF, data[index + 1] == 0xDA { - lastStartOfScan = index - numberOfScans += 1 - } - index += 1 - } - - // Found more scans this the previous time - guard numberOfScans > self.numberOfScans else { - return nil - } - self.numberOfScans = numberOfScans - - // `> 1` checks that we've received a first scan (SOS) and then received - // and also received a second scan (SOS). This way we know that we have - // at least one full scan available. - guard numberOfScans > 1 && lastStartOfScan > 0 else { - return nil - } - - return lastStartOfScan - 1 - } -} - -extension ImageDecoders.Default { - static func _decode(_ data: Data, scale: CGFloat?) -> PlatformImage? { - #if os(macOS) - return NSImage(data: data) - #else - return UIImage(data: data, scale: scale ?? Screen.scale) - #endif - } -} - -// MARK: - ImageDecoders.Empty - -extension ImageDecoders { - /// A decoder that returns an empty placeholder image and attaches image - /// data to the image container. - struct Empty: ImageDecoding { - let isProgressive: Bool - private let imageType: ImageType? - - var isAsynchronous: Bool { - false - } - - /// Initializes the decoder. - /// - /// - Parameters: - /// - type: Image type to be associated with an image container. - /// `nil` by defalt. - /// - isProgressive: If `false`, returns nil for every progressive - /// scan. `false` by default. - init(imageType: ImageType? = nil, isProgressive: Bool = false) { - self.imageType = imageType - self.isProgressive = isProgressive - } - - func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { - isProgressive ? ImageContainer(image: PlatformImage(), type: imageType, data: data, userInfo: [:]) : nil - } - - func decode(_ data: Data) -> ImageContainer? { - ImageContainer(image: PlatformImage(), type: imageType, data: data, userInfo: [:]) - } - } -} - -// MARK: - ImageDecoderRegistering - -/// An image decoder which supports automatically registering in the decoder register. -protocol ImageDecoderRegistering: ImageDecoding { - /// Returns non-nil if the decoder can be used to decode the given data. - /// - /// - parameter data: The same data is going to be delivered to decoder via - /// `decode(_:)` method. The same instance of the decoder is going to be used. - init?(data: Data, context: ImageDecodingContext) - - /// Returns non-nil if the decoder can be used to progressively decode the - /// given partially downloaded data. - /// - /// - parameter data: The first and the next data chunks are going to be - /// delivered to the decoder via `decodePartiallyDownloadedData(_:)` method. - init?(partiallyDownloadedData data: Data, context: ImageDecodingContext) -} - -extension ImageDecoderRegistering { - /// The default implementation which simply returns `nil` (no progressive - /// decoding available). - init?(partiallyDownloadedData data: Data, context: ImageDecodingContext) { - return nil - } -} - -// MARK: - ImageDecoderRegistry - -/// A registry of image codecs. -final class ImageDecoderRegistry { - /// A shared registry. - static let shared = ImageDecoderRegistry() - - private struct Match { - let closure: (ImageDecodingContext) -> ImageDecoding? - } - - private var matches = [Match]() - - init() { - self.register(ImageDecoders.Default.self) - } - - /// Returns a decoder which matches the given context. - func decoder(for context: ImageDecodingContext) -> ImageDecoding? { - for match in matches { - if let decoder = match.closure(context) { - return decoder - } - } - return nil - } - - // MARK: - Registering - - /// Registers the given decoder. - func register(_ decoder: Decoder.Type) { - register { context in - if context.isCompleted { - return decoder.init(data: context.data, context: context) - } else { - return decoder.init(partiallyDownloadedData: context.data, context: context) - } - } - } - - /// Registers a decoder to be used in a given decoding context. The closure - /// is going to be executed before all other already registered closures. - func register(_ match: @escaping (ImageDecodingContext) -> ImageDecoding?) { - matches.insert(Match(closure: match), at: 0) - } - - /// Removes all registered decoders. - func clear() { - matches = [] - } -} - -/// Image decoding context used when selecting which decoder to use. -struct ImageDecodingContext { - let request: ImageRequest - let data: Data - /// Returns `true` if the download was completed. - let isCompleted: Bool - let urlResponse: URLResponse? - - init(request: ImageRequest, data: Data, isCompleted: Bool, urlResponse: URLResponse?) { - self.request = request - self.data = data - self.isCompleted = isCompleted - self.urlResponse = urlResponse - } -} - -// MARK: - ImageType - -/// A uniform type identifier (UTI). -struct ImageType: ExpressibleByStringLiteral, Hashable { - let rawValue: String - - init(rawValue: String) { - self.rawValue = rawValue - } - - init(stringLiteral value: String) { - self.rawValue = value - } - - static let png: ImageType = "public.png" - static let jpeg: ImageType = "public.jpeg" - static let gif: ImageType = "com.compuserve.gif" - /// HEIF (High Efficiency Image Format) by Apple. - static let heic: ImageType = "public.heic" - - /// WebP - /// - /// Native decoding support only available on the following platforms: macOS 11, - /// iOS 14, watchOS 7, tvOS 14. - static let webp: ImageType = "public.webp" -} - -extension ImageType { - /// Determines a type of the image based on the given data. - init?(_ data: Data) { - guard let type = ImageType.make(data) else { - return nil - } - self = type - } - - private static func make(_ data: Data) -> ImageType? { - func _match(_ numbers: [UInt8?]) -> Bool { - guard data.count >= numbers.count else { - return false - } - return zip(numbers.indices, numbers).allSatisfy { index, number in - guard let number = number else { return true } - return data[index] == number - } - } - - // JPEG magic numbers https://en.wikipedia.org/wiki/JPEG - if _match([0xFF, 0xD8, 0xFF]) { return .jpeg } - - // PNG Magic numbers https://en.wikipedia.org/wiki/Portable_Network_Graphics - if _match([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { return .png } - - // GIF magic numbers https://en.wikipedia.org/wiki/GIF - if _match([0x47, 0x49, 0x46]) { return .gif } - - // WebP magic numbers https://en.wikipedia.org/wiki/List_of_file_signatures - if _match([0x52, 0x49, 0x46, 0x46, nil, nil, nil, nil, 0x57, 0x45, 0x42, 0x50]) { return .webp } - - // Either not enough data, or we just don't support this format. - return nil - } -} - -// MARK: - ImageProperties - -enum ImageProperties {} - -// Keeping this private for now, not sure neither about the API, not the implementation. -extension ImageProperties { - struct JPEG { - var isProgressive: Bool - - init?(_ data: Data) { - guard let isProgressive = ImageProperties.JPEG.isProgressive(data) else { - return nil - } - self.isProgressive = isProgressive - } - - private static func isProgressive(_ data: Data) -> Bool? { - var index = 3 // start scanning right after magic numbers - while index < (data.count - 1) { - // A example of first few bytes of progressive jpeg image: - // FF D8 FF E0 00 10 4A 46 49 46 00 01 01 00 00 48 00 ... - // - // 0xFF, 0xC0 - Start Of Frame (baseline DCT) - // 0xFF, 0xC2 - Start Of Frame (progressive DCT) - // https://en.wikipedia.org/wiki/JPEG - // - // As an alternative, Image I/O provides facilities to parse - // JPEG metadata via CGImageSourceCopyPropertiesAtIndex. It is a - // bit too convoluted to use and most likely slightly less - // efficient that checking this one special bit directly. - if data[index] == 0xFF { - if data[index + 1] == 0xC2 { - return true - } - if data[index + 1] == 0xC0 { - return false // baseline - } - } - index += 1 - } - return nil - } - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImageEncoding.swift b/Sources/StreamChatUI/StreamNuke/Core/ImageEncoding.swift deleted file mode 100644 index f1734c7f7f7..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/ImageEncoding.swift +++ /dev/null @@ -1,139 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -#if !os(macOS) -import UIKit -#else -import Cocoa -#endif - -#if os(watchOS) -import WatchKit -#endif - -import ImageIO - -// MARK: - ImageEncoding - -/// An image encoder. -protocol ImageEncoding { - /// Encodes the given image. - func encode(_ image: PlatformImage) -> Data? - - /// An optional method which encodes the given image container. - func encode(_ container: ImageContainer, context: ImageEncodingContext) -> Data? -} - -extension ImageEncoding { - func encode(_ container: ImageContainer, context: ImageEncodingContext) -> Data? { - self.encode(container.image) - } -} - -// MARK: - ImageEncoder - -/// Image encoding context used when selecting which encoder to use. -struct ImageEncodingContext { - let request: ImageRequest - let image: PlatformImage - let urlResponse: URLResponse? -} - -// MARK: - ImageEncoders - -/// A namespace with all available encoders. -enum ImageEncoders {} - -// MARK: - ImageEncoders.Default - -extension ImageEncoders { - /// A default adaptive encoder which uses best encoder available depending - /// on the input image and its configuration. - struct Default: ImageEncoding { - var compressionQuality: Float - - /// Set to `true` to switch to HEIF when it is available on the current hardware. - /// `false` by default. - var isHEIFPreferred = false - - init(compressionQuality: Float = 0.8) { - self.compressionQuality = compressionQuality - } - - func encode(_ image: PlatformImage) -> Data? { - guard let cgImage = image.cgImage else { - return nil - } - let type: ImageType - if cgImage.isOpaque { - if isHEIFPreferred && ImageEncoders.ImageIO.isSupported(type: .heic) { - type = .heic - } else { - type = .jpeg - } - } else { - type = .png - } - let encoder = ImageEncoders.ImageIO(type: type, compressionRatio: compressionQuality) - return encoder.encode(image) - } - } -} - -// MARK: - ImageEncoders.ImageIO - -extension ImageEncoders { - /// An Image I/O based encoder. - /// - /// Image I/O is a system framework that allows applications to read and - /// write most image file formats. This framework offers high efficiency, - /// color management, and access to image metadata. - struct ImageIO: ImageEncoding { - let type: ImageType - let compressionRatio: Float - - /// - parameter format: The output format. Make sure that the format is - /// supported on the current hardware.s - /// - parameter compressionRatio: 0.8 by default. - init(type: ImageType, compressionRatio: Float = 0.8) { - self.type = type - self.compressionRatio = compressionRatio - } - - private static let lock = NSLock() - private static var availability = [ImageType: Bool]() - - /// Retuns `true` if the encoding is available for the given format on - /// the current hardware. Some of the most recent formats might not be - /// available so its best to check before using them. - static func isSupported(type: ImageType) -> Bool { - lock.lock() - defer { lock.unlock() } - if let isAvailable = availability[type] { - return isAvailable - } - let isAvailable = CGImageDestinationCreateWithData( - NSMutableData() as CFMutableData, type.rawValue as CFString, 1, nil - ) != nil - availability[type] = isAvailable - return isAvailable - } - - func encode(_ image: PlatformImage) -> Data? { - let data = NSMutableData() - let options: NSDictionary = [ - kCGImageDestinationLossyCompressionQuality: compressionRatio - ] - guard let source = image.cgImage, - let destination = CGImageDestinationCreateWithData( - data as CFMutableData, type.rawValue as CFString, 1, nil - ) else { - return nil - } - CGImageDestinationAddImage(destination, source, options) - CGImageDestinationFinalize(destination) - return data as Data - } - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImagePipeline.swift b/Sources/StreamChatUI/StreamNuke/Core/ImagePipeline.swift deleted file mode 100644 index 97f7a8f6cc9..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/ImagePipeline.swift +++ /dev/null @@ -1,428 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// `ImagePipeline` is the primary way to load images directly (without a UI). -/// -/// The pipeline is fully customizable. You can change its configuration using -/// `ImagePipeline.Configuration` type: set custom data loader and cache, configure -/// image encoders and decoders, etc. You can also set an `ImagePipelineDelegate` -/// to get even more granular control on a per-request basis. -/// -/// See ["Image Pipeline"](https://kean.blog/nuke/guides/image-pipeline) to learn -/// more about how to use the pipeline. You can also learn about they way it -/// works internally in a [dedicated guide](https://kean.blog/nuke/guides/image-pipeline-guide). -/// -/// `ImagePipeline` also suppors Combine. You can learn more in a dedicated -/// [guide](https://kean.blog/nuke/guides/combine) with some common use-cases. -/// -/// `ImagePipeline` is fully thread-safe. -final class ImagePipeline { - /// Shared image pipeline. - static var shared = ImagePipeline(configuration: .withURLCache) - - /// The pipeline configuration. - let configuration: Configuration - - /// Provides access to the underlying caching subsystems. - var cache: ImagePipeline.Cache { ImagePipeline.Cache(pipeline: self) } - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use ImagePipelineDelegate") - var observer: ImagePipelineObserving? - - let delegate: ImagePipelineDelegate // swiftlint:disable:this all - private(set) var imageCache: ImageCache? - - private var tasks = [ImageTask: TaskSubscription]() - - private let tasksLoadData: TaskPool - private let tasksLoadImage: TaskPool - private let tasksFetchDecodedImage: TaskPool - private let tasksFetchOriginalImageData: TaskPool - private let tasksFetchWithPublisher: TaskPool - private let tasksProcessImage: TaskPool - - // The queue on which the entire subsystem is synchronized. - let queue = DispatchQueue(label: "com.github.kean.Nuke.ImagePipeline", qos: .userInitiated) - private var isInvalidated = false - - private var nextTaskId: Int64 { OSAtomicIncrement64(_nextTaskId) } - private let _nextTaskId: UnsafeMutablePointer - - let rateLimiter: RateLimiter? - let id = UUID() - - deinit { - _nextTaskId.deallocate() - - ResumableDataStorage.shared.unregister(self) - #if TRACK_ALLOCATIONS - Allocations.decrement("ImagePipeline") - #endif - } - - /// Initializes `ImagePipeline` instance with the given configuration. - /// - /// - parameter configuration: `Configuration()` by default. - /// - parameter delegate: `nil` by default. - init(configuration: Configuration = Configuration(), delegate: ImagePipelineDelegate? = nil) { - self.configuration = configuration - self.rateLimiter = configuration.isRateLimiterEnabled ? RateLimiter(queue: queue) : nil - self.delegate = delegate ?? ImagePipelineDefaultDelegate() - - let isCoalescingEnabled = configuration.isTaskCoalescingEnabled - self.tasksLoadData = TaskPool(isCoalescingEnabled) - self.tasksLoadImage = TaskPool(isCoalescingEnabled) - self.tasksFetchDecodedImage = TaskPool(isCoalescingEnabled) - self.tasksFetchOriginalImageData = TaskPool(isCoalescingEnabled) - self.tasksFetchWithPublisher = TaskPool(isCoalescingEnabled) - self.tasksProcessImage = TaskPool(isCoalescingEnabled) - - self._nextTaskId = UnsafeMutablePointer.allocate(capacity: 1) - self._nextTaskId.initialize(to: 0) - - if let imageCache = configuration.imageCache as? ImageCache { - self.imageCache = imageCache - } - - ResumableDataStorage.shared.register(self) - - #if TRACK_ALLOCATIONS - Allocations.increment("ImagePipeline") - #endif - } - - convenience init(delegate: ImagePipelineDelegate? = nil, _ configure: (inout ImagePipeline.Configuration) -> Void) { - var configuration = ImagePipeline.Configuration() - configure(&configuration) - self.init(configuration: configuration, delegate: delegate) - } - - /// Invalidates the pipeline and cancels all outstanding tasks. No new - /// requests can be started. - func invalidate() { - queue.async { - guard !self.isInvalidated else { return } - self.isInvalidated = true - self.tasks.keys.forEach(self.cancel) - } - } - - // MARK: - Loading Images - - /// Loads an image for the given request. - @discardableResult func loadImage( - with request: ImageRequestConvertible, - completion: @escaping (_ result: Result) -> Void - ) -> ImageTask { - loadImage(with: request, queue: nil, progress: nil, completion: completion) - } - - /// Loads an image for the given request. - /// - /// See [Nuke Docs](https://kean.blog/nuke/guides/image-pipeline) to learn more. - /// - /// - parameter request: An image request. - /// - parameter queue: A queue on which to execute `progress` and `completion` - /// callbacks. By default, the pipeline uses `.main` queue. - /// - parameter progress: A closure to be called periodically on the main thread - /// when the progress is updated. `nil` by default. - /// - parameter completion: A closure to be called on the main thread when the - /// request is finished. `nil` by default. - @discardableResult func loadImage( - with request: ImageRequestConvertible, - queue: DispatchQueue? = nil, - progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)?, - completion: @escaping ((_ result: Result) -> Void) - ) -> ImageTask { - loadImage(with: request.asImageRequest(), isConfined: false, queue: queue, progress: progress, completion: completion) - } - - func loadImage( - with request: ImageRequest, - isConfined: Bool, - queue: DispatchQueue?, - progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)?, - completion: ((_ result: Result) -> Void)? - ) -> ImageTask { - let request = configuration.inheritOptions(request) - let task = ImageTask(taskId: nextTaskId, request: request, isDataTask: false) - task.pipeline = self - if isConfined { - self.startImageTask(task, callbackQueue: queue, progress: progress, completion: completion) - } else { - self.queue.async { - self.startImageTask(task, callbackQueue: queue, progress: progress, completion: completion) - } - } - return task - } - - private func startImageTask( - _ task: ImageTask, - callbackQueue: DispatchQueue?, - progress progressHandler: ((ImageResponse?, Int64, Int64) -> Void)?, - completion: ((_ result: Result) -> Void)? - ) { - guard !isInvalidated else { return } - - self.send(.started, task) - - tasks[task] = makeTaskLoadImage(for: task.request) - .subscribe(priority: task._priority.taskPriority, subscriber: task) { [weak self, weak task] event in - guard let self = self, let task = task else { return } - - self.send(ImageTaskEvent(event), task) - - if event.isCompleted { - self.tasks[task] = nil - } - - self.dispatchCallback(to: callbackQueue) { - guard !task.isCancelled else { return } - - switch event { - case let .value(response, isCompleted): - if isCompleted { - completion?(.success(response)) - } else { - progressHandler?(response, task.completedUnitCount, task.totalUnitCount) - } - case let .progress(progress): - task.setProgress(progress) - progressHandler?(nil, progress.completed, progress.total) - case let .error(error): - completion?(.failure(error)) - } - } - } - } - - // MARK: - Loading Image Data - - /// Loads the image data for the given request. The data doesn't get decoded - /// or processed in any other way. - @discardableResult func loadData( - with request: ImageRequestConvertible, - completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void - ) -> ImageTask { - loadData(with: request, queue: nil, progress: nil, completion: completion) - } - - /// Loads the image data for the given request. The data doesn't get decoded - /// or processed in any other way. - /// - /// You can call `loadImage(:)` for the request at any point after calling - /// `loadData(:)`, the pipeline will use the same operation to load the data, - /// no duplicated work will be performed. - /// - /// - parameter request: An image request. - /// - parameter queue: A queue on which to execute `progress` and `completion` - /// callbacks. By default, the pipeline uses `.main` queue. - /// - parameter progress: A closure to be called periodically on the main thread - /// when the progress is updated. `nil` by default. - /// - parameter completion: A closure to be called on the main thread when the - /// request is finished. - @discardableResult func loadData( - with request: ImageRequestConvertible, - queue: DispatchQueue? = nil, - progress: ((_ completed: Int64, _ total: Int64) -> Void)?, - completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void - ) -> ImageTask { - loadData(with: request.asImageRequest(), isConfined: false, queue: queue, progress: progress, completion: completion) - } - - func loadData( - with request: ImageRequest, - isConfined: Bool, - queue callbackQueue: DispatchQueue?, - progress: ((_ completed: Int64, _ total: Int64) -> Void)?, - completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void - ) -> ImageTask { - let task = ImageTask(taskId: nextTaskId, request: request, isDataTask: true) - task.pipeline = self - if isConfined { - self.startDataTask(task, callbackQueue: callbackQueue, progress: progress, completion: completion) - } else { - self.queue.async { - self.startDataTask(task, callbackQueue: callbackQueue, progress: progress, completion: completion) - } - } - return task - } - - private func startDataTask( - _ task: ImageTask, - callbackQueue: DispatchQueue?, - progress progressHandler: ((_ completed: Int64, _ total: Int64) -> Void)?, - completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void - ) { - guard !isInvalidated else { return } - - tasks[task] = makeTaskLoadData(for: task.request) - .subscribe(priority: task._priority.taskPriority, subscriber: task) { [weak self, weak task] event in - guard let self = self, let task = task else { return } - - if event.isCompleted { - self.tasks[task] = nil - } - - self.dispatchCallback(to: callbackQueue) { - guard !task.isCancelled else { return } - - switch event { - case let .value(response, isCompleted): - if isCompleted { - completion(.success(response)) - } - case let .progress(progress): - task.setProgress(progress) - progressHandler?(progress.completed, progress.total) - case let .error(error): - completion(.failure(error)) - } - } - } - } - - // MARK: - Errors - - /// Represents all possible image pipeline errors. - enum Error: Swift.Error, CustomStringConvertible { - /// Data loader failed to load image data with a wrapped error. - case dataLoadingFailed(Swift.Error) - /// Decoder failed to produce a final image. - case decodingFailed - /// Processor failed to produce a final image. - case processingFailed(ImageProcessing) - - var description: String { - switch self { - case let .dataLoadingFailed(error): return "Failed to load image data: \(error)" - case .decodingFailed: return "Failed to create an image from the image data" - case .processingFailed(let processor): return "Failed to process the image using processor \(processor)" - } - } - - /// Returns underlying data loading error. - var dataLoadingError: Swift.Error? { - switch self { - case .dataLoadingFailed(let error): - return error - default: - return nil - } - } - } - - // MARK: - Image Task Events - - func imageTaskCancelCalled(_ task: ImageTask) { - queue.async { - self.cancel(task) - } - } - - private func cancel(_ task: ImageTask) { - guard let subscription = self.tasks.removeValue(forKey: task) else { return } - if !task.isDataTask { - self.send(.cancelled, task) - } - subscription.unsubscribe() - } - - func imageTaskUpdatePriorityCalled(_ task: ImageTask, priority: ImageRequest.Priority) { - queue.async { - task._priority = priority - guard let subscription = self.tasks[task] else { return } - if !task.isDataTask { - self.send(.priorityUpdated(priority: priority), task) - } - subscription.setPriority(priority.taskPriority) - } - } - - private func dispatchCallback(to callbackQueue: DispatchQueue?, _ closure: @escaping () -> Void) { - if callbackQueue === self.queue { - closure() - } else { - (callbackQueue ?? self.configuration.callbackQueue).async(execute: closure) - } - } - - // MARK: - Task Factory (Private) - - // When you request an image or image data, the pipeline creates a graph of tasks - // (some tasks are added to the graph on demand). - // - // `loadImage()` call is represented by TaskLoadImage: - // - // TaskLoadImage -> TaskFetchDecodedImage -> TaskFetchOriginalImageData - // -> TaskProcessImage - // - // `loadData()` call is represented by TaskLoadData: - // - // TaskLoadData -> TaskFetchOriginalImageData - // - // - // Each task represents a resource or a piece of work required to produce the - // final result. The pipeline reduces the amount of duplicated work by coalescing - // the tasks that represent the same work. For example, if you all `loadImage()` - // and `loadData()` with the same request, only on `TaskFetchOriginalImageData` - // is created. The work is split between tasks to minimize any duplicated work. - - func makeTaskLoadImage(for request: ImageRequest) -> Task.Publisher { - tasksLoadImage.publisherForKey(request.makeImageLoadKey()) { - TaskLoadImage(self, request) - } - } - - func makeTaskLoadData(for request: ImageRequest) -> Task<(Data, URLResponse?), Error>.Publisher { - tasksLoadData.publisherForKey(request.makeImageLoadKey()) { - TaskLoadData(self, request) - } - } - - func makeTaskProcessImage(key: ImageProcessingKey, process: @escaping () -> ImageResponse?) -> Task.Publisher { - tasksProcessImage.publisherForKey(key) { - OperationTask(self, configuration.imageProcessingQueue, process) - } - } - - func makeTaskFetchDecodedImage(for request: ImageRequest) -> Task.Publisher { - tasksFetchDecodedImage.publisherForKey(request.makeDataLoadKey()) { - TaskFetchDecodedImage(self, request) - } - } - - func makeTaskFetchOriginalImageData(for request: ImageRequest) -> Task<(Data, URLResponse?), Error>.Publisher { - tasksFetchOriginalImageData.publisherForKey(request.makeDataLoadKey()) { - request.publisher == nil ? - TaskFetchOriginalImageData(self, request) : - TaskFetchWithPublisher(self, request) - } - } -} - -// MARK: - Misc (Private) - -extension ImagePipeline: SendEventProtocol { - func send(_ event: ImageTaskEvent, _ task: ImageTask) { - delegate.pipeline(self, imageTask: task, didReceiveEvent: event) - (self as SendEventProtocol)._send(event, task) - } - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use ImagePipelineDelegate") - func _send(_ event: ImageTaskEvent, _ task: ImageTask) { - observer?.pipeline(self, imageTask: task, didReceiveEvent: event) - } -} - -// Just to workaround the deprecation warning. -private protocol SendEventProtocol { - func _send(_ event: ImageTaskEvent, _ task: ImageTask) -} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImagePipelineCache.swift b/Sources/StreamChatUI/StreamNuke/Core/ImagePipelineCache.swift deleted file mode 100644 index 4afb51b5a7d..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/ImagePipelineCache.swift +++ /dev/null @@ -1,261 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -extension ImagePipeline { - /// Provides a set of convenience APIs for managing the pipeline cache layers, - /// including `ImageCaching` (memory cache) and `DataCaching` (disk cache). - /// - /// - warning: This class doesn't work with a `URLCache`. For more info, - /// see ["Caching"](https://kean.blog/nuke/guides/caching). - /// - /// Thread-safe. - struct Cache { - let pipeline: ImagePipeline - private var configuration: ImagePipeline.Configuration { pipeline.configuration } - - // MARK: Subscript (Memory Cache) - - /// Returns an image from the memory cache for the given request. - subscript(request: ImageRequestConvertible) -> ImageContainer? { - get { self[request.asImageRequest()] } - nonmutating set { self[request.asImageRequest()] = newValue } - } - - subscript(request: ImageRequest) -> ImageContainer? { - get { - cachedImageFromMemoryCache(for: request) - } - nonmutating set { - if let image = newValue { - storeCachedImageInMemoryCache(image, for: request) - } else { - removeCachedImageFromMemoryCache(for: request) - } - } - } - - // MARK: Cached Images - - /// Returns a cached image any of the caches. - /// - /// - note: Respects request options such as `cachePolicy`. - /// - /// - parameter request: The request. Make sure to remove the processors - /// if you want to retrieve an original image (if it's stored). - /// - parameter caches: `[.all]`, by default. - func cachedImage(for request: ImageRequestConvertible, caches: Caches = [.all]) -> ImageContainer? { - let request = request.asImageRequest() - if caches.contains(.memory) { - if let image = cachedImageFromMemoryCache(for: request) { - return image - } - } - if caches.contains(.disk) { - if let data = cachedData(for: request), - let image = decodeImageData(data, for: request) { - return image - } - } - return nil - } - - /// Stores the image in all caches. To store image in the disk cache, it - /// will be encoded (see `ImageEncoding`) - /// - /// - note: Respects request cache options. - /// - /// - note: Default `DiskCache` stores data asynchronously, so it's safe - /// to call this method even from the main thread. - /// - /// - note: Image previews are not stored. - /// - /// - parameter request: The request. Make sure to remove the processors - /// if you want to retrieve an original image (if it's stored). - /// - parameter caches: `[.all]`, by default. - func storeCachedImage(_ image: ImageContainer, for request: ImageRequestConvertible, caches: Caches = [.all]) { - let request = request.asImageRequest() - if caches.contains(.memory) { - storeCachedImageInMemoryCache(image, for: request) - } - if caches.contains(.disk) { - if let data = encodeImage(image, for: request) { - storeCachedData(data, for: request) - } - } - } - - /// Removes the image from all caches. - func removeCachedImage(for request: ImageRequestConvertible, caches: Caches = [.all]) { - let request = request.asImageRequest() - if caches.contains(.memory) { - removeCachedImageFromMemoryCache(for: request) - } - if caches.contains(.disk) { - removeCachedData(for: request) - } - } - - /// Returns `true` if any of the caches contain the image. - func containsCachedImage(for request: ImageRequestConvertible, caches: Caches = [.all]) -> Bool { - let request = request.asImageRequest() - if caches.contains(.memory) && cachedImageFromMemoryCache(for: request) != nil { - return true - } - if caches.contains(.disk), let dataCache = dataCache(for: request) { - let key = makeDataCacheKey(for: request) - return dataCache.containsData(for: key) - } - return false - } - - private func cachedImageFromMemoryCache(for request: ImageRequest) -> ImageContainer? { - guard !request.options.contains(.disableMemoryCacheReads) else { - return nil - } - let key = makeImageCacheKey(for: request) - if let imageCache = pipeline.imageCache { - return imageCache[key] // Fast path for a default cache (no protocol call) - } - return configuration.imageCache?[key] - } - - private func storeCachedImageInMemoryCache(_ image: ImageContainer, for request: ImageRequest) { - guard !request.options.contains(.disableMemoryCacheWrites) else { - return - } - guard !image.isPreview || configuration.isStoringPreviewsInMemoryCache else { - return - } - let key = makeImageCacheKey(for: request) - configuration.imageCache?[key] = image - } - - private func removeCachedImageFromMemoryCache(for request: ImageRequest) { - let key = makeImageCacheKey(for: request) - configuration.imageCache?[key] = nil - } - - // MARK: Cached Data - - /// Returns cached data for the given request. - func cachedData(for request: ImageRequestConvertible) -> Data? { - let request = request.asImageRequest() - guard !request.options.contains(.disableDiskCacheReads) else { - return nil - } - guard let dataCache = dataCache(for: request) else { - return nil - } - let key = makeDataCacheKey(for: request) - return dataCache.cachedData(for: key) - } - - /// Stores data for the given request. - /// - /// - note: Default `DiskCache` stores data asynchronously, so it's safe - /// to call this method even from the main thread. - func storeCachedData(_ data: Data, for request: ImageRequestConvertible) { - let request = request.asImageRequest() - guard let dataCache = dataCache(for: request), - !request.options.contains(.disableDiskCacheWrites) else { - return - } - let key = makeDataCacheKey(for: request) - dataCache.storeData(data, for: key) - } - - /// Returns true if the data cache contains data for the given image - func containsData(for request: ImageRequestConvertible) -> Bool { - let request = request.asImageRequest() - guard let dataCache = dataCache(for: request) else { - return false - } - return dataCache.containsData(for: makeDataCacheKey(for: request)) - } - - /// Removes cached data for the given request. - func removeCachedData(for request: ImageRequestConvertible) { - let request = request.asImageRequest() - guard let dataCache = dataCache(for: request) else { - return - } - let key = makeDataCacheKey(for: request) - dataCache.removeData(for: key) - } - - // MARK: Keys - - /// Returns image cache (memory cache) key for the given request. - func makeImageCacheKey(for request: ImageRequestConvertible) -> ImageCacheKey { - makeImageCacheKey(for: request.asImageRequest()) - } - - func makeImageCacheKey(for request: ImageRequest) -> ImageCacheKey { - if let customKey = pipeline.delegate.cacheKey(for: request, pipeline: pipeline) { - return ImageCacheKey(key: customKey) - } - return ImageCacheKey(request: request) // Use the default key - } - - /// Returns data cache (disk cache) key for the given request. - func makeDataCacheKey(for request: ImageRequestConvertible) -> String { - makeDataCacheKey(for: request.asImageRequest()) - } - - func makeDataCacheKey(for request: ImageRequest) -> String { - if let customKey = pipeline.delegate.cacheKey(for: request, pipeline: pipeline) { - return customKey - } - return request.makeDataCacheKey() // Use the default key - } - - // MARK: Misc - - /// Removes both images and data from all cache layes. - func removeAll(caches: Caches = [.all]) { - if caches.contains(.memory) { - configuration.imageCache?.removeAll() - } - if caches.contains(.disk) { - configuration.dataCache?.removeAll() - } - } - - // MARK: Private - - private func decodeImageData(_ data: Data, for request: ImageRequest) -> ImageContainer? { - let context = ImageDecodingContext(request: request, data: data, isCompleted: true, urlResponse: nil) - guard let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) else { - return nil - } - return decoder.decode(data, urlResponse: nil, isCompleted: true, cacheType: .disk)?.container - } - - private func encodeImage(_ image: ImageContainer, for request: ImageRequest) -> Data? { - let context = ImageEncodingContext(request: request, image: image.image, urlResponse: nil) - let encoder = pipeline.delegate.imageEncoder(for: context, pipeline: pipeline) - return encoder.encode(image, context: context) - } - - private func dataCache(for request: ImageRequest) -> DataCaching? { - pipeline.delegate.dataCache(for: request, pipeline: pipeline) - } - - // MARK: Options - - /// Describes a set of cache layers to use. - struct Caches: OptionSet { - let rawValue: Int - init(rawValue: Int) { - self.rawValue = rawValue - } - - static let memory = Caches(rawValue: 1 << 0) - static let disk = Caches(rawValue: 1 << 1) - static let all: Caches = [.memory, .disk] - } - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImagePipelineConfiguration.swift b/Sources/StreamChatUI/StreamNuke/Core/ImagePipelineConfiguration.swift deleted file mode 100644 index 496e4aa598e..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/ImagePipelineConfiguration.swift +++ /dev/null @@ -1,242 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation -import os - -// MARK: - ImagePipeline.Configuration - -extension ImagePipeline { - /// The pipeline configuration. - struct Configuration { - // MARK: - Dependencies - - /// Image cache used by the pipeline. - var imageCache: ImageCaching? { - // This exists simply to ensure we don't init ImageCache.shared if the - // user provides their own instance. - get { isCustomImageCacheProvided ? customImageCache : ImageCache.shared } - set { - customImageCache = newValue - isCustomImageCacheProvided = true - } - } - private var customImageCache: ImageCaching? - - /// Data loader used by the pipeline. - var dataLoader: DataLoading - - /// Data cache used by the pipeline. - var dataCache: DataCaching? - - /// Default implementation uses shared `ImageDecoderRegistry` to create - /// a decoder that matches the context. - var makeImageDecoder: (ImageDecodingContext) -> ImageDecoding? = ImageDecoderRegistry.shared.decoder(for:) - - /// Returns `ImageEncoders.Default()` by default. - var makeImageEncoder: (ImageEncodingContext) -> ImageEncoding = { _ in - ImageEncoders.Default() - } - - // MARK: - Operation Queues - - /// Data loading queue. Default maximum concurrent task count is 6. - var dataLoadingQueue = OperationQueue(maxConcurrentCount: 6) - - /// Data caching queue. Default maximum concurrent task count is 2. - var dataCachingQueue = OperationQueue(maxConcurrentCount: 2) - - /// Image decoding queue. Default maximum concurrent task count is 1. - var imageDecodingQueue = OperationQueue(maxConcurrentCount: 1) - - /// Image encoding queue. Default maximum concurrent task count is 1. - var imageEncodingQueue = OperationQueue(maxConcurrentCount: 1) - - /// Image processing queue. Default maximum concurrent task count is 2. - var imageProcessingQueue = OperationQueue(maxConcurrentCount: 2) - - #if !os(macOS) - /// Image decompressing queue. Default maximum concurrent task count is 2. - var imageDecompressingQueue = OperationQueue(maxConcurrentCount: 2) - #endif - - // MARK: - Options - - /// A queue on which all callbacks, like `progress` and `completion` - /// callbacks are called. `.main` by default. - var callbackQueue = DispatchQueue.main - - #if !os(macOS) - /// Decompresses the loaded images. `true` by default. - /// - /// Decompressing compressed image formats (such as JPEG) can significantly - /// improve drawing performance as it allows a bitmap representation to be - /// created in a background rather than on the main thread. - var isDecompressionEnabled = true - #endif - - /// `.storeOriginalData` by default. - var dataCachePolicy = DataCachePolicy.storeOriginalData - - /// Determines what images are stored in the disk cache. - enum DataCachePolicy { - /// For requests with processors, encode and store processed images. - /// For requests with no processors, store original image data, unless - /// the resource is local (file:// or data:// scheme is used). - /// - /// - warning: With this policy, the pipeline `loadData()` method - /// will not store the images in the disk cache for requests with - /// any processors applied – this method only loads data and doesn't - /// decode images. - case automatic - - /// For all requests, only store the original image data, unless - /// the resource is local (file:// or data:// scheme is used). - case storeOriginalData - - /// For all requests, encode and store decoded images after all - /// processors are applied. - /// - /// - note: This is useful if you want to store images in a format - /// different than provided by a server, e.g. decompressed. In other - /// scenarios, consider using `.automatic` policy instead. - /// - /// - warning: With this policy, the pipeline `loadData()` method - /// will not store the images in the disk cache – this method only - /// loads data and doesn't decode images. - case storeEncodedImages - - /// For requests with processors, encode and store processed images. - /// For all requests, store original image data. - case storeAll - } - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use `dataCachePolicy` instead.") - var dataCacheOptions: DataCacheOptions = DataCacheOptions() { - didSet { - let items = dataCacheOptions.storedItems - if items == [.finalImage] { - dataCachePolicy = .storeEncodedImages - } else if items == [.originalImageData] { - dataCachePolicy = .storeOriginalData - } else if items == [.finalImage, .originalImageData] { - dataCachePolicy = .storeAll - } - } - } - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use `dataCachePolicy` instead. The recommended policy is the new `.automatic` policy.") - struct DataCacheOptions { - var storedItems: Set = [.originalImageData] - } - - // Deprecated in 10.0.0 - var _processors: [ImageProcessing] = [] - - /// `true` by default. If `true` the pipeline avoids duplicated work when - /// loading images. The work only gets cancelled when all the registered - /// requests are. The pipeline also automatically manages the priority of the - /// deduplicated work. - /// - /// Let's take these two requests for example: - /// - /// ```swift - /// let url = URL(string: "http://example.com/image") - /// pipeline.loadImage(with: ImageRequest(url: url, processors: [ - /// ImageProcessors.Resize(size: CGSize(width: 44, height: 44)), - /// ImageProcessors.GaussianBlur(radius: 8) - /// ])) - /// pipeline.loadImage(with: ImageRequest(url: url, processors: [ - /// ImageProcessors.Resize(size: CGSize(width: 44, height: 44)) - /// ])) - /// ``` - /// - /// Nuke will load the image data only once, resize the image once and - /// apply the blur also only once. There is no duplicated work done at - /// any stage. - var isTaskCoalescingEnabled = true - - /// `true` by default. If `true` the pipeline will rate limit requests - /// to prevent trashing of the underlying systems (e.g. `URLSession`). - /// The rate limiter only comes into play when the requests are started - /// and cancelled at a high rate (e.g. scrolling through a collection view). - var isRateLimiterEnabled = true - - /// `false` by default. If `true` the pipeline will try to produce a new - /// image each time it receives a new portion of data from data loader. - /// The decoder used by the image loading session determines whether - /// to produce a partial image or not. The default image decoder - /// (`ImageDecoder`) supports progressive JPEG decoding. - var isProgressiveDecodingEnabled = true - - /// `false` by default. If `true`, the pipeline will store all of the - /// progressively generated previews in the memory cache. All of the - /// previews have `isPreview` flag set to `true`. - var isStoringPreviewsInMemoryCache = true - - /// If the data task is terminated (either because of a failure or a - /// cancellation) and the image was partially loaded, the next load will - /// resume where it left off. Supports both validators (`ETag`, - /// `Last-Modified`). Resumable downloads are enabled by default. - var isResumableDataEnabled = true - - // MARK: - Options (Shared) - - /// If `true` pipeline will detect GIFs and set `animatedImageData` - /// (`UIImage` property). It will also disable processing of such images, - /// and alter the way cache cost is calculated. However, this will not - /// enable actual animated image rendering. To do that take a look at - /// satellite projects (FLAnimatedImage and Gifu plugins for Nuke). - /// `false` by default (to preserve resources). - static var _isAnimatedImageDataEnabled = false - - /// `false` by default. If `true`, enables `os_signpost` logging for - /// measuring performance. You can visually see all the performance - /// metrics in `os_signpost` Instrument. For more information see - /// https://developer.apple.com/documentation/os/logging and - /// https://developer.apple.com/videos/play/wwdc2018/405/. - static var isSignpostLoggingEnabled = false { - didSet { - nukeLog = isSignpostLoggingEnabled ? - OSLog(subsystem: "com.github.kean.Nuke.ImagePipeline", category: "Image Loading") : - .disabled - } - } - - private var isCustomImageCacheProvided = false - - var debugIsSyncImageEncoding = false - - // MARK: - Initializer - - /// Instantiates a default pipeline configuration. - /// - /// - parameter dataLoader: `DataLoader()` by default. - init(dataLoader: DataLoading = DataLoader()) { - self.dataLoader = dataLoader - } - - /// A configuration with a `DataLoader` with an HTTP disk cache (`URLCache`) - /// with a size limit of 150 MB. - static var withURLCache: Configuration { Configuration() } - - /// A configuration with an aggressive disk cache (`DataCache`) with a - /// size limit of 150 MB. An HTTP cache (`URLCache`) is disabled. - static var withDataCache: Configuration { - let dataLoader: DataLoader = { - let config = URLSessionConfiguration.default - config.urlCache = nil - return DataLoader(configuration: config) - }() - - var config = Configuration() - config.dataLoader = dataLoader - config.dataCache = try? DataCache(name: "com.github.kean.Nuke.DataCache") - - return config - } - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImagePipelineDelegate.swift b/Sources/StreamChatUI/StreamNuke/Core/ImagePipelineDelegate.swift deleted file mode 100644 index eaa6626abb8..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/ImagePipelineDelegate.swift +++ /dev/null @@ -1,115 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// A delegate that allows you to customize the pipleine on a per-request basis. -/// -/// - warning: The delegate methods are performed on the pipeline queue in the -/// background. -protocol ImagePipelineDelegate: AnyObject { - // MARK: Configuration - - /// Returns data loader for the given request. - func dataLoader(for request: ImageRequest, pipeline: ImagePipeline) -> DataLoading - - /// Retuns disk cache for the given request. Return `nil` to prevent cache - /// reads and writes. - func dataCache(for request: ImageRequest, pipeline: ImagePipeline) -> DataCaching? - - /// Returns image decoder for the given context. - func imageDecoder(for context: ImageDecodingContext, pipeline: ImagePipeline) -> ImageDecoding? - - /// Returns image encoder for the given context. - func imageEncoder(for context: ImageEncodingContext, pipeline: ImagePipeline) -> ImageEncoding - - // MARK: Caching - - /// Returns a cache key identifying the image produced for the given request - /// (including image processors). - /// - /// Return `nil` to use a default key. - func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? - - /// Gets called when the pipeline is about to save data for the given request. - /// The implementation must call the completion closure passing `non-nil` data - /// to enable caching or `nil` to prevent it. - /// - /// This method calls only if the request parameters and data caching policy - /// of the pipeline already allow caching. - /// - /// - parameter data: Either the original data or the encoded image in case - /// of storing a processed or re-encoded image. - /// - parameter image: Non-nil in case storing an encoded image. - /// - parameter request: The request for which image is being stored. - /// - parameter completion: The implementation must call the completion closure - /// passing `non-nil` data to enable caching or `nil` to prevent it. You can - /// safely call it synchronously. The callback gets called on the background - /// thread. - func willCache(data: Data, image: ImageContainer?, for request: ImageRequest, pipeline: ImagePipeline, completion: @escaping (Data?) -> Void) - - // MARK: Monitoring - - /// Delivers the events produced by the image tasks started via `loadImage` method. - func pipeline(_ pipeline: ImagePipeline, imageTask: ImageTask, didReceiveEvent event: ImageTaskEvent) -} - -extension ImagePipelineDelegate { - func dataLoader(for request: ImageRequest, pipeline: ImagePipeline) -> DataLoading { - pipeline.configuration.dataLoader - } - - func dataCache(for request: ImageRequest, pipeline: ImagePipeline) -> DataCaching? { - pipeline.configuration.dataCache - } - - func imageDecoder(for context: ImageDecodingContext, pipeline: ImagePipeline) -> ImageDecoding? { - pipeline.configuration.makeImageDecoder(context) - } - - func imageEncoder(for context: ImageEncodingContext, pipeline: ImagePipeline) -> ImageEncoding { - pipeline.configuration.makeImageEncoder(context) - } - - func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? { - nil - } - - func willCache(data: Data, image: ImageContainer?, for request: ImageRequest, pipeline: ImagePipeline, completion: @escaping (Data?) -> Void) { - completion(data) - } - - func pipeline(_ pipeline: ImagePipeline, imageTask: ImageTask, didReceiveEvent event: ImageTaskEvent) { - // Do nothing - } -} - -/// An image task event sent by the pipeline. -enum ImageTaskEvent { - case started - case cancelled - case priorityUpdated(priority: ImageRequest.Priority) - case intermediateResponseReceived(response: ImageResponse) - case progressUpdated(completedUnitCount: Int64, totalUnitCount: Int64) - case completed(result: Result) -} - -extension ImageTaskEvent { - init(_ event: Task.Event) { - switch event { - case let .error(error): - self = .completed(result: .failure(error)) - case let .value(response, isCompleted): - if isCompleted { - self = .completed(result: .success(response)) - } else { - self = .intermediateResponseReceived(response: response) - } - case let .progress(progress): - self = .progressUpdated(completedUnitCount: progress.completed, totalUnitCount: progress.total) - } - } -} - -final class ImagePipelineDefaultDelegate: ImagePipelineDelegate {} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImageProcessing.swift b/Sources/StreamChatUI/StreamNuke/Core/ImageProcessing.swift deleted file mode 100644 index 285af1c9dfd..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/ImageProcessing.swift +++ /dev/null @@ -1,838 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -#if os(iOS) || os(tvOS) || os(watchOS) -import UIKit -#endif - -#if os(watchOS) -import WatchKit -#endif - -#if os(macOS) -import Cocoa -#endif - -// MARK: - ImageProcessing - -/// Performs image processing. -/// -/// For basic processing needs, implement the following method: -/// -/// ``` -/// func process(image: PlatformImage) -> PlatformImage? -/// ``` -/// -/// If your processor needs to manipulate image metadata (`ImageContainer`), or -/// get access to more information via the context (`ImageProcessingContext`), -/// there is an additional method that allows you to do that: -/// -/// ``` -/// func process(image container: ImageContainer, context: ImageProcessingContext) -> ImageContainer? -/// ``` -/// -/// You must implement either one of those methods. -protocol ImageProcessing { - /// Returns a processed image. By default, returns `nil`. - /// - /// - note: Gets called a background queue managed by the pipeline. - func process(_ image: PlatformImage) -> PlatformImage? - - /// Optional method. Returns a processed image. By default, this calls the - /// basic `process(image:)` method. - /// - /// - note: Gets called a background queue managed by the pipeline. - func process(_ container: ImageContainer, context: ImageProcessingContext) -> ImageContainer? - - /// Returns a string that uniquely identifies the processor. - /// - /// Consider using the reverse DNS notation. - var identifier: String { get } - - /// Returns a unique processor identifier. - /// - /// The default implementation simply returns `var identifier: String` but - /// can be overridden as a performance optimization - creating and comparing - /// strings is _expensive_ so you can opt-in to return something which is - /// fast to create and to compare. See `ImageProcessors.Resize` for an example. - /// - /// - note: A common approach is to make your processor `Hashable` and return `self` - /// from `hashableIdentifier`. - var hashableIdentifier: AnyHashable { get } -} - -extension ImageProcessing { - /// The default implementation simply calls the basic - /// `process(_ image: PlatformImage) -> PlatformImage?` method. - func process(_ container: ImageContainer, context: ImageProcessingContext) -> ImageContainer? { - container.map(process) - } - - /// The default impleemntation simply returns `var identifier: String`. - var hashableIdentifier: AnyHashable { identifier } -} - -/// Image processing context used when selecting which processor to use. -struct ImageProcessingContext { - let request: ImageRequest - let response: ImageResponse - let isFinal: Bool - - init(request: ImageRequest, response: ImageResponse, isFinal: Bool) { - self.request = request - self.response = response - self.isFinal = isFinal - } -} - -// MARK: - ImageProcessors - -/// A namespace for all processors that implement `ImageProcessing` protocol. -enum ImageProcessors {} - -// MARK: - ImageProcessors.Resize - -extension ImageProcessors { - /// Scales an image to a specified size. - struct Resize: ImageProcessing, Hashable, CustomStringConvertible { - private let size: Size - private let contentMode: ContentMode - private let crop: Bool - private let upscale: Bool - - /// An option for how to resize the image. - enum ContentMode: CustomStringConvertible { - /// Scales the image so that it completely fills the target area. - /// Maintains the aspect ratio of the original image. - case aspectFill - - /// Scales the image so that it fits the target size. Maintains the - /// aspect ratio of the original image. - case aspectFit - - var description: String { - switch self { - case .aspectFill: return ".aspectFill" - case .aspectFit: return ".aspectFit" - } - } - } - - /// Initializes the processor with the given size. - /// - /// - parameter size: The target size. - /// - parameter unit: Unit of the target size, `.points` by default. - /// - parameter contentMode: `.aspectFill` by default. - /// - parameter crop: If `true` will crop the image to match the target size. - /// Does nothing with content mode .aspectFill. `false` by default. - /// - parameter upscale: `false` by default. - init(size: CGSize, unit: ImageProcessingOptions.Unit = .points, contentMode: ContentMode = .aspectFill, crop: Bool = false, upscale: Bool = false) { - self.size = Size(cgSize: CGSize(size: size, unit: unit)) - self.contentMode = contentMode - self.crop = crop - self.upscale = upscale - } - - /// Resizes the image to the given width preserving aspect ratio. - /// - /// - parameter unit: Unit of the target size, `.points` by default. - init(width: CGFloat, unit: ImageProcessingOptions.Unit = .points, upscale: Bool = false) { - self.init(size: CGSize(width: width, height: 9999), unit: unit, contentMode: .aspectFit, crop: false, upscale: upscale) - } - - /// Resizes the image to the given height preserving aspect ratio. - /// - /// - parameter unit: Unit of the target size, `.points` by default. - init(height: CGFloat, unit: ImageProcessingOptions.Unit = .points, upscale: Bool = false) { - self.init(size: CGSize(width: 9999, height: height), unit: unit, contentMode: .aspectFit, crop: false, upscale: upscale) - } - - func process(_ image: PlatformImage) -> PlatformImage? { - if crop && contentMode == .aspectFill { - return image.processed.byResizingAndCropping(to: size.cgSize) - } - return image.processed.byResizing(to: size.cgSize, contentMode: contentMode, upscale: upscale) - } - - var identifier: String { - "com.github.kean/nuke/resize?s=\(size.cgSize),cm=\(contentMode),crop=\(crop),upscale=\(upscale)" - } - - var hashableIdentifier: AnyHashable { self } - - var description: String { - "Resize(size: \(size.cgSize) pixels, contentMode: \(contentMode), crop: \(crop), upscale: \(upscale))" - } - } -} - -// MARK: - ImageProcessors.Circle - -extension ImageProcessors { - - /// Rounds the corners of an image into a circle. If the image is not a square, - /// crops it to a square first. - struct Circle: ImageProcessing, Hashable, CustomStringConvertible { - private let border: ImageProcessingOptions.Border? - - init(border: ImageProcessingOptions.Border? = nil) { - self.border = border - } - - func process(_ image: PlatformImage) -> PlatformImage? { - image.processed.byDrawingInCircle(border: border) - } - - var identifier: String { - let suffix = border.map { "?border=\($0)" } - return "com.github.kean/nuke/circle" + (suffix ?? "") - } - - var hashableIdentifier: AnyHashable { self } - - var description: String { - "Circle(border: \(border?.description ?? "nil"))" - } - } -} - -// MARK: - ImageProcessors.RoundedCorners - -extension ImageProcessors { - /// Rounds the corners of an image to the specified radius. - /// - /// - warning: In order for the corners to be displayed correctly, the image must exactly match the size - /// of the image view in which it will be displayed. See `ImageProcessor.Resize` for more info. - struct RoundedCorners: ImageProcessing, Hashable, CustomStringConvertible { - private let radius: CGFloat - private let border: ImageProcessingOptions.Border? - - /// Initializes the processor with the given radius. - /// - /// - parameter radius: The radius of the corners. - /// - parameter unit: Unit of the radius, `.points` by default. - /// - parameter border: An optional border drawn around the image. - init(radius: CGFloat, unit: ImageProcessingOptions.Unit = .points, border: ImageProcessingOptions.Border? = nil) { - self.radius = radius.converted(to: unit) - self.border = border - } - - func process(_ image: PlatformImage) -> PlatformImage? { - image.processed.byAddingRoundedCorners(radius: radius, border: border) - } - - var identifier: String { - let suffix = border.map { ",border=\($0)" } - return "com.github.kean/nuke/rounded_corners?radius=\(radius)" + (suffix ?? "") - } - - var hashableIdentifier: AnyHashable { self } - - var description: String { - "RoundedCorners(radius: \(radius) pixels, border: \(border?.description ?? "nil"))" - } - } -} - -#if os(iOS) || os(tvOS) || os(macOS) - -// MARK: - ImageProcessors.CoreImageFilter - -import CoreImage - -extension ImageProcessors { - - /// Applies Core Image filter (`CIFilter`) to the image. - /// - /// # Performance Considerations. - /// - /// Prefer chaining multiple `CIFilter` objects using `Core Image` facilities - /// instead of using multiple instances of `ImageProcessors.CoreImageFilter`. - /// - /// # References - /// - /// - [Core Image Programming Guide](https://developer.apple.com/library/ios/documentation/GraphicsImaging/Conceptual/CoreImaging/ci_intro/ci_intro.html) - /// - [Core Image Filter Reference](https://developer.apple.com/library/prerelease/ios/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html) - struct CoreImageFilter: ImageProcessing, CustomStringConvertible { - private let name: String - private let parameters: [String: Any] - let identifier: String - - /// - parameter identifier: Uniquely identifies the processor. - init(name: String, parameters: [String: Any], identifier: String) { - self.name = name - self.parameters = parameters - self.identifier = identifier - } - - init(name: String) { - self.name = name - self.parameters = [:] - self.identifier = "com.github.kean/nuke/core_image?name=\(name))" - } - - func process(_ image: PlatformImage) -> PlatformImage? { - let filter = CIFilter(name: name, parameters: parameters) - return CoreImageFilter.apply(filter: filter, to: image) - } - - // MARK: - Apply Filter - - /// A default context shared between all Core Image filters. The context - /// has `.priorityRequestLow` option set to `true`. - static var context = CIContext(options: [.priorityRequestLow: true]) - - static func apply(filter: CIFilter?, to image: PlatformImage) -> PlatformImage? { - guard let filter = filter else { - return nil - } - return applyFilter(to: image) { - filter.setValue($0, forKey: kCIInputImageKey) - return filter.outputImage - } - } - - static func applyFilter(to image: PlatformImage, context: CIContext = context, closure: (CoreImage.CIImage) -> CoreImage.CIImage?) -> PlatformImage? { - let ciImage: CoreImage.CIImage? = { - if let image = image.ciImage { - return image - } - if let image = image.cgImage { - return CoreImage.CIImage(cgImage: image) - } - return nil - }() - guard let inputImage = ciImage, let outputImage = closure(inputImage) else { - return nil - } - guard let imageRef = context.createCGImage(outputImage, from: outputImage.extent) else { - return nil - } - return PlatformImage.make(cgImage: imageRef, source: image) - } - - var description: String { - "CoreImageFilter(name: \(name), parameters: \(parameters))" - } - } -} - -// MARK: - ImageProcessors.GaussianBlur - -extension ImageProcessors { - /// Blurs an image using `CIGaussianBlur` filter. - struct GaussianBlur: ImageProcessing, Hashable, CustomStringConvertible { - private let radius: Int - - /// Initializes the receiver with a blur radius. - init(radius: Int = 8) { - self.radius = radius - } - - /// Applies `CIGaussianBlur` filter to the image. - func process(_ image: PlatformImage) -> PlatformImage? { - let filter = CIFilter(name: "CIGaussianBlur", parameters: ["inputRadius": radius]) - return CoreImageFilter.apply(filter: filter, to: image) - } - - var identifier: String { - "com.github.kean/nuke/gaussian_blur?radius=\(radius)" - } - - var hashableIdentifier: AnyHashable { self } - - var description: String { - "GaussianBlur(radius: \(radius))" - } - } -} - -#endif - -// MARK: - ImageDecompression (Internal) - -struct ImageDecompression { - - static func decompress(image: PlatformImage) -> PlatformImage { - image.decompressed() ?? image - } - - // MARK: Managing Decompression State - - static var isDecompressionNeededAK = "ImageDecompressor.isDecompressionNeeded.AssociatedKey" - - static func setDecompressionNeeded(_ isDecompressionNeeded: Bool, for image: PlatformImage) { - objc_setAssociatedObject(image, &isDecompressionNeededAK, isDecompressionNeeded, .OBJC_ASSOCIATION_RETAIN) - } - - static func isDecompressionNeeded(for image: PlatformImage) -> Bool? { - objc_getAssociatedObject(image, &isDecompressionNeededAK) as? Bool - } -} - -// MARK: - ImageProcessors.Composition - -extension ImageProcessors { - /// Composes multiple processors. - struct Composition: ImageProcessing, Hashable, CustomStringConvertible { - let processors: [ImageProcessing] - - /// Composes multiple processors. - init(_ processors: [ImageProcessing]) { - // note: multiple compositions are not flatten by default. - self.processors = processors - } - - func process(_ image: PlatformImage) -> PlatformImage? { - processors.reduce(image) { image, processor in - autoreleasepool { - image.flatMap { processor.process($0) } - } - } - } - - /// Processes the given image by applying each processor in an order in - /// which they were added. If one of the processors fails to produce - /// an image the processing stops and `nil` is returned. - func process(_ container: ImageContainer, context: ImageProcessingContext) -> ImageContainer? { - processors.reduce(container) { container, processor in - autoreleasepool { - container.flatMap { processor.process($0, context: context) } - } - } - } - - var identifier: String { - processors.map({ $0.identifier }).joined() - } - - var hashableIdentifier: AnyHashable { self } - - func hash(into hasher: inout Hasher) { - for processor in processors { - hasher.combine(processor.hashableIdentifier) - } - } - - static func == (lhs: Composition, rhs: Composition) -> Bool { - lhs.processors == rhs.processors - } - - var description: String { - "Composition(processors: \(processors))" - } - } -} - -// MARK: - ImageProcessors.Anonymous - -extension ImageProcessors { - /// Processed an image using a specified closure. - struct Anonymous: ImageProcessing, CustomStringConvertible { - let identifier: String - private let closure: (PlatformImage) -> PlatformImage? - - init(id: String, _ closure: @escaping (PlatformImage) -> PlatformImage?) { - self.identifier = id - self.closure = closure - } - - func process(_ image: PlatformImage) -> PlatformImage? { - self.closure(image) - } - - var description: String { - "AnonymousProcessor(identifier: \(identifier)" - } - } -} - -// MARK: - Image Processing (Internal) - -private extension PlatformImage { - /// Draws the image in a `CGContext` in a canvas with the given size using - /// the specified draw rect. - /// - /// For example, if the canvas size is `CGSize(width: 10, height: 10)` and - /// the draw rect is `CGRect(x: -5, y: 0, width: 20, height: 10)` it would - /// draw the input image (which is horizontal based on the known draw rect) - /// in a square by centering it in the canvas. - /// - /// - parameter drawRect: `nil` by default. If `nil` will use the canvas rect. - func draw(inCanvasWithSize canvasSize: CGSize, drawRect: CGRect? = nil) -> PlatformImage? { - guard let cgImage = cgImage else { - return nil - } - guard let ctx = CGContext.make(cgImage, size: canvasSize) else { - return nil - } - ctx.draw(cgImage, in: drawRect ?? CGRect(origin: .zero, size: canvasSize)) - guard let outputCGImage = ctx.makeImage() else { - return nil - } - return PlatformImage.make(cgImage: outputCGImage, source: self) - } - - /// Decompresses the input image by drawing in the the `CGContext`. - func decompressed() -> PlatformImage? { - guard let cgImage = cgImage else { - return nil - } - return draw(inCanvasWithSize: cgImage.size, drawRect: CGRect(origin: .zero, size: cgImage.size)) - } -} - -// MARK: - ImageProcessingExtensions - -private extension PlatformImage { - var processed: ImageProcessingExtensions { - ImageProcessingExtensions(image: self) - } -} - -private struct ImageProcessingExtensions { - let image: PlatformImage - - func byResizing(to targetSize: CGSize, - contentMode: ImageProcessors.Resize.ContentMode, - upscale: Bool) -> PlatformImage? { - guard let cgImage = image.cgImage else { - return nil - } - #if os(iOS) || os(tvOS) || os(watchOS) - let targetSize = targetSize.rotatedForOrientation(image.imageOrientation) - #endif - let scale = cgImage.size.getScale(targetSize: targetSize, contentMode: contentMode) - guard scale < 1 || upscale else { - return image // The image doesn't require scaling - } - let size = cgImage.size.scaled(by: scale).rounded() - return image.draw(inCanvasWithSize: size) - } - - /// Crops the input image to the given size and resizes it if needed. - /// - note: this method will always upscale. - func byResizingAndCropping(to targetSize: CGSize) -> PlatformImage? { - guard let cgImage = image.cgImage else { - return nil - } - #if os(iOS) || os(tvOS) || os(watchOS) - let targetSize = targetSize.rotatedForOrientation(image.imageOrientation) - #endif - let scale = cgImage.size.getScale(targetSize: targetSize, contentMode: .aspectFill) - let scaledSize = cgImage.size.scaled(by: scale) - let drawRect = scaledSize.centeredInRectWithSize(targetSize) - return image.draw(inCanvasWithSize: targetSize, drawRect: drawRect) - } - - func byDrawingInCircle(border: ImageProcessingOptions.Border?) -> PlatformImage? { - guard let squared = byCroppingToSquare(), let cgImage = squared.cgImage else { - return nil - } - let radius = CGFloat(cgImage.width) // Can use any dimension since image is a square - return squared.processed.byAddingRoundedCorners(radius: radius / 2.0, border: border) - } - - /// Draws an image in square by preserving an aspect ratio and filling the - /// square if needed. If the image is already a square, returns an original image. - func byCroppingToSquare() -> PlatformImage? { - guard let cgImage = image.cgImage else { - return nil - } - - guard cgImage.width != cgImage.height else { - return image // Already a square - } - - let imageSize = cgImage.size - let side = min(cgImage.width, cgImage.height) - let targetSize = CGSize(width: side, height: side) - let cropRect = CGRect(origin: .zero, size: targetSize).offsetBy( - dx: max(0, (imageSize.width - targetSize.width) / 2), - dy: max(0, (imageSize.height - targetSize.height) / 2) - ) - guard let cropped = cgImage.cropping(to: cropRect) else { - return nil - } - return PlatformImage.make(cgImage: cropped, source: image) - } - - /// Adds rounded corners with the given radius to the image. - /// - parameter radius: Radius in pixels. - /// - parameter border: Optional stroke border. - func byAddingRoundedCorners(radius: CGFloat, border: ImageProcessingOptions.Border? = nil) -> PlatformImage? { - guard let cgImage = image.cgImage else { - return nil - } - guard let ctx = CGContext.make(cgImage, size: cgImage.size, alphaInfo: .premultipliedLast) else { - return nil - } - let rect = CGRect(origin: CGPoint.zero, size: cgImage.size) - let path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) - ctx.addPath(path) - ctx.clip() - ctx.draw(cgImage, in: CGRect(origin: CGPoint.zero, size: cgImage.size)) - - if let border = border { - ctx.setStrokeColor(border.color.cgColor) - ctx.addPath(path) - ctx.setLineWidth(border.width) - ctx.strokePath() - } - guard let outputCGImage = ctx.makeImage() else { - return nil - } - return PlatformImage.make(cgImage: outputCGImage, source: image) - } -} - -// MARK: - CoreGraphics Helpers (Internal) - -#if os(macOS) -typealias Color = NSColor -#else -typealias Color = UIColor -#endif - -#if os(macOS) -extension NSImage { - var cgImage: CGImage? { - cgImage(forProposedRect: nil, context: nil, hints: nil) - } - - var ciImage: CIImage? { - cgImage.map { CIImage(cgImage: $0) } - } - - static func make(cgImage: CGImage, source: NSImage) -> NSImage { - NSImage(cgImage: cgImage, size: .zero) - } -} -#else -extension UIImage { - static func make(cgImage: CGImage, source: UIImage) -> UIImage { - UIImage(cgImage: cgImage, scale: source.scale, orientation: source.imageOrientation) - } -} -#endif - -extension CGImage { - /// Returns `true` if the image doesn't contain alpha channel. - var isOpaque: Bool { - let alpha = alphaInfo - return alpha == .none || alpha == .noneSkipFirst || alpha == .noneSkipLast - } - - var size: CGSize { - CGSize(width: width, height: height) - } -} - -private extension CGFloat { - func converted(to unit: ImageProcessingOptions.Unit) -> CGFloat { - switch unit { - case .pixels: return self - case .points: return self * Screen.scale - } - } -} - -// Adds Hashable without making changes to CGSize API -private struct Size: Hashable { - let cgSize: CGSize - - func hash(into hasher: inout Hasher) { - hasher.combine(cgSize.width) - hasher.combine(cgSize.height) - } -} - -private extension CGSize { - /// Creates the size in pixels by scaling to the input size to the screen scale - /// if needed. - init(size: CGSize, unit: ImageProcessingOptions.Unit) { - switch unit { - case .pixels: self = size // The size is already in pixels - case .points: self = size.scaled(by: Screen.scale) - } - } - - func scaled(by scale: CGFloat) -> CGSize { - CGSize(width: width * scale, height: height * scale) - } - - func rounded() -> CGSize { - CGSize(width: CGFloat(round(width)), height: CGFloat(round(height))) - } -} - -#if os(iOS) || os(tvOS) || os(watchOS) -private extension CGSize { - func rotatedForOrientation(_ imageOrientation: UIImage.Orientation) -> CGSize { - switch imageOrientation { - case .left, .leftMirrored, .right, .rightMirrored: - return CGSize(width: height, height: width) // Rotate 90 degrees - case .up, .upMirrored, .down, .downMirrored: - return self - @unknown default: - return self - } - } -} -#endif - -extension CGSize { - func getScale(targetSize: CGSize, contentMode: ImageProcessors.Resize.ContentMode) -> CGFloat { - let scaleHor = targetSize.width / width - let scaleVert = targetSize.height / height - - switch contentMode { - case .aspectFill: - return max(scaleHor, scaleVert) - case .aspectFit: - return min(scaleHor, scaleVert) - } - } - - /// Calculates a rect such that the output rect will be in the center of - /// the rect of the input size (assuming origin: .zero) - func centeredInRectWithSize(_ targetSize: CGSize) -> CGRect { - // First, resize the original size to fill the target size. - CGRect(origin: .zero, size: self).offsetBy( - dx: -(width - targetSize.width) / 2, - dy: -(height - targetSize.height) / 2 - ) - } -} - -// MARK: - ImageProcessing Extensions (Internal) - -func == (lhs: [ImageProcessing], rhs: [ImageProcessing]) -> Bool { - guard lhs.count == rhs.count else { - return false - } - // Lazily creates `hashableIdentifiers` because for some processors the - // identifiers might be expensive to compute. - return zip(lhs, rhs).allSatisfy { - $0.hashableIdentifier == $1.hashableIdentifier - } -} - -// MARK: - ImageProcessingOptions - -/// A namespace with shared image processing options. -enum ImageProcessingOptions { - - enum Unit: CustomStringConvertible { - case points - case pixels - - var description: String { - switch self { - case .points: return "points" - case .pixels: return "pixels" - } - } - } - - /// Draws a border. - /// - /// - warning: To make sure that the border looks the way you expect, - /// make sure that the images you display exactly match the size of the - /// views in which they get displayed. If you can't guarantee that, pleasee - /// consider adding border to a view layer. This should be your primary - /// option regardless. - struct Border: Hashable, CustomStringConvertible { - let width: CGFloat - - #if os(iOS) || os(tvOS) || os(watchOS) - let color: UIColor - - /// - parameter color: Border color. - /// - parameter width: Border width. 1 points by default. - /// - parameter unit: Unit of the width, `.points` by default. - init(color: UIColor, width: CGFloat = 1, unit: Unit = .points) { - self.color = color - self.width = width.converted(to: unit) - } - #else - let color: NSColor - - /// - parameter color: Border color. - /// - parameter width: Border width. 1 points by default. - /// - parameter unit: Unit of the width, `.points` by default. - init(color: NSColor, width: CGFloat = 1, unit: Unit = .points) { - self.color = color - self.width = width.converted(to: unit) - } - #endif - - var description: String { - "Border(color: \(color.hex), width: \(width) pixels)" - } - } -} - -// MARK: - Misc (Internal) - -struct Screen { - #if os(iOS) || os(tvOS) - /// Returns the current screen scale. - static var scale: CGFloat { UIScreen.main.scale } - #elseif os(watchOS) - /// Returns the current screen scale. - static var scale: CGFloat { WKInterfaceDevice.current().screenScale } - #elseif os(macOS) - /// Always returns 1. - static var scale: CGFloat { 1 } - #endif -} - -extension Color { - /// Returns a hex representation of the color, e.g. "#FFFFAA". - var hex: String { - var (r, g, b, a) = (CGFloat(0), CGFloat(0), CGFloat(0), CGFloat(0)) - getRed(&r, green: &g, blue: &b, alpha: &a) - let components = [r, g, b, a < 1 ? a : nil] - return "#" + components - .compactMap { $0 } - .map { String(format: "%02lX", lroundf(Float($0) * 255)) } - .joined() - } -} - -private extension CGContext { - static func make(_ image: CGImage, size: CGSize, alphaInfo: CGImageAlphaInfo? = nil) -> CGContext? { - let alphaInfo: CGImageAlphaInfo = alphaInfo ?? (image.isOpaque ? .noneSkipLast : .premultipliedLast) - - // Create the context which matches the input image. - if let ctx = CGContext( - data: nil, - width: Int(size.width), - height: Int(size.height), - bitsPerComponent: 8, - bytesPerRow: 0, - space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(), - bitmapInfo: alphaInfo.rawValue - ) { - return ctx - } - - // In case the combination of parameters (color space, bits per component, etc) - // is nit supported by Core Graphics, switch to default context. - // - Quartz 2D Programming Guide - // - https://github.com/kean/Nuke/issues/35 - // - https://github.com/kean/Nuke/issues/57 - return CGContext( - data: nil, - width: Int(size.width), height: Int(size.height), - bitsPerComponent: 8, - bytesPerRow: 0, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: alphaInfo.rawValue - ) - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImageRequest.swift b/Sources/StreamChatUI/StreamNuke/Core/ImageRequest.swift deleted file mode 100644 index 3f018b0f0fe..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/ImageRequest.swift +++ /dev/null @@ -1,386 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation -import Combine - -// MARK: - ImageRequest - -/// Represents an image request. -struct ImageRequest: CustomStringConvertible { - - // MARK: Parameters - - /// Returns the request `URLRequest`. - /// - /// Returns `nil` for publisher-based requests. - var urlRequest: URLRequest? { - switch ref.resource { - case .url(let url): return url.map { URLRequest(url: $0) } // create lazily - case .urlRequest(let urlRequest): return urlRequest - case .publisher: return nil - } - } - - /// Returns the request `URL`. - /// - /// Returns `nil` for publisher-based requests. - var url: URL? { - switch ref.resource { - case .url(let url): return url - case .urlRequest(let request): return request.url - case .publisher: return nil - } - } - - /// Returns the ID of the underlying image. For URL-based request, it's an - /// image URL. For publisher – a custom ID. - var imageId: String? { - switch ref.resource { - case .url(let url): return url?.absoluteString - case .urlRequest(let urlRequest): return urlRequest.url?.absoluteString - case .publisher(let publisher): return publisher.id - } - } - - /// The relative priority of the request. The priority affects the order in - /// which the requests are performed. `.normal` by default. - var priority: Priority { - get { ref.priority } - set { mutate { $0.priority = newValue } } - } - - /// Processor to be applied to the image. Empty by default. - var processors: [ImageProcessing] { - get { ref.processors ?? [] } - set { mutate { $0.processors = newValue } } - } - - /// The request options. - var options: Options { - get { ref.options } - set { mutate { $0.options = newValue } } - } - - /// Custom info passed alongside the request. - var userInfo: [UserInfoKey: Any] { - get { ref.userInfo ?? [:] } - set { mutate { $0.userInfo = newValue } } - } - - /// The priority affecting the order in which the requests are performed. - enum Priority: Int, Comparable { - case veryLow = 0, low, normal, high, veryHigh - - static func < (lhs: Priority, rhs: Priority) -> Bool { - lhs.rawValue < rhs.rawValue - } - } - - /// A key use in `userInfo`. - struct UserInfoKey: Hashable, ExpressibleByStringLiteral { - let rawValue: String - - init(_ rawValue: String) { - self.rawValue = rawValue - } - - init(stringLiteral value: String) { - self.rawValue = value - } - - /// By default, a pipeline uses URLs as unique image identifiers for - /// caching and task coalescing. You can override this behavior by - /// providing an `imageIdKey` instead. For example, you can use it to remove - /// transient query parameters from the request. - /// - /// ``` - /// let request = ImageRequest( - /// url: URL(string: "http://example.com/image.jpeg?token=123"), - /// userInfo: [.imageIdKey: "http://example.com/image.jpeg"] - /// ) - /// ``` - static let imageIdKey: ImageRequest.UserInfoKey = "github.com/kean/nuke/imageId" - - /// The image scale to be used. By default, the scale matches the scale - /// of the current display. - static let scaleKey: ImageRequest.UserInfoKey = "github.com/kean/nuke/scale" - } - - // MARK: Initializers - - /// Initializes a request with the given URL. - /// - /// - parameter url: The request URL. - /// - parameter processors: Processors to be apply to the image. `nil` by default. - /// - parameter priority: The priority of the request, `.normal` by default. - /// - parameter options: Image loading options. `[]` by default. - /// - parameter userInfo: Custom info passed alongside the request. `nil` by default. - /// - /// ```swift - /// let request = ImageRequest( - /// url: URL(string: "http://..."), - /// processors: [ImageProcessors.Resize(size: imageView.bounds.size)], - /// priority: .high - /// ) - /// ``` - init(url: URL?, - processors: [ImageProcessing]? = nil, - priority: Priority = .normal, - options: Options = [], - userInfo: [UserInfoKey: Any]? = nil) { - self.ref = Container( - resource: Resource.url(url), - processors: processors, - priority: priority, - options: options, - userInfo: userInfo - ) - } - - /// Initializes a request with the given request. - /// - /// - parameter urlRequest: The URLRequest describing the image request. - /// - parameter processors: Processors to be apply to the image. `nil` by default. - /// - parameter priority: The priority of the request, `.normal` by default. - /// - parameter options: Image loading options. `[]` by default. - /// - parameter userInfo: Custom info passed alongside the request. `nil` by default. - /// - /// ```swift - /// let request = ImageRequest( - /// url: URLRequest(url: URL(string: "http://...")), - /// processors: [ImageProcessors.Resize(size: imageView.bounds.size)], - /// priority: .high - /// ) - /// ``` - init(urlRequest: URLRequest, - processors: [ImageProcessing]? = nil, - priority: Priority = .normal, - options: Options = [], - userInfo: [UserInfoKey: Any]? = nil) { - self.ref = Container( - resource: Resource.urlRequest(urlRequest), - processors: processors, - priority: priority, - options: options, - userInfo: userInfo - ) - } - - /// Initializes a request with the given data publisher. - /// - /// - parameter id: Uniquely identifies the image data. - /// - parameter data: A data publisher to be used for fetching image data. - /// - parameter processors: Processors to be apply to the image. `nil` by default. - /// - parameter priority: The priority of the request, `.normal` by default. - /// - parameter options: Image loading options. `[]` by default. - /// - parameter userInfo: Custom info passed alongside the request. `nil` by default. - /// - /// For example, here is how you can use it with Photos framework (the - /// `imageDataPublisher()` API is a convenience extension). - /// - /// ```swift - /// let request = ImageRequest( - /// id: asset.localIdentifier, - /// data: PHAssetManager.imageDataPublisher(for: asset) - /// ) - /// ``` - /// - /// - warning: If you don't want data to be stored in the disk cache, make - /// sure to create a pipeline without it or disable it on a per-request basis. - /// You can also disable it dynamically using `ImagePipelineDelegate`. - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) - init

(id: String, data: P, - processors: [ImageProcessing]? = nil, - priority: Priority = .normal, - options: Options = [], - userInfo: [UserInfoKey: Any]? = nil) where P: Publisher, P.Output == Data { - // It could technically be implemented without any special change to the - // pipeline by using a custom DataLoader, disabling resumable data, and - // passing a publisher in the request userInfo. - self.ref = Container( - resource: .publisher(DataPublisher(id: id, data)), - processors: processors, - priority: priority, - options: options, - userInfo: userInfo - ) - } - - // MARK: Options - - /// Image request options. - struct Options: OptionSet, Hashable { - /// Returns a raw value. - let rawValue: UInt16 - - /// Initialializes options with a given raw values. - init(rawValue: UInt16) { - self.rawValue = rawValue - } - - /// Disables memory cache reads (`ImageCaching`). - static let disableMemoryCacheReads = Options(rawValue: 1 << 0) - - /// Disables memory cache writes (`ImageCaching`). - static let disableMemoryCacheWrites = Options(rawValue: 1 << 1) - - /// Disables both memory cache reads and writes (`ImageCaching`). - static let disableMemoryCache: Options = [.disableMemoryCacheReads, .disableMemoryCacheWrites] - - /// Disables disk cache reads (`DataCaching`). - static let disableDiskCacheReads = Options(rawValue: 1 << 2) - - /// Disables disk cache writes (`DataCaching`). - static let disableDiskCacheWrites = Options(rawValue: 1 << 3) - - /// Disables both disk cache reads and writes (`DataCaching`). - static let disableDiskCache: Options = [.disableDiskCacheReads, .disableDiskCacheWrites] - - /// The image should be loaded only from the originating source. - /// - /// This option only works `ImageCaching` and `DataCaching`, but not - /// `URLCache`. If you want to ignore `URLCache`, initialize the request - /// with `URLRequest` with the respective policy - static let reloadIgnoringCachedData: Options = [.disableMemoryCacheReads, .disableDiskCacheReads] - - /// Use existing cache data and fail if no cached data is available. - static let returnCacheDataDontLoad = Options(rawValue: 1 << 4) - } - - // MARK: Internal - - private(set) var ref: Container - - private mutating func mutate(_ closure: (Container) -> Void) { - if !isKnownUniquelyReferenced(&ref) { - ref = Container(ref) - } - closure(ref) - } - - /// Just like many Swift built-in types, `ImageRequest` uses CoW approach to - /// avoid memberwise retain/releases when `ImageRequest` is passed around. - final class Container { - // It's benefitial to put resource before priority and options because - // of the resource size/stride of 9/16. Priority (1 byte) and Options - // (2 bytes) slot just right in the remaining space. - let resource: Resource - fileprivate(set) var priority: Priority - fileprivate(set) var options: Options - fileprivate(set) var processors: [ImageProcessing]? - fileprivate(set) var userInfo: [UserInfoKey: Any]? - // After trimming down the request size, it is no longer - // as beneficial using CoW for ImageRequest, but there - // still is a small but measurable difference. - - deinit { - #if TRACK_ALLOCATIONS - Allocations.decrement("ImageRequest.Container") - #endif - } - - /// Creates a resource with a default processor. - init(resource: Resource, processors: [ImageProcessing]?, priority: Priority, options: Options, userInfo: [UserInfoKey: Any]?) { - self.resource = resource - self.processors = processors - self.priority = priority - self.options = options - self.userInfo = userInfo - - #if TRACK_ALLOCATIONS - Allocations.increment("ImageRequest.Container") - #endif - } - - /// Creates a copy. - init(_ ref: Container) { - self.resource = ref.resource - self.processors = ref.processors - self.priority = ref.priority - self.options = ref.options - self.userInfo = ref.userInfo - - #if TRACK_ALLOCATIONS - Allocations.increment("ImageRequest.Container") - #endif - } - } - - // Every case takes 8 bytes and the enum 9 bytes overall (use stride!) - enum Resource: CustomStringConvertible { - case url(URL?) - case urlRequest(URLRequest) - case publisher(DataPublisher) - - var description: String { - switch self { - case .url(let url): return "\(url?.absoluteString ?? "nil")" - case .urlRequest(let urlRequest): return "\(urlRequest)" - case .publisher(let data): return "\(data)" - } - } - } - - var description: String { - "ImageRequest(resource: \(ref.resource), priority: \(priority), processors: \(processors), options: \(options), userInfo: \(userInfo))" - } - - func withProcessors(_ processors: [ImageProcessing]) -> ImageRequest { - var request = self - request.processors = processors - return request - } - - var preferredImageId: String { - if let imageId = ref.userInfo?[.imageIdKey] as? String { - return imageId - } - return imageId ?? "" - } - - var publisher: DataPublisher? { - guard case .publisher(let publisher) = ref.resource else { - return nil - } - return publisher - } -} - -// MARK: - ImageRequestConvertible - -/// Represents a type that can be converted to an `ImageRequest`. -protocol ImageRequestConvertible { - func asImageRequest() -> ImageRequest -} - -extension ImageRequest: ImageRequestConvertible { - func asImageRequest() -> ImageRequest { - self - } -} - -extension URL: ImageRequestConvertible { - func asImageRequest() -> ImageRequest { - ImageRequest(url: self) - } -} - -extension Optional: ImageRequestConvertible where Wrapped == URL { - func asImageRequest() -> ImageRequest { - ImageRequest(url: self) - } -} - -extension URLRequest: ImageRequestConvertible { - func asImageRequest() -> ImageRequest { - ImageRequest(urlRequest: self) - } -} - -extension String: ImageRequestConvertible { - func asImageRequest() -> ImageRequest { - ImageRequest(url: URL(string: self)) - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImageResponse.swift b/Sources/StreamChatUI/StreamNuke/Core/ImageResponse.swift deleted file mode 100644 index a528e29dddc..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/ImageResponse.swift +++ /dev/null @@ -1,124 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -#if !os(macOS) -import UIKit.UIImage -#else -import AppKit.NSImage -#endif - -// MARK: - ImageResponse - -/// An image response that contains a fetched image and some metadata. -struct ImageResponse { - /// An image container with an image and associated metadata. - let container: ImageContainer - - #if os(macOS) - /// A convenience computed property that returns an image from the container. - var image: NSImage { container.image } - #else - /// A convenience computed property that returns an image from the container. - var image: UIImage { container.image } - #endif - - /// A response. `nil` unless the resource was fetched from the network or an - /// HTTP cache. - let urlResponse: URLResponse? - - /// Contains a cache type in case the image was returned from one of the - /// pipeline caches (not including any of the HTTP caches if enabled). - let cacheType: CacheType? - - /// Initializes the response with the given image. - init(container: ImageContainer, urlResponse: URLResponse? = nil, cacheType: CacheType? = nil) { - self.container = container - self.urlResponse = urlResponse - self.cacheType = cacheType - } - - func map(_ transformation: (ImageContainer) -> ImageContainer?) -> ImageResponse? { - return autoreleasepool { - guard let output = transformation(container) else { - return nil - } - return ImageResponse(container: output, urlResponse: urlResponse, cacheType: cacheType) - } - } - - /// A cache type. - enum CacheType { - /// Memory cache (see `ImageCaching`) - case memory - /// Disk cache (see `DataCaching`) - case disk - } -} - -// MARK: - ImageContainer - -/// An image container with an image and associated metadata. -struct ImageContainer { - #if os(macOS) - /// A fetched image. - var image: NSImage - #else - /// A fetched image. - var image: UIImage - #endif - - /// An image type. - var type: ImageType? - - /// Returns `true` if the image in the container is a preview of the image. - var isPreview: Bool - - /// Contains the original image `data`, but only if the decoder decides to - /// attach it to the image. - /// - /// The default decoder (`ImageDecoders.Default`) attaches data to GIFs to - /// allow to display them using a rendering engine of your choice. - /// - /// - note: The `data`, along with the image container itself gets stored - /// in the memory cache. - var data: Data? - - /// An metadata provided by the user. - var userInfo: [UserInfoKey: Any] - - /// Initializes the container with the given image. - init(image: PlatformImage, type: ImageType? = nil, isPreview: Bool = false, data: Data? = nil, userInfo: [UserInfoKey: Any] = [:]) { - self.image = image - self.type = type - self.isPreview = isPreview - self.data = data - self.userInfo = userInfo - } - - /// Modifies the wrapped image and keeps all of the rest of the metadata. - func map(_ closure: (PlatformImage) -> PlatformImage?) -> ImageContainer? { - guard let image = closure(self.image) else { - return nil - } - return ImageContainer(image: image, type: type, isPreview: isPreview, data: data, userInfo: userInfo) - } - - /// A key use in `userInfo`. - struct UserInfoKey: Hashable, ExpressibleByStringLiteral { - let rawValue: String - - init(_ rawValue: String) { - self.rawValue = rawValue - } - - init(stringLiteral value: String) { - self.rawValue = value - } - - /// A user info key to get the scan number (Int). - static let scanNumberKey: UserInfoKey = "github.com/kean/nuke/scan-number" - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImageTask.swift b/Sources/StreamChatUI/StreamNuke/Core/ImageTask.swift deleted file mode 100644 index fd5099619b9..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Core/ImageTask.swift +++ /dev/null @@ -1,106 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// A task performed by the `ImagePipeline`. -/// -/// The pipeline maintains a strong reference to the task until the request -/// finishes or fails; you do not need to maintain a reference to the task unless -/// it is useful for your app. -final class ImageTask: Hashable, CustomStringConvertible { - /// An identifier that uniquely identifies the task within a given pipeline. - /// Unique only within that pipeline. - let taskId: Int64 - - /// The original request. - let request: ImageRequest - - let isDataTask: Bool - - /// Updates the priority of the task, even if it is already running. - var priority: ImageRequest.Priority { - didSet { - pipeline?.imageTaskUpdatePriorityCalled(self, priority: priority) - } - } - var _priority: ImageRequest.Priority // Backing store for access from pipeline - // Putting all smaller units closer together (1 byte / 1 byte / 1 byte) - - weak var pipeline: ImagePipeline? - - // MARK: Progress - - /// The number of bytes that the task has received. - private(set) var completedUnitCount: Int64 = 0 - - /// A best-guess upper bound on the number of bytes of the resource. - private(set) var totalUnitCount: Int64 = 0 - - /// Returns a progress object for the task, created lazily. - var progress: Progress { - if _progress == nil { _progress = Progress() } - return _progress! - } - private var _progress: Progress? - - var isCancelled: Bool { _isCancelled.pointee == 1 } - private let _isCancelled: UnsafeMutablePointer - - deinit { - self._isCancelled.deallocate() - #if TRACK_ALLOCATIONS - Allocations.decrement("ImageTask") - #endif - } - - init(taskId: Int64, request: ImageRequest, isDataTask: Bool) { - self.taskId = taskId - self.request = request - self._priority = request.priority - self.priority = request.priority - self.isDataTask = isDataTask - - self._isCancelled = UnsafeMutablePointer.allocate(capacity: 1) - self._isCancelled.initialize(to: 0) - - #if TRACK_ALLOCATIONS - Allocations.increment("ImageTask") - #endif - } - - /// Marks task as being cancelled. - /// - /// The pipeline will immediately cancel any work associated with a task - /// unless there is an equivalent outstanding task running (see - /// `ImagePipeline.Configuration.isCoalescingEnabled` for more info). - func cancel() { - if OSAtomicCompareAndSwap32Barrier(0, 1, _isCancelled) { - pipeline?.imageTaskCancelCalled(self) - } - } - - func setProgress(_ progress: TaskProgress) { - completedUnitCount = progress.completed - totalUnitCount = progress.total - _progress?.completedUnitCount = progress.completed - _progress?.totalUnitCount = progress.total - } - - // MARK: Hashable - - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self).hashValue) - } - - static func == (lhs: ImageTask, rhs: ImageTask) -> Bool { - ObjectIdentifier(lhs) == ObjectIdentifier(rhs) - } - - // MARK: CustomStringConvertible - - var description: String { - "ImageTask(id: \(taskId), priority: \(priority), completedUnitCount: \(completedUnitCount), totalUnitCount: \(totalUnitCount), isCancelled: \(isCancelled))" - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Allocations.swift b/Sources/StreamChatUI/StreamNuke/Internal/Allocations.swift deleted file mode 100644 index c18cc2671b6..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/Allocations.swift +++ /dev/null @@ -1,81 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -#if TRACK_ALLOCATIONS -enum Allocations { - static var allocations = [String: Int]() - static var total = 0 - static let lock = NSLock() - static var timer: Timer? - - static let isPrintingEnabled = ProcessInfo.processInfo.environment["NUKE_PRINT_ALL_ALLOCATIONS"] != nil - static let isTimerEnabled = ProcessInfo.processInfo.environment["NUKE_ALLOCATIONS_PERIODIC_LOG"] != nil - - static func increment(_ name: String) { - lock.lock() - defer { lock.unlock() } - - allocations[name, default: 0] += 1 - total += 1 - - if isPrintingEnabled { - debugPrint("Increment \(name): \(allocations[name] ?? 0) Total: \(totalAllocationCount)") - } - - if isTimerEnabled, timer == nil { - timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in - Allocations.printAllocations() - } - } - } - - static var totalAllocationCount: Int { - allocations.values.reduce(0, +) - } - - static func decrement(_ name: String) { - lock.lock() - defer { lock.unlock() } - - allocations[name, default: 0] -= 1 - - let totalAllocationCount = self.totalAllocationCount - - if isPrintingEnabled { - debugPrint("Decrement \(name): \(allocations[name] ?? 0) Total: \(totalAllocationCount)") - } - - if totalAllocationCount == 0 { - _onDeinitAll?() - _onDeinitAll = nil - } - } - - private static var _onDeinitAll: (() -> Void)? - - static func onDeinitAll(_ closure: @escaping () -> Void) { - lock.lock() - defer { lock.unlock() } - - if totalAllocationCount == 0 { - closure() - } else { - _onDeinitAll = closure - } - } - - static func printAllocations() { - lock.lock() - defer { lock.unlock() } - let allocations = self.allocations - .filter { $0.value > 0 } - .map { "\($0.key): \($0.value)" } - .sorted() - .joined(separator: " ") - debugPrint("Current: \(totalAllocationCount) Overall: \(total) \(allocations)") - } -} -#endif diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Combine.swift b/Sources/StreamChatUI/StreamNuke/Internal/Combine.swift deleted file mode 100644 index 951bc953ed6..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/Combine.swift +++ /dev/null @@ -1,48 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation -import Combine - -final class DataPublisher { - let id: String - private let _sink: (@escaping ((PublisherCompletion) -> Void), @escaping ((Data) -> Void)) -> Cancellable - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) - init(id: String, _ publisher: P) where P.Output == Data { - self.id = id - self._sink = { onCompletion, onValue in - let cancellable = publisher.sink(receiveCompletion: { - switch $0 { - case .finished: onCompletion(.finished) - case .failure(let error): onCompletion(.failure(error)) - } - }, receiveValue: { - onValue($0) - }) - return AnyCancellable(cancellable.cancel) - } - } - - func sink(receiveCompletion: @escaping ((PublisherCompletion) -> Void), receiveValue: @escaping ((Data) -> Void)) -> Cancellable { - _sink(receiveCompletion, receiveValue) - } -} - -private final class AnyCancellable: Cancellable { - let closure: () -> Void - - init(_ closure: @escaping () -> Void) { - self.closure = closure - } - - func cancel() { - closure() - } -} - -enum PublisherCompletion { - case finished - case failure(Error) -} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Deprecated.swift b/Sources/StreamChatUI/StreamNuke/Internal/Deprecated.swift deleted file mode 100644 index 8ae414bd921..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/Deprecated.swift +++ /dev/null @@ -1,300 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -// Deprecated in 9.4.1 -@available(*, deprecated, message: "Renamed to ImagePrefetcher") -typealias ImagePreheater = ImagePrefetcher - -extension ImagePrefetcher { - // Deprecated in 9.4.1 - @available(*, deprecated, message: "Renamed to startPrefetching") - func startPreheating(with urls: [URL]) { - startPrefetching(with: urls) - } - - // Deprecated in 9.4.1 - @available(*, deprecated, message: "Renamed to startPrefetching") - func startPreheating(with requests: [ImageRequest]) { - startPrefetching(with: requests) - } - - // Deprecated in 9.4.1 - @available(*, deprecated, message: "Renamed to stopPrefetching") - func stopPreheating(with urls: [URL]) { - stopPrefetching(with: urls) - } - - // Deprecated in 9.4.1 - @available(*, deprecated, message: "Renamed to stopPrefetching") - func stopPreheating(with requests: [ImageRequest]) { - stopPrefetching(with: requests) - } - - // Deprecated in 9.4.1 - @available(*, deprecated, message: "Renamed to stopPrefetching") - func stopPreheating() { - stopPrefetching() - } -} - -extension ImagePipeline { - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Use pipeline.cache[url] instead") - func cachedImage(for url: URL) -> ImageContainer? { - cachedImage(for: ImageRequest(url: url)) - } - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Use pipeline.cache[request] instead") - func cachedImage(for request: ImageRequest) -> ImageContainer? { - cache[request] - } - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "If needed, use pipeline.cache.makeDataCacheKey(for:) instead. For original image data, remove the processors from the request. In general, there should be no need to create the keys manually anymore.") - func cacheKey(for request: ImageRequest, item: DataCacheItem) -> String { - switch item { - case .originalImageData: - var request = request - request.processors = [] - return request.makeDataCacheKey() - case .finalImage: return request.makeDataCacheKey() - } - } - - @available(*, deprecated, message: "Please use `dataCachePolicy` instead. The recommended policy is the new `.automatic` policy.") - enum DataCacheItem { - /// Same as the new `DataCachePolicy.storeOriginalData` - case originalImageData - /// Same as the new `DataCachePolicy.storeEncodedImages` - case finalImage - } -} - -// Deprecated in 10.0.0 -@available(*, deprecated, message: "Please use ImagePipelineDelegate") -protocol ImagePipelineObserving { - /// Delivers the events produced by the image tasks started via `loadImage` method. - func pipeline(_ pipeline: ImagePipeline, imageTask: ImageTask, didReceiveEvent event: ImageTaskEvent) -} - -// Deprecated in 10.0.0 -@available(*, deprecated, message: "Please use the new initializer with `ImageRequest.Options`. It offers the same options and more. For more information see the migration guide at https://github.com/kean/Nuke/blob/master/Documentation/Migrations/Nuke%2010%20Migration%20Guide.md#imagerequestoptions.") -struct ImageRequestOptions { - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use `ImagePipeline.Options` instead: `disableMemoryCacheRead`, `disableMemoryCacheWrite`.") - struct MemoryCacheOptions { - /// `true` by default. - var isReadAllowed = true - - /// `true` by default. - var isWriteAllowed = true - - init(isReadAllowed: Bool = true, isWriteAllowed: Bool = true) { - self.isReadAllowed = isReadAllowed - self.isWriteAllowed = isWriteAllowed - } - } - - /// `MemoryCacheOptions()` (read allowed, write allowed) by default. - var memoryCacheOptions: MemoryCacheOptions - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please pass ") - var cacheKey: AnyHashable? - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "This API does nothing starting with Nuke 10. If you found an issue in coalescing, please report it on GitHub and consider disabling it using ImagePipeline.Configuration.") - var loadKey: AnyHashable? - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please pass imageId (`ImageRequest.UserInfoKey.imageIdKey`) in the request `userInfo`. The deprecated API does nothing starting with Nuke 10.") - var filteredURL: AnyHashable? - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please pass the `userInfo` directly to the request. The deprecated API does nothing starting with Nuke 10.") - var userInfo: [AnyHashable: Any] - - init(memoryCacheOptions: MemoryCacheOptions = .init(), - filteredURL: String? = nil, - cacheKey: AnyHashable? = nil, - loadKey: AnyHashable? = nil, - userInfo: [AnyHashable: Any] = [:]) { - self.memoryCacheOptions = memoryCacheOptions - self.filteredURL = filteredURL - self.cacheKey = cacheKey - self.loadKey = loadKey - self.userInfo = userInfo - } -} - -extension ImageRequest { - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use the new initializer with `ImageRequest.Options`. It offers the same options and more. For more information see the migration guide at https://github.com/kean/Nuke/blob/master/Documentation/Migrations/Nuke%2010%20Migration%20Guide.md#imagerequestoptions.") - init(url: URL, - processors: [ImageProcessing] = [], - cachePolicy: CachePolicy, - priority: ImageRequest.Priority = .normal, - options: ImageRequestOptions = .init()) { - var userInfo = [UserInfoKey: Any]() - if let filteredURL = options.filteredURL { - userInfo[.imageIdKey] = filteredURL - } - let options = ImageRequest.Options(cachePolicy, options) - self.init(url: url, processors: processors, priority: priority, options: options, userInfo: userInfo) - } - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use the new initializer with `ImageRequest.Options`. It offers the same options and more. For more information see the migration guide at https://github.com/kean/Nuke/blob/master/Documentation/Migrations/Nuke%2010%20Migration%20Guide.md#imagerequestoptions") - init(urlRequest: URLRequest, - processors: [ImageProcessing] = [], - cachePolicy: CachePolicy, - priority: ImageRequest.Priority = .normal, - options: ImageRequestOptions = .init()) { - var userInfo = [UserInfoKey: Any]() - if let filteredURL = options.filteredURL { - userInfo[.imageIdKey] = filteredURL - } - let options = ImageRequest.Options(cachePolicy, options) - self.init(urlRequest: urlRequest, processors: processors, priority: priority, options: options, userInfo: userInfo) - } - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use `ImageRequest.Options` instead, it offers the same options under the same names.") - var cachePolicy: CachePolicy { - get { - if options.contains(.returnCacheDataDontLoad) { - return .returnCacheDataDontLoad - } - if options.contains(.reloadIgnoringCachedData) { - return .reloadIgnoringCachedData - } - return .default - } - set { - options.insert(.init(newValue)) - } - } - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use `ImageRequest.Options` instead, it offers the same options under the same names. And .reloadIgnoringCachedData no longer affects URLCache!") - enum CachePolicy { - case `default` - /// The image should be loaded only from the originating source. - /// - /// If you initialize the request with `URLRequest`, make sure to provide - /// the correct policy in the request too. - @available(*, deprecated, message: "Please use `ImageRequest.Options` instead. This option is available under the same name: .reloadIgnoringCachedData. This option is also no longer affects URLCache!") - case reloadIgnoringCachedData - - /// Use existing cache data and fail if no cached data is available. - @available(*, deprecated, message: "Please use `ImageRequest.Options` instead. This option is available under the same name: .returnCacheDataDontLoad.") - case returnCacheDataDontLoad - } -} - -private extension ImageRequest.Options { - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use `ImageRequest.Options` instead, it offers the same options under the same names.") - init(_ cachePolicy: ImageRequest.CachePolicy) { - switch cachePolicy { - case .default: - self = [] - case .reloadIgnoringCachedData: - self = .reloadIgnoringCachedData - case .returnCacheDataDontLoad: - self = .returnCacheDataDontLoad - } - } - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use `ImageRequest.Options` instead, it offers the same options under the same names.") - init(_ cachePolicy: ImageRequest.CachePolicy, _ oldOptions: ImageRequestOptions) { - var options: ImageRequest.Options = .init(cachePolicy) - if !oldOptions.memoryCacheOptions.isReadAllowed { - options.insert(.disableMemoryCacheReads) - } - if !oldOptions.memoryCacheOptions.isWriteAllowed { - options.insert(.disableMemoryCacheWrites) - } - self = options - } -} - -extension ImageDecoders.Default { - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use `ImageConatainer.UserInfoKey.scanNumber.") - static let scanNumberKey = "ImageDecoders.Default.scanNumberKey" -} - -extension ImagePipeline.Configuration { - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use `ImageConatainer` `data` instead. The default image decoder now automatically attaches image data to the ImageContainer type. To learn how to implement animated image support using this new type, see the new Image Formats guide https://github.com/kean/Nuke/blob/9.6.0/Documentation/Guides/image-formats.md. Also see Nuke 10 migration guide https://github.com/kean/Nuke/blob/master/Documentation/Migrations/Nuke%2010%20Migration%20Guide.md.") - static var isAnimatedImageDataEnabled: Bool { - get { _isAnimatedImageDataEnabled } - set { _isAnimatedImageDataEnabled = newValue } - } -} - -private var _animatedImageDataAK = "Nuke.AnimatedImageData.AssociatedKey" - -extension PlatformImage { - // Deprecated in 10.0.0 - /// - warning: Soft-deprecated in Nuke 9.0. - @available(*, deprecated, message: "Please use `ImageConatainer` `data` instead") - var animatedImageData: Data? { - get { _animatedImageData } - set { _animatedImageData = newValue } - } - - // Deprecated in 10.0.0 - internal var _animatedImageData: Data? { - get { objc_getAssociatedObject(self, &_animatedImageDataAK) as? Data } - set { objc_setAssociatedObject(self, &_animatedImageDataAK, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } - } -} - -extension ImagePipeline.Configuration { - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Please use `ImageConfiguration.default` and provide a `dataLoader` afterwards or use a closure-based ImagePipeline initializer.") - init(dataLoader: DataLoading = DataLoader(), imageCache: ImageCaching?) { - self.init(dataLoader: dataLoader) - self.imageCache = imageCache - } - - // Deprecated in 10.0.0 - @available(*, deprecated, message: "Renamed to isTaskCoalescingEnabled") - var isDeduplicationEnabled: Bool { - get { isTaskCoalescingEnabled } - set { isTaskCoalescingEnabled = newValue } - } - - // Deprecated in 10.0.0 - // There is simply no way to make it work consistently across subsystems. - @available(*, deprecated, message: "Deprecated and will be removed. Please use the new ImageLoadingOptions processors option, or create another way to apply processors by default.") - var processors: [ImageProcessing] { - get { _processors } - set { _processors = newValue } - } - - /// Inherits some of the pipeline configuration options like processors. - func inheritOptions(_ request: ImageRequest) -> ImageRequest { - guard !_processors.isEmpty, request.processors.isEmpty else { - return request - } - var request = request - request.processors = _processors - return request - } -} - -// Deprecated in 10.0.0 -@available(*, deprecated, message: "Please use ImageDecoders.Default directly") -typealias ImageDecoder = ImageDecoders.Default - -// Deprecated in 10.0.0 -@available(*, deprecated, message: "Please use ImageEncoders.Default directly") -typealias ImageEncoder = ImageEncoders.Default diff --git a/Sources/StreamChatUI/StreamNuke/Internal/ImageRequestKeys.swift b/Sources/StreamChatUI/StreamNuke/Internal/ImageRequestKeys.swift deleted file mode 100644 index 678aab9b8bd..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/ImageRequestKeys.swift +++ /dev/null @@ -1,95 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -extension ImageRequest { - - // MARK: - Cache Keys - - /// A key for processed image in memory cache. - func makeImageCacheKey() -> CacheKey { - CacheKey(self) - } - - /// A key for processed image data in disk cache. - func makeDataCacheKey() -> String { - "\(preferredImageId)\(ImageProcessors.Composition(processors).identifier)" - } - - // MARK: - Load Keys - - /// A key for deduplicating operations for fetching the processed image. - func makeImageLoadKey() -> ImageLoadKey { - ImageLoadKey(self) - } - - /// A key for deduplicating operations for fetching the original image. - func makeDataLoadKey() -> DataLoadKey { - DataLoadKey(self) - } -} - -// Uniquely identifies a cache processed image. -struct CacheKey: Hashable { - private let imageId: String? - private let processors: [ImageProcessing]? - - init(_ request: ImageRequest) { - self.imageId = request.preferredImageId - self.processors = request.ref.processors - } - - func hash(into hasher: inout Hasher) { - hasher.combine(imageId) - hasher.combine(processors?.count ?? 0) - } - - static func == (lhs: CacheKey, rhs: CacheKey) -> Bool { - guard lhs.imageId == rhs.imageId else { return false } - return (lhs.processors ?? []) == (rhs.processors ?? []) - } -} - -// Uniquely identifies a task of retrieving the processed image. -struct ImageLoadKey: Hashable { - let cacheKey: CacheKey - let options: ImageRequest.Options - let loadKey: DataLoadKey - - init(_ request: ImageRequest) { - self.cacheKey = CacheKey(request) - self.options = request.options - self.loadKey = DataLoadKey(request) - } -} - -// Uniquely identifies a task of retrieving the original image dataa. -struct DataLoadKey: Hashable { - private let imageId: String? - private let cachePolicy: URLRequest.CachePolicy - private let allowsCellularAccess: Bool - - init(_ request: ImageRequest) { - self.imageId = request.imageId - switch request.ref.resource { - case .url, .publisher: - self.cachePolicy = .useProtocolCachePolicy - self.allowsCellularAccess = true - case let .urlRequest(urlRequest): - self.cachePolicy = urlRequest.cachePolicy - self.allowsCellularAccess = urlRequest.allowsCellularAccess - } - } -} - -struct ImageProcessingKey: Equatable, Hashable { - let imageId: ObjectIdentifier - let processorId: AnyHashable - - init(image: ImageResponse, processor: ImageProcessing) { - self.imageId = ObjectIdentifier(image.image) - self.processorId = processor.hashableIdentifier - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Log.swift b/Sources/StreamChatUI/StreamNuke/Internal/Log.swift deleted file mode 100644 index 696f042bef4..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/Log.swift +++ /dev/null @@ -1,73 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation -import os - -func signpost(_ log: OSLog, _ object: AnyObject, _ name: StaticString, _ type: SignpostType) { - guard ImagePipeline.Configuration.isSignpostLoggingEnabled else { return } - if #available(OSX 10.14, iOS 12.0, watchOS 5.0, tvOS 12.0, *) { - let signpostId = OSSignpostID(log: log, object: object) - os_signpost(type.os, log: log, name: name, signpostID: signpostId) - } -} - -func signpost(_ log: OSLog, _ object: AnyObject, _ name: StaticString, _ type: SignpostType, _ message: @autoclosure () -> String) { - guard ImagePipeline.Configuration.isSignpostLoggingEnabled else { return } - if #available(OSX 10.14, iOS 12.0, watchOS 5.0, tvOS 12.0, *) { - let signpostId = OSSignpostID(log: log, object: object) - os_signpost(type.os, log: log, name: name, signpostID: signpostId, "%{public}s", message()) - } -} - -func signpost(_ log: OSLog, _ name: StaticString, _ work: () -> T) -> T { - if #available(OSX 10.14, iOS 12.0, watchOS 5.0, tvOS 12.0, *), ImagePipeline.Configuration.isSignpostLoggingEnabled { - let signpostId = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: name, signpostID: signpostId) - let result = work() - os_signpost(.end, log: log, name: name, signpostID: signpostId) - return result - } else { - return work() - } -} - -func signpost(_ log: OSLog, _ name: StaticString, _ message: @autoclosure () -> String, _ work: () -> T) -> T { - if #available(OSX 10.14, iOS 12.0, watchOS 5.0, tvOS 12.0, *), ImagePipeline.Configuration.isSignpostLoggingEnabled { - let signpostId = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: name, signpostID: signpostId, "%{public}s", message()) - let result = work() - os_signpost(.end, log: log, name: name, signpostID: signpostId) - return result - } else { - return work() - } -} - -var nukeLog: OSLog = .disabled - -private let byteFormatter = ByteCountFormatter() - -enum Formatter { - static func bytes(_ count: Int) -> String { - bytes(Int64(count)) - } - - static func bytes(_ count: Int64) -> String { - byteFormatter.string(fromByteCount: count) - } -} - -enum SignpostType { - case begin, event, end - - @available(OSX 10.14, iOS 12.0, watchOS 5.0, tvOS 12.0, *) - var os: OSSignpostType { - switch self { - case .begin: return .begin - case .event: return .event - case .end: return .end - } - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Operation.swift b/Sources/StreamChatUI/StreamNuke/Internal/Operation.swift deleted file mode 100644 index cc337e644cc..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/Operation.swift +++ /dev/null @@ -1,97 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -final class Operation: Foundation.Operation, @unchecked Sendable { - private let _isExecuting: UnsafeMutablePointer - private let _isFinished: UnsafeMutablePointer - private let isFinishCalled: UnsafeMutablePointer - - override var isExecuting: Bool { - get { _isExecuting.pointee == 1 } - set { - guard OSAtomicCompareAndSwap32Barrier(newValue ? 0 : 1, newValue ? 1 : 0, _isExecuting) else { - return assertionFailure("Invalid state, operation is already (not) executing") - } - willChangeValue(forKey: "isExecuting") - didChangeValue(forKey: "isExecuting") - } - } - override var isFinished: Bool { - get { _isFinished.pointee == 1 } - set { - guard OSAtomicCompareAndSwap32Barrier(newValue ? 0 : 1, newValue ? 1 : 0, _isFinished) else { - return assertionFailure("Invalid state, operation is already finished") - } - willChangeValue(forKey: "isFinished") - didChangeValue(forKey: "isFinished") - } - } - - typealias Starter = (_ finish: @escaping () -> Void) -> Void - private let starter: Starter - - deinit { - self._isExecuting.deallocate() - self._isFinished.deallocate() - self.isFinishCalled.deallocate() - - #if TRACK_ALLOCATIONS - Allocations.decrement("Operation") - #endif - } - - init(starter: @escaping Starter) { - self.starter = starter - - self._isExecuting = UnsafeMutablePointer.allocate(capacity: 1) - self._isExecuting.initialize(to: 0) - - self._isFinished = UnsafeMutablePointer.allocate(capacity: 1) - self._isFinished.initialize(to: 0) - - self.isFinishCalled = UnsafeMutablePointer.allocate(capacity: 1) - self.isFinishCalled.initialize(to: 0) - - #if TRACK_ALLOCATIONS - Allocations.increment("Operation") - #endif - } - - override func start() { - guard !isCancelled else { - isFinished = true - return - } - isExecuting = true - starter { [weak self] in - self?._finish() - } - } - - private func _finish() { - // Make sure that we ignore if `finish` is called more than once. - if OSAtomicCompareAndSwap32Barrier(0, 1, isFinishCalled) { - isExecuting = false - isFinished = true - } - } -} - -extension OperationQueue { - /// Adds simple `BlockOperation`. - func add(_ closure: @escaping () -> Void) -> BlockOperation { - let operation = BlockOperation(block: closure) - addOperation(operation) - return operation - } - - /// Adds asynchronous operation (`Nuke.Operation`) with the given starter. - func add(_ starter: @escaping Operation.Starter) -> Operation { - let operation = Operation(starter: starter) - addOperation(operation) - return operation - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/ImagePipelineTask.swift b/Sources/StreamChatUI/StreamNuke/Internal/Tasks/ImagePipelineTask.swift deleted file mode 100644 index 3fd2a4010b5..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/ImagePipelineTask.swift +++ /dev/null @@ -1,43 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -// Each task holds a strong reference to the pipeline. This is by design. The -// user does not need to hold a strong reference to the pipeline. -class ImagePipelineTask: Task { - let pipeline: ImagePipeline - // A canonical request representing the unit work performed by the task. - let request: ImageRequest - - init(_ pipeline: ImagePipeline, _ request: ImageRequest) { - self.pipeline = pipeline - self.request = request - } - - /// Executes work on the pipeline synchronization queue. - func async(_ work: @escaping () -> Void) { - pipeline.queue.async(execute: work) - } -} - -// Returns all image tasks subscribed to the current pipeline task. -// A suboptimal approach just to make the new DiskCachPolicy.automatic work. -protocol ImageTaskSubscribers { - var imageTasks: [ImageTask] { get } -} - -extension ImageTask: ImageTaskSubscribers { - var imageTasks: [ImageTask] { - [self] - } -} - -extension ImagePipelineTask: ImageTaskSubscribers { - var imageTasks: [ImageTask] { - subscribers.flatMap { subscribers -> [ImageTask] in - (subscribers as? ImageTaskSubscribers)?.imageTasks ?? [] - } - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/OperationTask.swift b/Sources/StreamChatUI/StreamNuke/Internal/Tasks/OperationTask.swift deleted file mode 100644 index fe47966dfa9..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/OperationTask.swift +++ /dev/null @@ -1,34 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// A one-shot task for performing a single () -> T function. -final class OperationTask: Task { - private let pipeline: ImagePipeline - private let queue: OperationQueue - private let process: () -> T? - - init(_ pipeline: ImagePipeline, _ queue: OperationQueue, _ process: @escaping () -> T?) { - self.pipeline = pipeline - self.queue = queue - self.process = process - } - - override func start() { - operation = queue.add { [weak self] in - guard let self = self else { return } - let output = self.process() - self.pipeline.queue.async { - guard let output = output else { - self.send(error: Error()) - return - } - self.send(value: output, isCompleted: true) - } - } - } - - struct Error: Swift.Error {} -} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskFetchDecodedImage.swift b/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskFetchDecodedImage.swift deleted file mode 100644 index 4bbae2716aa..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskFetchDecodedImage.swift +++ /dev/null @@ -1,86 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// Receives data from ``TaskLoadImageData` and decodes it as it arrives. -final class TaskFetchDecodedImage: ImagePipelineTask { - private var decoder: ImageDecoding? - - override func start() { - dependency = pipeline.makeTaskFetchOriginalImageData(for: request).subscribe(self) { [weak self] in - self?.didReceiveData($0.0, urlResponse: $0.1, isCompleted: $1) - } - } - - /// Receiving data from `OriginalDataTask`. - private func didReceiveData(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool) { - guard isCompleted || pipeline.configuration.isProgressiveDecodingEnabled else { - return - } - - if !isCompleted && operation != nil { - return // Back pressure - already decoding another progressive data chunk - } - - if isCompleted { - operation?.cancel() // Cancel any potential pending progressive decoding tasks - } - - // Sanity check - guard !data.isEmpty else { - if isCompleted { - send(error: .decodingFailed) - } - return - } - - guard let decoder = decoder(data: data, urlResponse: urlResponse, isCompleted: isCompleted) else { - if isCompleted { - send(error: .decodingFailed) - } // Try again when more data is downloaded. - return - } - - // Fast-track default decoders, most work is already done during - // initialization anyway. - let decode = { - signpost(nukeLog, "DecodeImageData", isCompleted ? "FinalImage" : "ProgressiveImage") { - decoder.decode(data, urlResponse: urlResponse, isCompleted: isCompleted, cacheType: nil) - } - } - if !decoder.isAsynchronous { - self.sendResponse(decode(), isCompleted: isCompleted) - } else { - operation = pipeline.configuration.imageDecodingQueue.add { [weak self] in - guard let self = self else { return } - - let response = decode() - self.async { - self.sendResponse(response, isCompleted: isCompleted) - } - } - } - } - - private func sendResponse(_ response: ImageResponse?, isCompleted: Bool) { - if let response = response { - send(value: response, isCompleted: isCompleted) - } else if isCompleted { - send(error: .decodingFailed) - } - } - - // Lazily creates decoding for task - private func decoder(data: Data, urlResponse: URLResponse?, isCompleted: Bool) -> ImageDecoding? { - // Return the existing processor in case it has already been created. - if let decoder = self.decoder { - return decoder - } - let context = ImageDecodingContext(request: request, data: data, isCompleted: isCompleted, urlResponse: urlResponse) - let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) - self.decoder = decoder - return decoder - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskFetchWithPublisher.swift b/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskFetchWithPublisher.swift deleted file mode 100644 index bbca838ff9c..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskFetchWithPublisher.swift +++ /dev/null @@ -1,61 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// Fetches data using the publisher provided with the request. -/// Unlike `TaskFetchOriginalImageData`, there is no resumable data involved. -final class TaskFetchWithPublisher: ImagePipelineTask<(Data, URLResponse?)> { - private lazy var data = Data() - - override func start() { - // Wrap data request in an operation to limit the maximum number of - // concurrent data tasks. - operation = pipeline.configuration.dataLoadingQueue.add { [weak self] finish in - guard let self = self else { - return finish() - } - self.async { - self.loadData(finish: finish) - } - } - } - - // This methods gets called inside data loading operation (Operation). - private func loadData(finish: @escaping () -> Void) { - guard !isDisposed else { - return finish() - } - - guard let publisher = request.publisher else { - self.send(error: .dataLoadingFailed(URLError(.unknown, userInfo: [:]))) - return assertionFailure("This should never happen") - } - - let cancellable = publisher.sink(receiveCompletion: { [weak self] result in - finish() // Finish the operation! - guard let self = self else { return } - self.async { - switch result { - case .finished: - guard !self.data.isEmpty else { - return self.send(error: .dataLoadingFailed(URLError(.resourceUnavailable, userInfo: [:]))) - } - self.send(value: (self.data, nil), isCompleted: true) - case .failure(let error): - self.send(error: .dataLoadingFailed(error)) - } - } - }, receiveValue: { [weak self] data in - guard let self = self else { return } - self.async { - self.data.append(data) - } - }) - - onCancelled = { - cancellable.cancel() - } - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskLoadData.swift b/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskLoadData.swift deleted file mode 100644 index 54a624add16..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskLoadData.swift +++ /dev/null @@ -1,56 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// Wrapper for tasks created by `loadData` calls. -final class TaskLoadData: ImagePipelineTask<(Data, URLResponse?)> { - override func start() { - guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), - !request.options.contains(.disableDiskCacheReads) else { - loadData() - return - } - operation = pipeline.configuration.dataCachingQueue.add { [weak self] in - self?.getCachedData(dataCache: dataCache) - } - } - - private func getCachedData(dataCache: DataCaching) { - let data = signpost(nukeLog, "ReadCachedImageData") { - pipeline.cache.cachedData(for: request) - } - async { - if let data = data { - self.send(value: (data, nil), isCompleted: true) - } else { - self.loadData() - } - } - } - - private func loadData() { - guard !request.options.contains(.returnCacheDataDontLoad) else { - // Same error that URLSession produces when .returnCacheDataDontLoad is specified and the - // data is no found in the cache. - let error = NSError(domain: URLError.errorDomain, code: URLError.resourceUnavailable.rawValue, userInfo: nil) - return send(error: .dataLoadingFailed(error)) - } - - let request = self.request.withProcessors([]) - dependency = pipeline.makeTaskFetchOriginalImageData(for: request).subscribe(self) { [weak self] in - self?.didReceiveData($0.0, urlResponse: $0.1, isCompleted: $1) - } - } - - private func didReceiveData(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool) { - // Sanity check, should never happen in practice - guard !data.isEmpty else { - send(error: .dataLoadingFailed(URLError(.unknown, userInfo: [:]))) - return - } - - send(value: (data, urlResponse), isCompleted: isCompleted) - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskLoadImage.swift b/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskLoadImage.swift deleted file mode 100644 index ad7669b48a1..00000000000 --- a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskLoadImage.swift +++ /dev/null @@ -1,279 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// Wrapper for tasks created by `loadData` calls. -/// -/// Performs all the quick cache lookups and also manages image processing. -/// The coalesing for image processing is implemented on demand (extends the -/// scenarios in which coalescing can kick in). -final class TaskLoadImage: ImagePipelineTask { - override func start() { - // Memory cache lookup - if let image = pipeline.cache[request] { - let response = ImageResponse(container: image, cacheType: .memory) - send(value: response, isCompleted: !image.isPreview) - if !image.isPreview { - return // Already got the result! - } - } - - // Disk cache lookup - if let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), - !request.options.contains(.disableDiskCacheReads) { - operation = pipeline.configuration.dataCachingQueue.add { [weak self] in - self?.getCachedData(dataCache: dataCache) - } - return - } - - // Fetch image - fetchImage() - } - - // MARK: Disk Cache Lookup - - private func getCachedData(dataCache: DataCaching) { - let data = signpost(nukeLog, "ReadCachedProcessedImageData") { - pipeline.cache.cachedData(for: request) - } - async { - if let data = data { - self.didReceiveCachedData(data) - } else { - self.fetchImage() - } - } - } - - private func didReceiveCachedData(_ data: Data) { - guard !isDisposed else { return } - - let context = ImageDecodingContext(request: request, data: data, isCompleted: true, urlResponse: nil) - guard let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) else { - // This shouldn't happen in practice unless encoder/decoder pair - // for data cache is misconfigured. - return fetchImage() - } - - let decode = { - signpost(nukeLog, "DecodeCachedProcessedImageData") { - decoder.decode(data, urlResponse: nil, isCompleted: true, cacheType: .disk) - } - } - if !decoder.isAsynchronous { - didDecodeCachedData(decode()) - } else { - operation = pipeline.configuration.imageDecodingQueue.add { [weak self] in - guard let self = self else { return } - let response = decode() - self.async { - self.didDecodeCachedData(response) - } - } - } - } - - private func didDecodeCachedData(_ response: ImageResponse?) { - if let response = response { - decompressImage(response, isCompleted: true, isFromDiskCache: true) - } else { - fetchImage() - } - } - - // MARK: Fetch Image - - private func fetchImage() { - // Memory cache lookup for intermediate images. - // For example, for processors ["p1", "p2"], check only ["p1"]. - // Then apply the remaining processors. - // - // We are not performing data cache lookup for intermediate requests - // for now (because it's not free), but maybe adding an option would be worth it. - // You can emulate this behavior by manually creating intermediate requests. - if request.processors.count > 1 { - var processors = request.processors - var remaining: [ImageProcessing] = [] - if let last = processors.popLast() { - remaining.append(last) - } - while !processors.isEmpty { - if let image = pipeline.cache[request.withProcessors(processors)] { - let response = ImageResponse(container: image, cacheType: .memory) - process(response, isCompleted: !image.isPreview, processors: remaining) - if !image.isPreview { - return // Nothing left to do, just apply the processors - } else { - break - } - } - if let last = processors.popLast() { - remaining.append(last) - } - } - } - - let processors: [ImageProcessing] = request.processors.reversed() - // The only remaining choice is to fetch the image - if request.options.contains(.returnCacheDataDontLoad) { - // Same error that URLSession produces when .returnCacheDataDontLoad - // is specified and the data is no found in the cache. - let error = NSError(domain: URLError.errorDomain, code: URLError.resourceUnavailable.rawValue, userInfo: nil) - send(error: .dataLoadingFailed(error)) - } else if request.processors.isEmpty { - dependency = pipeline.makeTaskFetchDecodedImage(for: request).subscribe(self) { [weak self] in - self?.process($0, isCompleted: $1, processors: processors) - } - } else { - let request = self.request.withProcessors([]) - dependency = pipeline.makeTaskLoadImage(for: request).subscribe(self) { [weak self] in - self?.process($0, isCompleted: $1, processors: processors) - } - } - } - - // MARK: Processing - - /// - parameter processors: Remaining processors to by applied - private func process(_ response: ImageResponse, isCompleted: Bool, processors: [ImageProcessing]) { - guard !(ImagePipeline.Configuration._isAnimatedImageDataEnabled && response.image._animatedImageData != nil) else { - self.decompressImage(response, isCompleted: isCompleted) - return - } - - if isCompleted { - dependency2?.unsubscribe() // Cancel any potential pending progressive processing tasks - } else if dependency2 != nil { - return // Back pressure - already processing another progressive image - } - - _process(response, isCompleted: isCompleted, processors: processors) - } - - /// - parameter processors: Remaining processors to by applied - private func _process(_ response: ImageResponse, isCompleted: Bool, processors: [ImageProcessing]) { - guard let processor = processors.last else { - self.decompressImage(response, isCompleted: isCompleted) - return - } - - let key = ImageProcessingKey(image: response, processor: processor) - dependency2 = pipeline.makeTaskProcessImage(key: key, process: { [request] in - let context = ImageProcessingContext(request: request, response: response, isFinal: isCompleted) - return signpost(nukeLog, "ProcessImage", isCompleted ? "FinalImage" : "ProgressiveImage") { - response.map { processor.process($0, context: context) } - } - }).subscribe(priority: priority) { [weak self] event in - guard let self = self else { return } - if event.isCompleted { - self.dependency2 = nil - } - switch event { - case .value(let response, _): - self._process(response, isCompleted: isCompleted, processors: processors.dropLast()) - case .error: - if isCompleted { - self.send(error: .processingFailed(processor)) - } - case .progress: - break // Do nothing (Not reported by OperationTask) - } - } - } - - // MARK: Decompression - - #if os(macOS) - private func decompressImage(_ response: ImageResponse, isCompleted: Bool, isFromDiskCache: Bool = false) { - storeImageInCaches(response, isFromDiskCache: isFromDiskCache) - send(value: response, isCompleted: isCompleted) // There is no decompression on macOS - } - #else - private func decompressImage(_ response: ImageResponse, isCompleted: Bool, isFromDiskCache: Bool = false) { - guard isDecompressionNeeded(for: response) else { - storeImageInCaches(response, isFromDiskCache: isFromDiskCache) - send(value: response, isCompleted: isCompleted) - return - } - - if isCompleted { - operation?.cancel() // Cancel any potential pending progressive decompression tasks - } else if operation != nil { - return // Back-pressure: we are receiving data too fast - } - - guard !isDisposed else { return } - - operation = pipeline.configuration.imageDecompressingQueue.add { [weak self] in - guard let self = self else { return } - - let response = signpost(nukeLog, "DecompressImage", isCompleted ? "FinalImage" : "ProgressiveImage") { - response.map { $0.map(ImageDecompression.decompress(image:)) } ?? response - } - - self.async { - self.storeImageInCaches(response, isFromDiskCache: isFromDiskCache) - self.send(value: response, isCompleted: isCompleted) - } - } - } - - private func isDecompressionNeeded(for response: ImageResponse) -> Bool { - return pipeline.configuration.isDecompressionEnabled && - ImageDecompression.isDecompressionNeeded(for: response.image) ?? false && - !(ImagePipeline.Configuration._isAnimatedImageDataEnabled && response.image._animatedImageData != nil) - } - #endif - - // MARK: Caching - - private func storeImageInCaches(_ response: ImageResponse, isFromDiskCache: Bool) { - guard subscribers.contains(where: { $0 is ImageTask }) else { - return // Only store for direct requests - } - // Memory cache (ImageCaching) - pipeline.cache[request] = response.container - // Disk cache (DataCaching) - if !isFromDiskCache { - storeImageInDataCache(response) - } - } - - private func storeImageInDataCache(_ response: ImageResponse) { - guard !response.container.isPreview else { - return - } - guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), shouldStoreFinalImageInDiskCache() else { - return - } - let context = ImageEncodingContext(request: request, image: response.image, urlResponse: response.urlResponse) - let encoder = pipeline.delegate.imageEncoder(for: context, pipeline: pipeline) - let key = pipeline.cache.makeDataCacheKey(for: request) - pipeline.configuration.imageEncodingQueue.addOperation { [weak pipeline, request] in - guard let pipeline = pipeline else { return } - let encodedData = signpost(nukeLog, "EncodeImage") { - encoder.encode(response.container, context: context) - } - guard let data = encodedData else { return } - pipeline.delegate.willCache(data: data, image: response.container, for: request, pipeline: pipeline) { - guard let data = $0 else { return } - // Important! Storing directly ignoring `ImageRequest.Options`. - dataCache.storeData(data, for: key) // This is instant, writes are async - } - } - if pipeline.configuration.debugIsSyncImageEncoding { // Only for debug - pipeline.configuration.imageEncodingQueue.waitUntilAllOperationsAreFinished() - } - } - - private func shouldStoreFinalImageInDiskCache() -> Bool { - guard request.url?.isCacheable ?? false else { - return false - } - let policy = pipeline.configuration.dataCachePolicy - return ((policy == .automatic || policy == .storeAll) && !request.processors.isEmpty) || policy == .storeEncodedImages - } -} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Caching/Cache.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Caching/Cache.swift new file mode 100644 index 00000000000..8332af026b2 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Caching/Cache.swift @@ -0,0 +1,213 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if os(iOS) || os(tvOS) || os(visionOS) +import UIKit.UIApplication +#endif + +// Internal memory-cache implementation. +final class NukeCache: @unchecked Sendable { + // Can't use `NSCache` because it is not LRU + + struct Configuration { + var costLimit: Int + var countLimit: Int + var ttl: TimeInterval? + var entryCostLimit: Double + } + + var conf: Configuration { + get { withLock { _conf } } + set { withLock { _conf = newValue } } + } + + private var _conf: Configuration { + didSet { _trim() } + } + + var totalCost: Int { + withLock { _totalCost } + } + + var totalCount: Int { + withLock { map.count } + } + + private var _totalCost = 0 + private var map = [Key: LinkedList.Node]() + private let list = LinkedList() + private let lock: os_unfair_lock_t + private let memoryPressure: DispatchSourceMemoryPressure + private var notificationObserver: AnyObject? + + init(costLimit: Int, countLimit: Int) { + self._conf = Configuration(costLimit: costLimit, countLimit: countLimit, ttl: nil, entryCostLimit: 0.1) + + self.lock = .allocate(capacity: 1) + self.lock.initialize(to: os_unfair_lock()) + + self.memoryPressure = DispatchSource.makeMemoryPressureSource(eventMask: [.warning, .critical], queue: .main) + self.memoryPressure.setEventHandler { [weak self] in + self?.removeAllCachedValues() + } + self.memoryPressure.resume() + +#if os(iOS) || os(tvOS) || os(visionOS) + Task { + await registerForEnterBackground() + } +#endif + } + + deinit { + lock.deinitialize(count: 1) + lock.deallocate() + + memoryPressure.cancel() + } + +#if os(iOS) || os(tvOS) || os(visionOS) + @MainActor private func registerForEnterBackground() { + notificationObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in + self?.clearCacheOnEnterBackground() + } + } +#endif + + func value(forKey key: Key) -> Value? { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + + guard let node = map[key] else { + return nil + } + + guard !node.value.isExpired else { + _remove(node: node) + return nil + } + + // bubble node up to make it last added (most recently used) + list.remove(node) + list.append(node) + + return node.value.value + } + + func set(_ value: Value, forKey key: Key, cost: Int = 0, ttl: TimeInterval? = nil) { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + + // Take care of overflow or cache size big enough to fit any + // reasonable content (and also of costLimit = Int.max). + let sanitizedEntryLimit = max(0, min(_conf.entryCostLimit, 1)) + guard _conf.costLimit > 2_147_483_647 || cost < Int(sanitizedEntryLimit * Double(_conf.costLimit)) else { + return + } + + let ttl = ttl ?? _conf.ttl + let expiration = ttl.map { Date() + $0 } + let entry = Entry(value: value, key: key, cost: cost, expiration: expiration) + _add(entry) + _trim() // _trim is extremely fast, it's OK to call it each time + } + + @discardableResult + func removeValue(forKey key: Key) -> Value? { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + + guard let node = map[key] else { + return nil + } + _remove(node: node) + return node.value.value + } + + private func _add(_ element: Entry) { + if let existingNode = map[element.key] { + // This is slightly faster than calling _remove because of the + // skipped dictionary access + list.remove(existingNode) + _totalCost -= existingNode.value.cost + } + map[element.key] = list.append(element) + _totalCost += element.cost + } + + private func _remove(node: LinkedList.Node) { + list.remove(node) + map[node.value.key] = nil + _totalCost -= node.value.cost + } + + func removeAllCachedValues() { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + + map.removeAll() + list.removeAllElements() + _totalCost = 0 + } + + private dynamic func clearCacheOnEnterBackground() { + // Remove most of the stored items when entering background. + // This behavior is similar to `NSCache` (which removes all + // items). This feature is not documented and may be subject + // to change in future Nuke versions. + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + + _trim(toCost: Int(Double(_conf.costLimit) * 0.1)) + _trim(toCount: Int(Double(_conf.countLimit) * 0.1)) + } + + private func _trim() { + _trim(toCost: _conf.costLimit) + _trim(toCount: _conf.countLimit) + } + + func trim(toCost limit: Int) { + withLock { _trim(toCost: limit) } + } + + private func _trim(toCost limit: Int) { + _trim(while: { _totalCost > limit }) + } + + func trim(toCount limit: Int) { + withLock { _trim(toCount: limit) } + } + + private func _trim(toCount limit: Int) { + _trim(while: { map.count > limit }) + } + + private func _trim(while condition: () -> Bool) { + while condition(), let node = list.first { // least recently used + _remove(node: node) + } + } + + private func withLock(_ closure: () -> T) -> T { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return closure() + } + + private struct Entry { + let value: Value + let key: Key + let cost: Int + let expiration: Date? + var isExpired: Bool { + guard let expiration else { + return false + } + return expiration.timeIntervalSinceNow < 0 + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Core/DataCache.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Caching/DataCache.swift similarity index 73% rename from Sources/StreamChatUI/StreamNuke/Core/DataCache.swift rename to Sources/StreamChatUI/StreamNuke/Nuke/Caching/DataCache.swift index f47d3e26c1f..bf373926fd1 100644 --- a/Sources/StreamChatUI/StreamNuke/Core/DataCache.swift +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Caching/DataCache.swift @@ -1,68 +1,39 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation -// MARK: - DataCaching - -/// Data cache. -/// -/// - warning: The implementation must be thread safe. -protocol DataCaching { - /// Retrieves data from cache for the given key. - func cachedData(for key: String) -> Data? - - /// Returns `true` if the cache contains data for the given key. - func containsData(for key: String) -> Bool - - /// Stores data for the given key. - /// - note: The implementation must return immediately and store data - /// asynchronously. - func storeData(_ data: Data, for key: String) - - /// Removes data for the given key. - func removeData(for key: String) - - /// Removes all items. - func removeAll() -} - -// MARK: - DataCache - /// An LRU disk cache that stores data in separate files. /// -/// The DataCache uses LRU cleanup policy (least recently used items are removed +/// ``DataCache`` uses LRU cleanup policy (least recently used items are removed /// first). The elements stored in the cache are automatically discarded if /// either *cost* or *count* limit is reached. The sweeps are performed periodically. /// /// DataCache always writes and removes data asynchronously. It also allows for -/// reading and writing data in parallel. This is implemented using a "staging" +/// reading and writing data in parallel. It is implemented using a staging /// area which stores changes until they are flushed to disk: /// -/// // Schedules data to be written asynchronously and returns immediately -/// cache[key] = data -/// -/// // The data is returned from the staging area -/// let data = cache[key] +/// ```swift +/// // Schedules data to be written asynchronously and returns immediately +/// cache[key] = data /// -/// // Schedules data to be removed asynchronously and returns immediately -/// cache[key] = nil +/// // The data is returned from the staging area +/// let data = cache[key] /// -/// // Data is nil -/// let data = cache[key] +/// // Schedules data to be removed asynchronously and returns immediately +/// cache[key] = nil /// -/// Thread-safe. +/// // Data is nil +/// let data = cache[key] +/// ``` /// -/// - warning: It's possible to have more than one instance of `DataCache` with -/// the same `path` but it is not recommended. -final class DataCache: DataCaching { - /// A cache key. - typealias Key = String - +/// - important: It's possible to have more than one instance of ``DataCache`` with +/// the same path but it is not recommended. +final class DataCache: DataCaching, @unchecked Sendable { /// Size limit in bytes. `150 Mb` by default. /// - /// Changes to `sizeLimit` will take effect when the next LRU sweep is run. + /// Changes to the size limit will take effect when the next LRU sweep is run. var sizeLimit: Int = 1024 * 1024 * 150 /// When performing a sweep, the cache will remote entries until the size of @@ -74,17 +45,16 @@ final class DataCache: DataCaching { /// The path for the directory managed by the cache. let path: URL - /// The number of seconds between each LRU sweep. 30 by default. - /// The first sweep is performed right after the cache is initialized. - /// - /// Sweeps are performed in a background and can be performed in parallel - /// with reading. - var sweepInterval: TimeInterval = 30 + /// The time interval between cache sweeps. The default value is 1 hour. + var sweepInterval: TimeInterval = 3600 - /// The delay after which the initial sweep is performed. 10 by default. - /// The initial sweep is performed after a delay to avoid competing with - /// other subsystems for the resources. - private var initialSweepDelay: TimeInterval = 10 + // Deprecated in Nuke 12.2 + @available(*, deprecated, message: "It's not recommended to use compression with the popular image formats that already compress the data") + var isCompressionEnabled: Bool { + get { _isCompressionEnabled } + set { _isCompressionEnabled = newValue } + } + var _isCompressionEnabled = false // Staging @@ -92,8 +62,13 @@ final class DataCache: DataCaching { private var staging = Staging() private var isFlushNeeded = false private var isFlushScheduled = false + var flushInterval: DispatchTimeInterval = .seconds(1) + private struct Metadata: Codable { + var lastSweepDate: Date? + } + /// A queue which is used for disk I/O. let queue = DispatchQueue(label: "com.github.kean.Nuke.DataCache.WriteQueue", qos: .utility) @@ -112,6 +87,7 @@ final class DataCache: DataCaching { /// - parameter filenameGenerator: Generates a filename for the given URL. /// The default implementation generates a filename using SHA1 hash function. convenience init(name: String, filenameGenerator: @escaping (String) -> String? = DataCache.filename(for:)) throws { + // This should be replaced with URL.cachesDirectory on iOS 16, which never fails guard let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil) } @@ -125,35 +101,37 @@ final class DataCache: DataCaching { self.path = path self.filenameGenerator = filenameGenerator try self.didInit() - - #if TRACK_ALLOCATIONS - Allocations.increment("DataCache") - #endif - } - - deinit { - #if TRACK_ALLOCATIONS - Allocations.decrement("ImageCache") - #endif } /// A `FilenameGenerator` implementation which uses SHA1 hash function to /// generate a filename from the given key. static func filename(for key: String) -> String? { - key.sha1 + key.isEmpty ? nil : key.sha1 } private func didInit() throws { try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true, attributes: nil) - queue.asyncAfter(deadline: .now() + initialSweepDelay) { [weak self] in - self?.performAndScheduleSweep() + scheduleSweep() + } + + private func scheduleSweep() { + if let lastSweepDate = getMetadata().lastSweepDate, + Date().timeIntervalSince(lastSweepDate) < sweepInterval { + return // Already completed recently + } + // Add a bit of a delay to free the resources during launch + queue.asyncAfter(deadline: .now() + 5.0, qos: .background) { [weak self] in + self?.performSweep() + self?.updateMetadata { + $0.lastSweepDate = Date() + } } } // MARK: DataCaching /// Retrieves data for the given key. - func cachedData(for key: Key) -> Data? { + func cachedData(for key: String) -> Data? { if let change = change(for: key) { switch change { // Change wasn't flushed to disk yet case let .add(data): @@ -165,7 +143,7 @@ final class DataCache: DataCaching { guard let url = url(for: key) else { return nil } - return try? Data(contentsOf: url) + return try? decompressed(Data(contentsOf: url)) } /// Returns `true` if the cache contains the data for the given key. @@ -192,20 +170,20 @@ final class DataCache: DataCaching { /// Stores data for the given key. The method returns instantly and the data /// is written asynchronously. - func storeData(_ data: Data, for key: Key) { + func storeData(_ data: Data, for key: String) { stage { staging.add(data: data, for: key) } } /// Removes data for the given key. The method returns instantly, the data /// is removed asynchronously. - func removeData(for key: Key) { + func removeData(for key: String) { stage { staging.removeData(for: key) } } /// Removes all items. The method returns instantly, the data is removed /// asynchronously. func removeAll() { - stage { staging.removeAll() } + stage { staging.removeAllStagedChanges() } } private func stage(_ change: () -> Void) { @@ -224,19 +202,20 @@ final class DataCache: DataCaching { /// in a staging area and returns immediately. The staging area allows for /// reading and writing data in parallel. /// - /// // Schedules data to be written asynchronously and returns immediately - /// cache[key] = data + /// ```swift + /// // Schedules data to be written asynchronously and returns immediately + /// cache[key] = data /// - /// // The data is returned from the staging area - /// let data = cache[key] + /// // The data is returned from the staging area + /// let data = cache[key] /// - /// // Schedules data to be removed asynchronously and returns immediately - /// cache[key] = nil + /// // Schedules data to be removed asynchronously and returns immediately + /// cache[key] = nil /// - /// // Data is nil - /// let data = cache[key] - /// - subscript(key: Key) -> Data? { + /// // Data is nil + /// let data = cache[key] + /// ``` + subscript(key: String) -> Data? { get { cachedData(for: key) } @@ -251,17 +230,15 @@ final class DataCache: DataCaching { // MARK: Managing URLs - /// Uses the `FilenameGenerator` that the cache was initialized with to + /// Uses the the filename generator that the cache was initialized with to /// generate and return a filename for the given key. - func filename(for key: Key) -> String? { + func filename(for key: String) -> String? { filenameGenerator(key) } /// Returns `url` for the given cache key. - func url(for key: Key) -> URL? { - guard let filename = self.filename(for: key) else { - return nil - } + func url(for key: String) -> URL? { + guard let filename = self.filename(for: key) else { return nil } return self.path.appendingPathComponent(filename, isDirectory: false) } @@ -270,16 +247,16 @@ final class DataCache: DataCaching { /// Synchronously waits on the caller's thread until all outstanding disk I/O /// operations are finished. func flush() { - queue.sync(execute: flushChangesIfNeeded) + queue.sync { self.flushChangesIfNeeded() } } /// Synchronously waits on the caller's thread until all outstanding disk I/O /// operations for the given key are finished. - func flush(for key: Key) { + func flush(for key: String) { queue.sync { - guard let change = lock.sync({ staging.changes[key] }) else { return } + guard let change = lock.withLock({ staging.changes[key] }) else { return } perform(change) - lock.sync { staging.flushed(change) } + lock.withLock { staging.flushed(change) } } } @@ -292,7 +269,7 @@ final class DataCache: DataCaching { private func scheduleNextFlush() { guard !isFlushScheduled else { return } isFlushScheduled = true - queue.asyncAfter(deadline: .now() + flushInterval, execute: flushChangesIfNeeded) + queue.asyncAfter(deadline: .now() + flushInterval) { self.flushChangesIfNeeded() } } private func flushChangesIfNeeded() { @@ -345,30 +322,39 @@ final class DataCache: DataCaching { switch change.type { case let .add(data): do { - try data.write(to: url) + try compressed(data).write(to: url) } catch let error as NSError { guard error.code == CocoaError.fileNoSuchFile.rawValue && error.domain == CocoaError.errorDomain else { return } try? FileManager.default.createDirectory(at: self.path, withIntermediateDirectories: true, attributes: nil) - try? data.write(to: url) // re-create a directory and try again + try? compressed(data).write(to: url) // re-create a directory and try again } case .remove: try? FileManager.default.removeItem(at: url) } } - // MARK: Sweep + // MARK: Compression - private func performAndScheduleSweep() { - performSweep() - queue.asyncAfter(deadline: .now() + sweepInterval) { [weak self] in - self?.performAndScheduleSweep() + private func compressed(_ data: Data) throws -> Data { + guard _isCompressionEnabled else { + return data } + return try (data as NSData).compressed(using: .lzfse) as Data } + private func decompressed(_ data: Data) throws -> Data { + guard _isCompressionEnabled else { + return data + } + return try (data as NSData).decompressed(using: .lzfse) as Data + } + + // MARK: Sweep + /// Synchronously performs a cache sweep and removes the least recently items /// which no longer fit in cache. func sweep() { - queue.sync(execute: performSweep) + queue.sync { self.performSweep() } } /// Discards the least recently used items first. @@ -383,7 +369,7 @@ final class DataCache: DataCaching { return // All good, no need to perform any work. } - let targetSizeLimit = Int(Double(self.sizeLimit) * trimRatio) + let targetSizeLimit = Int(Double(sizeLimit) * trimRatio) // Most recently accessed items first let past = Date.distantPast @@ -418,10 +404,31 @@ final class DataCache: DataCaching { } } + // MARK: Metadata + + private func getMetadata() -> Metadata { + if let data = try? Data(contentsOf: metadataFileURL), + let metadata = try? JSONDecoder().decode(Metadata.self, from: data) { + return metadata + } + return Metadata() + } + + private func updateMetadata(_ closure: (inout Metadata) -> Void) { + var metadata = getMetadata() + closure(&metadata) + try? JSONEncoder().encode(metadata).write(to: metadataFileURL) + } + + private var metadataFileURL: URL { + path.appendingPathComponent(".data-cache-info", isDirectory: false) + } + // MARK: Inspection /// The total number of items in the cache. - /// - warning: Requires disk IO, avoid using from the main thread. + /// + /// - important: Requires disk IO, avoid using from the main thread. var totalCount: Int { contents().count } @@ -432,7 +439,7 @@ final class DataCache: DataCaching { /// The total allocated size (see `totalAllocatedSize`. on disk might /// actually be bigger. /// - /// - warning: Requires disk IO, avoid using from the main thread. + /// - important: Requires disk IO, avoid using from the main thread. var totalSize: Int { contents(keys: [.fileSizeKey]).reduce(0) { $0 + ($1.meta.fileSize ?? 0) @@ -443,7 +450,7 @@ final class DataCache: DataCaching { /// /// Uses `URLResourceKey.totalFileAllocatedSizeKey`. /// - /// - warning: Requires disk IO, avoid using from the main thread. + /// - important: Requires disk IO, avoid using from the main thread. var totalAllocatedSize: Int { contents(keys: [.totalFileAllocatedSizeKey]).reduce(0) { $0 + ($1.meta.totalFileAllocatedSize ?? 0) @@ -504,7 +511,7 @@ private struct Staging { changes[key] = Change(key: key, id: nextChangeId, type: .remove) } - mutating func removeAll() { + mutating func removeAllStagedChanges() { nextChangeId += 1 changeRemoveAll = ChangeRemoveAll(id: nextChangeId) changes.removeAll() diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Caching/DataCaching.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Caching/DataCaching.swift new file mode 100644 index 00000000000..39e58c7b56f --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Caching/DataCaching.swift @@ -0,0 +1,27 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Data cache. +/// +/// - important: The implementation must be thread safe. +protocol DataCaching: Sendable { + /// Retrieves data from cache for the given key. + func cachedData(for key: String) -> Data? + + /// Returns `true` if the cache contains data for the given key. + func containsData(for key: String) -> Bool + + /// Stores data for the given key. + /// - note: The implementation must return immediately and store data + /// asynchronously. + func storeData(_ data: Data, for key: String) + + /// Removes data for the given key. + func removeData(for key: String) + + /// Removes all items. + func removeAll() +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Caching/ImageCache.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Caching/ImageCache.swift new file mode 100644 index 00000000000..dc6af2da747 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Caching/ImageCache.swift @@ -0,0 +1,116 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation +#if !os(macOS) +import UIKit +#else +import Cocoa +#endif + +/// An LRU memory cache. +/// +/// The elements stored in cache are automatically discarded if either *cost* or +/// *count* limit is reached. The default cost limit represents a number of bytes +/// and is calculated based on the amount of physical memory available on the +/// device. The default count limit is set to `Int.max`. +/// +/// ``ImageCache`` automatically removes all stored elements when it receives a +/// memory warning. It also automatically removes *most* stored elements +/// when the app enters the background. +final class ImageCache: ImageCaching { + private let impl: NukeCache + + /// The maximum total cost that the cache can hold. + var costLimit: Int { + get { impl.conf.costLimit } + set { impl.conf.costLimit = newValue } + } + + /// The maximum number of items that the cache can hold. + var countLimit: Int { + get { impl.conf.countLimit } + set { impl.conf.countLimit = newValue } + } + + /// Default TTL (time to live) for each entry. Can be used to make sure that + /// the entries get validated at some point. `nil` (never expire) by default. + var ttl: TimeInterval? { + get { impl.conf.ttl } + set { impl.conf.ttl = newValue } + } + + /// The maximum cost of an entry in proportion to the ``costLimit``. + /// By default, `0.1`. + var entryCostLimit: Double { + get { impl.conf.entryCostLimit } + set { impl.conf.entryCostLimit = newValue } + } + + /// The total number of items in the cache. + var totalCount: Int { impl.totalCount } + + /// The total cost of items in the cache. + var totalCost: Int { impl.totalCost } + + /// Shared `Cache` instance. + static let shared = ImageCache() + + /// Initializes `Cache`. + /// - parameter costLimit: Default value represents a number of bytes and is + /// calculated based on the amount of the physical memory available on the device. + /// - parameter countLimit: `Int.max` by default. + init(costLimit: Int = ImageCache.defaultCostLimit(), countLimit: Int = Int.max) { + impl = NukeCache(costLimit: costLimit, countLimit: countLimit) + } + + /// Returns a cost limit computed based on the amount of the physical memory + /// available on the device. The limit is capped at 512 MB. + static func defaultCostLimit() -> Int { + let physicalMemory = ProcessInfo.processInfo.physicalMemory + let ratio = physicalMemory <= (536_870_912 /* 512 Mb */) ? 0.1 : 0.2 + let limit = min(536_870_912, physicalMemory / UInt64(1 / ratio)) + return Int(limit) + } + + subscript(key: ImageCacheKey) -> ImageContainer? { + get { impl.value(forKey: key) } + set { + if let image = newValue { + impl.set(image, forKey: key, cost: cost(for: image)) + } else { + impl.removeValue(forKey: key) + } + } + } + + /// Removes all cached images. + func removeAll() { + impl.removeAllCachedValues() + } + /// Removes least recently used items from the cache until the total cost + /// of the remaining items is less than the given cost limit. + func trim(toCost limit: Int) { + impl.trim(toCost: limit) + } + + /// Removes least recently used items from the cache until the total count + /// of the remaining items is less than the given count limit. + func trim(toCount limit: Int) { + impl.trim(toCount: limit) + } + + /// Returns cost for the given image by approximating its bitmap size in bytes in memory. + func cost(for container: ImageContainer) -> Int { + let dataCost = container.data?.count ?? 0 + + // bytesPerRow * height gives a rough estimation of how much memory + // image uses in bytes. In practice this algorithm combined with a + // conservative default cost limit works OK. + guard let cgImage = container.image.cgImage else { + return 1 + dataCost + } + return cgImage.bytesPerRow * cgImage.height + dataCost + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Caching/ImageCaching.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Caching/ImageCaching.swift new file mode 100644 index 00000000000..9ea9c42931d --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Caching/ImageCaching.swift @@ -0,0 +1,37 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// In-memory image cache. +/// +/// The implementation must be thread safe. +protocol ImageCaching: AnyObject, Sendable { + /// Access the image cached for the given request. + subscript(key: ImageCacheKey) -> ImageContainer? { get set } + + /// Removes all caches items. + func removeAll() +} + +/// An opaque container that acts as a cache key. +/// +/// In general, you don't construct it directly, and use ``ImagePipeline`` or ``ImagePipeline/Cache-swift.struct`` APIs. +struct ImageCacheKey: Hashable, Sendable { + let key: Inner + + // This is faster than using AnyHashable (and it shows in performance tests). + enum Inner: Hashable, Sendable { + case custom(String) + case `default`(MemoryCacheKey) + } + + init(key: String) { + self.key = .custom(key) + } + + init(request: ImageRequest) { + self.key = .default(MemoryCacheKey(request)) + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/AssetType.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/AssetType.swift new file mode 100644 index 00000000000..a6ab62f5c73 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/AssetType.swift @@ -0,0 +1,89 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A uniform type identifier (UTI). +struct AssetType: ExpressibleByStringLiteral, Hashable, Sendable { + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } + + init(stringLiteral value: String) { + self.rawValue = value + } + + static let png: AssetType = "public.png" + static let jpeg: AssetType = "public.jpeg" + static let gif: AssetType = "com.compuserve.gif" + /// HEIF (High Efficiency Image Format) by Apple. + static let heic: AssetType = "public.heic" + + /// WebP + /// + /// Native decoding support only available on the following platforms: macOS 11, + /// iOS 14, watchOS 7, tvOS 14. + static let webp: AssetType = "public.webp" + + static let mp4: AssetType = "public.mpeg4" + + /// The M4V file format is a video container format developed by Apple and + /// is very similar to the MP4 format. The primary difference is that M4V + /// files may optionally be protected by DRM copy protection. + static let m4v: AssetType = "public.m4v" + + static let mov: AssetType = "public.mov" +} + +extension AssetType { + /// Determines a type of the image based on the given data. + init?(_ data: Data) { + guard let type = AssetType.make(data) else { + return nil + } + self = type + } + + private static func make(_ data: Data) -> AssetType? { + func _match(_ numbers: [UInt8?], offset: Int = 0) -> Bool { + guard data.count >= numbers.count else { + return false + } + return zip(numbers.indices, numbers).allSatisfy { index, number in + guard let number else { return true } + guard (index + offset) < data.count else { return false } + return data[index + offset] == number + } + } + + // JPEG magic numbers https://en.wikipedia.org/wiki/JPEG + if _match([0xFF, 0xD8, 0xFF]) { return .jpeg } + + // PNG Magic numbers https://en.wikipedia.org/wiki/Portable_Network_Graphics + if _match([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { return .png } + + // GIF magic numbers https://en.wikipedia.org/wiki/GIF + if _match([0x47, 0x49, 0x46]) { return .gif } + + // WebP magic numbers https://en.wikipedia.org/wiki/List_of_file_signatures + if _match([0x52, 0x49, 0x46, 0x46, nil, nil, nil, nil, 0x57, 0x45, 0x42, 0x50]) { return .webp } + + // see https://stackoverflow.com/questions/21879981/avfoundation-avplayer-supported-formats-no-vob-or-mpg-containers + // https://en.wikipedia.org/wiki/List_of_file_signatures + if _match([0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D], offset: 4) { return .mp4 } + + // https://www.garykessler.net/library/file_sigs.html + if _match([0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32], offset: 4) { return .m4v } + + if _match([0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x56, 0x20], offset: 4) { return .m4v } + + // MOV magic numbers https://www.garykessler.net/library/file_sigs.html + if _match([0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20], offset: 4) { return .mov } + + // Either not enough data, or we just don't support this format. + return nil + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoderRegistry.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoderRegistry.swift new file mode 100644 index 00000000000..ca23b5adc8a --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoderRegistry.swift @@ -0,0 +1,72 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A registry of image codecs. +final class ImageDecoderRegistry: @unchecked Sendable { + /// A shared registry. + static let shared = ImageDecoderRegistry() + + private var matches = [(ImageDecodingContext) -> (any ImageDecoding)?]() + private let lock = NSLock() + + /// Initializes a custom registry. + init() { + register(ImageDecoders.Default.init) + } + + /// Returns a decoder that matches the given context. + func decoder(for context: ImageDecodingContext) -> (any ImageDecoding)? { + lock.lock() + defer { lock.unlock() } + + for match in matches.reversed() { + if let decoder = match(context) { + return decoder + } + } + return nil + } + + /// Registers a decoder to be used in a given decoding context. + /// + /// **Progressive Decoding** + /// + /// The decoder is created once and is used for the entire decoding session, + /// including progressively decoded images. If the decoder doesn't support + /// progressive decoding, return `nil` when `isCompleted` is `false`. + func register(_ match: @escaping (ImageDecodingContext) -> (any ImageDecoding)?) { + lock.lock() + defer { lock.unlock() } + + matches.append(match) + } + + /// Removes all registered decoders. + func clear() { + lock.lock() + defer { lock.unlock() } + + matches = [] + } +} + +/// Image decoding context used when selecting which decoder to use. +struct ImageDecodingContext: @unchecked Sendable { + var request: ImageRequest + var data: Data + /// Returns `true` if the download was completed. + var isCompleted: Bool + var urlResponse: URLResponse? + var cacheType: ImageResponse.CacheType? + + init(request: ImageRequest, data: Data, isCompleted: Bool = true, urlResponse: URLResponse? = nil, cacheType: ImageResponse.CacheType? = nil) { + self.request = request + self.data = data + self.isCompleted = isCompleted + self.urlResponse = urlResponse + self.cacheType = cacheType + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoders+Default.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoders+Default.swift new file mode 100644 index 00000000000..7e07d71d8d1 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoders+Default.swift @@ -0,0 +1,217 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +#if !os(macOS) +import UIKit +#else +import Cocoa +#endif + +/// A namespace with all available decoders. +enum ImageDecoders {} + +extension ImageDecoders { + + /// A decoder that supports all of the formats natively supported by the system. + /// + /// - note: The decoder automatically sets the scale of the decoded images to + /// match the scale of the screen. + /// + /// - note: The default decoder supports progressive JPEG. It produces a new + /// preview every time it encounters a new full frame. + final class Default: ImageDecoding, @unchecked Sendable { + // Number of scans that the decoder has found so far. The last scan might be + // incomplete at this point. + var numberOfScans: Int { scanner.numberOfScans } + private var scanner = ProgressiveJPEGScanner() + + private var isPreviewForGIFGenerated = false + private var scale: CGFloat = 1.0 + private var thumbnail: ImageRequest.ThumbnailOptions? + private let lock = NSLock() + + var isAsynchronous: Bool { thumbnail != nil } + + init() { } + + /// Returns `nil` if progressive decoding is not allowed for the given + /// content. + init?(context: ImageDecodingContext) { + self.scale = context.request.scale.map { CGFloat($0) } ?? self.scale + self.thumbnail = context.request.thumbnail + + if !context.isCompleted && !isProgressiveDecodingAllowed(for: context.data) { + return nil // Progressive decoding not allowed for this image + } + } + + func decode(_ data: Data) throws -> ImageContainer { + lock.lock() + defer { lock.unlock() } + + func makeImage() -> PlatformImage? { + if let thumbnail { + return makeThumbnail(data: data, + options: thumbnail, + scale: scale) + } + return ImageDecoders.Default._decode(data, scale: scale) + } + guard let image = makeImage() else { + throw ImageDecodingError.unknown + } + let type = AssetType(data) + var container = ImageContainer(image: image) + container.type = type + if type == .gif { + container.data = data + } + if numberOfScans > 0 { + container.userInfo[.scanNumberKey] = numberOfScans + } + if thumbnail != nil { + container.userInfo[.isThumbnailKey] = true + } + return container + } + + func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { + lock.lock() + defer { lock.unlock() } + + let assetType = AssetType(data) + if assetType == .gif { // Special handling for GIF + if !isPreviewForGIFGenerated, let image = ImageDecoders.Default._decode(data, scale: scale) { + isPreviewForGIFGenerated = true + return ImageContainer(image: image, type: .gif, isPreview: true, userInfo: [:]) + } + return nil + } + + guard let endOfScan = scanner.scan(data), endOfScan > 0 else { + return nil + } + guard let image = ImageDecoders.Default._decode(data[0...endOfScan], scale: scale) else { + return nil + } + return ImageContainer(image: image, type: assetType, isPreview: true, userInfo: [.scanNumberKey: numberOfScans]) + } + } +} + +private func isProgressiveDecodingAllowed(for data: Data) -> Bool { + let assetType = AssetType(data) + + // Determined whether the image supports progressive decoding or not + // (only proressive JPEG is allowed for now, but you can add support + // for other formats by implementing your own decoder). + if assetType == .jpeg, ImageProperties.JPEG(data)?.isProgressive == true { + return true + } + + // Generate one preview for GIF. + if assetType == .gif { + return true + } + + return false +} + +private struct ProgressiveJPEGScanner: Sendable { + // Number of scans that the decoder has found so far. The last scan might be + // incomplete at this point. + private(set) var numberOfScans = 0 + private var lastStartOfScan: Int = 0 // Index of the last found Start of Scan + private var scannedIndex: Int = -1 // Index at which previous scan was finished + + /// Scans the given data. If finds new scans, returns the last index of the + /// last available scan. + mutating func scan(_ data: Data) -> Int? { + // Check if there is more data to scan. + guard (scannedIndex + 1) < data.count else { + return nil + } + + // Start scanning from the where it left off previous time. + var index = (scannedIndex + 1) + var numberOfScans = self.numberOfScans + while index < (data.count - 1) { + scannedIndex = index + // 0xFF, 0xDA - Start Of Scan + if data[index] == 0xFF, data[index + 1] == 0xDA { + lastStartOfScan = index + numberOfScans += 1 + } + index += 1 + } + + // Found more scans this the previous time + guard numberOfScans > self.numberOfScans else { + return nil + } + self.numberOfScans = numberOfScans + + // `> 1` checks that we've received a first scan (SOS) and then received + // and also received a second scan (SOS). This way we know that we have + // at least one full scan available. + guard numberOfScans > 1 && lastStartOfScan > 0 else { + return nil + } + + return lastStartOfScan - 1 + } +} + +extension ImageDecoders.Default { + private static func _decode(_ data: Data, scale: CGFloat) -> PlatformImage? { +#if os(macOS) + return NSImage(data: data) +#else + return UIImage(data: data, scale: scale) +#endif + } +} + +enum ImageProperties {} + +// Keeping this private for now, not sure neither about the API, not the implementation. +extension ImageProperties { + struct JPEG { + var isProgressive: Bool + + init?(_ data: Data) { + guard let isProgressive = ImageProperties.JPEG.isProgressive(data) else { + return nil + } + self.isProgressive = isProgressive + } + + private static func isProgressive(_ data: Data) -> Bool? { + var index = 3 // start scanning right after magic numbers + while index < (data.count - 1) { + // A example of first few bytes of progressive jpeg image: + // FF D8 FF E0 00 10 4A 46 49 46 00 01 01 00 00 48 00 ... + // + // 0xFF, 0xC0 - Start Of Frame (baseline DCT) + // 0xFF, 0xC2 - Start Of Frame (progressive DCT) + // https://en.wikipedia.org/wiki/JPEG + // + // As an alternative, Image I/O provides facilities to parse + // JPEG metadata via CGImageSourceCopyPropertiesAtIndex. It is a + // bit too convoluted to use and most likely slightly less + // efficient that checking this one special bit directly. + if data[index] == 0xFF { + if data[index + 1] == 0xC2 { + return true + } + if data[index + 1] == 0xC0 { + return false // baseline + } + } + index += 1 + } + return nil + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoders+Empty.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoders+Empty.swift new file mode 100644 index 00000000000..5213aa76836 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoders+Empty.swift @@ -0,0 +1,36 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImageDecoders { + /// A decoder that returns an empty placeholder image and attaches image + /// data to the image container. + struct Empty: ImageDecoding, Sendable { + let isProgressive: Bool + private let assetType: AssetType? + + var isAsynchronous: Bool { false } + + /// Initializes the decoder. + /// + /// - Parameters: + /// - type: Image type to be associated with an image container. + /// `nil` by default. + /// - isProgressive: If `false`, returns nil for every progressive + /// scan. `false` by default. + init(assetType: AssetType? = nil, isProgressive: Bool = false) { + self.assetType = assetType + self.isProgressive = isProgressive + } + + func decode(_ data: Data) throws -> ImageContainer { + ImageContainer(image: PlatformImage(), type: assetType, data: data, userInfo: [:]) + } + + func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { + isProgressive ? ImageContainer(image: PlatformImage(), type: assetType, data: data, userInfo: [:]) : nil + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoding.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoding.swift new file mode 100644 index 00000000000..70c588dfb9d --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Decoding/ImageDecoding.swift @@ -0,0 +1,64 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// An image decoder. +/// +/// A decoder is a one-shot object created for a single image decoding session. +/// +/// - note: If you need additional information in the decoder, you can pass +/// anything that you might need from the ``ImageDecodingContext``. +protocol ImageDecoding: Sendable { + /// Return `true` if you want the decoding to be performed on the decoding + /// queue (see ``ImagePipeline/Configuration-swift.struct/imageDecodingQueue``). If `false`, the decoding will be + /// performed synchronously on the pipeline operation queue. By default, `true`. + var isAsynchronous: Bool { get } + + /// Produces an image from the given image data. + func decode(_ data: Data) throws -> ImageContainer + + /// Produces an image from the given partially downloaded image data. + /// This method might be called multiple times during a single decoding + /// session. When the image download is complete, ``decode(_:)`` method is called. + /// + /// - returns: nil by default. + func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? +} + +extension ImageDecoding { + /// Returns `true` by default. + var isAsynchronous: Bool { true } + + /// The default implementation which simply returns `nil` (no progressive + /// decoding available). + func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { nil } +} + +enum ImageDecodingError: Error, CustomStringConvertible, Sendable { + case unknown + + var description: String { "Unknown" } +} + +extension ImageDecoding { + func decode(_ context: ImageDecodingContext) throws -> ImageResponse { + let container: ImageContainer = try autoreleasepool { + if context.isCompleted { + return try decode(context.data) + } else { + if let preview = decodePartiallyDownloadedData(context.data) { + return preview + } + throw ImageDecodingError.unknown + } + } +#if !os(macOS) + if container.userInfo[.isThumbnailKey] == nil { + ImageDecompression.setDecompressionNeeded(true, for: container.image) + } +#endif + return ImageResponse(container: container, request: context.request, urlResponse: context.urlResponse, cacheType: context.cacheType) + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoders+Default.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoders+Default.swift new file mode 100644 index 00000000000..92d842281f1 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoders+Default.swift @@ -0,0 +1,45 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if !os(macOS) +import UIKit +#else +import AppKit +#endif + +extension ImageEncoders { + /// A default adaptive encoder which uses best encoder available depending + /// on the input image and its configuration. + struct Default: ImageEncoding { + var compressionQuality: Float + + /// Set to `true` to switch to HEIF when it is available on the current hardware. + /// `false` by default. + var isHEIFPreferred = false + + init(compressionQuality: Float = 0.8) { + self.compressionQuality = compressionQuality + } + + func encode(_ image: PlatformImage) -> Data? { + guard let cgImage = image.cgImage else { + return nil + } + let type: AssetType + if cgImage.isOpaque { + if isHEIFPreferred && ImageEncoders.ImageIO.isSupported(type: .heic) { + type = .heic + } else { + type = .jpeg + } + } else { + type = .png + } + let encoder = ImageEncoders.ImageIO(type: type, compressionRatio: compressionQuality) + return encoder.encode(image) + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoders+ImageIO.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoders+ImageIO.swift new file mode 100644 index 00000000000..2e20ee9bb12 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoders+ImageIO.swift @@ -0,0 +1,68 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation +import CoreGraphics +import ImageIO + +#if !os(macOS) +import UIKit +#else +import AppKit +#endif + +extension ImageEncoders { + /// An Image I/O based encoder. + /// + /// Image I/O is a system framework that allows applications to read and + /// write most image file formats. This framework offers high efficiency, + /// color management, and access to image metadata. + struct ImageIO: ImageEncoding { + let type: AssetType + let compressionRatio: Float + + /// - parameter format: The output format. Make sure that the format is + /// supported on the current hardware.s + /// - parameter compressionRatio: 0.8 by default. + init(type: AssetType, compressionRatio: Float = 0.8) { + self.type = type + self.compressionRatio = compressionRatio + } + + private static let availability = NukeAtomic<[AssetType: Bool]>(value: [:]) + + /// Returns `true` if the encoding is available for the given format on + /// the current hardware. Some of the most recent formats might not be + /// available so its best to check before using them. + static func isSupported(type: AssetType) -> Bool { + if let isAvailable = availability.value[type] { + return isAvailable + } + let isAvailable = CGImageDestinationCreateWithData( + NSMutableData() as CFMutableData, type.rawValue as CFString, 1, nil + ) != nil + availability.withLock { $0[type] = isAvailable } + return isAvailable + } + + func encode(_ image: PlatformImage) -> Data? { + guard let source = image.cgImage, + let data = CFDataCreateMutable(nil, 0), + let destination = CGImageDestinationCreateWithData(data, type.rawValue as CFString, 1, nil) else { + return nil + } + var options: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: compressionRatio + ] +#if canImport(UIKit) + options[kCGImagePropertyOrientation] = CGImagePropertyOrientation(image.imageOrientation).rawValue +#endif + CGImageDestinationAddImage(destination, source, options as CFDictionary) + guard CGImageDestinationFinalize(destination) else { + return nil + } + return data as Data + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoders.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoders.swift new file mode 100644 index 00000000000..4ce372792e8 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoders.swift @@ -0,0 +1,20 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A namespace with all available encoders. +enum ImageEncoders {} + +extension ImageEncoding where Self == ImageEncoders.Default { + static func `default`(compressionQuality: Float = 0.8) -> ImageEncoders.Default { + ImageEncoders.Default(compressionQuality: compressionQuality) + } +} + +extension ImageEncoding where Self == ImageEncoders.ImageIO { + static func imageIO(type: AssetType, compressionRatio: Float = 0.8) -> ImageEncoders.ImageIO { + ImageEncoders.ImageIO(type: type, compressionRatio: compressionRatio) + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoding.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoding.swift new file mode 100644 index 00000000000..b2c48759d14 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Encoding/ImageEncoding.swift @@ -0,0 +1,40 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +import ImageIO + +// MARK: - ImageEncoding + +/// An image encoder. +protocol ImageEncoding: Sendable { + /// Encodes the given image. + func encode(_ image: PlatformImage) -> Data? + + /// An optional method which encodes the given image container. + func encode(_ container: ImageContainer, context: ImageEncodingContext) -> Data? +} + +extension ImageEncoding { + func encode(_ container: ImageContainer, context: ImageEncodingContext) -> Data? { + if container.type == .gif { + return container.data + } + return self.encode(container.image) + } +} + +/// Image encoding context used when selecting which encoder to use. +struct ImageEncodingContext: @unchecked Sendable { + let request: ImageRequest + let image: PlatformImage + let urlResponse: URLResponse? +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/ImageContainer.swift b/Sources/StreamChatUI/StreamNuke/Nuke/ImageContainer.swift new file mode 100644 index 00000000000..af9823850e4 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/ImageContainer.swift @@ -0,0 +1,132 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +#if !os(watchOS) +import AVKit +#endif + +import Foundation + +#if !os(macOS) +import UIKit.UIImage +/// Alias for `UIImage`. +typealias PlatformImage = UIImage +#else +import AppKit.NSImage +/// Alias for `NSImage`. +typealias PlatformImage = NSImage +#endif + +/// An image container with an image and associated metadata. +struct ImageContainer: @unchecked Sendable { +#if os(macOS) + /// A fetched image. + var image: NSImage { + get { ref.image } + set { mutate { $0.image = newValue } } + } +#else + /// A fetched image. + var image: UIImage { + get { ref.image } + set { mutate { $0.image = newValue } } + } +#endif + + /// An image type. + var type: AssetType? { + get { ref.type } + set { mutate { $0.type = newValue } } + } + + /// Returns `true` if the image in the container is a preview of the image. + var isPreview: Bool { + get { ref.isPreview } + set { mutate { $0.isPreview = newValue } } + } + + /// Contains the original image `data`, but only if the decoder decides to + /// attach it to the image. + /// + /// The default decoder (``ImageDecoders/Default``) attaches data to GIFs to + /// allow to display them using a rendering engine of your choice. + /// + /// - note: The `data`, along with the image container itself gets stored + /// in the memory cache. + var data: Data? { + get { ref.data } + set { mutate { $0.data = newValue } } + } + + /// An metadata provided by the user. + var userInfo: [UserInfoKey: Any] { + get { ref.userInfo } + set { mutate { $0.userInfo = newValue } } + } + + private var ref: Container + + /// Initializes the container with the given image. + init(image: PlatformImage, type: AssetType? = nil, isPreview: Bool = false, data: Data? = nil, userInfo: [UserInfoKey: Any] = [:]) { + self.ref = Container(image: image, type: type, isPreview: isPreview, data: data, userInfo: userInfo) + } + + func map(_ closure: (PlatformImage) throws -> PlatformImage) rethrows -> ImageContainer { + var copy = self + copy.image = try closure(image) + return copy + } + + /// A key use in ``userInfo``. + struct UserInfoKey: Hashable, ExpressibleByStringLiteral, Sendable { + let rawValue: String + + init(_ rawValue: String) { + self.rawValue = rawValue + } + + init(stringLiteral value: String) { + self.rawValue = value + } + + // For internal purposes. + static let isThumbnailKey: UserInfoKey = "com.github/kean/nuke/skip-decompression" + + /// A user info key to get the scan number (Int). + static let scanNumberKey: UserInfoKey = "com.github/kean/nuke/scan-number" + } + + // MARK: - Copy-on-Write + + private mutating func mutate(_ closure: (Container) -> Void) { + if !isKnownUniquelyReferenced(&ref) { + ref = Container(ref) + } + closure(ref) + } + + private final class Container: @unchecked Sendable { + var image: PlatformImage + var type: AssetType? + var isPreview: Bool + var data: Data? + var userInfo: [UserInfoKey: Any] + + init(image: PlatformImage, type: AssetType?, isPreview: Bool, data: Data? = nil, userInfo: [UserInfoKey: Any]) { + self.image = image + self.type = type + self.isPreview = isPreview + self.data = data + self.userInfo = userInfo + } + + init(_ ref: Container) { + self.image = ref.image + self.type = ref.type + self.isPreview = ref.isPreview + self.data = ref.data + self.userInfo = ref.userInfo + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/ImageRequest.swift b/Sources/StreamChatUI/StreamNuke/Nuke/ImageRequest.swift new file mode 100644 index 00000000000..d3662a691be --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/ImageRequest.swift @@ -0,0 +1,540 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Combine +import CoreGraphics + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +/// Represents an image request that specifies what images to download, how to +/// process them, set the request priority, and more. +/// +/// Creating a request: +/// +/// ```swift +/// let request = ImageRequest( +/// url: URL(string: "http://example.com/image.jpeg"), +/// processors: [.resize(width: 320)], +/// priority: .high, +/// options: [.reloadIgnoringCachedData] +/// ) +/// let image = try await pipeline.image(for: request) +/// ``` +struct ImageRequest: CustomStringConvertible, Sendable, ExpressibleByStringLiteral { + // MARK: Options + + /// The relative priority of the request. The priority affects the order in + /// which the requests are performed. ``Priority-swift.enum/normal`` by default. + /// + /// - note: You can change the priority of a running task using ``ImageTask/priority``. + var priority: Priority { + get { ref.priority } + set { mutate { $0.priority = newValue } } + } + + /// Processor to be applied to the image. Empty by default. + /// + /// See to learn more. + var processors: [any ImageProcessing] { + get { ref.processors } + set { mutate { $0.processors = newValue } } + } + + /// The request options. For a complete list of options, see ``ImageRequest/Options-swift.struct``. + var options: Options { + get { ref.options } + set { mutate { $0.options = newValue } } + } + + /// Custom info passed alongside the request. + var userInfo: [UserInfoKey: Any] { + get { ref.userInfo ?? [:] } + set { mutate { $0.userInfo = newValue } } + } + + // MARK: Instance Properties + + /// Returns the request `URLRequest`. + /// + /// Returns `nil` for publisher-based requests. + var urlRequest: URLRequest? { + switch ref.resource { + case .url(let url): return url.map { URLRequest(url: $0) } // create lazily + case .urlRequest(let urlRequest): return urlRequest + case .publisher: return nil + } + } + + /// Returns the request `URL`. + /// + /// Returns `nil` for publisher-based requests. + var url: URL? { + switch ref.resource { + case .url(let url): return url + case .urlRequest(let request): return request.url + case .publisher: return nil + } + } + + /// Returns the ID of the underlying image. For URL-based requests, it's an + /// image URL. For an async function – a custom ID provided in initializer. + var imageId: String? { ref.originalImageId } + + /// Returns a debug request description. + var description: String { + "ImageRequest(resource: \(ref.resource), priority: \(priority), processors: \(processors), options: \(options), userInfo: \(userInfo))" + } + + // MARK: Initializers + + /// Initializes the request with the given string. + init(stringLiteral value: String) { + self.init(url: URL(string: value)) + } + + /// Initializes a request with the given `URL`. + /// + /// - parameters: + /// - url: The request URL. + /// - processors: Processors to be apply to the image. See to learn more. + /// - priority: The priority of the request. + /// - options: Image loading options. + /// - userInfo: Custom info passed alongside the request. + /// + /// ```swift + /// let request = ImageRequest( + /// url: URL(string: "http://..."), + /// processors: [.resize(size: imageView.bounds.size)], + /// priority: .high + /// ) + /// ``` + init( + url: URL?, + processors: [any ImageProcessing] = [], + priority: Priority = .normal, + options: Options = [], + userInfo: [UserInfoKey: Any]? = nil + ) { + self.ref = Container( + resource: Resource.url(url), + processors: processors, + priority: priority, + options: options, + userInfo: userInfo + ) + } + + /// Initializes a request with the given `URLRequest`. + /// + /// - parameters: + /// - urlRequest: The URLRequest describing the image request. + /// - processors: Processors to be apply to the image. See to learn more. + /// - priority: The priority of the request. + /// - options: Image loading options. + /// - userInfo: Custom info passed alongside the request. + /// + /// ```swift + /// let request = ImageRequest( + /// url: URLRequest(url: URL(string: "http://...")), + /// processors: [.resize(size: imageView.bounds.size)], + /// priority: .high + /// ) + /// ``` + init( + urlRequest: URLRequest, + processors: [any ImageProcessing] = [], + priority: Priority = .normal, + options: Options = [], + userInfo: [UserInfoKey: Any]? = nil + ) { + self.ref = Container( + resource: Resource.urlRequest(urlRequest), + processors: processors, + priority: priority, + options: options, + userInfo: userInfo + ) + } + + /// Initializes a request with the given async function. + /// + /// For example, you can use it with the Photos framework after wrapping its + /// API in an async function. + /// + /// ```swift + /// ImageRequest( + /// id: asset.localIdentifier, + /// data: { try await PHAssetManager.default.imageData(for: asset) } + /// ) + /// ``` + /// + /// - important: If you are using a pipeline with a custom configuration that + /// enables aggressive disk cache, fetched data will be stored in this cache. + /// You can use ``Options-swift.struct/disableDiskCache`` to disable it. + /// + /// - note: If the resource is identifiable with a `URL`, consider + /// implementing a custom data loader instead. See . + /// + /// - parameters: + /// - id: Uniquely identifies the fetched image. + /// - data: An async function to be used to fetch image data. + /// - processors: Processors to be apply to the image. See to learn more. + /// - priority: The priority of the request. + /// - options: Image loading options. + /// - userInfo: Custom info passed alongside the request. + init( + id: String, + data: @Sendable @escaping () async throws -> Data, + processors: [any ImageProcessing] = [], + priority: Priority = .normal, + options: Options = [], + userInfo: [UserInfoKey: Any]? = nil + ) { + // It could technically be implemented without any special change to the + // pipeline by using a custom DataLoader and passing an async function in + // the request userInfo. g + self.ref = Container( + resource: .publisher(DataPublisher(id: id, data)), + processors: processors, + priority: priority, + options: options, + userInfo: userInfo + ) + } + + /// Initializes a request with the given data publisher. + /// + /// For example, here is how you can use it with the Photos framework (the + /// `imageDataPublisher` API is a custom convenience extension not included + /// in the framework). + /// + /// ```swift + /// let request = ImageRequest( + /// id: asset.localIdentifier, + /// dataPublisher: PHAssetManager.imageDataPublisher(for: asset) + /// ) + /// ``` + /// + /// - important: If you are using a pipeline with a custom configuration that + /// enables aggressive disk cache, fetched data will be stored in this cache. + /// You can use ``Options-swift.struct/disableDiskCache`` to disable it. + /// + /// - parameters: + /// - id: Uniquely identifies the fetched image. + /// - data: A data publisher to be used for fetching image data. + /// - processors: Processors to be apply to the image. See to learn more. + /// - priority: The priority of the request, ``Priority-swift.enum/normal`` by default. + /// - options: Image loading options. + /// - userInfo: Custom info passed alongside the request. + init

( + id: String, + dataPublisher: P, + processors: [any ImageProcessing] = [], + priority: Priority = .normal, + options: Options = [], + userInfo: [UserInfoKey: Any]? = nil + ) where P: Publisher, P.Output == Data { + // It could technically be implemented without any special change to the + // pipeline by using a custom DataLoader and passing a publisher in the + // request userInfo. + self.ref = Container( + resource: .publisher(DataPublisher(id: id, dataPublisher)), + processors: processors, + priority: priority, + options: options, + userInfo: userInfo + ) + } + + // MARK: Nested Types + + /// The priority affecting the order in which the requests are performed. + enum Priority: Int, Comparable, Sendable { + case veryLow = 0, low, normal, high, veryHigh + + static func < (lhs: Priority, rhs: Priority) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + /// Image request options. + /// + /// By default, the pipeline makes full use of all of its caching layers. You can change this behavior using options. For example, you can ignore local caches using ``ImageRequest/Options-swift.struct/reloadIgnoringCachedData`` option. + /// + /// ```swift + /// request.options = [.reloadIgnoringCachedData] + /// ``` + /// + /// Another useful cache policy is ``ImageRequest/Options-swift.struct/returnCacheDataDontLoad`` + /// that terminates the request if no cached data is available. + struct Options: OptionSet, Hashable, Sendable { + /// Returns a raw value. + let rawValue: UInt16 + + /// Initializes options with a given raw values. + init(rawValue: UInt16) { + self.rawValue = rawValue + } + + /// Disables memory cache reads (see ``ImageCaching``). + static let disableMemoryCacheReads = Options(rawValue: 1 << 0) + + /// Disables memory cache writes (see ``ImageCaching``). + static let disableMemoryCacheWrites = Options(rawValue: 1 << 1) + + /// Disables both memory cache reads and writes (see ``ImageCaching``). + static let disableMemoryCache: Options = [.disableMemoryCacheReads, .disableMemoryCacheWrites] + + /// Disables disk cache reads (see ``DataCaching``). + static let disableDiskCacheReads = Options(rawValue: 1 << 2) + + /// Disables disk cache writes (see ``DataCaching``). + static let disableDiskCacheWrites = Options(rawValue: 1 << 3) + + /// Disables both disk cache reads and writes (see ``DataCaching``). + static let disableDiskCache: Options = [.disableDiskCacheReads, .disableDiskCacheWrites] + + /// The image should be loaded only from the originating source. + /// + /// This option only works ``ImageCaching`` and ``DataCaching``, but not + /// `URLCache`. If you want to ignore `URLCache`, initialize the request + /// with `URLRequest` with the respective policy + static let reloadIgnoringCachedData: Options = [.disableMemoryCacheReads, .disableDiskCacheReads] + + /// Use existing cache data and fail if no cached data is available. + static let returnCacheDataDontLoad = Options(rawValue: 1 << 4) + + /// Skip decompression ("bitmapping") for the given image. Decompression + /// will happen lazily when you display the image. + static let skipDecompression = Options(rawValue: 1 << 5) + + /// Perform data loading immediately, ignoring ``ImagePipeline/Configuration-swift.struct/dataLoadingQueue``. It + /// can be used to elevate priority of certain tasks. + /// + /// - important: If there is an outstanding task for loading the same + /// resource but without this option, a new task will be created. + static let skipDataLoadingQueue = Options(rawValue: 1 << 6) + } + + /// A key used in `userInfo` for providing custom request options. + /// + /// There are a couple of built-in options that are passed using user info + /// as well, including ``imageIdKey``, ``scaleKey``, and ``thumbnailKey``. + struct UserInfoKey: Hashable, ExpressibleByStringLiteral, Sendable { + /// Returns a key raw value. + let rawValue: String + + /// Initializes the key with a raw value. + init(_ rawValue: String) { + self.rawValue = rawValue + } + + /// Initializes the key with a raw value. + init(stringLiteral value: String) { + self.rawValue = value + } + + /// Overrides the image identifier used for caching and task coalescing. + /// + /// By default, ``ImagePipeline`` uses an image URL as a unique identifier + /// for caching and task coalescing. You can override this behavior by + /// providing a custom identifier. For example, you can use it to remove + /// transient query parameters from the URL, like access token. + /// + /// ```swift + /// let request = ImageRequest( + /// url: URL(string: "http://example.com/image.jpeg?token=123"), + /// userInfo: [.imageIdKey: "http://example.com/image.jpeg"] + /// ) + /// ``` + static let imageIdKey: ImageRequest.UserInfoKey = "github.com/kean/nuke/imageId" + + /// The image scale to be used. By default, the scale is `1`. + static let scaleKey: ImageRequest.UserInfoKey = "github.com/kean/nuke/scale" + + /// Specifies whether the pipeline should retrieve or generate a thumbnail + /// instead of a full image. The thumbnail creation is generally significantly + /// more efficient, especially in terms of memory usage, than image resizing + /// (``ImageProcessors/Resize``). + /// + /// - note: You must be using the default image decoder to make it work. + static let thumbnailKey: ImageRequest.UserInfoKey = "github.com/kean/nuke/thumbnail" + } + + /// Thumbnail options. + /// + /// For more info, see https://developer.apple.com/documentation/imageio/cgimagesource/image_source_option_dictionary_keys + struct ThumbnailOptions: Hashable, Sendable { + enum TargetSize: Hashable { + case fixed(Float) + case flexible(size: ImageTargetSize, contentMode: ImageProcessingOptions.ContentMode) + + var parameters: String { + switch self { + case .fixed(let size): + return "maxPixelSize=\(size)" + case let .flexible(size, contentMode): + return "width=\(size.cgSize.width),height=\(size.cgSize.height),contentMode=\(contentMode)" + } + } + } + + let targetSize: TargetSize + + /// Whether a thumbnail should be automatically created for an image if + /// a thumbnail isn't present in the image source file. The thumbnail is + /// created from the full image, subject to the limit specified by size. + var createThumbnailFromImageIfAbsent = true + + /// Whether a thumbnail should be created from the full image even if a + /// thumbnail is present in the image source file. The thumbnail is created + /// from the full image, subject to the limit specified by size. + var createThumbnailFromImageAlways = true + + /// Whether the thumbnail should be rotated and scaled according to the + /// orientation and pixel aspect ratio of the full image. + var createThumbnailWithTransform = true + + /// Specifies whether image decoding and caching should happen at image + /// creation time. + var shouldCacheImmediately = true + + /// Initializes the options with the given pixel size. The thumbnail is + /// resized to fit the target size. + /// + /// This option performs slightly faster than ``ImageRequest/ThumbnailOptions/init(size:unit:contentMode:)`` + /// because it doesn't need to read the image size. + init(maxPixelSize: Float) { + self.targetSize = .fixed(maxPixelSize) + } + + /// Initializes the options with the given size. + /// + /// - parameters: + /// - size: The target size. + /// - unit: Unit of the target size. + /// - contentMode: A target content mode. + init(size: CGSize, unit: ImageProcessingOptions.Unit = .points, contentMode: ImageProcessingOptions.ContentMode = .aspectFill) { + self.targetSize = .flexible(size: ImageTargetSize(size: size, unit: unit), contentMode: contentMode) + } + + /// Generates a thumbnail from the given image data. + func makeThumbnail(with data: Data) -> PlatformImage? { + StreamChatUI.makeThumbnail(data: data, options: self) + } + + var identifier: String { + "com.github/kean/nuke/thumbnail?\(targetSize.parameters),options=\(createThumbnailFromImageIfAbsent)\(createThumbnailFromImageAlways)\(createThumbnailWithTransform)\(shouldCacheImmediately)" + } + } + + // MARK: Internal + + private var ref: Container + + private mutating func mutate(_ closure: (Container) -> Void) { + if !isKnownUniquelyReferenced(&ref) { + ref = Container(ref) + } + closure(ref) + } + + var resource: Resource { ref.resource } + + func withProcessors(_ processors: [any ImageProcessing]) -> ImageRequest { + var request = self + request.processors = processors + return request + } + + var preferredImageId: String { + if let imageId = ref.userInfo?[.imageIdKey] as? String { + return imageId + } + return imageId ?? "" + } + + var thumbnail: ThumbnailOptions? { + ref.userInfo?[.thumbnailKey] as? ThumbnailOptions + } + + var scale: Float? { + (ref.userInfo?[.scaleKey] as? NSNumber)?.floatValue + } + + var publisher: DataPublisher? { + if case .publisher(let publisher) = ref.resource { return publisher } + return nil + } +} + +// MARK: - ImageRequest (Private) + +extension ImageRequest { + /// Just like many Swift built-in types, ``ImageRequest`` uses CoW approach to + /// avoid memberwise retain/releases when ``ImageRequest`` is passed around. + private final class Container: @unchecked Sendable { + // It's beneficial to put resource before priority and options because + // of the resource size/stride of 9/16. Priority (1 byte) and Options + // (2 bytes) slot just right in the remaining space. + let resource: Resource + var priority: Priority + var options: Options + var originalImageId: String? + var processors: [any ImageProcessing] + var userInfo: [UserInfoKey: Any]? + // After trimming the request size in Nuke 10, CoW it is no longer as + // beneficial, but there still is a measurable difference. + + /// Creates a resource with a default processor. + init(resource: Resource, processors: [any ImageProcessing], priority: Priority, options: Options, userInfo: [UserInfoKey: Any]?) { + self.resource = resource + self.processors = processors + self.priority = priority + self.options = options + self.originalImageId = resource.imageId + self.userInfo = userInfo + } + + /// Creates a copy. + init(_ ref: Container) { + self.resource = ref.resource + self.processors = ref.processors + self.priority = ref.priority + self.options = ref.options + self.originalImageId = ref.originalImageId + self.userInfo = ref.userInfo + } + } + + // Every case takes 8 bytes and the enum 9 bytes overall (use stride!) + enum Resource: CustomStringConvertible { + case url(URL?) + case urlRequest(URLRequest) + case publisher(DataPublisher) + + var description: String { + switch self { + case .url(let url): return "\(url?.absoluteString ?? "nil")" + case .urlRequest(let urlRequest): return "\(urlRequest)" + case .publisher(let data): return "\(data)" + } + } + + var imageId: String? { + switch self { + case .url(let url): return url?.absoluteString + case .urlRequest(let urlRequest): return urlRequest.url?.absoluteString + case .publisher(let publisher): return publisher.id + } + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/ImageResponse.swift b/Sources/StreamChatUI/StreamNuke/Nuke/ImageResponse.swift new file mode 100644 index 00000000000..fb22a93c462 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/ImageResponse.swift @@ -0,0 +1,57 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +/// An image response that contains a fetched image and some metadata. +struct ImageResponse: @unchecked Sendable { + /// An image container with an image and associated metadata. + var container: ImageContainer + +#if os(macOS) + /// A convenience computed property that returns an image from the container. + var image: NSImage { container.image } +#else + /// A convenience computed property that returns an image from the container. + var image: UIImage { container.image } +#endif + + /// Returns `true` if the image in the container is a preview of the image. + var isPreview: Bool { container.isPreview } + + /// The request for which the response was created. + var request: ImageRequest + + /// A response. `nil` unless the resource was fetched from the network or an + /// HTTP cache. + var urlResponse: URLResponse? + + /// Contains a cache type in case the image was returned from one of the + /// pipeline caches (not including any of the HTTP caches if enabled). + var cacheType: CacheType? + + /// Initializes the response with the given image. + init(container: ImageContainer, request: ImageRequest, urlResponse: URLResponse? = nil, cacheType: CacheType? = nil) { + self.container = container + self.request = request + self.urlResponse = urlResponse + self.cacheType = cacheType + } + + /// A cache type. + enum CacheType: Sendable { + /// Memory cache (see ``ImageCaching``) + case memory + /// Disk cache (see ``DataCaching``) + case disk + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/ImageTask.swift b/Sources/StreamChatUI/StreamNuke/Nuke/ImageTask.swift new file mode 100644 index 00000000000..edd401b10d1 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/ImageTask.swift @@ -0,0 +1,337 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation +@preconcurrency import Combine + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +/// A task performed by the ``ImagePipeline``. +/// +/// The pipeline maintains a strong reference to the task until the request +/// finishes or fails; you do not need to maintain a reference to the task unless +/// it is useful for your app. +final class ImageTask: Hashable, CustomStringConvertible, @unchecked Sendable { + /// An identifier that uniquely identifies the task within a given pipeline. + /// Unique only within that pipeline. + let taskId: Int64 + + /// The original request that the task was created with. + let request: ImageRequest + + /// The priority of the task. The priority can be updated dynamically even + /// for a task that is already running. + var priority: ImageRequest.Priority { + get { withLock { $0.priority } } + set { setPriority(newValue) } + } + + /// Returns the current download progress. Returns zeros before the download + /// is started and the expected size of the resource is known. + var currentProgress: Progress { + withLock { $0.progress } + } + + /// The download progress. + struct Progress: Hashable, Sendable { + /// The number of bytes that the task has received. + let completed: Int64 + /// A best-guess upper bound on the number of bytes of the resource. + let total: Int64 + + /// Returns the fraction of the completion. + var fraction: Float { + guard total > 0 else { return 0 } + return min(1, Float(completed) / Float(total)) + } + + /// Initializes progress with the given status. + init(completed: Int64, total: Int64) { + (self.completed, self.total) = (completed, total) + } + } + + /// The current state of the task. + var state: State { + withLock { $0.state } + } + + /// The state of the image task. + enum State { + /// The task is currently running. + case running + /// The task has received a cancel message. + case cancelled + /// The task has completed (without being canceled). + case completed + } + + // MARK: - Async/Await + + /// Returns the response image. + var image: PlatformImage { + get async throws { + try await response.image + } + } + + /// Returns the image response. + var response: ImageResponse { + get async throws { + try await withTaskCancellationHandler { + try await _task.value + } onCancel: { + cancel() + } + } + } + + /// The stream of progress updates. + var progress: AsyncStream { + makeStream { + if case .progress(let value) = $0 { return value } + return nil + } + } + + /// The stream of image previews generated for images that support + /// progressive decoding. + /// + /// - seealso: ``ImagePipeline/Configuration-swift.struct/isProgressiveDecodingEnabled`` + var previews: AsyncStream { + makeStream { + if case .preview(let value) = $0 { return value } + return nil + } + } + + // MARK: - Events + + /// The events sent by the pipeline during the task execution. + var events: AsyncStream { makeStream { $0 } } + + /// An event produced during the runetime of the task. + enum Event: Sendable { + /// The download progress was updated. + case progress(Progress) + /// The pipeline generated a progressive scan of the image. + case preview(ImageResponse) + /// The task was cancelled. + /// + /// - note: You are guaranteed to receive either `.cancelled` or + /// `.finished`, but never both. + case cancelled + /// The task finish with the given response. + case finished(Result) + } + + private var publicState: PublicState + private let isDataTask: Bool + private let onEvent: ((Event, ImageTask) -> Void)? + private let lock: os_unfair_lock_t + private let queue: DispatchQueue + private weak var pipeline: ImagePipeline? + + // State synchronized on `pipeline.queue`. + var _task: Task! + var _continuation: UnsafeContinuation? + var _state: State = .running + private var _events: PassthroughSubject? + + deinit { + lock.deinitialize(count: 1) + lock.deallocate() + } + + init(taskId: Int64, request: ImageRequest, isDataTask: Bool, pipeline: ImagePipeline, onEvent: ((Event, ImageTask) -> Void)?) { + self.taskId = taskId + self.request = request + self.publicState = PublicState(priority: request.priority) + self.isDataTask = isDataTask + self.pipeline = pipeline + self.queue = pipeline.queue + self.onEvent = onEvent + + lock = .allocate(capacity: 1) + lock.initialize(to: os_unfair_lock()) + } + + /// Marks task as being cancelled. + /// + /// The pipeline will immediately cancel any work associated with a task + /// unless there is an equivalent outstanding task running. + func cancel() { + let didChange: Bool = withLock { + guard $0.state == .running else { return false } + $0.state = .cancelled + return true + } + guard didChange else { return } // Make sure it gets called once (expensive) + pipeline?.imageTaskCancelCalled(self) + } + + private func setPriority(_ newValue: ImageRequest.Priority) { + let didChange: Bool = withLock { + guard $0.priority != newValue else { return false } + $0.priority = newValue + return $0.state == .running + } + guard didChange else { return } + pipeline?.imageTaskUpdatePriorityCalled(self, priority: newValue) + } + + // MARK: Internals + + /// Gets called when the task is cancelled either by the user or by an + /// external event such as session invalidation. + /// + /// synchronized on `pipeline.queue`. + func _cancel() { + guard _setState(.cancelled) else { return } + _dispatch(.cancelled) + } + + /// Gets called when the associated task sends a new event. + /// + /// synchronized on `pipeline.queue`. + func _process(_ event: AsyncTask.Event) { + switch event { + case let .value(response, isCompleted): + if isCompleted { + _finish(.success(response)) + } else { + _dispatch(.preview(response)) + } + case let .progress(value): + withLock { $0.progress = value } + _dispatch(.progress(value)) + case let .error(error): + _finish(.failure(error)) + } + } + + /// Synchronized on `pipeline.queue`. + private func _finish(_ result: Result) { + guard _setState(.completed) else { return } + _dispatch(.finished(result)) + } + + /// Synchronized on `pipeline.queue`. + func _setState(_ state: State) -> Bool { + guard _state == .running else { return false } + _state = state + if onEvent == nil { + withLock { $0.state = state } + } + return true + } + + /// Dispatches the given event to the observers. + /// + /// - warning: The task needs to be fully wired (`_continuation` present) + /// before it can start sending the events. + /// + /// synchronized on `pipeline.queue`. + func _dispatch(_ event: Event) { + guard _continuation != nil else { + return // Task isn't fully wired yet + } + _events?.send(event) + switch event { + case .cancelled: + _events?.send(completion: .finished) + _continuation?.resume(throwing: CancellationError()) + case .finished(let result): + let result = result.mapError { $0 as Error } + _events?.send(completion: .finished) + _continuation?.resume(with: result) + default: + break + } + + onEvent?(event, self) + pipeline?.imageTask(self, didProcessEvent: event, isDataTask: isDataTask) + } + + // MARK: Hashable + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self).hashValue) + } + + static func == (lhs: ImageTask, rhs: ImageTask) -> Bool { + ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + } + + // MARK: CustomStringConvertible + + var description: String { + "ImageTask(id: \(taskId), priority: \(priority), progress: \(currentProgress.completed) / \(currentProgress.total), state: \(state))" + } +} + +@available(*, deprecated, renamed: "ImageTask", message: "Async/Await support was added directly to the existing `ImageTask` type") +typealias AsyncImageTask = ImageTask + +// MARK: - ImageTask (Private) + +extension ImageTask { + private func makeStream(of closure: @Sendable @escaping (Event) -> T?) -> AsyncStream { + AsyncStream { continuation in + self.queue.async { + guard let events = self._makeEventsSubject() else { + return continuation.finish() + } + let cancellable = events.sink { _ in + continuation.finish() + } receiveValue: { event in + if let value = closure(event) { + continuation.yield(value) + } + switch event { + case .cancelled, .finished: + continuation.finish() + default: + break + } + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + } + } + + // Synchronized on `pipeline.queue` + private func _makeEventsSubject() -> PassthroughSubject? { + guard _state == .running else { + return nil + } + if _events == nil { + _events = PassthroughSubject() + } + return _events! + } + + private func withLock(_ closure: (inout PublicState) -> T) -> T { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return closure(&publicState) + } + + /// Contains the state synchronized using the internal lock. + /// + /// - warning: Must be accessed using `withLock`. + private struct PublicState { + var state: ImageTask.State = .running + var priority: ImageRequest.Priority + var progress = Progress(completed: 0, total: 0) + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Atomic.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Atomic.swift new file mode 100644 index 00000000000..20fd2709fad --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Atomic.swift @@ -0,0 +1,40 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +final class NukeAtomic: @unchecked Sendable { + private var _value: T + private let lock: os_unfair_lock_t + + init(value: T) { + self._value = value + self.lock = .allocate(capacity: 1) + self.lock.initialize(to: os_unfair_lock()) + } + + deinit { + lock.deinitialize(count: 1) + lock.deallocate() + } + + var value: T { + get { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return _value + } + set { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + _value = newValue + } + } + + func withLock(_ closure: (inout T) -> U) -> U { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return closure(&_value) + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Internal/DataPublisher.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/DataPublisher.swift new file mode 100644 index 00000000000..fc3afca54c7 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/DataPublisher.swift @@ -0,0 +1,60 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation +@preconcurrency import Combine + +final class DataPublisher { + let id: String + private let _sink: (@escaping ((PublisherCompletion) -> Void), @escaping ((Data) -> Void)) -> any Cancellable + + init(id: String, _ publisher: P) where P.Output == Data { + self.id = id + self._sink = { onCompletion, onValue in + let cancellable = publisher.sink(receiveCompletion: { + switch $0 { + case .finished: onCompletion(.finished) + case .failure(let error): onCompletion(.failure(error)) + } + }, receiveValue: { + onValue($0) + }) + return AnonymousCancellable { cancellable.cancel() } + } + } + + convenience init(id: String, _ data: @Sendable @escaping () async throws -> Data) { + self.init(id: id, publisher(from: data)) + } + + func sink(receiveCompletion: @escaping ((PublisherCompletion) -> Void), receiveValue: @escaping ((Data) -> Void)) -> any Cancellable { + _sink(receiveCompletion, receiveValue) + } +} + +private func publisher(from closure: @Sendable @escaping () async throws -> Data) -> AnyPublisher { + Deferred { + Future { promise in + let promise = UncheckedSendableBox(value: promise) + Task { + do { + let data = try await closure() + promise.value(.success(data)) + } catch { + promise.value(.failure(error)) + } + } + } + }.eraseToAnyPublisher() +} + +enum PublisherCompletion { + case finished + case failure(Error) +} + +/// - warning: Avoid using it! +struct UncheckedSendableBox: @unchecked Sendable { + let value: Value +} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Extensions.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Extensions.swift similarity index 52% rename from Sources/StreamChatUI/StreamNuke/Internal/Extensions.swift rename to Sources/StreamChatUI/StreamNuke/Nuke/Internal/Extensions.swift index 72a10a8c4bb..03b46dc5f57 100644 --- a/Sources/StreamChatUI/StreamNuke/Internal/Extensions.swift +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Extensions.swift @@ -1,9 +1,9 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation -import CommonCrypto +import CryptoKit extension String { /// Calculates SHA1 from the given string and returns its hex representation. @@ -13,32 +13,21 @@ extension String { /// // prints "50334ee0b51600df6397ce93ceed4728c37fee4e" /// ``` var sha1: String? { - guard !isEmpty, let input = self.data(using: .utf8) else { - return nil + guard let input = self.data(using: .utf8) else { + return nil // The conversion to .utf8 should never fail } - - let hash = input.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> [UInt8] in - var hash = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) - CC_SHA1(bytes.baseAddress, CC_LONG(input.count), &hash) - return hash + let digest = Insecure.SHA1.hash(data: input) + var output = "" + for byte in digest { + output.append(String(format: "%02x", byte)) } - - return hash.map({ String(format: "%02x", $0) }).joined() - } -} - -extension NSLock { - func sync(_ closure: () -> T) -> T { - lock() - defer { unlock() } - return closure() + return output } } extension URL { - var isCacheable: Bool { - let scheme = self.scheme - return scheme != "file" && scheme != "data" + var isLocalResource: Bool { + scheme == "file" || scheme == "data" } } @@ -60,3 +49,15 @@ extension ImageRequest.Priority { } } } + +final class AnonymousCancellable: Cancellable { + let onCancel: @Sendable () -> Void + + init(_ onCancel: @Sendable @escaping () -> Void) { + self.onCancel = onCancel + } + + func cancel() { + onCancel() + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Graphics.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Graphics.swift new file mode 100644 index 00000000000..adb40b85f8f --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Graphics.swift @@ -0,0 +1,422 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if os(watchOS) +import ImageIO +import CoreGraphics +import WatchKit.WKInterfaceDevice +#endif + +#if canImport(UIKit) + import UIKit + #endif + + #if canImport(AppKit) + import AppKit + #endif + +extension PlatformImage { + var processed: ImageProcessingExtensions { + ImageProcessingExtensions(image: self) + } +} + +struct ImageProcessingExtensions { + let image: PlatformImage + + func byResizing(to targetSize: CGSize, + contentMode: ImageProcessingOptions.ContentMode, + upscale: Bool) -> PlatformImage? { + guard let cgImage = image.cgImage else { + return nil + } +#if canImport(UIKit) + let targetSize = targetSize.rotatedForOrientation(image.imageOrientation) +#endif + let scale = cgImage.size.getScale(targetSize: targetSize, contentMode: contentMode) + guard scale < 1 || upscale else { + return image // The image doesn't require scaling + } + let size = cgImage.size.scaled(by: scale).rounded() + return image.draw(inCanvasWithSize: size) + } + + /// Crops the input image to the given size and resizes it if needed. + /// - note: this method will always upscale. + func byResizingAndCropping(to targetSize: CGSize) -> PlatformImage? { + guard let cgImage = image.cgImage else { + return nil + } +#if canImport(UIKit) + let targetSize = targetSize.rotatedForOrientation(image.imageOrientation) +#endif + let scale = cgImage.size.getScale(targetSize: targetSize, contentMode: .aspectFill) + let scaledSize = cgImage.size.scaled(by: scale) + let drawRect = scaledSize.centeredInRectWithSize(targetSize) + return image.draw(inCanvasWithSize: targetSize, drawRect: drawRect) + } + + func byDrawingInCircle(border: ImageProcessingOptions.Border?) -> PlatformImage? { + guard let squared = byCroppingToSquare(), let cgImage = squared.cgImage else { + return nil + } + let radius = CGFloat(cgImage.width) // Can use any dimension since image is a square + return squared.processed.byAddingRoundedCorners(radius: radius / 2.0, border: border) + } + + /// Draws an image in square by preserving an aspect ratio and filling the + /// square if needed. If the image is already a square, returns an original image. + func byCroppingToSquare() -> PlatformImage? { + guard let cgImage = image.cgImage else { + return nil + } + + guard cgImage.width != cgImage.height else { + return image // Already a square + } + + let imageSize = cgImage.size + let side = min(cgImage.width, cgImage.height) + let targetSize = CGSize(width: side, height: side) + let cropRect = CGRect(origin: .zero, size: targetSize).offsetBy( + dx: max(0, (imageSize.width - targetSize.width) / 2).rounded(.down), + dy: max(0, (imageSize.height - targetSize.height) / 2).rounded(.down) + ) + guard let cropped = cgImage.cropping(to: cropRect) else { + return nil + } + return PlatformImage.make(cgImage: cropped, source: image) + } + + /// Adds rounded corners with the given radius to the image. + /// - parameter radius: Radius in pixels. + /// - parameter border: Optional stroke border. + func byAddingRoundedCorners(radius: CGFloat, border: ImageProcessingOptions.Border? = nil) -> PlatformImage? { + guard let cgImage = image.cgImage else { + return nil + } + guard let ctx = CGContext.make(cgImage, size: cgImage.size, alphaInfo: .premultipliedLast) else { + return nil + } + let rect = CGRect(origin: CGPoint.zero, size: cgImage.size) + let path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) + ctx.addPath(path) + ctx.clip() + ctx.draw(cgImage, in: CGRect(origin: CGPoint.zero, size: cgImage.size)) + + if let border { + ctx.setStrokeColor(border.color.cgColor) + ctx.addPath(path) + ctx.setLineWidth(border.width) + ctx.strokePath() + } + guard let outputCGImage = ctx.makeImage() else { + return nil + } + return PlatformImage.make(cgImage: outputCGImage, source: image) + } +} + +extension PlatformImage { + /// Draws the image in a `CGContext` in a canvas with the given size using + /// the specified draw rect. + /// + /// For example, if the canvas size is `CGSize(width: 10, height: 10)` and + /// the draw rect is `CGRect(x: -5, y: 0, width: 20, height: 10)` it would + /// draw the input image (which is horizontal based on the known draw rect) + /// in a square by centering it in the canvas. + /// + /// - parameter drawRect: `nil` by default. If `nil` will use the canvas rect. + func draw(inCanvasWithSize canvasSize: CGSize, drawRect: CGRect? = nil) -> PlatformImage? { + guard let cgImage else { + return nil + } + guard let ctx = CGContext.make(cgImage, size: canvasSize) else { + return nil + } + ctx.draw(cgImage, in: drawRect ?? CGRect(origin: .zero, size: canvasSize)) + guard let outputCGImage = ctx.makeImage() else { + return nil + } + return PlatformImage.make(cgImage: outputCGImage, source: self) + } + + /// Decompresses the input image by drawing in the the `CGContext`. + func decompressed(isUsingPrepareForDisplay: Bool) -> PlatformImage? { +#if os(iOS) || os(tvOS) || os(visionOS) + if isUsingPrepareForDisplay, #available(iOS 15.0, tvOS 15.0, *) { + return preparingForDisplay() + } +#endif + guard let cgImage else { + return nil + } + return draw(inCanvasWithSize: cgImage.size, drawRect: CGRect(origin: .zero, size: cgImage.size)) + } +} + +private extension CGContext { + static func make(_ image: CGImage, size: CGSize, alphaInfo: CGImageAlphaInfo? = nil) -> CGContext? { + if let ctx = CGContext.make(image, size: size, alphaInfo: alphaInfo, colorSpace: image.colorSpace ?? CGColorSpaceCreateDeviceRGB()) { + return ctx + } + // In case the combination of parameters (color space, bits per component, etc) + // is nit supported by Core Graphics, switch to default context. + // - Quartz 2D Programming Guide + // - https://github.com/kean/Nuke/issues/35 + // - https://github.com/kean/Nuke/issues/57 + return CGContext.make(image, size: size, alphaInfo: alphaInfo, colorSpace: CGColorSpaceCreateDeviceRGB()) + } + + static func make(_ image: CGImage, size: CGSize, alphaInfo: CGImageAlphaInfo?, colorSpace: CGColorSpace) -> CGContext? { + CGContext( + data: nil, + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: (alphaInfo ?? preferredAlphaInfo(for: image, colorSpace: colorSpace)).rawValue + ) + } + + /// - See https://developer.apple.com/library/archive/documentation/GraphicsImaging/Conceptual/drawingwithquartz2d/dq_context/dq_context.html#//apple_ref/doc/uid/TP30001066-CH203-BCIBHHBB + private static func preferredAlphaInfo(for image: CGImage, colorSpace: CGColorSpace) -> CGImageAlphaInfo { + guard image.isOpaque else { + return .premultipliedLast + } + if colorSpace.numberOfComponents == 1 && image.bitsPerPixel == 8 { + return .none // The only pixel format supported for grayscale CS + } + return .noneSkipLast + } +} + +extension CGFloat { + func converted(to unit: ImageProcessingOptions.Unit) -> CGFloat { + switch unit { + case .pixels: return self + case .points: return self * Screen.scale + } + } +} + +extension CGSize { + func getScale(targetSize: CGSize, contentMode: ImageProcessingOptions.ContentMode) -> CGFloat { + let scaleHor = targetSize.width / width + let scaleVert = targetSize.height / height + + switch contentMode { + case .aspectFill: + return max(scaleHor, scaleVert) + case .aspectFit: + return min(scaleHor, scaleVert) + } + } + + /// Calculates a rect such that the output rect will be in the center of + /// the rect of the input size (assuming origin: .zero) + func centeredInRectWithSize(_ targetSize: CGSize) -> CGRect { + // First, resize the original size to fill the target size. + CGRect(origin: .zero, size: self).offsetBy( + dx: -(width - targetSize.width) / 2, + dy: -(height - targetSize.height) / 2 + ) + } +} + +#if canImport(UIKit) +extension CGImagePropertyOrientation { + init(_ orientation: UIImage.Orientation) { + switch orientation { + case .up: self = .up + case .upMirrored: self = .upMirrored + case .down: self = .down + case .downMirrored: self = .downMirrored + case .left: self = .left + case .leftMirrored: self = .leftMirrored + case .right: self = .right + case .rightMirrored: self = .rightMirrored + @unknown default: self = .up + } + } +} + +extension UIImage.Orientation { + init(_ cgOrientation: CGImagePropertyOrientation) { + switch cgOrientation { + case .up: self = .up + case .upMirrored: self = .upMirrored + case .down: self = .down + case .downMirrored: self = .downMirrored + case .left: self = .left + case .leftMirrored: self = .leftMirrored + case .right: self = .right + case .rightMirrored: self = .rightMirrored + } + } +} + +private extension CGSize { + func rotatedForOrientation(_ imageOrientation: CGImagePropertyOrientation) -> CGSize { + switch imageOrientation { + case .left, .leftMirrored, .right, .rightMirrored: + return CGSize(width: height, height: width) // Rotate 90 degrees + case .up, .upMirrored, .down, .downMirrored: + return self + } + } + + func rotatedForOrientation(_ imageOrientation: UIImage.Orientation) -> CGSize { + switch imageOrientation { + case .left, .leftMirrored, .right, .rightMirrored: + return CGSize(width: height, height: width) // Rotate 90 degrees + case .up, .upMirrored, .down, .downMirrored: + return self + @unknown default: + return self + } + } +} +#endif + +#if os(macOS) +extension NSImage { + var cgImage: CGImage? { + cgImage(forProposedRect: nil, context: nil, hints: nil) + } + + var ciImage: CIImage? { + cgImage.map { CIImage(cgImage: $0) } + } + + static func make(cgImage: CGImage, source: NSImage) -> NSImage { + NSImage(cgImage: cgImage, size: .zero) + } + + convenience init(cgImage: CGImage) { + self.init(cgImage: cgImage, size: .zero) + } +} +#else +extension UIImage { + static func make(cgImage: CGImage, source: UIImage) -> UIImage { + UIImage(cgImage: cgImage, scale: source.scale, orientation: source.imageOrientation) + } +} +#endif + +extension CGImage { + /// Returns `true` if the image doesn't contain alpha channel. + var isOpaque: Bool { + let alpha = alphaInfo + return alpha == .none || alpha == .noneSkipFirst || alpha == .noneSkipLast + } + + var size: CGSize { + CGSize(width: width, height: height) + } +} + +extension CGSize { + func scaled(by scale: CGFloat) -> CGSize { + CGSize(width: width * scale, height: height * scale) + } + + func rounded() -> CGSize { + CGSize(width: CGFloat(round(width)), height: CGFloat(round(height))) + } +} + +enum Screen { +#if os(iOS) || os(tvOS) + /// Returns the current screen scale. + static let scale: CGFloat = UITraitCollection.current.displayScale +#elseif os(watchOS) + /// Returns the current screen scale. + static let scale: CGFloat = WKInterfaceDevice.current().screenScale +#else + /// Always returns 1. + static let scale: CGFloat = 1 +#endif +} + +#if os(macOS) +typealias Color = NSColor +#else +typealias Color = UIColor +#endif + +extension Color { + /// Returns a hex representation of the color, e.g. "#FFFFAA". + var hex: String { + var (r, g, b, a) = (CGFloat(0), CGFloat(0), CGFloat(0), CGFloat(0)) + getRed(&r, green: &g, blue: &b, alpha: &a) + let components = [r, g, b, a < 1 ? a : nil] + return "#" + components + .compactMap { $0 } + .map { String(format: "%02lX", lroundf(Float($0) * 255)) } + .joined() + } +} + +/// Creates an image thumbnail. Uses significantly less memory than other options. +/// - parameter data: Data object from which to read the image. +/// - parameter options: Image loading options. +/// - parameter scale: The scale factor to assume when interpreting the image data, defaults to 1. +func makeThumbnail(data: Data, options: ImageRequest.ThumbnailOptions, scale: CGFloat = 1.0) -> PlatformImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary) else { + return nil + } + + let maxPixelSize = getMaxPixelSize(for: source, options: options) + let options = [ + kCGImageSourceCreateThumbnailFromImageAlways: options.createThumbnailFromImageAlways, + kCGImageSourceCreateThumbnailFromImageIfAbsent: options.createThumbnailFromImageIfAbsent, + kCGImageSourceShouldCacheImmediately: options.shouldCacheImmediately, + kCGImageSourceCreateThumbnailWithTransform: options.createThumbnailWithTransform, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize] as [CFString: Any] + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + +#if canImport(UIKit) + var orientation: UIImage.Orientation = .up + if let imageProperties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [AnyHashable: Any], + let orientationValue = imageProperties[kCGImagePropertyOrientation as String] as? UInt32, + let cgOrientation = CGImagePropertyOrientation(rawValue: orientationValue) { + orientation = UIImage.Orientation(cgOrientation) + } + return PlatformImage(cgImage: image, scale: scale, orientation: orientation) +#else + return PlatformImage(cgImage: image) +#endif +} + +private func getMaxPixelSize(for source: CGImageSource, options thumbnailOptions: ImageRequest.ThumbnailOptions) -> CGFloat { + switch thumbnailOptions.targetSize { + case .fixed(let size): + return CGFloat(size) + case let .flexible(size, contentMode): + var targetSize = size.cgSize + let options = [kCGImageSourceShouldCache: false] as CFDictionary + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, options) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? CGFloat, + let height = properties[kCGImagePropertyPixelHeight] as? CGFloat else { + return max(targetSize.width, targetSize.height) + } + + let orientation = (properties[kCGImagePropertyOrientation] as? UInt32).flatMap(CGImagePropertyOrientation.init) ?? .up +#if canImport(UIKit) + targetSize = targetSize.rotatedForOrientation(orientation) +#endif + + let imageSize = CGSize(width: width, height: height) + let scale = imageSize.getScale(targetSize: targetSize, contentMode: contentMode) + let size = imageSize.scaled(by: scale).rounded() + return max(size.width, size.height) + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Combine/ImagePublisher.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/ImagePublisher.swift similarity index 65% rename from Sources/StreamChatUI/StreamNuke/Combine/ImagePublisher.swift rename to Sources/StreamChatUI/StreamNuke/Nuke/Internal/ImagePublisher.swift index 08c62536ea6..985fbc992e4 100644 --- a/Sources/StreamChatUI/StreamNuke/Combine/ImagePublisher.swift +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/ImagePublisher.swift @@ -1,20 +1,10 @@ // The MIT License (MIT) // -// Copyright (c) 2020-2021 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). import Foundation import Combine -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -extension ImagePipeline { - /// Returns a publisher which starts a new `ImageTask` when a subscriber is added. - /// - /// - note: For more information, see `ImagePublisher`. - func imagePublisher(with request: ImageRequestConvertible) -> ImagePublisher { - ImagePublisher(request: request.asImageRequest(), pipeline: self) - } -} - /// A publisher that starts a new `ImageTask` when a subscriber is added. /// /// If the requested image is available in the memory cache, the value is @@ -24,32 +14,24 @@ extension ImagePipeline { /// - note: In case the pipeline has `isProgressiveDecodingEnabled` option enabled /// and the image being downloaded supports progressive decoding, the publisher /// might emit more than a single value. -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -struct ImagePublisher: Publisher { +struct ImagePublisher: Publisher, Sendable { typealias Output = ImageResponse typealias Failure = ImagePipeline.Error let request: ImageRequest let pipeline: ImagePipeline - init(request: ImageRequest, pipeline: ImagePipeline) { - self.request = request - self.pipeline = pipeline - } - - func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { + func receive(subscriber: S) where S: Subscriber, S: Sendable, Failure == S.Failure, Output == S.Input { let subscription = ImageSubscription( request: self.request, pipeline: self.pipeline, subscriber: subscriber ) - subscriber.receive(subscription: subscription) } } -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -private final class ImageSubscription: Subscription where S.Input == ImageResponse, S.Failure == ImagePipeline.Error { +private final class ImageSubscription: Subscription where S: Subscriber, S: Sendable, S.Input == ImageResponse, S.Failure == ImagePipeline.Error { private var task: ImageTask? private let subscriber: S? private let request: ImageRequest @@ -65,12 +47,10 @@ private final class ImageSubscription: Subscription where S.Input func request(_ demand: Subscribers.Demand) { guard demand > 0 else { return } - guard let subscriber = subscriber else { return } - - let request = pipeline.configuration.inheritOptions(self.request) + guard let subscriber else { return } if let image = pipeline.cache[request] { - _ = subscriber.receive(ImageResponse(container: image, cacheType: .memory)) + _ = subscriber.receive(ImageResponse(container: image, request: request, cacheType: .memory)) if !image.isPreview { subscriber.receive(completion: .finished) @@ -80,9 +60,8 @@ private final class ImageSubscription: Subscription where S.Input task = pipeline.loadImage( with: request, - queue: nil, progress: { response, _, _ in - if let response = response { + if let response { // Send progressively decoded image (if enabled and if any) _ = subscriber.receive(response) } diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Internal/ImageRequestKeys.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/ImageRequestKeys.swift new file mode 100644 index 00000000000..90046776b20 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/ImageRequestKeys.swift @@ -0,0 +1,89 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Uniquely identifies a cache processed image. +final class MemoryCacheKey: Hashable, Sendable { + // Using a reference type turned out to be significantly faster + private let imageId: String? + private let scale: Float + private let thumbnail: ImageRequest.ThumbnailOptions? + private let processors: [any ImageProcessing] + + init(_ request: ImageRequest) { + self.imageId = request.preferredImageId + self.scale = request.scale ?? 1 + self.thumbnail = request.thumbnail + self.processors = request.processors + } + + func hash(into hasher: inout Hasher) { + hasher.combine(imageId) + hasher.combine(scale) + hasher.combine(thumbnail) + hasher.combine(processors.count) + } + + static func == (lhs: MemoryCacheKey, rhs: MemoryCacheKey) -> Bool { + lhs.imageId == rhs.imageId && lhs.scale == rhs.scale && lhs.thumbnail == rhs.thumbnail && lhs.processors == rhs.processors + } +} + +// MARK: - Identifying Tasks + +/// Uniquely identifies a task of retrieving the processed image. +final class TaskLoadImageKey: Hashable, Sendable { + private let loadKey: TaskFetchOriginalImageKey + private let options: ImageRequest.Options + private let processors: [any ImageProcessing] + + init(_ request: ImageRequest) { + self.loadKey = TaskFetchOriginalImageKey(request) + self.options = request.options + self.processors = request.processors + } + + func hash(into hasher: inout Hasher) { + hasher.combine(loadKey.hashValue) + hasher.combine(options.hashValue) + hasher.combine(processors.count) + } + + static func == (lhs: TaskLoadImageKey, rhs: TaskLoadImageKey) -> Bool { + lhs.loadKey == rhs.loadKey && lhs.options == rhs.options && lhs.processors == rhs.processors + } +} + +/// Uniquely identifies a task of retrieving the original image. +struct TaskFetchOriginalImageKey: Hashable { + private let dataLoadKey: TaskFetchOriginalDataKey + private let scale: Float + private let thumbnail: ImageRequest.ThumbnailOptions? + + init(_ request: ImageRequest) { + self.dataLoadKey = TaskFetchOriginalDataKey(request) + self.scale = request.scale ?? 1 + self.thumbnail = request.thumbnail + } +} + +/// Uniquely identifies a task of retrieving the original image data. +struct TaskFetchOriginalDataKey: Hashable { + private let imageId: String? + private let cachePolicy: URLRequest.CachePolicy + private let allowsCellularAccess: Bool + + init(_ request: ImageRequest) { + self.imageId = request.imageId + switch request.resource { + case .url, .publisher: + self.cachePolicy = .useProtocolCachePolicy + self.allowsCellularAccess = true + case let .urlRequest(urlRequest): + self.cachePolicy = urlRequest.cachePolicy + self.allowsCellularAccess = urlRequest.allowsCellularAccess + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/LinkedList.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/LinkedList.swift similarity index 82% rename from Sources/StreamChatUI/StreamNuke/Internal/LinkedList.swift rename to Sources/StreamChatUI/StreamNuke/Nuke/Internal/LinkedList.swift index bbed9e92d5e..2f33ab63d25 100644 --- a/Sources/StreamChatUI/StreamNuke/Internal/LinkedList.swift +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/LinkedList.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -11,17 +11,9 @@ final class LinkedList { private(set) var last: Node? deinit { - removeAll() - - #if TRACK_ALLOCATIONS - Allocations.decrement("LinkedList") - #endif - } - - init() { - #if TRACK_ALLOCATIONS - Allocations.increment("LinkedList") - #endif + // This way we make sure that the deallocations do no happen recursively + // (and potentially overflow the stack). + removeAllElements() } var isEmpty: Bool { @@ -38,7 +30,7 @@ final class LinkedList { /// Adds a node to the end of the list. func append(_ node: Node) { - if let last = last { + if let last { last.next = node node.previous = last self.last = node @@ -61,7 +53,7 @@ final class LinkedList { node.previous = nil } - func removeAll() { + func removeAllElements() { // avoid recursive Nodes deallocation var node = first while let next = node?.next { diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Log.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Log.swift new file mode 100644 index 00000000000..21830a7316e --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Log.swift @@ -0,0 +1,24 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation +import os + +func signpost(_ object: AnyObject, _ name: StaticString, _ type: OSSignpostType, _ message: @autoclosure () -> String) {} + +func signpost(_ name: StaticString, _ work: () throws -> T) rethrows -> T { + return try work() +} + +private let nukeLog = NukeAtomic(value: OSLog(subsystem: "com.github.kean.Nuke.ImagePipeline", category: "Image Loading")) + +enum Formatter { + static func bytes(_ count: Int) -> String { + bytes(Int64(count)) + } + + static func bytes(_ count: Int64) -> String { + ByteCountFormatter().string(fromByteCount: count) + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Operation.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Operation.swift new file mode 100644 index 00000000000..a112d1436f9 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/Operation.swift @@ -0,0 +1,98 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +final class Operation: Foundation.Operation, @unchecked Sendable { + override var isExecuting: Bool { + get { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return _isExecuting + } + set { + os_unfair_lock_lock(lock) + _isExecuting = newValue + os_unfair_lock_unlock(lock) + + willChangeValue(forKey: "isExecuting") + didChangeValue(forKey: "isExecuting") + } + } + + override var isFinished: Bool { + get { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return _isFinished + } + set { + os_unfair_lock_lock(lock) + _isFinished = newValue + os_unfair_lock_unlock(lock) + + willChangeValue(forKey: "isFinished") + didChangeValue(forKey: "isFinished") + } + } + + typealias Starter = @Sendable (_ finish: @Sendable @escaping () -> Void) -> Void + private let starter: Starter + + private var _isExecuting = false + private var _isFinished = false + private var isFinishCalled = false + private let lock: os_unfair_lock_t + + deinit { + lock.deinitialize(count: 1) + lock.deallocate() + } + + init(starter: @escaping Starter) { + self.starter = starter + + self.lock = .allocate(capacity: 1) + self.lock.initialize(to: os_unfair_lock()) + } + + override func start() { + guard !isCancelled else { + isFinished = true + return + } + isExecuting = true + starter { [weak self] in + self?._finish() + } + } + + private func _finish() { + os_unfair_lock_lock(lock) + guard !isFinishCalled else { + return os_unfair_lock_unlock(lock) + } + isFinishCalled = true + os_unfair_lock_unlock(lock) + + isExecuting = false + isFinished = true + } +} + +extension OperationQueue { + /// Adds simple `BlockOperation`. + func add(_ closure: @Sendable @escaping () -> Void) -> BlockOperation { + let operation = BlockOperation(block: closure) + addOperation(operation) + return operation + } + + /// Adds asynchronous operation (`Nuke.Operation`) with the given starter. + func add(_ starter: @escaping Operation.Starter) -> Operation { + let operation = Operation(starter: starter) + addOperation(operation) + return operation + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/RateLimiter.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/RateLimiter.swift similarity index 85% rename from Sources/StreamChatUI/StreamNuke/Internal/RateLimiter.swift rename to Sources/StreamChatUI/StreamNuke/Nuke/Internal/RateLimiter.swift index c399aa38e32..d85c34a412c 100644 --- a/Sources/StreamChatUI/StreamNuke/Internal/RateLimiter.swift +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/RateLimiter.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -13,7 +13,10 @@ import Foundation /// The implementation supports quick bursts of requests which can be executed /// without any delays when "the bucket is full". This is important to prevent /// rate limiter from affecting "normal" requests flow. -final class RateLimiter { +final class RateLimiter: @unchecked Sendable { + // This type isn't really Sendable and requires the caller to use the same + // queue as it does for synchronization. + private let bucket: TokenBucket private let queue: DispatchQueue private var pending = LinkedList() // fast append, fast remove first @@ -22,23 +25,14 @@ final class RateLimiter { typealias Work = () -> Bool /// Initializes the `RateLimiter` with the given configuration. - /// - parameter queue: Queue on which to execute pending tasks. - /// - parameter rate: Maximum number of requests per second. 80 by default. - /// - parameter burst: Maximum number of requests which can be executed without - /// any delays when "bucket is full". 25 by default. + /// - parameters: + /// - queue: Queue on which to execute pending tasks. + /// - rate: Maximum number of requests per second. 80 by default. + /// - burst: Maximum number of requests which can be executed without any + /// delays when "bucket is full". 25 by default. init(queue: DispatchQueue, rate: Int = 80, burst: Int = 25) { self.queue = queue self.bucket = TokenBucket(rate: Double(rate), burst: Double(burst)) - - #if TRACK_ALLOCATIONS - Allocations.increment("RateLimiter") - #endif - } - - deinit { - #if TRACK_ALLOCATIONS - Allocations.decrement("RateLimiter") - #endif } /// - parameter closure: Returns `true` if the close was executed, `false` @@ -62,7 +56,7 @@ final class RateLimiter { let bucketRate = 1000.0 / bucket.rate let delay = Int(2.1 * bucketRate) // 14 ms for rate 80 (default) let bounds = min(100, max(15, delay)) - queue.asyncAfter(deadline: .now() + .milliseconds(bounds), execute: executePendingTasks) + queue.asyncAfter(deadline: .now() + .milliseconds(bounds)) { self.executePendingTasks() } } private func executePendingTasks() { diff --git a/Sources/StreamChatUI/StreamNuke/Internal/ResumableData.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/ResumableData.swift similarity index 85% rename from Sources/StreamChatUI/StreamNuke/Internal/ResumableData.swift rename to Sources/StreamChatUI/StreamNuke/Nuke/Internal/ResumableData.swift index 44d0950bad9..85f7f9a3988 100644 --- a/Sources/StreamChatUI/StreamNuke/Internal/ResumableData.swift +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Internal/ResumableData.swift @@ -1,12 +1,12 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation /// Resumable data support. For more info see: /// - https://developer.apple.com/library/content/qa/qa1761/_index.html -struct ResumableData { +struct ResumableData: @unchecked Sendable { let data: Data let validator: String // Either Last-Modified or ETag @@ -63,7 +63,7 @@ struct ResumableData { } /// Shared cache, uses the same memory pool across multiple pipelines. -final class ResumableDataStorage { +final class ResumableDataStorage: @unchecked Sendable { static let shared = ResumableDataStorage() private let lock = NSLock() @@ -74,7 +74,8 @@ final class ResumableDataStorage { // MARK: Registration func register(_ pipeline: ImagePipeline) { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } if registeredPipelines.isEmpty { // 32 MB @@ -84,7 +85,8 @@ final class ResumableDataStorage { } func unregister(_ pipeline: ImagePipeline) { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } registeredPipelines.remove(pipeline.id) if registeredPipelines.isEmpty { @@ -92,27 +94,26 @@ final class ResumableDataStorage { } } - func removeAll() { - lock.lock(); defer { lock.unlock() } + func removeAllResponses() { + lock.lock() + defer { lock.unlock() } - cache?.removeAll() + cache?.removeAllCachedValues() } // MARK: Storage func removeResumableData(for request: ImageRequest, pipeline: ImagePipeline) -> ResumableData? { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } - guard let cache = cache, - cache.totalCount > 0, - let key = Key(request: request, pipeline: pipeline) else { - return nil - } - return cache.removeValue(forKey: key) + guard let key = Key(request: request, pipeline: pipeline) else { return nil } + return cache?.removeValue(forKey: key) } func storeResumableData(_ data: ResumableData, for request: ImageRequest, pipeline: ImagePipeline) { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } guard let key = Key(request: request, pipeline: pipeline) else { return } cache?.set(data, forKey: key, cost: data.data.count) @@ -120,14 +121,14 @@ final class ResumableDataStorage { private struct Key: Hashable { let pipelineId: UUID - let url: String + let imageId: String init?(request: ImageRequest, pipeline: ImagePipeline) { guard let imageId = request.imageId else { return nil } self.pipelineId = pipeline.id - self.url = imageId + self.imageId = imageId } } } diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Loading/DataLoader.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Loading/DataLoader.swift new file mode 100644 index 00000000000..810e760ecd7 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Loading/DataLoader.swift @@ -0,0 +1,232 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Provides basic networking using `URLSession`. +final class DataLoader: DataLoading, @unchecked Sendable { + let session: URLSession + private let impl: _DataLoader + + /// Determines whether to deliver a partial response body in increments. By + /// default, `false`. + var prefersIncrementalDelivery = false + + /// The delegate that gets called for the callbacks handled by the data loader. + /// You can use it for observing the session events and modifying some of the + /// task behavior, e.g. handling authentication challenges. + /// + /// For example, you can use it to log network requests using [Pulse](https://github.com/kean/Pulse) + /// which is optimized to work with images. + /// + /// ```swift + /// (ImagePipeline.shared.configuration.dataLoader as? DataLoader)?.delegate = URLSessionProxyDelegate() + /// ``` + /// + /// - note: The delegate is retained. + var delegate: URLSessionDelegate? { + didSet { impl.delegate = delegate } + } + + deinit { + session.invalidateAndCancel() + } + + /// Initializes ``DataLoader`` with the given configuration. + /// + /// - parameters: + /// - configuration: `URLSessionConfiguration.default` with `URLCache` with + /// 0 MB memory capacity and 150 MB disk capacity by default. + /// - validate: Validates the response. By default, check if the status + /// code is in the acceptable range (`200..<300`). + init(configuration: URLSessionConfiguration = DataLoader.defaultConfiguration, + validate: @Sendable @escaping (URLResponse) -> Swift.Error? = DataLoader.validate) { + self.impl = _DataLoader(validate: validate) + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + self.session = URLSession(configuration: configuration, delegate: impl, delegateQueue: queue) + self.session.sessionDescription = "Nuke URLSession" + } + + /// Returns a default configuration which has a `sharedUrlCache` set + /// as a `urlCache`. + static var defaultConfiguration: URLSessionConfiguration { + let conf = URLSessionConfiguration.default + conf.urlCache = DataLoader.sharedUrlCache + return conf + } + + /// Validates `HTTP` responses by checking that the status code is 2xx. If + /// it's not returns ``DataLoader/Error/statusCodeUnacceptable(_:)``. + @Sendable static func validate(response: URLResponse) -> Swift.Error? { + guard let response = response as? HTTPURLResponse else { + return nil + } + return (200..<300).contains(response.statusCode) ? nil : Error.statusCodeUnacceptable(response.statusCode) + } + +#if !os(macOS) && !targetEnvironment(macCatalyst) + private static let cachePath = "com.github.kean.Nuke.Cache" +#else + private static let cachePath: String = { + let cachePaths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true) + if let cachePath = cachePaths.first, let identifier = Bundle.main.bundleIdentifier { + return cachePath.appending("/" + identifier) + } + + return "" + }() +#endif + + /// Shared url cached used by a default ``DataLoader``. The cache is + /// initialized with 0 MB memory capacity and 150 MB disk capacity. + static let sharedUrlCache: URLCache = { + let diskCapacity = 150 * 1048576 // 150 MB +#if targetEnvironment(macCatalyst) + return URLCache(memoryCapacity: 0, diskCapacity: diskCapacity, directory: URL(fileURLWithPath: cachePath)) +#else + return URLCache(memoryCapacity: 0, diskCapacity: diskCapacity, diskPath: cachePath) +#endif + }() + + func loadData(with request: URLRequest, + didReceiveData: @escaping (Data, URLResponse) -> Void, + completion: @escaping (Swift.Error?) -> Void) -> any Cancellable { + let task = session.dataTask(with: request) + if #available(iOS 14.5, tvOS 14.5, watchOS 7.4, macOS 11.3, *) { + task.prefersIncrementalDelivery = prefersIncrementalDelivery + } + return impl.loadData(with: task, session: session, didReceiveData: didReceiveData, completion: completion) + } + + /// Errors produced by ``DataLoader``. + enum Error: Swift.Error, CustomStringConvertible { + /// Validation failed. + case statusCodeUnacceptable(Int) + + var description: String { + switch self { + case let .statusCodeUnacceptable(code): + return "Response status code was unacceptable: \(code.description)" + } + } + } +} + +// Actual data loader implementation. Hide NSObject inheritance, hide +// URLSessionDataDelegate conformance, and break retain cycle between URLSession +// and URLSessionDataDelegate. +private final class _DataLoader: NSObject, URLSessionDataDelegate, @unchecked Sendable { + let validate: @Sendable (URLResponse) -> Swift.Error? + private var handlers = [URLSessionTask: _Handler]() + var delegate: URLSessionDelegate? + + init(validate: @Sendable @escaping (URLResponse) -> Swift.Error?) { + self.validate = validate + } + + /// Loads data with the given request. + func loadData(with task: URLSessionDataTask, + session: URLSession, + didReceiveData: @escaping (Data, URLResponse) -> Void, + completion: @escaping (Error?) -> Void) -> any Cancellable { + let handler = _Handler(didReceiveData: didReceiveData, completion: completion) + session.delegateQueue.addOperation { // `URLSession` is configured to use this same queue + self.handlers[task] = handler + } + task.resume() + return AnonymousCancellable { task.cancel() } + } + + // MARK: URLSessionDelegate + +#if !os(macOS) && !targetEnvironment(macCatalyst) && swift(>=5.7) + func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + (delegate as? URLSessionTaskDelegate)?.urlSession?(session, didCreateTask: task) + } else { + // Doesn't exist on earlier versions + } + } +#endif + + func urlSession(_ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + (delegate as? URLSessionDataDelegate)?.urlSession?(session, dataTask: dataTask, didReceive: response, completionHandler: { _ in }) + + guard let handler = handlers[dataTask] else { + completionHandler(.cancel) + return + } + if let error = validate(response) { + handler.completion(error) + completionHandler(.cancel) + return + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + (delegate as? URLSessionTaskDelegate)?.urlSession?(session, task: task, didCompleteWithError: error) + assert(task is URLSessionDataTask) + guard let handler = handlers[task] else { + return + } + handlers[task] = nil + handler.completion(error) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + (delegate as? URLSessionTaskDelegate)?.urlSession?(session, task: task, didFinishCollecting: metrics) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @Sendable @escaping (URLRequest?) -> Void) { + (delegate as? URLSessionTaskDelegate)?.urlSession?(session, task: task, willPerformHTTPRedirection: response, newRequest: request, completionHandler: completionHandler) ?? completionHandler(request) + } + + func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) { + (delegate as? URLSessionTaskDelegate)?.urlSession?(session, taskIsWaitingForConnectivity: task) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @Sendable @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + (delegate as? URLSessionTaskDelegate)?.urlSession?(session, task: task, didReceive: challenge, completionHandler: completionHandler) ?? + completionHandler(.performDefaultHandling, nil) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, willBeginDelayedRequest request: URLRequest, completionHandler: @Sendable @escaping (URLSession.DelayedRequestDisposition, URLRequest?) -> Void) { + (delegate as? URLSessionTaskDelegate)?.urlSession?(session, task: task, willBeginDelayedRequest: request, completionHandler: completionHandler) ?? + completionHandler(.continueLoading, nil) + } + + // MARK: URLSessionDataDelegate + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + (delegate as? URLSessionDataDelegate)?.urlSession?(session, dataTask: dataTask, didReceive: data) + + guard let handler = handlers[dataTask], let response = dataTask.response else { + return + } + // Don't store data anywhere, just send it to the pipeline. + handler.didReceiveData(data, response) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @Sendable @escaping (CachedURLResponse?) -> Void) { + (delegate as? URLSessionDataDelegate)?.urlSession?(session, dataTask: dataTask, willCacheResponse: proposedResponse, completionHandler: completionHandler) ?? + completionHandler(proposedResponse) + } + + // MARK: Internal + + private final class _Handler: @unchecked Sendable { + let didReceiveData: (Data, URLResponse) -> Void + let completion: (Error?) -> Void + + init(didReceiveData: @escaping (Data, URLResponse) -> Void, completion: @escaping (Error?) -> Void) { + self.didReceiveData = didReceiveData + self.completion = completion + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Loading/DataLoading.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Loading/DataLoading.swift new file mode 100644 index 00000000000..bfe95f8eeb3 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Loading/DataLoading.swift @@ -0,0 +1,16 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Fetches original image data. +protocol DataLoading: Sendable { + /// - parameter didReceiveData: Can be called multiple times if streaming + /// is supported. + /// - parameter completion: Must be called once after all (or none in case + /// of an error) `didReceiveData` closures have been called. + func loadData(with request: URLRequest, + didReceiveData: @escaping (Data, URLResponse) -> Void, + completion: @escaping (Error?) -> Void) -> any Cancellable +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Cache.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Cache.swift new file mode 100644 index 00000000000..d8f95800880 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Cache.swift @@ -0,0 +1,261 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImagePipeline { + /// Provides a set of convenience APIs for managing the pipeline cache layers, + /// including ``ImageCaching`` (memory cache) and ``DataCaching`` (disk cache). + /// + /// - important: This class doesn't work with a `URLCache`. For more info, + /// see . + struct Cache: Sendable { + let pipeline: ImagePipeline + private var configuration: ImagePipeline.Configuration { pipeline.configuration } + } +} + +extension ImagePipeline.Cache { + // MARK: Subscript (Memory Cache) + + /// Returns an image from the memory cache for the given URL. + subscript(url: URL) -> ImageContainer? { + get { self[ImageRequest(url: url)] } + nonmutating set { self[ImageRequest(url: url)] = newValue } + } + + /// Returns an image from the memory cache for the given request. + subscript(request: ImageRequest) -> ImageContainer? { + get { + cachedImageFromMemoryCache(for: request) + } + nonmutating set { + if let image = newValue { + storeCachedImageInMemoryCache(image, for: request) + } else { + removeCachedImageFromMemoryCache(for: request) + } + } + } + + // MARK: Cached Images + + /// Returns a cached image any of the caches. + /// + /// - note: Respects request options such as its cache policy. + /// + /// - parameters: + /// - request: The request. Make sure to remove the processors if you want + /// to retrieve an original image (if it's stored). + /// - caches: `[.all]`, by default. + func cachedImage(for request: ImageRequest, caches: Caches = [.all]) -> ImageContainer? { + if caches.contains(.memory) { + if let image = cachedImageFromMemoryCache(for: request) { + return image + } + } + if caches.contains(.disk) { + if let data = cachedData(for: request), + let image = decodeImageData(data, for: request) { + return image + } + } + return nil + } + + /// Stores the image in all caches. To store image in the disk cache, it + /// will be encoded (see ``ImageEncoding``) + /// + /// - note: Respects request cache options. + /// + /// - note: Default ``DataCache`` stores data asynchronously, so it's safe + /// to call this method even from the main thread. + /// + /// - note: Image previews are not stored. + /// + /// - parameters: + /// - request: The request. Make sure to remove the processors if you want + /// to retrieve an original image (if it's stored). + /// - caches: `[.all]`, by default. + func storeCachedImage(_ image: ImageContainer, for request: ImageRequest, caches: Caches = [.all]) { + if caches.contains(.memory) { + storeCachedImageInMemoryCache(image, for: request) + } + if caches.contains(.disk) { + if let data = encodeImage(image, for: request) { + storeCachedData(data, for: request) + } + } + } + + /// Removes the image from all caches. + func removeCachedImage(for request: ImageRequest, caches: Caches = [.all]) { + if caches.contains(.memory) { + removeCachedImageFromMemoryCache(for: request) + } + if caches.contains(.disk) { + removeCachedData(for: request) + } + } + + /// Returns `true` if any of the caches contain the image. + func containsCachedImage(for request: ImageRequest, caches: Caches = [.all]) -> Bool { + if caches.contains(.memory) && cachedImageFromMemoryCache(for: request) != nil { + return true + } + if caches.contains(.disk), let dataCache = dataCache(for: request) { + let key = makeDataCacheKey(for: request) + return dataCache.containsData(for: key) + } + return false + } + + private func cachedImageFromMemoryCache(for request: ImageRequest) -> ImageContainer? { + guard !request.options.contains(.disableMemoryCacheReads) else { + return nil + } + guard let imageCache = imageCache(for: request) else { + return nil + } + return imageCache[makeImageCacheKey(for: request)] + } + + private func storeCachedImageInMemoryCache(_ image: ImageContainer, for request: ImageRequest) { + guard !request.options.contains(.disableMemoryCacheWrites) else { + return + } + guard !image.isPreview || configuration.isStoringPreviewsInMemoryCache else { + return + } + guard let imageCache = imageCache(for: request) else { + return + } + imageCache[makeImageCacheKey(for: request)] = image + } + + private func removeCachedImageFromMemoryCache(for request: ImageRequest) { + guard let imageCache = imageCache(for: request) else { + return + } + imageCache[makeImageCacheKey(for: request)] = nil + } + + // MARK: Cached Data + + /// Returns cached data for the given request. + func cachedData(for request: ImageRequest) -> Data? { + guard !request.options.contains(.disableDiskCacheReads) else { + return nil + } + guard let dataCache = dataCache(for: request) else { + return nil + } + let key = makeDataCacheKey(for: request) + return dataCache.cachedData(for: key) + } + + /// Stores data for the given request. + /// + /// - note: Default ``DataCache`` stores data asynchronously, so it's safe + /// to call this method even from the main thread. + func storeCachedData(_ data: Data, for request: ImageRequest) { + guard let dataCache = dataCache(for: request), + !request.options.contains(.disableDiskCacheWrites) else { + return + } + let key = makeDataCacheKey(for: request) + dataCache.storeData(data, for: key) + } + + /// Returns true if the data cache contains data for the given image + func containsData(for request: ImageRequest) -> Bool { + guard let dataCache = dataCache(for: request) else { + return false + } + return dataCache.containsData(for: makeDataCacheKey(for: request)) + } + + /// Removes cached data for the given request. + func removeCachedData(for request: ImageRequest) { + guard let dataCache = dataCache(for: request) else { + return + } + let key = makeDataCacheKey(for: request) + dataCache.removeData(for: key) + } + + // MARK: Keys + + /// Returns image cache (memory cache) key for the given request. + func makeImageCacheKey(for request: ImageRequest) -> ImageCacheKey { + if let customKey = pipeline.delegate.cacheKey(for: request, pipeline: pipeline) { + return ImageCacheKey(key: customKey) + } + return ImageCacheKey(request: request) // Use the default key + } + + /// Returns data cache (disk cache) key for the given request. + func makeDataCacheKey(for request: ImageRequest) -> String { + if let customKey = pipeline.delegate.cacheKey(for: request, pipeline: pipeline) { + return customKey + } + return "\(request.preferredImageId)\(request.thumbnail?.identifier ?? "")\(ImageProcessors.Composition(request.processors).identifier)" + } + + // MARK: Misc + + /// Removes both images and data from all cache layes. + /// + /// - important: It clears only caches set in the pipeline configuration. If + /// you implement ``ImagePipelineDelegate`` that uses different caches for + /// different requests, this won't remove images from them. + func removeAll(caches: Caches = [.all]) { + if caches.contains(.memory) { + configuration.imageCache?.removeAll() + } + if caches.contains(.disk) { + configuration.dataCache?.removeAll() + } + } + + // MARK: Private + + private func decodeImageData(_ data: Data, for request: ImageRequest) -> ImageContainer? { + let context = ImageDecodingContext(request: request, data: data, cacheType: .disk) + guard let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) else { + return nil + } + return (try? decoder.decode(context))?.container + } + + private func encodeImage(_ image: ImageContainer, for request: ImageRequest) -> Data? { + let context = ImageEncodingContext(request: request, image: image.image, urlResponse: nil) + let encoder = pipeline.delegate.imageEncoder(for: context, pipeline: pipeline) + return encoder.encode(image, context: context) + } + + private func imageCache(for request: ImageRequest) -> (any ImageCaching)? { + pipeline.delegate.imageCache(for: request, pipeline: pipeline) + } + + private func dataCache(for request: ImageRequest) -> (any DataCaching)? { + pipeline.delegate.dataCache(for: request, pipeline: pipeline) + } + + // MARK: Options + + /// Describes a set of cache layers to use. + struct Caches: OptionSet { + let rawValue: Int + init(rawValue: Int) { + self.rawValue = rawValue + } + + static let memory = Caches(rawValue: 1 << 0) + static let disk = Caches(rawValue: 1 << 1) + static let all: Caches = [.memory, .disk] + } +} + +extension ImagePipeline.Cache.Caches: Sendable {} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Configuration.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Configuration.swift new file mode 100644 index 00000000000..60c0984a800 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Configuration.swift @@ -0,0 +1,266 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImagePipeline { + /// The pipeline configuration. + struct Configuration: @unchecked Sendable { + // MARK: - Dependencies + + /// Data loader used by the pipeline. + var dataLoader: any DataLoading + + /// Data cache used by the pipeline. + var dataCache: (any DataCaching)? + + /// Image cache used by the pipeline. + var imageCache: (any ImageCaching)? { + // This exists simply to ensure we don't init ImageCache.shared if the + // user provides their own instance. + get { isCustomImageCacheProvided ? customImageCache : ImageCache.shared } + set { + customImageCache = newValue + isCustomImageCacheProvided = true + } + } + private var customImageCache: (any ImageCaching)? + + /// Default implementation uses shared ``ImageDecoderRegistry`` to create + /// a decoder that matches the context. + var makeImageDecoder: @Sendable (ImageDecodingContext) -> (any ImageDecoding)? = { + ImageDecoderRegistry.shared.decoder(for: $0) + } + + /// Returns `ImageEncoders.Default()` by default. + var makeImageEncoder: @Sendable (ImageEncodingContext) -> any ImageEncoding = { _ in + ImageEncoders.Default() + } + + // MARK: - Options + + /// Decompresses the loaded images. By default, enabled on all platforms + /// except for `macOS`. + /// + /// Decompressing compressed image formats (such as JPEG) can significantly + /// improve drawing performance as it allows a bitmap representation to be + /// created in a background rather than on the main thread. + var isDecompressionEnabled: Bool { + get { _isDecompressionEnabled } + set { _isDecompressionEnabled = newValue } + } + + /// Set this to `true` to use native `preparingForDisplay()` method for + /// decompression on iOS and tvOS 15.0 and later. Disabled by default. + /// If disabled, CoreGraphics-based decompression is used. + var isUsingPrepareForDisplay: Bool = false + +#if os(macOS) + var _isDecompressionEnabled = false +#else + var _isDecompressionEnabled = true +#endif + + /// If you use an aggressive disk cache ``DataCaching``, you can specify + /// a cache policy with multiple available options and + /// ``ImagePipeline/DataCachePolicy/storeOriginalData`` used by default. + var dataCachePolicy = ImagePipeline.DataCachePolicy.storeOriginalData + + /// `true` by default. If `true` the pipeline avoids duplicated work when + /// loading images. The work only gets cancelled when all the registered + /// requests are. The pipeline also automatically manages the priority of the + /// deduplicated work. + /// + /// Let's take these two requests for example: + /// + /// ```swift + /// let url = URL(string: "http://example.com/image") + /// pipeline.loadImage(with: ImageRequest(url: url, processors: [ + /// .resize(size: CGSize(width: 44, height: 44)), + /// .gaussianBlur(radius: 8) + /// ])) + /// pipeline.loadImage(with: ImageRequest(url: url, processors: [ + /// .resize(size: CGSize(width: 44, height: 44)) + /// ])) + /// ``` + /// + /// Nuke will load the image data only once, resize the image once and + /// apply the blur also only once. There is no duplicated work done at + /// any stage. + var isTaskCoalescingEnabled = true + + /// `true` by default. If `true` the pipeline will rate limit requests + /// to prevent trashing of the underlying systems (e.g. `URLSession`). + /// The rate limiter only comes into play when the requests are started + /// and cancelled at a high rate (e.g. scrolling through a collection view). + var isRateLimiterEnabled = true + + /// `false` by default. If `true` the pipeline will try to produce a new + /// image each time it receives a new portion of data from data loader. + /// The decoder used by the image loading session determines whether + /// to produce a partial image or not. The default image decoder + /// ``ImageDecoders/Default`` supports progressive JPEG decoding. + var isProgressiveDecodingEnabled = false + + /// `true` by default. If `true`, the pipeline will store all of the + /// progressively generated previews in the memory cache. All of the + /// previews have ``ImageContainer/isPreview`` flag set to `true`. + var isStoringPreviewsInMemoryCache = true + + /// If the data task is terminated (either because of a failure or a + /// cancellation) and the image was partially loaded, the next load will + /// resume where it left off. Supports both validators (`ETag`, + /// `Last-Modified`). Resumable downloads are enabled by default. + var isResumableDataEnabled = true + + /// If enabled, the pipeline will load the local resources (`file` and + /// `data` schemes) inline without using the data loader. By default, `true`. + var isLocalResourcesSupportEnabled = true + + /// A queue on which all callbacks, like `progress` and `completion` + /// callbacks are called. `.main` by default. + @available(*, deprecated, message: "`ImagePipeline` no longer supports changing the callback queue") + var callbackQueue: DispatchQueue { + get { _callbackQueue } + set { _callbackQueue = newValue } + } + + var _callbackQueue = DispatchQueue.main + + // MARK: - Options (Shared) + + /// `false` by default. If `true`, enables `os_signpost` logging for + /// measuring performance. You can visually see all the performance + /// metrics in `os_signpost` Instrument. For more information see + /// https://developer.apple.com/documentation/os/logging and + /// https://developer.apple.com/videos/play/wwdc2018/405/. + static var isSignpostLoggingEnabled: Bool { + get { _isSignpostLoggingEnabled.value } + set { _isSignpostLoggingEnabled.value = newValue } + } + + private static let _isSignpostLoggingEnabled = NukeAtomic(value: false) + + private var isCustomImageCacheProvided = false + + var debugIsSyncImageEncoding = false + + // MARK: - Operation Queues + + /// Data loading queue. Default maximum concurrent task count is 6. + var dataLoadingQueue = OperationQueue(maxConcurrentCount: 6) + + // Deprecated in Nuke 12.6 + @available(*, deprecated, message: "The pipeline now performs cache lookup on the internal queue, reducing the amount of context switching") + var dataCachingQueue = OperationQueue(maxConcurrentCount: 2) + + /// Image decoding queue. Default maximum concurrent task count is 1. + var imageDecodingQueue = OperationQueue(maxConcurrentCount: 1) + + /// Image encoding queue. Default maximum concurrent task count is 1. + var imageEncodingQueue = OperationQueue(maxConcurrentCount: 1) + + /// Image processing queue. Default maximum concurrent task count is 2. + var imageProcessingQueue = OperationQueue(maxConcurrentCount: 2) + + /// Image decompressing queue. Default maximum concurrent task count is 2. + var imageDecompressingQueue = OperationQueue(maxConcurrentCount: 2) + + // MARK: - Initializer + + /// Instantiates a pipeline configuration. + /// + /// - parameter dataLoader: `DataLoader()` by default. + init(dataLoader: any DataLoading = DataLoader()) { + self.dataLoader = dataLoader + } + + // MARK: - Predefined Configurations + + /// A configuration with an HTTP disk cache (`URLCache`) with a size limit + /// of 150 MB. This is a default configuration. + /// + /// Also uses ``ImageCache/shared`` for in-memory caching with the size + /// that adjusts bsed on the amount of device memory. + static var withURLCache: Configuration { Configuration() } + + /// A configuration with an aggressive disk cache (``DataCache``) with a + /// size limit of 150 MB. An HTTP cache (`URLCache`) is disabled. + /// + /// Also uses ``ImageCache/shared`` for in-memory caching with the size + /// that adjusts bsed on the amount of device memory. + static var withDataCache: Configuration { + withDataCache() + } + + /// A configuration with an aggressive disk cache (``DataCache``) with a + /// size limit of 150 MB by default. An HTTP cache (`URLCache`) is disabled. + /// + /// Also uses ``ImageCache/shared`` for in-memory caching with the size + /// that adjusts bsed on the amount of device memory. + /// + /// - parameters: + /// - name: Data cache name. + /// - sizeLimit: Size limit, by default 150 MB. + static func withDataCache( + name: String = "com.github.kean.Nuke.DataCache", + sizeLimit: Int? = nil + ) -> Configuration { + let dataLoader: DataLoader = { + let config = URLSessionConfiguration.default + config.urlCache = nil + return DataLoader(configuration: config) + }() + + var config = Configuration() + config.dataLoader = dataLoader + + let dataCache = try? DataCache(name: name) + if let sizeLimit { + dataCache?.sizeLimit = sizeLimit + } + config.dataCache = dataCache + + return config + } + } + + /// Determines what images are stored in the disk cache. + enum DataCachePolicy: Sendable { + /// Store original image data for requests with no processors. Store + /// _only_ processed images for requests with processors. + /// + /// - note: Store only processed images for local resources (file:// or + /// data:// URL scheme). + /// + /// - important: With this policy, the pipeline's ``ImagePipeline/loadData(with:completion:)-6cwk3`` + /// method will not store the images in the disk cache for requests with + /// any processors applied – this method only loads data and doesn't + /// decode images. + case automatic + + /// Store only original image data. + /// + /// - note: If the resource is local (file:// or data:// URL scheme), + /// data isn't stored. + case storeOriginalData + + /// Encode and store images. + /// + /// - note: This is useful if you want to store images in a format + /// different than provided by a server, e.g. decompressed. In other + /// scenarios, consider using ``automatic`` policy instead. + /// + /// - important: With this policy, the pipeline's ``ImagePipeline/loadData(with:completion:)-6cwk3`` + /// method will not store the images in the disk cache – this method only + /// loads data and doesn't decode images. + case storeEncodedImages + + /// Stores both processed images and the original image data. + /// + /// - note: If the resource is local (has file:// or data:// scheme), + /// only the processed images are stored. + case storeAll + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Delegate.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Delegate.swift new file mode 100644 index 00000000000..d5650a33d7c --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Delegate.swift @@ -0,0 +1,142 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A delegate that allows you to customize the pipeline dynamically on a per-request basis. +/// +/// - important: The delegate methods are performed on the pipeline queue in the +/// background. +protocol ImagePipelineDelegate: AnyObject, Sendable { + // MARK: Configuration + + /// Returns data loader for the given request. + func dataLoader(for request: ImageRequest, pipeline: ImagePipeline) -> any DataLoading + + /// Returns image decoder for the given context. + func imageDecoder(for context: ImageDecodingContext, pipeline: ImagePipeline) -> (any ImageDecoding)? + + /// Returns image encoder for the given context. + func imageEncoder(for context: ImageEncodingContext, pipeline: ImagePipeline) -> any ImageEncoding + + // MARK: Caching + + /// Returns in-memory image cache for the given request. Return `nil` to prevent cache reads and writes. + func imageCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any ImageCaching)? + + /// Returns disk cache for the given request. Return `nil` to prevent cache + /// reads and writes. + func dataCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any DataCaching)? + + /// Returns a cache key identifying the image produced for the given request + /// (including image processors). The key is used for both in-memory and + /// on-disk caches. + /// + /// Return `nil` to use a default key. + func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? + + /// Gets called when the pipeline is about to save data for the given request. + /// The implementation must call the completion closure passing `non-nil` data + /// to enable caching or `nil` to prevent it. + /// + /// This method calls only if the request parameters and data caching policy + /// of the pipeline already allow caching. + /// + /// - parameters: + /// - data: Either the original data or the encoded image in case of storing + /// a processed or re-encoded image. + /// - image: Non-nil in case storing an encoded image. + /// - request: The request for which image is being stored. + /// - completion: The implementation must call the completion closure + /// passing `non-nil` data to enable caching or `nil` to prevent it. You can + /// safely call it synchronously. The callback gets called on the background + /// thread. + func willCache(data: Data, image: ImageContainer?, for request: ImageRequest, pipeline: ImagePipeline, completion: @escaping (Data?) -> Void) + + // MARK: Decompression + + func shouldDecompress(response: ImageResponse, for request: ImageRequest, pipeline: ImagePipeline) -> Bool + + func decompress(response: ImageResponse, request: ImageRequest, pipeline: ImagePipeline) -> ImageResponse + + // MARK: ImageTask + + /// Gets called when the task is created. Unlike other methods, it is called + /// immediately on the caller's queue. + func imageTaskCreated(_ task: ImageTask, pipeline: ImagePipeline) + + /// Gets called when the task receives an event. + func imageTask(_ task: ImageTask, didReceiveEvent event: ImageTask.Event, pipeline: ImagePipeline) + + /// - warning: Soft-deprecated in Nuke 12.7. + func imageTaskDidStart(_ task: ImageTask, pipeline: ImagePipeline) + + /// - warning: Soft-deprecated in Nuke 12.7. + func imageTask(_ task: ImageTask, didUpdateProgress progress: ImageTask.Progress, pipeline: ImagePipeline) + + /// - warning: Soft-deprecated in Nuke 12.7. + func imageTask(_ task: ImageTask, didReceivePreview response: ImageResponse, pipeline: ImagePipeline) + + /// - warning: Soft-deprecated in Nuke 12.7. + func imageTaskDidCancel(_ task: ImageTask, pipeline: ImagePipeline) + + /// - warning: Soft-deprecated in Nuke 12.7. + func imageTask(_ task: ImageTask, didCompleteWithResult result: Result, pipeline: ImagePipeline) +} + +extension ImagePipelineDelegate { + func imageCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any ImageCaching)? { + pipeline.configuration.imageCache + } + + func dataLoader(for request: ImageRequest, pipeline: ImagePipeline) -> any DataLoading { + pipeline.configuration.dataLoader + } + + func dataCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any DataCaching)? { + pipeline.configuration.dataCache + } + + func imageDecoder(for context: ImageDecodingContext, pipeline: ImagePipeline) -> (any ImageDecoding)? { + pipeline.configuration.makeImageDecoder(context) + } + + func imageEncoder(for context: ImageEncodingContext, pipeline: ImagePipeline) -> any ImageEncoding { + pipeline.configuration.makeImageEncoder(context) + } + + func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? { + nil + } + + func willCache(data: Data, image: ImageContainer?, for request: ImageRequest, pipeline: ImagePipeline, completion: @escaping (Data?) -> Void) { + completion(data) + } + + func shouldDecompress(response: ImageResponse, for request: ImageRequest, pipeline: ImagePipeline) -> Bool { + pipeline.configuration.isDecompressionEnabled + } + + func decompress(response: ImageResponse, request: ImageRequest, pipeline: ImagePipeline) -> ImageResponse { + var response = response + response.container.image = ImageDecompression.decompress(image: response.image, isUsingPrepareForDisplay: pipeline.configuration.isUsingPrepareForDisplay) + return response + } + + func imageTaskCreated(_ task: ImageTask, pipeline: ImagePipeline) {} + + func imageTask(_ task: ImageTask, didReceiveEvent event: ImageTask.Event, pipeline: ImagePipeline) {} + + func imageTaskDidStart(_ task: ImageTask, pipeline: ImagePipeline) {} + + func imageTask(_ task: ImageTask, didUpdateProgress progress: ImageTask.Progress, pipeline: ImagePipeline) {} + + func imageTask(_ task: ImageTask, didReceivePreview response: ImageResponse, pipeline: ImagePipeline) {} + + func imageTaskDidCancel(_ task: ImageTask, pipeline: ImagePipeline) {} + + func imageTask(_ task: ImageTask, didCompleteWithResult result: Result, pipeline: ImagePipeline) {} +} + +final class ImagePipelineDefaultDelegate: ImagePipelineDelegate {} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Error.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Error.swift new file mode 100644 index 00000000000..326333e1d08 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Error.swift @@ -0,0 +1,65 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImagePipeline { + /// Represents all possible image pipeline errors. + enum Error: Swift.Error, CustomStringConvertible, @unchecked Sendable { + /// Returned if data not cached and ``ImageRequest/Options-swift.struct/returnCacheDataDontLoad`` option is specified. + case dataMissingInCache + /// Data loader failed to load image data with a wrapped error. + case dataLoadingFailed(error: Swift.Error) + /// Data loader returned empty data. + case dataIsEmpty + /// No decoder registered for the given data. + /// + /// This error can only be thrown if the pipeline has custom decoders. + /// By default, the pipeline uses ``ImageDecoders/Default`` as a catch-all. + case decoderNotRegistered(context: ImageDecodingContext) + /// Decoder failed to produce a final image. + case decodingFailed(decoder: any ImageDecoding, context: ImageDecodingContext, error: Swift.Error) + /// Processor failed to produce a final image. + case processingFailed(processor: any ImageProcessing, context: ImageProcessingContext, error: Swift.Error) + /// Load image method was called with no image request. + case imageRequestMissing + /// Image pipeline is invalidated and no requests can be made. + case pipelineInvalidated + } +} + +extension ImagePipeline.Error { + /// Returns underlying data loading error. + var dataLoadingError: Swift.Error? { + switch self { + case .dataLoadingFailed(let error): + return error + default: + return nil + } + } + + var description: String { + switch self { + case .dataMissingInCache: + return "Failed to load data from cache and download is disabled." + case let .dataLoadingFailed(error): + return "Failed to load image data. Underlying error: \(error)." + case .dataIsEmpty: + return "Data loader returned empty data." + case .decoderNotRegistered: + return "No decoders registered for the downloaded data." + case let .decodingFailed(decoder, _, error): + let underlying = error is ImageDecodingError ? "" : " Underlying error: \(error)." + return "Failed to decode image data using decoder \(decoder).\(underlying)" + case let .processingFailed(processor, _, error): + let underlying = error is ImageProcessingError ? "" : " Underlying error: \(error)." + return "Failed to process the image using processor \(processor).\(underlying)" + case .imageRequestMissing: + return "Load image method was called with no image request or no URL." + case .pipelineInvalidated: + return "Image pipeline is invalidated and no requests can be made." + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline.swift new file mode 100644 index 00000000000..0fbef07c703 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Pipeline/ImagePipeline.swift @@ -0,0 +1,439 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Combine + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +/// The pipeline downloads and caches images, and prepares them for display. +final class ImagePipeline: @unchecked Sendable { + /// Returns the shared image pipeline. + static var shared: ImagePipeline { + get { _shared.value } + set { _shared.value = newValue } + } + + private static let _shared = NukeAtomic(value: ImagePipeline(configuration: .withURLCache)) + + /// The pipeline configuration. + let configuration: Configuration + + /// Provides access to the underlying caching subsystems. + var cache: ImagePipeline.Cache { .init(pipeline: self) } + + let delegate: any ImagePipelineDelegate + + private var tasks = [ImageTask: TaskSubscription]() + + private let tasksLoadData: TaskPool + private let tasksLoadImage: TaskPool + private let tasksFetchOriginalImage: TaskPool + private let tasksFetchOriginalData: TaskPool + + // The queue on which the entire subsystem is synchronized. + let queue = DispatchQueue(label: "com.github.kean.Nuke.ImagePipeline", qos: .userInitiated) + private var isInvalidated = false + + private var nextTaskId: Int64 { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + _nextTaskId += 1 + return _nextTaskId + } + private var _nextTaskId: Int64 = 0 + private let lock: os_unfair_lock_t + + let rateLimiter: RateLimiter? + let id = UUID() + var onTaskStarted: ((ImageTask) -> Void)? // Debug purposes + + deinit { + lock.deinitialize(count: 1) + lock.deallocate() + + ResumableDataStorage.shared.unregister(self) + } + + /// Initializes the instance with the given configuration. + /// + /// - parameters: + /// - configuration: The pipeline configuration. + /// - delegate: Provides more ways to customize the pipeline behavior on per-request basis. + init(configuration: Configuration = Configuration(), delegate: (any ImagePipelineDelegate)? = nil) { + self.configuration = configuration + self.rateLimiter = configuration.isRateLimiterEnabled ? RateLimiter(queue: queue) : nil + self.delegate = delegate ?? ImagePipelineDefaultDelegate() + (configuration.dataLoader as? DataLoader)?.prefersIncrementalDelivery = configuration.isProgressiveDecodingEnabled + + let isCoalescingEnabled = configuration.isTaskCoalescingEnabled + self.tasksLoadData = TaskPool(isCoalescingEnabled) + self.tasksLoadImage = TaskPool(isCoalescingEnabled) + self.tasksFetchOriginalImage = TaskPool(isCoalescingEnabled) + self.tasksFetchOriginalData = TaskPool(isCoalescingEnabled) + + self.lock = .allocate(capacity: 1) + self.lock.initialize(to: os_unfair_lock()) + + ResumableDataStorage.shared.register(self) + } + + /// A convenience way to initialize the pipeline with a closure. + /// + /// Example usage: + /// + /// ```swift + /// ImagePipeline { + /// $0.dataCache = try? DataCache(name: "com.myapp.datacache") + /// $0.dataCachePolicy = .automatic + /// } + /// ``` + /// + /// - parameters: + /// - configuration: The pipeline configuration. + /// - delegate: Provides more ways to customize the pipeline behavior on per-request basis. + convenience init(delegate: (any ImagePipelineDelegate)? = nil, _ configure: (inout ImagePipeline.Configuration) -> Void) { + var configuration = ImagePipeline.Configuration() + configure(&configuration) + self.init(configuration: configuration, delegate: delegate) + } + + /// Invalidates the pipeline and cancels all outstanding tasks. Any new + /// requests will immediately fail with ``ImagePipeline/Error/pipelineInvalidated`` error. + func invalidate() { + queue.async { + guard !self.isInvalidated else { return } + self.isInvalidated = true + self.tasks.keys.forEach(self.cancelImageTask) + } + } + + // MARK: - Loading Images (Async/Await) + + /// Creates a task with the given URL. + /// + /// The task starts executing the moment it is created. + func imageTask(with url: URL) -> ImageTask { + imageTask(with: ImageRequest(url: url)) + } + + /// Creates a task with the given request. + /// + /// The task starts executing the moment it is created. + func imageTask(with request: ImageRequest) -> ImageTask { + makeStartedImageTask(with: request) + } + + /// Returns an image for the given URL. + func image(for url: URL) async throws -> PlatformImage { + try await image(for: ImageRequest(url: url)) + } + + /// Returns an image for the given request. + func image(for request: ImageRequest) async throws -> PlatformImage { + try await imageTask(with: request).image + } + + // MARK: - Loading Data (Async/Await) + + /// Returns image data for the given request. + /// + /// - parameter request: An image request. + func data(for request: ImageRequest) async throws -> (Data, URLResponse?) { + let task = makeStartedImageTask(with: request, isDataTask: true) + let response = try await task.response + return (response.container.data ?? Data(), response.urlResponse) + } + + // MARK: - Loading Images (Closures) + + /// Loads an image for the given request. + /// + /// - parameters: + /// - request: An image request. + /// - completion: A closure to be called on the main thread when the request + /// is finished. + @discardableResult func loadImage( + with url: URL, + completion: @escaping (_ result: Result) -> Void + ) -> ImageTask { + _loadImage(with: ImageRequest(url: url), progress: nil, completion: completion) + } + + /// Loads an image for the given request. + /// + /// - parameters: + /// - request: An image request. + /// - completion: A closure to be called on the main thread when the request + /// is finished. + @discardableResult func loadImage( + with request: ImageRequest, + completion: @escaping (_ result: Result) -> Void + ) -> ImageTask { + _loadImage(with: request, progress: nil, completion: completion) + } + + /// Loads an image for the given request. + /// + /// - parameters: + /// - request: An image request. + /// - progress: A closure to be called periodically on the main thread when + /// the progress is updated. + /// - completion: A closure to be called on the main thread when the request + /// is finished. + @discardableResult func loadImage( + with request: ImageRequest, + queue: DispatchQueue? = nil, + progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)?, + completion: @escaping (_ result: Result) -> Void + ) -> ImageTask { + _loadImage(with: request, queue: queue, progress: { + progress?($0, $1.completed, $1.total) + }, completion: completion) + } + + func _loadImage( + with request: ImageRequest, + isDataTask: Bool = false, + queue callbackQueue: DispatchQueue? = nil, + progress: ((ImageResponse?, ImageTask.Progress) -> Void)?, + completion: @escaping (Result) -> Void + ) -> ImageTask { + makeStartedImageTask(with: request, isDataTask: isDataTask) { [weak self] event, task in + self?.dispatchCallback(to: callbackQueue) { + // The callback-based API guarantees that after cancellation no + // event are called on the callback queue. + guard task.state != .cancelled else { return } + switch event { + case .progress(let value): progress?(nil, value) + case .preview(let response): progress?(response, task.currentProgress) + case .cancelled: break // The legacy APIs do not send cancellation events + case .finished(let result): + _ = task._setState(.completed) // Important to do it on the callback queue + completion(result) + } + } + } + } + + // nuke-13: requires callbacks to be @MainActor @Sendable or deprecate this entire API + private func dispatchCallback(to callbackQueue: DispatchQueue?, _ closure: @escaping () -> Void) { + let box = UncheckedSendableBox(value: closure) + if callbackQueue === self.queue { + closure() + } else { + (callbackQueue ?? self.configuration._callbackQueue).async { + box.value() + } + } + } + + // MARK: - Loading Data (Closures) + + /// Loads image data for the given request. The data doesn't get decoded + /// or processed in any other way. + @discardableResult func loadData(with request: ImageRequest, completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void) -> ImageTask { + _loadData(with: request, queue: nil, progress: nil, completion: completion) + } + + private func _loadData( + with request: ImageRequest, + queue: DispatchQueue?, + progress progressHandler: ((_ completed: Int64, _ total: Int64) -> Void)?, + completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void + ) -> ImageTask { + _loadImage(with: request, isDataTask: true, queue: queue) { _, progress in + progressHandler?(progress.completed, progress.total) + } completion: { result in + let result = result.map { response in + // Data should never be empty + (data: response.container.data ?? Data(), response: response.urlResponse) + } + completion(result) + } + } + + /// Loads the image data for the given request. The data doesn't get decoded + /// or processed in any other way. + /// + /// You can call ``loadImage(with:completion:)-43osv`` for the request at any point after calling + /// ``loadData(with:completion:)-6cwk3``, the pipeline will use the same operation to load the data, + /// no duplicated work will be performed. + /// + /// - parameters: + /// - request: An image request. + /// - queue: A queue on which to execute `progress` and `completion` + /// callbacks. By default, the pipeline uses `.main` queue. + /// - progress: A closure to be called periodically on the main thread when the progress is updated. + /// - completion: A closure to be called on the main thread when the request is finished. + @discardableResult func loadData( + with request: ImageRequest, + queue: DispatchQueue? = nil, + progress progressHandler: ((_ completed: Int64, _ total: Int64) -> Void)?, + completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void + ) -> ImageTask { + _loadImage(with: request, isDataTask: true, queue: queue) { _, progress in + progressHandler?(progress.completed, progress.total) + } completion: { result in + let result = result.map { response in + // Data should never be empty + (data: response.container.data ?? Data(), response: response.urlResponse) + } + completion(result) + } + } + + // MARK: - Loading Images (Combine) + + /// Returns a publisher which starts a new ``ImageTask`` when a subscriber is added. + func imagePublisher(with url: URL) -> AnyPublisher { + imagePublisher(with: ImageRequest(url: url)) + } + + /// Returns a publisher which starts a new ``ImageTask`` when a subscriber is added. + func imagePublisher(with request: ImageRequest) -> AnyPublisher { + ImagePublisher(request: request, pipeline: self).eraseToAnyPublisher() + } + + // MARK: - ImageTask (Internal) + + private func makeStartedImageTask(with request: ImageRequest, isDataTask: Bool = false, onEvent: ((ImageTask.Event, ImageTask) -> Void)? = nil) -> ImageTask { + let task = ImageTask(taskId: nextTaskId, request: request, isDataTask: isDataTask, pipeline: self, onEvent: onEvent) + // Important to call it before `imageTaskStartCalled` + if !isDataTask { + delegate.imageTaskCreated(task, pipeline: self) + } + task._task = Task { + try await withUnsafeThrowingContinuation { continuation in + self.queue.async { + task._continuation = continuation + self.startImageTask(task, isDataTask: isDataTask) + } + } + } + return task + } + + // By this time, the task has `continuation` set and is fully wired. + private func startImageTask(_ task: ImageTask, isDataTask: Bool) { + guard task._state != .cancelled else { + // The task gets started asynchronously in a `Task` and cancellation + // can happen before the pipeline reached `startImageTask`. In that + // case, the `cancel` method do no send the task event. + return task._dispatch(.cancelled) + } + guard !isInvalidated else { + return task._process(.error(.pipelineInvalidated)) + } + let worker = isDataTask ? makeTaskLoadData(for: task.request) : makeTaskLoadImage(for: task.request) + tasks[task] = worker.subscribe(priority: task.priority.taskPriority, subscriber: task) { [weak task] in + task?._process($0) + } + delegate.imageTaskDidStart(task, pipeline: self) + onTaskStarted?(task) + } + + private func cancelImageTask(_ task: ImageTask) { + tasks.removeValue(forKey: task)?.unsubscribe() + task._cancel() + } + + // MARK: - Image Task Events + + func imageTaskCancelCalled(_ task: ImageTask) { + queue.async { self.cancelImageTask(task) } + } + + func imageTaskUpdatePriorityCalled(_ task: ImageTask, priority: ImageRequest.Priority) { + queue.async { + self.tasks[task]?.setPriority(priority.taskPriority) + } + } + + func imageTask(_ task: ImageTask, didProcessEvent event: ImageTask.Event, isDataTask: Bool) { + switch event { + case .cancelled, .finished: + tasks[task] = nil + default: break + } + + if !isDataTask { + delegate.imageTask(task, didReceiveEvent: event, pipeline: self) + switch event { + case .progress(let progress): + delegate.imageTask(task, didUpdateProgress: progress, pipeline: self) + case .preview(let response): + delegate.imageTask(task, didReceivePreview: response, pipeline: self) + case .cancelled: + delegate.imageTaskDidCancel(task, pipeline: self) + case .finished(let result): + delegate.imageTask(task, didCompleteWithResult: result, pipeline: self) + } + } + } + + // MARK: - Task Factory (Private) + + // When you request an image or image data, the pipeline creates a graph of tasks + // (some tasks are added to the graph on demand). + // + // `loadImage()` call is represented by TaskLoadImage: + // + // TaskLoadImage -> TaskFetchOriginalImage -> TaskFetchOriginalData + // + // `loadData()` call is represented by TaskLoadData: + // + // TaskLoadData -> TaskFetchOriginalData + // + // + // Each task represents a resource or a piece of work required to produce the + // final result. The pipeline reduces the amount of duplicated work by coalescing + // the tasks that represent the same work. For example, if you all `loadImage()` + // and `loadData()` with the same request, only on `TaskFetchOriginalImageData` + // is created. The work is split between tasks to minimize any duplicated work. + + func makeTaskLoadImage(for request: ImageRequest) -> AsyncTask.Publisher { + tasksLoadImage.publisherForKey(TaskLoadImageKey(request)) { + TaskLoadImage(self, request) + } + } + + func makeTaskLoadData(for request: ImageRequest) -> AsyncTask.Publisher { + tasksLoadData.publisherForKey(TaskLoadImageKey(request)) { + TaskLoadData(self, request) + } + } + + func makeTaskFetchOriginalImage(for request: ImageRequest) -> AsyncTask.Publisher { + tasksFetchOriginalImage.publisherForKey(TaskFetchOriginalImageKey(request)) { + TaskFetchOriginalImage(self, request) + } + } + + func makeTaskFetchOriginalData(for request: ImageRequest) -> AsyncTask<(Data, URLResponse?), Error>.Publisher { + tasksFetchOriginalData.publisherForKey(TaskFetchOriginalDataKey(request)) { + request.publisher == nil ? TaskFetchOriginalData(self, request) : TaskFetchWithPublisher(self, request) + } + } + + // MARK: - Deprecated + + // Deprecated in Nuke 12.7 + @available(*, deprecated, message: "Please the variant variant that accepts `ImageRequest` as a parameter") + @discardableResult func loadData(with url: URL, completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void) -> ImageTask { + loadData(with: ImageRequest(url: url), queue: nil, progress: nil, completion: completion) + } + + // Deprecated in Nuke 12.7 + @available(*, deprecated, message: "Please the variant that accepts `ImageRequest` as a parameter") + @discardableResult func data(for url: URL) async throws -> (Data, URLResponse?) { + try await data(for: ImageRequest(url: url)) + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Core/ImagePrefetcher.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Prefetching/ImagePrefetcher.swift similarity index 57% rename from Sources/StreamChatUI/StreamNuke/Core/ImagePrefetcher.swift rename to Sources/StreamChatUI/StreamNuke/Nuke/Prefetching/ImagePrefetcher.swift index dde1fa8c218..779fd4eeef7 100644 --- a/Sources/StreamChatUI/StreamNuke/Core/ImagePrefetcher.swift +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Prefetching/ImagePrefetcher.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -9,15 +9,9 @@ import Foundation /// /// The prefetcher cancels all of the outstanding tasks when deallocated. /// -/// All `ImagePrefetcher` methods are thread-safe and are optimized to be used +/// All ``ImagePrefetcher`` methods are thread-safe and are optimized to be used /// even from the main thread during scrolling. -final class ImagePrefetcher { - private let pipeline: ImagePipeline - private var tasks = [ImageLoadKey: Task]() - private let destination: Destination - let queue = OperationQueue() // internal for testing - var didComplete: (() -> Void)? - +final class ImagePrefetcher: @unchecked Sendable { /// Pauses the prefetching. /// /// - note: When you pause, the prefetcher will finish outstanding tasks @@ -26,7 +20,7 @@ final class ImagePrefetcher { didSet { queue.isSuspended = isPaused } } - /// The priority of the requests. By default, `.low`. + /// The priority of the requests. By default, ``ImageRequest/Priority-swift.enum/low``. /// /// Changing the priority also changes the priority of all of the outstanding /// tasks managed by the prefetcher. @@ -36,10 +30,9 @@ final class ImagePrefetcher { pipeline.queue.async { self.didUpdatePriority(to: newValue) } } } - private var _priority: ImageRequest.Priority = .low /// Prefetching destination. - enum Destination { + enum Destination: Sendable { /// Prefetches the image and stores it in both the memory and the disk /// cache (make sure to enable it). case memoryCache @@ -47,15 +40,30 @@ final class ImagePrefetcher { /// Prefetches the image data and stores it in disk caches. It does not /// require decoding the image data and therefore requires less CPU. /// - /// - warning: This option is incompatible with `DataCachePolicy.automatic` - /// (for requests with processors) and `DataCachePolicy.storeEncodedImages`. + /// - important: This option is incompatible with ``ImagePipeline/DataCachePolicy/automatic`` + /// (for requests with processors) and ``ImagePipeline/DataCachePolicy/storeEncodedImages``. case diskCache } - /// Initializes the `ImagePrefetcher` instance. - /// - parameter manager: `Loader.shared` by default. - /// - parameter destination: `.memoryCache` by default. - /// - parameter `maxConcurrentRequestCount`: 2 by default. + /// The closure that gets called when the prefetching completes for all the + /// scheduled requests. The closure is always called on completion, + /// regardless of whether the requests succeed or some fail. + /// + /// - note: The closure is called on the main queue. + var didComplete: (@MainActor @Sendable () -> Void)? + + private let pipeline: ImagePipeline + private var tasks = [TaskLoadImageKey: Task]() + private let destination: Destination + private var _priority: ImageRequest.Priority = .low + let queue = OperationQueue() // internal for testing + + /// Initializes the ``ImagePrefetcher`` instance. + /// + /// - parameters: + /// - pipeline: The pipeline used for loading images. + /// - destination: By default load images in all cache layers. + /// - maxConcurrentRequestCount: 2 by default. init(pipeline: ImagePipeline = ImagePipeline.shared, destination: Destination = .memoryCache, maxConcurrentRequestCount: Int = 2) { @@ -63,73 +71,74 @@ final class ImagePrefetcher { self.destination = destination self.queue.maxConcurrentOperationCount = maxConcurrentRequestCount self.queue.underlyingQueue = pipeline.queue - - #if TRACK_ALLOCATIONS - Allocations.increment("ImagePrefetcher") - #endif } deinit { let tasks = self.tasks.values // Make sure we don't retain self + self.tasks.removeAll() + pipeline.queue.async { for task in tasks { task.cancel() } } + } - #if TRACK_ALLOCATIONS - Allocations.decrement("ImagePrefetcher") - #endif + /// Starts prefetching images for the given URL. + /// + /// See also ``startPrefetching(with:)-718dg`` that works with ``ImageRequest``. + func startPrefetching(with urls: [URL]) { + startPrefetching(with: urls.map { ImageRequest(url: $0) }) } /// Starts prefetching images for the given requests. /// - /// When you need to display the same image later, use the `ImagePipeline` + /// When you need to display the same image later, use the ``ImagePipeline`` /// or the view extensions to load it as usual. The pipeline will take care /// of coalescing the requests to avoid any duplicate work. /// /// The priority of the requests is set to the priority of the prefetcher /// (`.low` by default). - func startPrefetching(with requests: [ImageRequestConvertible]) { + /// + /// See also ``startPrefetching(with:)-1jef2`` that works with `URL`. + func startPrefetching(with requests: [ImageRequest]) { pipeline.queue.async { - for request in requests { - var request = request.asImageRequest() - request.priority = self._priority - self._startPrefetching(with: request) + self._startPrefetching(with: requests) + } + } + + func _startPrefetching(with requests: [ImageRequest]) { + for request in requests { + var request = request + if _priority != request.priority { + request.priority = _priority } + _startPrefetching(with: request) } + sendCompletionIfNeeded() } private func _startPrefetching(with request: ImageRequest) { guard pipeline.cache[request] == nil else { - return // The image is already in memory cache + return } - - let key = request.makeImageLoadKey() + let key = TaskLoadImageKey(request) guard tasks[key] == nil else { - return // Already started prefetching + return } - let task = Task(request: request, key: key) task.operation = queue.add { [weak self] finish in - guard let self = self else { return finish() } + guard let self else { return finish() } self.loadImage(task: task, finish: finish) } tasks[key] = task + return } private func loadImage(task: Task, finish: @escaping () -> Void) { - switch destination { - case .diskCache: - task.imageTask = pipeline.loadData(with: task.request, isConfined: true, queue: pipeline.queue, progress: nil) { [weak self] _ in - self?._remove(task) - finish() - } - case .memoryCache: - task.imageTask = pipeline.loadImage(with: task.request, isConfined: true, queue: pipeline.queue, progress: nil) { [weak self] _ in - self?._remove(task) - finish() - } + task.imageTask = pipeline._loadImage(with: task.request, isDataTask: destination == .diskCache, queue: pipeline.queue, progress: nil) { [weak self] _ in + self?._remove(task) + finish() } task.onCancelled = finish } @@ -137,9 +146,22 @@ final class ImagePrefetcher { private func _remove(_ task: Task) { guard tasks[task.key] === task else { return } // Should never happen tasks[task.key] = nil - if tasks.isEmpty { - didComplete?() + sendCompletionIfNeeded() + } + + private func sendCompletionIfNeeded() { + guard tasks.isEmpty, let callback = didComplete else { + return } + DispatchQueue.main.async(execute: callback) + } + + /// Stops prefetching images for the given URLs and cancels outstanding + /// requests. + /// + /// See also ``stopPrefetching(with:)-8cdam`` that works with ``ImageRequest``. + func stopPrefetching(with urls: [URL]) { + stopPrefetching(with: urls.map { ImageRequest(url: $0) }) } /// Stops prefetching images for the given requests and cancels outstanding @@ -147,19 +169,19 @@ final class ImagePrefetcher { /// /// You don't need to balance the number of `start` and `stop` requests. /// If you have multiple screens with prefetching, create multiple instances - /// of `ImagePrefetcher`. + /// of ``ImagePrefetcher``. /// - /// - parameter destination: `.memoryCache` by default. - func stopPrefetching(with requests: [ImageRequestConvertible]) { + /// See also ``stopPrefetching(with:)-2tcyq`` that works with `URL`. + func stopPrefetching(with requests: [ImageRequest]) { pipeline.queue.async { for request in requests { - self._stopPrefetching(with: request.asImageRequest()) + self._stopPrefetching(with: request) } } } private func _stopPrefetching(with request: ImageRequest) { - if let task = tasks.removeValue(forKey: request.makeImageLoadKey()) { + if let task = tasks.removeValue(forKey: TaskLoadImageKey(request)) { task.cancel() } } @@ -180,14 +202,14 @@ final class ImagePrefetcher { } } - private final class Task { - let key: ImageLoadKey + private final class Task: @unchecked Sendable { + let key: TaskLoadImageKey let request: ImageRequest weak var imageTask: ImageTask? weak var operation: Operation? var onCancelled: (() -> Void)? - init(request: ImageRequest, key: ImageLoadKey) { + init(request: ImageRequest, key: TaskLoadImageKey) { self.request = request self.key = key } diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageDecompression.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageDecompression.swift new file mode 100644 index 00000000000..2dfade3500c --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageDecompression.swift @@ -0,0 +1,32 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +enum ImageDecompression { + static func isDecompressionNeeded(for response: ImageResponse) -> Bool { + isDecompressionNeeded(for: response.image) ?? false + } + + static func decompress(image: PlatformImage, isUsingPrepareForDisplay: Bool = false) -> PlatformImage { + image.decompressed(isUsingPrepareForDisplay: isUsingPrepareForDisplay) ?? image + } + + // MARK: Managing Decompression State + +#if swift(>=5.10) + // Safe because it's never mutated. + nonisolated(unsafe) static let isDecompressionNeededAK = malloc(1)! +#else + static let isDecompressionNeededAK = malloc(1)! +#endif + + static func setDecompressionNeeded(_ isDecompressionNeeded: Bool, for image: PlatformImage) { + objc_setAssociatedObject(image, isDecompressionNeededAK, isDecompressionNeeded, .OBJC_ASSOCIATION_RETAIN) + } + + static func isDecompressionNeeded(for image: PlatformImage) -> Bool? { + objc_getAssociatedObject(image, isDecompressionNeededAK) as? Bool + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessing.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessing.swift new file mode 100644 index 00000000000..0b5f065aee4 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessing.swift @@ -0,0 +1,107 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if !os(macOS) +import UIKit +#else +import AppKit +#endif + +/// Performs image processing. +/// +/// For basic processing needs, implement the following method: +/// +/// ```swift +/// func process(image: PlatformImage) -> PlatformImage? +/// ``` +/// +/// If your processor needs to manipulate image metadata (``ImageContainer``), or +/// get access to more information via the context (``ImageProcessingContext``), +/// there is an additional method that allows you to do that: +/// +/// ```swift +/// func process(image container: ImageContainer, context: ImageProcessingContext) -> ImageContainer? +/// ``` +/// +/// You must implement either one of those methods. +protocol ImageProcessing: Sendable { + /// Returns a processed image. By default, returns `nil`. + /// + /// - note: Gets called a background queue managed by the pipeline. + func process(_ image: PlatformImage) -> PlatformImage? + + /// Optional method. Returns a processed image. By default, this calls the + /// basic `process(image:)` method. + /// + /// - note: Gets called a background queue managed by the pipeline. + func process(_ container: ImageContainer, context: ImageProcessingContext) throws -> ImageContainer + + /// Returns a string that uniquely identifies the processor. + /// + /// Consider using the reverse DNS notation. + var identifier: String { get } + + /// Returns a unique processor identifier. + /// + /// The default implementation simply returns `var identifier: String` but + /// can be overridden as a performance optimization - creating and comparing + /// strings is _expensive_ so you can opt-in to return something which is + /// fast to create and to compare. See ``ImageProcessors/Resize`` for an example. + /// + /// - note: A common approach is to make your processor `Hashable` and return `self` + /// as a hashable identifier. + var hashableIdentifier: AnyHashable { get } +} + +extension ImageProcessing { + /// The default implementation simply calls the basic + /// `process(_ image: PlatformImage) -> PlatformImage?` method. + func process(_ container: ImageContainer, context: ImageProcessingContext) throws -> ImageContainer { + guard let output = process(container.image) else { + throw ImageProcessingError.unknown + } + var container = container + container.image = output + return container + } + + /// The default impleemntation simply returns `var identifier: String`. + var hashableIdentifier: AnyHashable { identifier } +} + +extension ImageProcessing where Self: Hashable { + var hashableIdentifier: AnyHashable { self } +} + +/// Image processing context used when selecting which processor to use. +struct ImageProcessingContext: Sendable { + var request: ImageRequest + var response: ImageResponse + var isCompleted: Bool + + init(request: ImageRequest, response: ImageResponse, isCompleted: Bool) { + self.request = request + self.response = response + self.isCompleted = isCompleted + } +} + +enum ImageProcessingError: Error, CustomStringConvertible, Sendable { + case unknown + + var description: String { "Unknown" } +} + +func == (lhs: [any ImageProcessing], rhs: [any ImageProcessing]) -> Bool { + guard lhs.count == rhs.count else { + return false + } + // Lazily creates `hashableIdentifiers` because for some processors the + // identifiers might be expensive to compute. + return zip(lhs, rhs).allSatisfy { + $0.hashableIdentifier == $1.hashableIdentifier + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessingOptions.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessingOptions.swift new file mode 100644 index 00000000000..67a817eee01 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessingOptions.swift @@ -0,0 +1,86 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +/// A namespace with shared image processing options. +enum ImageProcessingOptions: Sendable { + + enum Unit: CustomStringConvertible, Sendable { + case points + case pixels + + var description: String { + switch self { + case .points: return "points" + case .pixels: return "pixels" + } + } + } + + /// Draws a border. + /// + /// - important: To make sure that the border looks the way you expect, + /// make sure that the images you display exactly match the size of the + /// views in which they get displayed. If you can't guarantee that, pleasee + /// consider adding border to a view layer. This should be your primary + /// option regardless. + struct Border: Hashable, CustomStringConvertible, @unchecked Sendable { + let width: CGFloat + +#if canImport(UIKit) + let color: UIColor + + /// - parameters: + /// - color: Border color. + /// - width: Border width. + /// - unit: Unit of the width. + init(color: UIColor, width: CGFloat = 1, unit: Unit = .points) { + self.color = color + self.width = width.converted(to: unit) + } +#else + let color: NSColor + + /// - parameters: + /// - color: Border color. + /// - width: Border width. + /// - unit: Unit of the width. + init(color: NSColor, width: CGFloat = 1, unit: Unit = .points) { + self.color = color + self.width = width.converted(to: unit) + } +#endif + + var description: String { + "Border(color: \(color.hex), width: \(width) pixels)" + } + } + + /// An option for how to resize the image. + enum ContentMode: CustomStringConvertible, Sendable { + /// Scales the image so that it completely fills the target area. + /// Maintains the aspect ratio of the original image. + case aspectFill + + /// Scales the image so that it fits the target size. Maintains the + /// aspect ratio of the original image. + case aspectFit + + var description: String { + switch self { + case .aspectFill: return ".aspectFill" + case .aspectFit: return ".aspectFit" + } + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Anonymous.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Anonymous.swift new file mode 100644 index 00000000000..3c40d71d21b --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Anonymous.swift @@ -0,0 +1,32 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if !os(macOS) +import UIKit +#else +import AppKit +#endif + +extension ImageProcessors { + /// Processed an image using a specified closure. + struct Anonymous: ImageProcessing, CustomStringConvertible { + let identifier: String + private let closure: @Sendable (PlatformImage) -> PlatformImage? + + init(id: String, _ closure: @Sendable @escaping (PlatformImage) -> PlatformImage?) { + self.identifier = id + self.closure = closure + } + + func process(_ image: PlatformImage) -> PlatformImage? { + closure(image) + } + + var description: String { + "AnonymousProcessor(identifier: \(identifier)" + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Circle.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Circle.swift new file mode 100644 index 00000000000..1d185e988d9 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Circle.swift @@ -0,0 +1,38 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if !os(macOS) +import UIKit +#else +import AppKit +#endif + +extension ImageProcessors { + + /// Rounds the corners of an image into a circle. If the image is not a square, + /// crops it to a square first. + struct Circle: ImageProcessing, Hashable, CustomStringConvertible { + private let border: ImageProcessingOptions.Border? + + /// - parameter border: `nil` by default. + init(border: ImageProcessingOptions.Border? = nil) { + self.border = border + } + + func process(_ image: PlatformImage) -> PlatformImage? { + image.processed.byDrawingInCircle(border: border) + } + + var identifier: String { + let suffix = border.map { "?border=\($0)" } + return "com.github.kean/nuke/circle" + (suffix ?? "") + } + + var description: String { + "Circle(border: \(border?.description ?? "nil"))" + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Composition.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Composition.swift new file mode 100644 index 00000000000..a4744a07606 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Composition.swift @@ -0,0 +1,67 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if !os(macOS) +import UIKit +#else +import AppKit +#endif + +extension ImageProcessors { + /// Composes multiple processors. + struct Composition: ImageProcessing, Hashable, CustomStringConvertible { + let processors: [any ImageProcessing] + + /// Composes multiple processors. + init(_ processors: [any ImageProcessing]) { + // note: multiple compositions are not flatten by default. + self.processors = processors + } + + /// Processes the given image by applying each processor in an order in + /// which they were added. If one of the processors fails to produce + /// an image the processing stops and `nil` is returned. + func process(_ image: PlatformImage) -> PlatformImage? { + processors.reduce(image) { image, processor in + autoreleasepool { + image.flatMap(processor.process) + } + } + } + + /// Processes the given image by applying each processor in an order in + /// which they were added. If one of the processors fails to produce + /// an image the processing stops and an error is thrown. + func process(_ container: ImageContainer, context: ImageProcessingContext) throws -> ImageContainer { + try processors.reduce(container) { container, processor in + try autoreleasepool { + try processor.process(container, context: context) + } + } + } + + /// Returns combined identifier of all the underlying processors. + var identifier: String { + processors.map({ $0.identifier }).joined() + } + + /// Creates a combined hash of all the given processors. + func hash(into hasher: inout Hasher) { + for processor in processors { + hasher.combine(processor.hashableIdentifier) + } + } + + /// Compares all the underlying processors for equality. + static func == (lhs: Composition, rhs: Composition) -> Bool { + lhs.processors == rhs.processors + } + + var description: String { + "Composition(processors: \(processors))" + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+CoreImage.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+CoreImage.swift new file mode 100644 index 00000000000..0842ac49b16 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+CoreImage.swift @@ -0,0 +1,147 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) + +import Foundation +import CoreImage + +#if !os(macOS) +import UIKit +#else +import AppKit +#endif + +extension ImageProcessors { + + /// Applies Core Image filter (`CIFilter`) to the image. + /// + /// # Performance Considerations. + /// + /// Prefer chaining multiple `CIFilter` objects using `Core Image` facilities + /// instead of using multiple instances of `ImageProcessors.CoreImageFilter`. + /// + /// # References + /// + /// - [Core Image Programming Guide](https://developer.apple.com/library/ios/documentation/GraphicsImaging/Conceptual/CoreImaging/ci_intro/ci_intro.html) + /// - [Core Image Filter Reference](https://developer.apple.com/library/prerelease/ios/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html) + struct CoreImageFilter: ImageProcessing, CustomStringConvertible, @unchecked Sendable { + let filter: Filter + let identifier: String + + enum Filter { + case named(String, parameters: [String: Any]) + case custom(CIFilter) + } + + /// Initializes the processor with a name of the `CIFilter` and its parameters. + /// + /// - parameter identifier: Uniquely identifies the processor. + init(name: String, parameters: [String: Any], identifier: String) { + self.filter = .named(name, parameters: parameters) + self.identifier = identifier + } + + /// Initializes the processor with a name of the `CIFilter`. + init(name: String) { + self.filter = .named(name, parameters: [:]) + self.identifier = "com.github.kean/nuke/core_image?name=\(name))" + } + + /// Initialize the processor with the given `CIFilter`. + /// + /// - parameter identifier: Uniquely identifies the processor. + init(_ filter: CIFilter, identifier: String) { + self.filter = .custom(filter) + self.identifier = identifier + } + + func process(_ image: PlatformImage) -> PlatformImage? { + try? _process(image) + } + + func process(_ container: ImageContainer, context: ImageProcessingContext) throws -> ImageContainer { + try container.map(_process) + } + + private func _process(_ image: PlatformImage) throws -> PlatformImage { + switch filter { + case let .named(name, parameters): + return try CoreImageFilter.applyFilter(named: name, parameters: parameters, to: image) + case .custom(let filter): + return try CoreImageFilter.apply(filter: filter, to: image) + } + } + + // MARK: - Apply Filter + + /// A default context shared between all Core Image filters. The context + /// has `.priorityRequestLow` option set to `true`. + static var context: CIContext { + get { _context.value } + set { _context.value = newValue } + } + + private static let _context = NukeAtomic(value: CIContext(options: [.priorityRequestLow: true])) + + static func applyFilter(named name: String, parameters: [String: Any] = [:], to image: PlatformImage) throws -> PlatformImage { + guard let filter = CIFilter(name: name, parameters: parameters) else { + throw Error.failedToCreateFilter(name: name, parameters: parameters) + } + return try CoreImageFilter.apply(filter: filter, to: image) + } + + /// Applies filter to the given image. + static func apply(filter: CIFilter, to image: PlatformImage) throws -> PlatformImage { + func getCIImage() throws -> CoreImage.CIImage { + if let image = image.ciImage { + return image + } + if let image = image.cgImage { + return CoreImage.CIImage(cgImage: image) + } + throw Error.inputImageIsEmpty(inputImage: image) + } + filter.setValue(try getCIImage(), forKey: kCIInputImageKey) + guard let outputImage = filter.outputImage else { + throw Error.failedToApplyFilter(filter: filter) + } + guard let imageRef = context.createCGImage(outputImage, from: outputImage.extent) else { + throw Error.failedToCreateOutputCGImage(image: outputImage) + } + return PlatformImage.make(cgImage: imageRef, source: image) + } + + var description: String { + switch filter { + case let .named(name, parameters): + return "CoreImageFilter(name: \(name), parameters: \(parameters))" + case .custom(let filter): + return "CoreImageFilter(filter: \(filter))" + } + } + + enum Error: Swift.Error, CustomStringConvertible, @unchecked Sendable { + case failedToCreateFilter(name: String, parameters: [String: Any]) + case inputImageIsEmpty(inputImage: PlatformImage) + case failedToApplyFilter(filter: CIFilter) + case failedToCreateOutputCGImage(image: CIImage) + + var description: String { + switch self { + case let .failedToCreateFilter(name, parameters): + return "Failed to create filter named \(name) with parameters: \(parameters)" + case let .inputImageIsEmpty(inputImage): + return "Failed to create input CIImage for \(inputImage)" + case let .failedToApplyFilter(filter): + return "Failed to apply filter: \(filter.name)" + case let .failedToCreateOutputCGImage(image): + return "Failed to create output image for extent: \(image.extent) from \(image)" + } + } + } + } +} + +#endif diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+GaussianBlur.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+GaussianBlur.swift new file mode 100644 index 00000000000..1921c78899b --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+GaussianBlur.swift @@ -0,0 +1,52 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) + +import Foundation +import CoreImage + +#if !os(macOS) +import UIKit +#else +import AppKit +#endif + +extension ImageProcessors { + /// Blurs an image using `CIGaussianBlur` filter. + struct GaussianBlur: ImageProcessing, Hashable, CustomStringConvertible { + private let radius: Int + + /// Initializes the receiver with a blur radius. + /// + /// - parameter radius: `8` by default. + init(radius: Int = 8) { + self.radius = radius + } + + /// Applies `CIGaussianBlur` filter to the image. + func process(_ image: PlatformImage) -> PlatformImage? { + try? _process(image) + } + + /// Applies `CIGaussianBlur` filter to the image. + func process(_ container: ImageContainer, context: ImageProcessingContext) throws -> ImageContainer { + try container.map(_process(_:)) + } + + private func _process(_ image: PlatformImage) throws -> PlatformImage { + try CoreImageFilter.applyFilter(named: "CIGaussianBlur", parameters: ["inputRadius": radius], to: image) + } + + var identifier: String { + "com.github.kean/nuke/gaussian_blur?radius=\(radius)" + } + + var description: String { + "GaussianBlur(radius: \(radius))" + } + } +} + +#endif diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Resize.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Resize.swift new file mode 100644 index 00000000000..7431c5f3ae2 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+Resize.swift @@ -0,0 +1,96 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation +import CoreGraphics + +#if !os(macOS) +import UIKit +#else +import AppKit +#endif + +extension ImageProcessors { + /// Scales an image to a specified size. + struct Resize: ImageProcessing, Hashable, CustomStringConvertible { + private let size: ImageTargetSize + private let contentMode: ImageProcessingOptions.ContentMode + private let crop: Bool + private let upscale: Bool + + // Deprecated in Nuke 12.0 + @available(*, deprecated, message: "Renamed to `ImageProcessingOptions.ContentMode") + typealias ContentMode = ImageProcessingOptions.ContentMode + + /// Initializes the processor with the given size. + /// + /// - parameters: + /// - size: The target size. + /// - unit: Unit of the target size. + /// - contentMode: A target content mode. + /// - crop: If `true` will crop the image to match the target size. + /// Does nothing with content mode .aspectFill. + /// - upscale: By default, upscaling is not allowed. + init(size: CGSize, unit: ImageProcessingOptions.Unit = .points, contentMode: ImageProcessingOptions.ContentMode = .aspectFill, crop: Bool = false, upscale: Bool = false) { + self.size = ImageTargetSize(size: size, unit: unit) + self.contentMode = contentMode + self.crop = crop + self.upscale = upscale + } + + /// Scales an image to the given width preserving aspect ratio. + /// + /// - parameters: + /// - width: The target width. + /// - unit: Unit of the target size. + /// - upscale: `false` by default. + init(width: CGFloat, unit: ImageProcessingOptions.Unit = .points, upscale: Bool = false) { + self.init(size: CGSize(width: width, height: 9999), unit: unit, contentMode: .aspectFit, crop: false, upscale: upscale) + } + + /// Scales an image to the given height preserving aspect ratio. + /// + /// - parameters: + /// - height: The target height. + /// - unit: Unit of the target size. + /// - upscale: By default, upscaling is not allowed. + init(height: CGFloat, unit: ImageProcessingOptions.Unit = .points, upscale: Bool = false) { + self.init(size: CGSize(width: 9999, height: height), unit: unit, contentMode: .aspectFit, crop: false, upscale: upscale) + } + + func process(_ image: PlatformImage) -> PlatformImage? { + if crop && contentMode == .aspectFill { + return image.processed.byResizingAndCropping(to: size.cgSize) + } + return image.processed.byResizing(to: size.cgSize, contentMode: contentMode, upscale: upscale) + } + + var identifier: String { + "com.github.kean/nuke/resize?s=\(size.cgSize),cm=\(contentMode),crop=\(crop),upscale=\(upscale)" + } + + var description: String { + "Resize(size: \(size.cgSize) pixels, contentMode: \(contentMode), crop: \(crop), upscale: \(upscale))" + } + } +} + +// Adds Hashable without making changes to CGSize API +struct ImageTargetSize: Hashable { + let cgSize: CGSize + + /// Creates the size in pixels by scaling to the input size to the screen scale + /// if needed. + init(size: CGSize, unit: ImageProcessingOptions.Unit) { + switch unit { + case .pixels: self.cgSize = size // The size is already in pixels + case .points: self.cgSize = size.scaled(by: Screen.scale) + } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(cgSize.width) + hasher.combine(cgSize.height) + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+RoundedCorners.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+RoundedCorners.swift new file mode 100644 index 00000000000..d43ec4a1e0b --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors+RoundedCorners.swift @@ -0,0 +1,47 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation +import CoreGraphics + +#if !os(macOS) +import UIKit +#else +import AppKit +#endif + +extension ImageProcessors { + /// Rounds the corners of an image to the specified radius. + /// + /// - important: In order for the corners to be displayed correctly, the image must exactly match the size + /// of the image view in which it will be displayed. See ``ImageProcessors/Resize`` for more info. + struct RoundedCorners: ImageProcessing, Hashable, CustomStringConvertible { + private let radius: CGFloat + private let border: ImageProcessingOptions.Border? + + /// Initializes the processor with the given radius. + /// + /// - parameters: + /// - radius: The radius of the corners. + /// - unit: Unit of the radius. + /// - border: An optional border drawn around the image. + init(radius: CGFloat, unit: ImageProcessingOptions.Unit = .points, border: ImageProcessingOptions.Border? = nil) { + self.radius = radius.converted(to: unit) + self.border = border + } + + func process(_ image: PlatformImage) -> PlatformImage? { + image.processed.byAddingRoundedCorners(radius: radius, border: border) + } + + var identifier: String { + let suffix = border.map { ",border=\($0)" } + return "com.github.kean/nuke/rounded_corners?radius=\(radius)" + (suffix ?? "") + } + + var description: String { + "RoundedCorners(radius: \(radius) pixels, border: \(border?.description ?? "nil"))" + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors.swift new file mode 100644 index 00000000000..8223ea49caa --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Processing/ImageProcessors.swift @@ -0,0 +1,119 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +/// A namespace for all processors that implement ``ImageProcessing`` protocol. +enum ImageProcessors {} + +extension ImageProcessing where Self == ImageProcessors.Resize { + /// Scales an image to a specified size. + /// + /// - parameters + /// - size: The target size. + /// - unit: Unit of the target size. By default, `.points`. + /// - contentMode: Target content mode. + /// - crop: If `true` will crop the image to match the target size. Does + /// nothing with content mode .aspectFill. `false` by default. + /// - upscale: Upscaling is not allowed by default. + static func resize(size: CGSize, unit: ImageProcessingOptions.Unit = .points, contentMode: ImageProcessingOptions.ContentMode = .aspectFill, crop: Bool = false, upscale: Bool = false) -> ImageProcessors.Resize { + ImageProcessors.Resize(size: size, unit: unit, contentMode: contentMode, crop: crop, upscale: upscale) + } + + /// Scales an image to the given width preserving aspect ratio. + /// + /// - parameters: + /// - width: The target width. + /// - unit: Unit of the target size. By default, `.points`. + /// - upscale: `false` by default. + static func resize(width: CGFloat, unit: ImageProcessingOptions.Unit = .points, upscale: Bool = false) -> ImageProcessors.Resize { + ImageProcessors.Resize(width: width, unit: unit, upscale: upscale) + } + + /// Scales an image to the given height preserving aspect ratio. + /// + /// - parameters: + /// - height: The target height. + /// - unit: Unit of the target size. By default, `.points`. + /// - upscale: `false` by default. + static func resize(height: CGFloat, unit: ImageProcessingOptions.Unit = .points, upscale: Bool = false) -> ImageProcessors.Resize { + ImageProcessors.Resize(height: height, unit: unit, upscale: upscale) + } +} + +extension ImageProcessing where Self == ImageProcessors.Circle { + /// Rounds the corners of an image into a circle. If the image is not a square, + /// crops it to a square first. + /// + /// - parameter border: `nil` by default. + static func circle(border: ImageProcessingOptions.Border? = nil) -> ImageProcessors.Circle { + ImageProcessors.Circle(border: border) + } +} + +extension ImageProcessing where Self == ImageProcessors.RoundedCorners { + /// Rounds the corners of an image to the specified radius. + /// + /// - parameters: + /// - radius: The radius of the corners. + /// - unit: Unit of the radius. + /// - border: An optional border drawn around the image. + /// + /// - important: In order for the corners to be displayed correctly, the image must exactly match the size + /// of the image view in which it will be displayed. See ``ImageProcessors/Resize`` for more info. + static func roundedCorners(radius: CGFloat, unit: ImageProcessingOptions.Unit = .points, border: ImageProcessingOptions.Border? = nil) -> ImageProcessors.RoundedCorners { + ImageProcessors.RoundedCorners(radius: radius, unit: unit, border: border) + } +} + +extension ImageProcessing where Self == ImageProcessors.Anonymous { + /// Creates a custom processor with a given closure. + /// + /// - parameters: + /// - id: Uniquely identifies the operation performed by the processor. + /// - closure: A closure that transforms the images. + static func process(id: String, _ closure: @Sendable @escaping (PlatformImage) -> PlatformImage?) -> ImageProcessors.Anonymous { + ImageProcessors.Anonymous(id: id, closure) + } +} + +#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) + +extension ImageProcessing where Self == ImageProcessors.CoreImageFilter { + /// Applies Core Image filter – `CIFilter` – to the image. + /// + /// - parameter identifier: Uniquely identifies the processor. + static func coreImageFilter(name: String, parameters: [String: Any], identifier: String) -> ImageProcessors.CoreImageFilter { + ImageProcessors.CoreImageFilter(name: name, parameters: parameters, identifier: identifier) + } + + /// Applies Core Image filter – `CIFilter` – to the image. + /// + static func coreImageFilter(name: String) -> ImageProcessors.CoreImageFilter { + ImageProcessors.CoreImageFilter(name: name) + } + + static func coreImageFilter(_ filter: CIFilter, identifier: String) -> ImageProcessors.CoreImageFilter { + ImageProcessors.CoreImageFilter(filter, identifier: identifier) + } +} + +extension ImageProcessing where Self == ImageProcessors.GaussianBlur { + /// Blurs an image using `CIGaussianBlur` filter. + /// + /// - parameter radius: `8` by default. + static func gaussianBlur(radius: Int = 8) -> ImageProcessors.GaussianBlur { + ImageProcessors.GaussianBlur(radius: radius) + } +} + +#endif diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/AsyncPipelineTask.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/AsyncPipelineTask.swift new file mode 100644 index 00000000000..2865e3baebe --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/AsyncPipelineTask.swift @@ -0,0 +1,61 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +// Each task holds a strong reference to the pipeline. This is by design. The +// user does not need to hold a strong reference to the pipeline. +class AsyncPipelineTask: AsyncTask, @unchecked Sendable { + let pipeline: ImagePipeline + // A canonical request representing the unit work performed by the task. + let request: ImageRequest + + init(_ pipeline: ImagePipeline, _ request: ImageRequest) { + self.pipeline = pipeline + self.request = request + } +} + +// Returns all image tasks subscribed to the current pipeline task. +// A suboptimal approach just to make the new DiskCachPolicy.automatic work. +protocol ImageTaskSubscribers { + var imageTasks: [ImageTask] { get } +} + +extension ImageTask: ImageTaskSubscribers { + var imageTasks: [ImageTask] { + [self] + } +} + +extension AsyncPipelineTask: ImageTaskSubscribers { + var imageTasks: [ImageTask] { + subscribers.flatMap { subscribers -> [ImageTask] in + (subscribers as? ImageTaskSubscribers)?.imageTasks ?? [] + } + } +} + +extension AsyncPipelineTask { + /// Decodes the data on the dedicated queue and calls the completion + /// on the pipeline's internal queue. + func decode(_ context: ImageDecodingContext, decoder: any ImageDecoding, _ completion: @Sendable @escaping (Result) -> Void) { + @Sendable func decode() -> Result { + signpost(context.isCompleted ? "DecodeImageData" : "DecodeProgressiveImageData") { + Result { try decoder.decode(context) } + .mapError { .decodingFailed(decoder: decoder, context: context, error: $0) } + } + } + guard decoder.isAsynchronous else { + return completion(decode()) + } + operation = pipeline.configuration.imageDecodingQueue.add { [weak self] in + guard let self else { return } + let response = decode() + self.pipeline.queue.async { + completion(response) + } + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/Task.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/AsyncTask.swift similarity index 82% rename from Sources/StreamChatUI/StreamNuke/Internal/Tasks/Task.swift rename to Sources/StreamChatUI/StreamNuke/Nuke/Tasks/AsyncTask.swift index 4bbbd814ae2..e381f51febf 100644 --- a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/Task.swift +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/AsyncTask.swift @@ -1,13 +1,13 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation /// Represents a task with support for multiple observers, cancellation, /// progress reporting, dependencies – everything that `ImagePipeline` needs. /// -/// A `Task` can have zero or more subscriptions (`TaskSubscription`) which can +/// A `AsyncTask` can have zero or more subscriptions (`TaskSubscription`) which can /// be used to later unsubscribe or change the priority of the subscription. /// /// The task has built-in support for operations (`Foundation.Operation`) – it @@ -15,7 +15,7 @@ import Foundation /// image pipeline are represented using Operation to take advantage of these features. /// /// - warning: Must be thread-confined! -class Task: TaskSubscriptionDelegate { +class AsyncTask: AsyncTaskSubscriptionDelegate, @unchecked Sendable { private struct Subscription { let closure: (Event) -> Void @@ -50,7 +50,6 @@ class Task: TaskSubscriptionDelegate { guard oldValue != priority else { return } operation?.queuePriority = priority.queuePriority dependency?.setPriority(priority) - dependency2?.setPriority(priority) } } @@ -64,14 +63,6 @@ class Task: TaskSubscriptionDelegate { } } - // The tasks only ever need up to 2 dependencies and this code is much faster - // than creating an array. - var dependency2: TaskSubscription? { - didSet { - dependency2?.setPriority(priority) - } - } - weak var operation: Foundation.Operation? { didSet { guard priority != .normal else { return } @@ -82,23 +73,13 @@ class Task: TaskSubscriptionDelegate { /// Publishes the results of the task. var publisher: Publisher { Publisher(task: self) } - #if TRACK_ALLOCATIONS - deinit { - Allocations.decrement("Task") - } - - init() { - Allocations.increment("Task") - } - #endif - /// Override this to start image task. Only gets called once. func start() {} // MARK: - Managing Observers /// - notes: Returns `nil` if the task was disposed. - private func subscribe(priority: TaskPriority = .normal, subscriber: AnyObject? = nil, _ closure: @escaping (Event) -> Void) -> TaskSubscription? { + private func subscribe(priority: TaskPriority = .normal, subscriber: AnyObject, _ closure: @escaping (Event) -> Void) -> TaskSubscription? { guard !isDisposed else { return nil } let subscriptionKey = nextSubscriptionKey @@ -121,7 +102,6 @@ class Task: TaskSubscriptionDelegate { // The task may have been completed synchronously by `starter`. guard !isDisposed else { return nil } - return subscription } @@ -184,7 +164,7 @@ class Task: TaskSubscriptionDelegate { } inlineSubscription?.closure(event) - if let subscriptions = subscriptions { + if let subscriptions { for subscription in subscriptions.values { subscription.closure(event) } @@ -204,7 +184,6 @@ class Task: TaskSubscriptionDelegate { if reason == .cancelled { operation?.cancel() dependency?.unsubscribe() - dependency2?.unsubscribe() onCancelled?() } onDisposed?() @@ -213,7 +192,7 @@ class Task: TaskSubscriptionDelegate { // MARK: - Priority private func updatePriority(suggestedPriority: TaskPriority?) { - if let suggestedPriority = suggestedPriority, suggestedPriority >= priority { + if let suggestedPriority, suggestedPriority >= priority { // No need to recompute, won't go higher than that priority = suggestedPriority return @@ -222,7 +201,7 @@ class Task: TaskSubscriptionDelegate { var newPriority = inlineSubscription?.priority // Same as subscriptions.map { $0?.priority }.max() but without allocating // any memory for redundant arrays - if let subscriptions = subscriptions { + if let subscriptions { for subscription in subscriptions.values { if newPriority == nil { newPriority = subscription.priority @@ -235,25 +214,25 @@ class Task: TaskSubscriptionDelegate { } } -// MARK: - Task (Publisher) +// MARK: - AsyncTask (Publisher) -extension Task { +extension AsyncTask { /// Publishes the results of the task. struct Publisher { - fileprivate let task: Task + fileprivate let task: AsyncTask /// Attaches the subscriber to the task. /// - notes: Returns `nil` if the task is already disposed. - func subscribe(priority: TaskPriority = .normal, subscriber: AnyObject? = nil, _ closure: @escaping (Event) -> Void) -> TaskSubscription? { + func subscribe(priority: TaskPriority = .normal, subscriber: AnyObject, _ closure: @escaping (Event) -> Void) -> TaskSubscription? { task.subscribe(priority: priority, subscriber: subscriber, closure) } /// Attaches the subscriber to the task. Automatically forwards progress - /// andd error events to the given task. + /// and error events to the given task. /// - notes: Returns `nil` if the task is already disposed. - func subscribe(_ task: Task, onValue: @escaping (Value, Bool) -> Void) -> TaskSubscription? { + func subscribe(_ task: AsyncTask, onValue: @escaping (Value, Bool) -> Void) -> TaskSubscription? { subscribe(subscriber: task) { [weak task] event in - guard let task = task else { return } + guard let task else { return } switch event { case let .value(value, isCompleted): onValue(value, isCompleted) @@ -267,10 +246,7 @@ extension Task { } } -struct TaskProgress: Hashable { - let completed: Int64 - let total: Int64 -} +typealias TaskProgress = ImageTask.Progress // Using typealias for simplicity enum TaskPriority: Int, Comparable { case veryLow = 0, low, normal, high, veryHigh @@ -290,34 +266,26 @@ enum TaskPriority: Int, Comparable { } } -// MARK: - Task.Event { -extension Task { +// MARK: - AsyncTask.Event { +extension AsyncTask { enum Event { case value(Value, isCompleted: Bool) case progress(TaskProgress) case error(Error) - - var isCompleted: Bool { - switch self { - case let .value(_, isCompleted): return isCompleted - case .progress: return false - case .error: return true - } - } } } -extension Task.Event: Equatable where Value: Equatable, Error: Equatable {} +extension AsyncTask.Event: Equatable where Value: Equatable, Error: Equatable {} // MARK: - TaskSubscription /// Represents a subscription to a task. The observer must retain a strong /// reference to a subscription. -struct TaskSubscription { - private let task: TaskSubscriptionDelegate +struct TaskSubscription: Sendable { + private let task: any AsyncTaskSubscriptionDelegate private let key: TaskSubscriptionKey - fileprivate init(task: TaskSubscriptionDelegate, key: TaskSubscriptionKey) { + fileprivate init(task: any AsyncTaskSubscriptionDelegate, key: TaskSubscriptionKey) { self.task = task self.key = key } @@ -343,7 +311,7 @@ struct TaskSubscription { } } -private protocol TaskSubscriptionDelegate: AnyObject { +private protocol AsyncTaskSubscriptionDelegate: AnyObject, Sendable { func unsubsribe(key: TaskSubscriptionKey) func setPriority(_ priority: TaskPriority, for observer: TaskSubscriptionKey) } @@ -353,9 +321,9 @@ private typealias TaskSubscriptionKey = Int // MARK: - TaskPool /// Contains the tasks which haven't completed yet. -final class TaskPool { +final class TaskPool { private let isCoalescingEnabled: Bool - private var map = [Key: Task]() + private var map = [Key: AsyncTask]() init(_ isCoalescingEnabled: Bool) { self.isCoalescingEnabled = isCoalescingEnabled @@ -364,7 +332,7 @@ final class TaskPool { /// Creates a task with the given key. If there is an outstanding task with /// the given key in the pool, the existing task is returned. Tasks are /// automatically removed from the pool when they are disposed. - func publisherForKey(_ key: @autoclosure () -> Key, _ make: () -> Task) -> Task.Publisher { + func publisherForKey(_ key: @autoclosure () -> Key, _ make: () -> AsyncTask) -> AsyncTask.Publisher { guard isCoalescingEnabled else { return make().publisher } diff --git a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskFetchOriginalImageData.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalData.swift similarity index 51% rename from Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskFetchOriginalImageData.swift rename to Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalData.swift index 8da2f29b4a8..519330f22e0 100644 --- a/Sources/StreamChatUI/StreamNuke/Internal/Tasks/TaskFetchOriginalImageData.swift +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalData.swift @@ -1,58 +1,74 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation /// Fetches original image from the data loader (`DataLoading`) and stores it /// in the disk cache (`DataCaching`). -final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> { +final class TaskFetchOriginalData: AsyncPipelineTask<(Data, URLResponse?)>, @unchecked Sendable { private var urlResponse: URLResponse? private var resumableData: ResumableData? private var resumedDataCount: Int64 = 0 - private lazy var data = Data() + private var data = Data() override func start() { + guard let urlRequest = request.urlRequest, let url = urlRequest.url else { + // A malformed URL prevented a URL request from being initiated. + send(error: .dataLoadingFailed(error: URLError(.badURL))) + return + } + + if url.isLocalResource && pipeline.configuration.isLocalResourcesSupportEnabled { + do { + let data = try Data(contentsOf: url) + send(value: (data, nil), isCompleted: true) + } catch { + send(error: .dataLoadingFailed(error: error)) + } + return + } + if let rateLimiter = pipeline.rateLimiter { // Rate limiter is synchronized on pipeline's queue. Delayed work is // executed asynchronously also on the same queue. rateLimiter.execute { [weak self] in - guard let self = self, !self.isDisposed else { + guard let self, !self.isDisposed else { return false } - self.loadData() + self.loadData(urlRequest: urlRequest) return true } } else { // Start loading immediately. - loadData() + loadData(urlRequest: urlRequest) } } - private func loadData() { - // Wrap data request in an operation to limit the maximum number of - // concurrent data tasks. - operation = pipeline.configuration.dataLoadingQueue.add { [weak self] finish in - guard let self = self else { - return finish() - } - self.async { - self.loadData(finish: finish) + private func loadData(urlRequest: URLRequest) { + if request.options.contains(.skipDataLoadingQueue) { + loadData(urlRequest: urlRequest, finish: { /* do nothing */ }) + } else { + // Wrap data request in an operation to limit the maximum number of + // concurrent data tasks. + operation = pipeline.configuration.dataLoadingQueue.add { [weak self] finish in + guard let self else { + return finish() + } + self.pipeline.queue.async { + self.loadData(urlRequest: urlRequest, finish: finish) + } } } } // This methods gets called inside data loading operation (Operation). - private func loadData(finish: @escaping () -> Void) { + private func loadData(urlRequest: URLRequest, finish: @escaping () -> Void) { guard !isDisposed else { return finish() } // Read and remove resumable data from cache (we're going to insert it // back in the cache if the request fails to complete again). - guard var urlRequest = request.urlRequest else { - self.send(error: .dataLoadingFailed(URLError(.badURL))) - return - } - + var urlRequest = urlRequest if pipeline.configuration.isResumableDataEnabled, let resumableData = ResumableDataStorage.shared.removeResumableData(for: request, pipeline: pipeline) { // Update headers to add "Range" and "If-Range" headers @@ -62,27 +78,27 @@ final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> self.resumableData = resumableData } - signpost(nukeLog, self, "LoadImageData", .begin, "URL: \(urlRequest.url?.absoluteString ?? ""), resumable data: \(Formatter.bytes(resumableData?.data.count ?? 0))") + signpost(self, "LoadImageData", .begin, "URL: \(urlRequest.url?.absoluteString ?? ""), resumable data: \(Formatter.bytes(resumableData?.data.count ?? 0))") let dataLoader = pipeline.delegate.dataLoader(for: request, pipeline: pipeline) let dataTask = dataLoader.loadData(with: urlRequest, didReceiveData: { [weak self] data, response in - guard let self = self else { return } - self.async { + guard let self else { return } + self.pipeline.queue.async { self.dataTask(didReceiveData: data, response: response) } }, completion: { [weak self] error in finish() // Finish the operation! - guard let self = self else { return } - signpost(nukeLog, self, "LoadImageData", .end, "Finished with size \(Formatter.bytes(self.data.count))") - self.async { + guard let self else { return } + signpost(self, "LoadImageData", .end, "Finished with size \(Formatter.bytes(self.data.count))") + self.pipeline.queue.async { self.dataTaskDidFinish(error: error) } }) onCancelled = { [weak self] in - guard let self = self else { return } + guard let self else { return } - signpost(nukeLog, self, "LoadImageData", .end, "Cancelled") + signpost(self, "LoadImageData", .end, "Cancelled") dataTask.cancel() finish() // Finish the operation! @@ -94,16 +110,20 @@ final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> // Check if this is the first response. if urlResponse == nil { // See if the server confirmed that the resumable data can be used - if let resumableData = resumableData, ResumableData.isResumedResponse(response) { + if let resumableData, ResumableData.isResumedResponse(response) { data = resumableData.data resumedDataCount = Int64(resumableData.data.count) - signpost(nukeLog, self, "LoadImageData", .event, "Resumed with data \(Formatter.bytes(resumedDataCount))") + signpost(self, "LoadImageData", .event, "Resumed with data \(Formatter.bytes(resumedDataCount))") } resumableData = nil // Get rid of resumable data } // Append data and save response - data.append(chunk) + if data.isEmpty { + data = chunk + } else { + data.append(chunk) + } urlResponse = response let progress = TaskProgress(completed: Int64(data.count), total: response.expectedContentLength + resumedDataCount) @@ -118,42 +138,24 @@ final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> } private func dataTaskDidFinish(error: Swift.Error?) { - if let error = error { + if let error { tryToSaveResumableData() - send(error: .dataLoadingFailed(error)) + send(error: .dataLoadingFailed(error: error)) return } // Sanity check, should never happen in practice guard !data.isEmpty else { - send(error: .dataLoadingFailed(URLError(.unknown, userInfo: [:]))) + send(error: .dataIsEmpty) return } // Store in data cache - if let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), shouldStoreDataInDiskCache() { - let key = pipeline.cache.makeDataCacheKey(for: request) - pipeline.delegate.willCache(data: data, image: nil, for: request, pipeline: pipeline) { - guard let data = $0 else { return } - // Important! Storing directly ignoring `ImageRequest.Options`. - dataCache.storeData(data, for: key) - } - } + storeDataInCacheIfNeeded(data) send(value: (data, urlResponse), isCompleted: true) } - private func shouldStoreDataInDiskCache() -> Bool { - guard request.url?.isCacheable ?? false else { - return false - } - let policy = pipeline.configuration.dataCachePolicy - guard imageTasks.contains(where: { !$0.request.options.contains(.disableDiskCacheWrites) }) else { - return false - } - return policy == .storeOriginalData || policy == .storeAll || (policy == .automatic && imageTasks.contains { $0.request.processors.isEmpty }) - } - private func tryToSaveResumableData() { // Try to save resumable data in case the task was cancelled // (`URLError.cancelled`) or failed to complete with other error. @@ -164,3 +166,47 @@ final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> } } } + +extension AsyncPipelineTask where Value == (Data, URLResponse?) { + func storeDataInCacheIfNeeded(_ data: Data) { + let request = makeSanitizedRequest() + guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), shouldStoreDataInDiskCache() else { + return + } + let key = pipeline.cache.makeDataCacheKey(for: request) + pipeline.delegate.willCache(data: data, image: nil, for: request, pipeline: pipeline) { + guard let data = $0 else { return } + // Important! Storing directly ignoring `ImageRequest.Options`. + dataCache.storeData(data, for: key) + } + } + + /// Returns a request that doesn't contain any information non-related + /// to data loading. + private func makeSanitizedRequest() -> ImageRequest { + var request = request + request.processors = [] + request.userInfo[.thumbnailKey] = nil + return request + } + + private func shouldStoreDataInDiskCache() -> Bool { + let imageTasks = imageTasks + guard imageTasks.contains(where: { !$0.request.options.contains(.disableDiskCacheWrites) }) else { + return false + } + guard !(request.url?.isLocalResource ?? false) else { + return false + } + switch pipeline.configuration.dataCachePolicy { + case .automatic: + return imageTasks.contains { $0.request.processors.isEmpty } + case .storeOriginalData: + return true + case .storeEncodedImages: + return false + case .storeAll: + return true + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImage.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImage.swift new file mode 100644 index 00000000000..1f9901de270 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImage.swift @@ -0,0 +1,69 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Receives data from ``TaskLoadImageData`` and decodes it as it arrives. +final class TaskFetchOriginalImage: AsyncPipelineTask, @unchecked Sendable { + private var decoder: (any ImageDecoding)? + + override func start() { + dependency = pipeline.makeTaskFetchOriginalData(for: request).subscribe(self) { [weak self] in + self?.didReceiveData($0.0, urlResponse: $0.1, isCompleted: $1) + } + } + + /// Receiving data from `TaskFetchOriginalData`. + private func didReceiveData(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool) { + guard isCompleted || pipeline.configuration.isProgressiveDecodingEnabled else { + return + } + + if !isCompleted && operation != nil { + return // Back pressure - already decoding another progressive data chunk + } + + if isCompleted { + operation?.cancel() // Cancel any potential pending progressive decoding tasks + } + + let context = ImageDecodingContext(request: request, data: data, isCompleted: isCompleted, urlResponse: urlResponse) + guard let decoder = getDecoder(for: context) else { + if isCompleted { + send(error: .decoderNotRegistered(context: context)) + } else { + // Try again when more data is downloaded. + } + return + } + + decode(context, decoder: decoder) { [weak self] in + self?.didFinishDecoding(context: context, result: $0) + } + } + + private func didFinishDecoding(context: ImageDecodingContext, result: Result) { + operation = nil + + switch result { + case .success(let response): + send(value: response, isCompleted: context.isCompleted) + case .failure(let error): + if context.isCompleted { + send(error: error) + } + } + } + + // Lazily creates decoding for task + private func getDecoder(for context: ImageDecodingContext) -> (any ImageDecoding)? { + // Return the existing processor in case it has already been created. + if let decoder { + return decoder + } + let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) + self.decoder = decoder + return decoder + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskFetchWithPublisher.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskFetchWithPublisher.swift new file mode 100644 index 00000000000..19faec29462 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskFetchWithPublisher.swift @@ -0,0 +1,72 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Fetches data using the publisher provided with the request. +/// Unlike `TaskFetchOriginalImageData`, there is no resumable data involved. +final class TaskFetchWithPublisher: AsyncPipelineTask<(Data, URLResponse?)>, @unchecked Sendable { + private lazy var data = Data() + + override func start() { + if request.options.contains(.skipDataLoadingQueue) { + loadData(finish: { /* do nothing */ }) + } else { + // Wrap data request in an operation to limit the maximum number of + // concurrent data tasks. + operation = pipeline.configuration.dataLoadingQueue.add { [weak self] finish in + guard let self else { + return finish() + } + self.pipeline.queue.async { + self.loadData { finish() } + } + } + } + } + + // This methods gets called inside data loading operation (Operation). + private func loadData(finish: @escaping () -> Void) { + guard !isDisposed else { + return finish() + } + + guard let publisher = request.publisher else { + send(error: .dataLoadingFailed(error: URLError(.unknown))) // This is just a placeholder error, never thrown + return assertionFailure("This should never happen") + } + + let cancellable = publisher.sink(receiveCompletion: { [weak self] result in + finish() // Finish the operation! + guard let self else { return } + self.pipeline.queue.async { + self.dataTaskDidFinish(result) + } + }, receiveValue: { [weak self] data in + guard let self else { return } + self.pipeline.queue.async { + self.data.append(data) + } + }) + + onCancelled = { + finish() + cancellable.cancel() + } + } + + private func dataTaskDidFinish(_ result: PublisherCompletion) { + switch result { + case .finished: + guard !data.isEmpty else { + send(error: .dataIsEmpty) + return + } + storeDataInCacheIfNeeded(data) + send(value: (data, nil), isCompleted: true) + case .failure(let error): + send(error: .dataLoadingFailed(error: error)) + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskLoadData.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskLoadData.swift new file mode 100644 index 00000000000..c571c666ecb --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskLoadData.swift @@ -0,0 +1,36 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Wrapper for tasks created by `loadData` calls. +final class TaskLoadData: AsyncPipelineTask, @unchecked Sendable { + override func start() { + if let data = pipeline.cache.cachedData(for: request) { + let container = ImageContainer(image: .init(), data: data) + let response = ImageResponse(container: container, request: request) + self.send(value: response, isCompleted: true) + } else { + self.loadData() + } + } + + private func loadData() { + guard !request.options.contains(.returnCacheDataDontLoad) else { + return send(error: .dataMissingInCache) + } + let request = request.withProcessors([]) + dependency = pipeline.makeTaskFetchOriginalData(for: request).subscribe(self) { [weak self] in + self?.didReceiveData($0.0, urlResponse: $0.1, isCompleted: $1) + } + } + + private func didReceiveData(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool) { + let container = ImageContainer(image: .init(), data: data) + let response = ImageResponse(container: container, request: request, urlResponse: urlResponse) + if isCompleted { + send(value: response, isCompleted: isCompleted) + } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskLoadImage.swift b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskLoadImage.swift new file mode 100644 index 00000000000..2f4b6b7e215 --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/Nuke/Tasks/TaskLoadImage.swift @@ -0,0 +1,200 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Wrapper for tasks created by `loadImage` calls. +/// +/// Performs all the quick cache lookups and also manages image processing. +/// The coalescing for image processing is implemented on demand (extends the +/// scenarios in which coalescing can kick in). +final class TaskLoadImage: AsyncPipelineTask, @unchecked Sendable { + override func start() { + if let container = pipeline.cache[request] { + let response = ImageResponse(container: container, request: request, cacheType: .memory) + send(value: response, isCompleted: !container.isPreview) + if !container.isPreview { + return // The final image is loaded + } + } + if let data = pipeline.cache.cachedData(for: request) { + decodeCachedData(data) + } else { + fetchImage() + } + } + + private func decodeCachedData(_ data: Data) { + let context = ImageDecodingContext(request: request, data: data, cacheType: .disk) + guard let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) else { + return didFinishDecoding(with: nil) + } + decode(context, decoder: decoder) { [weak self] in + self?.didFinishDecoding(with: try? $0.get()) + } + } + + private func didFinishDecoding(with response: ImageResponse?) { + if let response { + didReceiveImageResponse(response, isCompleted: true) + } else { + fetchImage() + } + } + + // MARK: Fetch Image + + private func fetchImage() { + guard !request.options.contains(.returnCacheDataDontLoad) else { + return send(error: .dataMissingInCache) + } + if let processor = request.processors.last { + let request = request.withProcessors(request.processors.dropLast()) + dependency = pipeline.makeTaskLoadImage(for: request).subscribe(self) { [weak self] in + self?.process($0, isCompleted: $1, processor: processor) + } + } else { + dependency = pipeline.makeTaskFetchOriginalImage(for: request).subscribe(self) { [weak self] in + self?.didReceiveImageResponse($0, isCompleted: $1) + } + } + } + + // MARK: Processing + + private func process(_ response: ImageResponse, isCompleted: Bool, processor: any ImageProcessing) { + guard !isDisposed else { return } + if isCompleted { + operation?.cancel() // Cancel any potential pending progressive + } else if operation != nil { + return // Back pressure - already processing another progressive image + } + let context = ImageProcessingContext(request: request, response: response, isCompleted: isCompleted) + operation = pipeline.configuration.imageProcessingQueue.add { [weak self] in + guard let self else { return } + let result = signpost(isCompleted ? "ProcessImage" : "ProcessProgressiveImage") { + Result { + var response = response + response.container = try processor.process(response.container, context: context) + return response + }.mapError { error in + ImagePipeline.Error.processingFailed(processor: processor, context: context, error: error) + } + } + self.pipeline.queue.async { + self.operation = nil + self.didFinishProcessing(result: result, isCompleted: isCompleted) + } + } + } + + private func didFinishProcessing(result: Result, isCompleted: Bool) { + switch result { + case .success(let response): + didReceiveImageResponse(response, isCompleted: isCompleted) + case .failure(let error): + if isCompleted { + send(error: error) + } + } + } + + // MARK: Decompression + + private func didReceiveImageResponse(_ response: ImageResponse, isCompleted: Bool) { + guard isDecompressionNeeded(for: response) else { + return didReceiveDecompressedImage(response, isCompleted: isCompleted) + } + guard !isDisposed else { return } + if isCompleted { + operation?.cancel() // Cancel any potential pending progressive decompression tasks + } else if operation != nil { + return // Back-pressure: receiving progressive scans too fast + } + operation = pipeline.configuration.imageDecompressingQueue.add { [weak self] in + guard let self else { return } + let response = signpost(isCompleted ? "DecompressImage" : "DecompressProgressiveImage") { + self.pipeline.delegate.decompress(response: response, request: self.request, pipeline: self.pipeline) + } + self.pipeline.queue.async { + self.operation = nil + self.didReceiveDecompressedImage(response, isCompleted: isCompleted) + } + } + } + + private func isDecompressionNeeded(for response: ImageResponse) -> Bool { + ImageDecompression.isDecompressionNeeded(for: response) && + !request.options.contains(.skipDecompression) && + hasDirectSubscribers && + pipeline.delegate.shouldDecompress(response: response, for: request, pipeline: pipeline) + } + + private func didReceiveDecompressedImage(_ response: ImageResponse, isCompleted: Bool) { + storeImageInCaches(response) + send(value: response, isCompleted: isCompleted) + } + + // MARK: Caching + + private func storeImageInCaches(_ response: ImageResponse) { + guard hasDirectSubscribers else { + return + } + pipeline.cache[request] = response.container + if shouldStoreResponseInDataCache(response) { + storeImageInDataCache(response) + } + } + + private func storeImageInDataCache(_ response: ImageResponse) { + guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline) else { + return + } + let context = ImageEncodingContext(request: request, image: response.image, urlResponse: response.urlResponse) + let encoder = pipeline.delegate.imageEncoder(for: context, pipeline: pipeline) + let key = pipeline.cache.makeDataCacheKey(for: request) + pipeline.configuration.imageEncodingQueue.addOperation { [weak pipeline, request] in + guard let pipeline else { return } + let encodedData = signpost("EncodeImage") { + encoder.encode(response.container, context: context) + } + guard let data = encodedData, !data.isEmpty else { return } + pipeline.delegate.willCache(data: data, image: response.container, for: request, pipeline: pipeline) { + guard let data = $0, !data.isEmpty else { return } + // Important! Storing directly ignoring `ImageRequest.Options`. + dataCache.storeData(data, for: key) // This is instant, writes are async + } + } + if pipeline.configuration.debugIsSyncImageEncoding { // Only for debug + pipeline.configuration.imageEncodingQueue.waitUntilAllOperationsAreFinished() + } + } + + private func shouldStoreResponseInDataCache(_ response: ImageResponse) -> Bool { + guard !response.container.isPreview, + !(response.cacheType == .disk), + !(request.url?.isLocalResource ?? false) else { + return false + } + let isProcessed = !request.processors.isEmpty || request.thumbnail != nil + switch pipeline.configuration.dataCachePolicy { + case .automatic: + return isProcessed + case .storeOriginalData: + return false + case .storeEncodedImages: + return true + case .storeAll: + return isProcessed + } + } + + /// Returns `true` if the task has at least one image task that was directly + /// subscribed to it, which means that the request was initiated by the + /// user and not the framework. + private var hasDirectSubscribers: Bool { + subscribers.contains { $0 is ImageTask } + } +} diff --git a/Sources/StreamChatUI/StreamNuke/NukeExtensions/ImageLoadingOptions.swift b/Sources/StreamChatUI/StreamNuke/NukeExtensions/ImageLoadingOptions.swift new file mode 100644 index 00000000000..fe31e9c4a4e --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/NukeExtensions/ImageLoadingOptions.swift @@ -0,0 +1,227 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if !os(macOS) +import UIKit.UIImage +import UIKit.UIColor +#else +import AppKit.NSImage +#endif + +/// A set of options that control how the image is loaded and displayed. +struct ImageLoadingOptions { + /// Shared options. + @MainActor static var shared = ImageLoadingOptions() + + /// Placeholder to be displayed when the image is loading. `nil` by default. + var placeholder: PlatformImage? + + /// Image to be displayed when the request fails. `nil` by default. + var failureImage: PlatformImage? + +#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) + + /// The image transition animation performed when displaying a loaded image. + /// Only runs when the image was not found in memory cache. `nil` by default. + var transition: Transition? + + /// The image transition animation performed when displaying a failure image. + /// `nil` by default. + var failureImageTransition: Transition? + + /// If true, the requested image will always appear with transition, even + /// when loaded from cache. + var alwaysTransition = false + + func transition(for response: ResponseType) -> Transition? { + switch response { + case .success: return transition + case .failure: return failureImageTransition + case .placeholder: return nil + } + } + +#endif + + /// If true, every time you request a new image for a view, the view will be + /// automatically prepared for reuse: image will be set to `nil`, and animations + /// will be removed. `true` by default. + var isPrepareForReuseEnabled = true + + /// If `true`, every progressively generated preview produced by the pipeline + /// is going to be displayed. `true` by default. + /// + /// - note: To enable progressive decoding, see `ImagePipeline.Configuration`, + /// `isProgressiveDecodingEnabled` option. + var isProgressiveRenderingEnabled = true + + /// Custom pipeline to be used. `nil` by default. + var pipeline: ImagePipeline? + + /// Image processors to be applied unless the processors are provided in the + /// request. `[]` by default. + var processors: [any ImageProcessing] = [] + +#if os(iOS) || os(tvOS) || os(visionOS) + + /// Content modes to be used for each image type (placeholder, success, + /// failure). `nil` by default (don't change content mode). + var contentModes: ContentModes? + + /// Custom content modes to be used for each image type (placeholder, success, + /// failure). + struct ContentModes { + /// Content mode to be used for the loaded image. + var success: UIView.ContentMode + /// Content mode to be used when displaying a `failureImage`. + var failure: UIView.ContentMode + /// Content mode to be used when displaying a `placeholder`. + var placeholder: UIView.ContentMode + + /// - parameters: + /// - success: A content mode to be used with a loaded image. + /// - failure: A content mode to be used with a `failureImage`. + /// - placeholder: A content mode to be used with a `placeholder`. + init(success: UIView.ContentMode, failure: UIView.ContentMode, placeholder: UIView.ContentMode) { + self.success = success; self.failure = failure; self.placeholder = placeholder + } + } + + func contentMode(for response: ResponseType) -> UIView.ContentMode? { + switch response { + case .success: return contentModes?.success + case .placeholder: return contentModes?.placeholder + case .failure: return contentModes?.failure + } + } + + /// Tint colors to be used for each image type (placeholder, success, + /// failure). `nil` by default (don't change tint color or rendering mode). + var tintColors: TintColors? + + /// Custom tint color to be used for each image type (placeholder, success, + /// failure). + struct TintColors { + /// Tint color to be used for the loaded image. + var success: UIColor? + /// Tint color to be used when displaying a `failureImage`. + var failure: UIColor? + /// Tint color to be used when displaying a `placeholder`. + var placeholder: UIColor? + + /// - parameters: + /// - success: A tint color to be used with a loaded image. + /// - failure: A tint color to be used with a `failureImage`. + /// - placeholder: A tint color to be used with a `placeholder`. + init(success: UIColor?, failure: UIColor?, placeholder: UIColor?) { + self.success = success; self.failure = failure; self.placeholder = placeholder + } + } + + func tintColor(for response: ResponseType) -> UIColor? { + switch response { + case .success: return tintColors?.success + case .placeholder: return tintColors?.placeholder + case .failure: return tintColors?.failure + } + } + +#endif + +#if os(iOS) || os(tvOS) || os(visionOS) + + /// - parameters: + /// - placeholder: Placeholder to be displayed when the image is loading. + /// - transition: The image transition animation performed when + /// displaying a loaded image. Only runs when the image was not found in + /// memory cache. + /// - failureImage: Image to be displayed when request fails. + /// - failureImageTransition: The image transition animation + /// performed when displaying a failure image. + /// - contentModes: Content modes to be used for each image type + /// (placeholder, success, failure). + init(placeholder: UIImage? = nil, transition: Transition? = nil, failureImage: UIImage? = nil, failureImageTransition: Transition? = nil, contentModes: ContentModes? = nil, tintColors: TintColors? = nil) { + self.placeholder = placeholder + self.transition = transition + self.failureImage = failureImage + self.failureImageTransition = failureImageTransition + self.contentModes = contentModes + self.tintColors = tintColors + } + +#elseif os(macOS) + + init(placeholder: NSImage? = nil, transition: Transition? = nil, failureImage: NSImage? = nil, failureImageTransition: Transition? = nil) { + self.placeholder = placeholder + self.transition = transition + self.failureImage = failureImage + self.failureImageTransition = failureImageTransition + } + +#elseif os(watchOS) + + init(placeholder: UIImage? = nil, failureImage: UIImage? = nil) { + self.placeholder = placeholder + self.failureImage = failureImage + } + +#endif + + /// An animated image transition. + struct Transition { + var style: Style + +#if os(iOS) || os(tvOS) || os(visionOS) + enum Style { // internal representation + case fadeIn(parameters: Parameters) + case custom((ImageDisplayingView, UIImage) -> Void) + } + + struct Parameters { // internal representation + let duration: TimeInterval + let options: UIView.AnimationOptions + } + + /// Fade-in transition (cross-fade in case the image view is already + /// displaying an image). + static func fadeIn(duration: TimeInterval, options: UIView.AnimationOptions = .allowUserInteraction) -> Transition { + Transition(style: .fadeIn(parameters: Parameters(duration: duration, options: options))) + } + + /// Custom transition. Only runs when the image was not found in memory cache. + static func custom(_ closure: @escaping (ImageDisplayingView, UIImage) -> Void) -> Transition { + Transition(style: .custom(closure)) + } +#elseif os(macOS) + enum Style { // internal representation + case fadeIn(parameters: Parameters) + case custom((ImageDisplayingView, NSImage) -> Void) + } + + struct Parameters { // internal representation + let duration: TimeInterval + } + + /// Fade-in transition. + static func fadeIn(duration: TimeInterval) -> Transition { + Transition(style: .fadeIn(parameters: Parameters(duration: duration))) + } + + /// Custom transition. Only runs when the image was not found in memory cache. + static func custom(_ closure: @escaping (ImageDisplayingView, NSImage) -> Void) -> Transition { + Transition(style: .custom(closure)) + } +#else + enum Style {} +#endif + } + + init() {} + + enum ResponseType { + case success, failure, placeholder + } +} diff --git a/Sources/StreamChatUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift b/Sources/StreamChatUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift new file mode 100644 index 00000000000..f45d3f6b01a --- /dev/null +++ b/Sources/StreamChatUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift @@ -0,0 +1,470 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if !os(macOS) +import UIKit.UIImage +import UIKit.UIColor +#else +import AppKit.NSImage +#endif + +#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) + +/// Displays images. Add the conformance to this protocol to your views to make +/// them compatible with Nuke image loading extensions. +/// +/// The protocol is defined as `@objc` to make it possible to override its +/// methods in extensions (e.g. you can override `nuke_display(image:data:)` in +/// `UIImageView` subclass like `Gifu.ImageView). +/// +/// The protocol and its methods have prefixes to make sure they don't clash +/// with other similar methods and protocol in Objective-C runtime. +@MainActor +@objc protocol Nuke_ImageDisplaying { + /// Display a given image. + @objc func nuke_display(image: PlatformImage?, data: Data?) + +#if os(macOS) + @objc var layer: CALayer? { get } +#endif +} + +extension Nuke_ImageDisplaying { + func display(_ container: ImageContainer) { + nuke_display(image: container.image, data: container.data) + } +} + +#if os(macOS) +extension Nuke_ImageDisplaying { + var layer: CALayer? { nil } +} +#endif + +#if os(iOS) || os(tvOS) || os(visionOS) +import UIKit +/// A `UIView` that implements `ImageDisplaying` protocol. +typealias ImageDisplayingView = UIView & Nuke_ImageDisplaying + +extension UIImageView: Nuke_ImageDisplaying { + /// Displays an image. + func nuke_display(image: UIImage?, data: Data? = nil) { + self.image = image + } +} +#elseif os(macOS) +import Cocoa +/// An `NSObject` that implements `ImageDisplaying` and `Animating` protocols. +/// Can support `NSView` and `NSCell`. The latter can return nil for layer. +typealias ImageDisplayingView = NSObject & Nuke_ImageDisplaying + +extension NSImageView: Nuke_ImageDisplaying { + /// Displays an image. + func nuke_display(image: NSImage?, data: Data? = nil) { + self.image = image + } +} +#endif + +#if os(tvOS) +import TVUIKit + +extension TVPosterView: Nuke_ImageDisplaying { + /// Displays an image. + func nuke_display(image: UIImage?, data: Data? = nil) { + self.image = image + } +} +#endif + +// MARK: - ImageView Extensions + +/// Loads an image with the given request and displays it in the view. +/// +/// See the complete method signature for more information. +@MainActor +@discardableResult func loadImage( + with url: URL?, + options: ImageLoadingOptions? = nil, + into view: ImageDisplayingView, + completion: @escaping (_ result: Result) -> Void +) -> ImageTask? { + loadImage(with: url, options: options, into: view, progress: nil, completion: completion) +} + +/// Loads an image with the given request and displays it in the view. +/// +/// Before loading a new image, the view is prepared for reuse by canceling any +/// outstanding requests and removing a previously displayed image. +/// +/// If the image is stored in the memory cache, it is displayed immediately with +/// no animations. If not, the image is loaded using an image pipeline. When the +/// image is loading, the `placeholder` is displayed. When the request +/// completes the loaded image is displayed (or `failureImage` in case of an error) +/// with the selected animation. +/// +/// - parameters: +/// - request: The image request. If `nil`, it's handled as a failure scenario. +/// - options: `ImageLoadingOptions.shared` by default. +/// - view: Nuke keeps a weak reference to the view. If the view is deallocated +/// the associated request automatically gets canceled. +/// - progress: A closure to be called periodically on the main thread +/// when the progress is updated. +/// - completion: A closure to be called on the main thread when the +/// request is finished. Gets called synchronously if the response was found in +/// the memory cache. +/// +/// - returns: An image task or `nil` if the image was found in the memory cache. +@MainActor +@discardableResult func loadImage( + with url: URL?, + options: ImageLoadingOptions? = nil, + into view: ImageDisplayingView, + progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? = nil, + completion: ((_ result: Result) -> Void)? = nil +) -> ImageTask? { + let controller = ImageViewController.controller(for: view) + return controller.loadImage(with: url.map({ ImageRequest(url: $0) }), options: options ?? .shared, progress: progress, completion: completion) +} + +/// Loads an image with the given request and displays it in the view. +/// +/// See the complete method signature for more information. +@MainActor +@discardableResult func loadImage( + with request: ImageRequest?, + options: ImageLoadingOptions? = nil, + into view: ImageDisplayingView, + completion: @escaping (_ result: Result) -> Void +) -> ImageTask? { + loadImage(with: request, options: options ?? .shared, into: view, progress: nil, completion: completion) +} + +/// Loads an image with the given request and displays it in the view. +/// +/// Before loading a new image, the view is prepared for reuse by canceling any +/// outstanding requests and removing a previously displayed image. +/// +/// If the image is stored in the memory cache, it is displayed immediately with +/// no animations. If not, the image is loaded using an image pipeline. When the +/// image is loading, the `placeholder` is displayed. When the request +/// completes the loaded image is displayed (or `failureImage` in case of an error) +/// with the selected animation. +/// +/// - parameters: +/// - request: The image request. If `nil`, it's handled as a failure scenario. +/// - options: `ImageLoadingOptions.shared` by default. +/// - view: Nuke keeps a weak reference to the view. If the view is deallocated +/// the associated request automatically gets canceled. +/// - progress: A closure to be called periodically on the main thread +/// when the progress is updated. +/// - completion: A closure to be called on the main thread when the +/// request is finished. Gets called synchronously if the response was found in +/// the memory cache. +/// +/// - returns: An image task or `nil` if the image was found in the memory cache. +@MainActor +@discardableResult func loadImage( + with request: ImageRequest?, + options: ImageLoadingOptions? = nil, + into view: ImageDisplayingView, + progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? = nil, + completion: ((_ result: Result) -> Void)? = nil +) -> ImageTask? { + let controller = ImageViewController.controller(for: view) + return controller.loadImage(with: request, options: options ?? .shared, progress: progress, completion: completion) +} + +/// Cancels an outstanding request associated with the view. +@MainActor +func cancelRequest(for view: ImageDisplayingView) { + ImageViewController.controller(for: view).cancelOutstandingTask() +} + +// MARK: - ImageViewController + +/// Manages image requests on behalf of an image view. +/// +/// - note: With a few modifications this might become at some point, +/// however as it stands today `ImageViewController` is just a helper class, +/// making it wouldn't expose any additional functionality to the users. +@MainActor +private final class ImageViewController { + private weak var imageView: ImageDisplayingView? + private var task: ImageTask? + private var options: ImageLoadingOptions + +#if os(iOS) || os(tvOS) || os(visionOS) + // Image view used for cross-fade transition between images with different + // content modes. + private lazy var transitionImageView = UIImageView() +#endif + + // Automatically cancel the request when the view is deallocated. + deinit { + task?.cancel() + } + + init(view: /* weak */ ImageDisplayingView) { + self.imageView = view + self.options = .shared + } + + // MARK: - Associating Controller + +#if swift(>=5.10) + // Safe because it's never mutated. + nonisolated(unsafe) static let controllerAK = malloc(1)! +#else + static let controllerAK = malloc(1)! +#endif + + // Lazily create a controller for a given view and associate it with a view. + static func controller(for view: ImageDisplayingView) -> ImageViewController { + if let controller = objc_getAssociatedObject(view, controllerAK) as? ImageViewController { + return controller + } + let controller = ImageViewController(view: view) + objc_setAssociatedObject(view, controllerAK, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return controller + } + + // MARK: - Loading Images + + func loadImage( + with request: ImageRequest?, + options: ImageLoadingOptions, + progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? = nil, + completion: ((_ result: Result) -> Void)? = nil + ) -> ImageTask? { + cancelOutstandingTask() + + guard let imageView else { + return nil + } + + self.options = options + + if options.isPrepareForReuseEnabled { // enabled by default +#if os(iOS) || os(tvOS) || os(visionOS) + imageView.layer.removeAllAnimations() +#elseif os(macOS) + let layer = (imageView as? NSView)?.layer ?? imageView.layer + layer?.removeAllAnimations() +#endif + } + + // Handle a scenario where request is `nil` (in the same way as a failure) + guard var request else { + if options.isPrepareForReuseEnabled { + imageView.nuke_display(image: nil, data: nil) + } + let result: Result = .failure(.imageRequestMissing) + handle(result: result, isFromMemory: true) + completion?(result) + return nil + } + + let pipeline = options.pipeline ?? ImagePipeline.shared + if !options.processors.isEmpty && request.processors.isEmpty { + request.processors = options.processors + } + + // Quick synchronous memory cache lookup. + if let image = pipeline.cache[request] { + display(image, true, .success) + if !image.isPreview { // Final image was downloaded + completion?(.success(ImageResponse(container: image, request: request, cacheType: .memory))) + return nil // No task to perform + } + } + + // Display a placeholder. + if let placeholder = options.placeholder { + display(ImageContainer(image: placeholder), true, .placeholder) + } else if options.isPrepareForReuseEnabled { + imageView.nuke_display(image: nil, data: nil) // Remove previously displayed images (if any) + } + + task = pipeline.loadImage(with: request, queue: .main, progress: { [weak self] response, completedCount, totalCount in + if let response, options.isProgressiveRenderingEnabled { + self?.handle(partialImage: response) + } + progress?(response, completedCount, totalCount) + }, completion: { [weak self] result in + self?.handle(result: result, isFromMemory: false) + completion?(result) + }) + return task + } + + func cancelOutstandingTask() { + task?.cancel() // The pipeline guarantees no callbacks to be deliver after cancellation + task = nil + } + + // MARK: - Handling Responses + + private func handle(result: Result, isFromMemory: Bool) { + switch result { + case let .success(response): + display(response.container, isFromMemory, .success) + case .failure: + if let failureImage = options.failureImage { + display(ImageContainer(image: failureImage), isFromMemory, .failure) + } + } + self.task = nil + } + + private func handle(partialImage response: ImageResponse) { + display(response.container, false, .success) + } + +#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) + + private func display(_ image: ImageContainer, _ isFromMemory: Bool, _ response: ImageLoadingOptions.ResponseType) { + guard let imageView else { + return + } + + var image = image + +#if os(iOS) || os(tvOS) || os(visionOS) + if let tintColor = options.tintColor(for: response) { + image.image = image.image.withRenderingMode(.alwaysTemplate) + imageView.tintColor = tintColor + } +#endif + + if !isFromMemory || options.alwaysTransition, let transition = options.transition(for: response) { + switch transition.style { + case let .fadeIn(params): + runFadeInTransition(image: image, params: params, response: response) + case let .custom(closure): + // The user is responsible for both displaying an image and performing + // animations. + closure(imageView, image.image) + } + } else { + imageView.display(image) + } + +#if os(iOS) || os(tvOS) || os(visionOS) + if let contentMode = options.contentMode(for: response) { + imageView.contentMode = contentMode + } +#endif + } + +#elseif os(watchOS) + + private func display(_ image: ImageContainer, _ isFromMemory: Bool, _ response: ImageLoadingOptions.ResponseType) { + imageView?.display(image) + } + +#endif +} + +// MARK: - ImageViewController (Transitions) + +extension ImageViewController { +#if os(iOS) || os(tvOS) || os(visionOS) + + private func runFadeInTransition(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters, response: ImageLoadingOptions.ResponseType) { + guard let imageView else { + return + } + + // Special case where it animates between content modes, only works + // on imageView subclasses. + if let contentMode = options.contentMode(for: response), imageView.contentMode != contentMode, let imageView = imageView as? UIImageView, imageView.image != nil { + runCrossDissolveWithContentMode(imageView: imageView, image: image, params: params) + } else { + runSimpleFadeIn(image: image, params: params) + } + } + + private func runSimpleFadeIn(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters) { + guard let imageView else { + return + } + + UIView.transition( + with: imageView, + duration: params.duration, + options: params.options.union(.transitionCrossDissolve), + animations: { + imageView.nuke_display(image: image.image, data: image.data) + }, + completion: nil + ) + } + + /// Performs cross-dissolve animation alongside transition to a new content + /// mode. This isn't natively supported feature and it requires a second + /// image view. There might be better ways to implement it. + private func runCrossDissolveWithContentMode(imageView: UIImageView, image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters) { + // Lazily create a transition view. + let transitionView = self.transitionImageView + + // Create a transition view which mimics current view's contents. + transitionView.image = imageView.image + transitionView.contentMode = imageView.contentMode + transitionView.frame = imageView.frame + transitionView.tintColor = imageView.tintColor + transitionView.tintAdjustmentMode = imageView.tintAdjustmentMode +#if swift(>=5.9) + if #available(iOS 17.0, tvOS 17.0, *) { + transitionView.preferredImageDynamicRange = imageView.preferredImageDynamicRange + } +#endif + transitionView.preferredSymbolConfiguration = imageView.preferredSymbolConfiguration + transitionView.isHidden = imageView.isHidden + transitionView.clipsToBounds = imageView.clipsToBounds + transitionView.layer.cornerRadius = imageView.layer.cornerRadius + transitionView.layer.cornerCurve = imageView.layer.cornerCurve + transitionView.layer.maskedCorners = imageView.layer.maskedCorners + imageView.superview?.insertSubview(transitionView, aboveSubview: imageView) + + // "Manual" cross-fade. + transitionView.alpha = 1 + imageView.alpha = 0 + imageView.display(image) // Display new image in current view + + UIView.animate( + withDuration: params.duration, + delay: 0, + options: params.options, + animations: { + transitionView.alpha = 0 + imageView.alpha = 1 + }, + completion: { [weak transitionView] isCompleted in + if isCompleted, let transitionView { + transitionView.removeFromSuperview() + transitionView.image = nil + } + } + ) + } + +#elseif os(macOS) + + private func runFadeInTransition(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters, response: ImageLoadingOptions.ResponseType) { + let animation = CABasicAnimation(keyPath: "opacity") + animation.duration = params.duration + animation.fromValue = 0 + animation.toValue = 1 + imageView?.layer?.add(animation, forKey: "imageTransition") + + imageView?.display(image) + } + +#endif +} + +#endif diff --git a/Sources/StreamChatUI/StreamNuke/UI/FetchImage.swift b/Sources/StreamChatUI/StreamNuke/UI/FetchImage.swift deleted file mode 100644 index be83c5caefe..00000000000 --- a/Sources/StreamChatUI/StreamNuke/UI/FetchImage.swift +++ /dev/null @@ -1,237 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import SwiftUI -import Combine - -/// An observable object that simplifies image loading in SwiftUI. -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -final class FetchImage: ObservableObject, Identifiable { - /// Returns the current fetch result. - @Published private(set) var result: Result? - - /// Returns the fetched image. - /// - /// - note: In case pipeline has `isProgressiveDecodingEnabled` option enabled - /// and the image being downloaded supports progressive decoding, the `image` - /// might be updated multiple times during the download. - var image: PlatformImage? { imageContainer?.image } - - /// Returns the fetched image. - /// - /// - note: In case pipeline has `isProgressiveDecodingEnabled` option enabled - /// and the image being downloaded supports progressive decoding, the `image` - /// might be updated multiple times during the download. - @Published private(set) var imageContainer: ImageContainer? - - /// Returns `true` if the image is being loaded. - @Published private(set) var isLoading: Bool = false - - /// Animations to be used when displaying the loaded images. By default, `nil`. - /// - /// - note: Animation isn't used when image is available in memory cache. - var animation: Animation? - - /// The download progress. - struct Progress: Equatable { - /// The number of bytes that the task has received. - let completed: Int64 - - /// A best-guess upper bound on the number of bytes the client expects to send. - let total: Int64 - } - - /// The progress of the image download. - @Published private(set) var progress = Progress(completed: 0, total: 0) - - /// Updates the priority of the task, even if the task is already running. - /// `nil` by default - var priority: ImageRequest.Priority? { - didSet { priority.map { imageTask?.priority = $0 } } - } - - /// Gets called when the request is started. - var onStart: ((_ task: ImageTask) -> Void)? - - /// Gets called when the request progress is updated. - var onProgress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? - - /// Gets called when the requests finished successfully. - var onSuccess: ((_ response: ImageResponse) -> Void)? - - /// Gets called when the requests fails. - var onFailure: ((_ response: Error) -> Void)? - - /// Gets called when the request is completed. - var onCompletion: ((_ result: Result) -> Void)? - - var pipeline: ImagePipeline = .shared - - /// Image processors to be applied unless the processors are provided in the request. - /// `nil` by default. - var processors: [ImageProcessing]? - - private var imageTask: ImageTask? - - // publisher support - private var lastResponse: ImageResponse? - private var cancellable: AnyCancellable? - - deinit { - cancel() - } - - init() {} - - // MARK: Load (ImageRequestConvertible) - - /// Loads an image with the given request. - func load(_ request: ImageRequestConvertible?) { - assert(Thread.isMainThread, "Must be called from the main thread") - - reset() - - guard var request = request?.asImageRequest() else { - handle(result: .failure(FetchImageError.sourceEmpty), isSync: true) - return - } - - if let processors = self.processors, !processors.isEmpty && request.processors.isEmpty { - request.processors = processors - } - if let priority = self.priority { - request.priority = priority - } - - // Quick synchronous memory cache lookup - if let image = pipeline.cache[request] { - if image.isPreview { - imageContainer = image // Display progressive image - } else { - let response = ImageResponse(container: image, cacheType: .memory) - handle(result: .success(response), isSync: true) - return - } - } - - isLoading = true - progress = Progress(completed: 0, total: 0) - - let task = pipeline.loadImage( - with: request, - progress: { [weak self] response, completed, total in - guard let self = self else { return } - self.progress = Progress(completed: completed, total: total) - if let response = response { - withAnimation(self.animation) { - self.handle(preview: response) - } - } - self.onProgress?(response, completed, total) - }, - completion: { [weak self] result in - guard let self = self else { return } - withAnimation(self.animation) { - self.handle(result: result.mapError { $0 }, isSync: false) - } - } - ) - imageTask = task - onStart?(task) - } - - private func handle(preview: ImageResponse) { - // Display progressively decoded image - self.imageContainer = preview.container - } - - private func handle(result: Result, isSync: Bool) { - isLoading = false - - if case .success(let response) = result { - self.imageContainer = response.container - } - self.result = result - - imageTask = nil - switch result { - case .success(let response): onSuccess?(response) - case .failure(let error): onFailure?(error) - } - onCompletion?(result) - } - - // MARK: Load (Publisher) - - /// Loads an image with the given publisher. - /// - /// - warning: Some `FetchImage` features, such as progress reporting and - /// dynamically changing the request priority, are not available when - /// working with a publisher. - func load(_ publisher: P) where P.Output == ImageResponse { - reset() - - // Not using `first()` because it should support progressive decoding - isLoading = true - cancellable = publisher.sink(receiveCompletion: { [weak self] completion in - guard let self = self else { return } - self.isLoading = false - switch completion { - case .finished: - if let response = self.lastResponse { - self.result = .success(response) - } // else was cancelled, do nothing - case .failure(let error): - self.result = .failure(error) - } - }, receiveValue: { [weak self] response in - guard let self = self else { return } - self.lastResponse = response - self.imageContainer = response.container - }) - } - - // MARK: Cancel - - /// Marks the request as being cancelled. Continues to display a downloaded - /// image. - func cancel() { - // pipeline-based - imageTask?.cancel() // Guarantees that no more callbacks are will be delivered - imageTask = nil - - // publisher-based - cancellable = nil - - // common - if isLoading { isLoading = false } - } - - /// Resets the `FetchImage` instance by cancelling the request and removing - /// all of the state including the loaded image. - func reset() { - cancel() - - // Avoid publishing unchanged values - if isLoading { isLoading = false } - if imageContainer != nil { imageContainer = nil } - if result != nil { result = nil } - lastResponse = nil // publisher-only - if progress != Progress(completed: 0, total: 0) { progress = Progress(completed: 0, total: 0) } - } - - // MARK: View - - var view: SwiftUI.Image? { - #if os(macOS) - return image.map(Image.init(nsImage:)) - #else - return image.map(Image.init(uiImage:)) - #endif - } -} - -enum FetchImageError: Swift.Error { - case sourceEmpty -} diff --git a/Sources/StreamChatUI/StreamNuke/UI/ImageViewExtensions.swift b/Sources/StreamChatUI/StreamNuke/UI/ImageViewExtensions.swift deleted file mode 100644 index e8a68bc8ba6..00000000000 --- a/Sources/StreamChatUI/StreamNuke/UI/ImageViewExtensions.swift +++ /dev/null @@ -1,618 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -#if !os(macOS) -import UIKit.UIImage -import UIKit.UIColor -/// Alias for `UIImage`. -typealias PlatformImage = UIImage -#else -import AppKit.NSImage -/// Alias for `NSImage`. -typealias PlatformImage = NSImage -#endif - -/// Displays images. Add the conformance to this protocol to your views to make -/// them compatible with Nuke image loading extensions. -/// -/// The protocol is defined as `@objc` to make it possible to override its -/// methods in extensions (e.g. you can override `nuke_display(image:data:)` in -/// `UIImageView` subclass like `Gifu.ImageView). -/// -/// The protocol and its methods have prefixes to make sure they don't clash -/// with other similar methods and protocol in Objective-C runtime. -@objc protocol Nuke_ImageDisplaying { - /// Display a given image. - @objc func nuke_display(image: PlatformImage?, data: Data?) - - #if os(macOS) - @objc var layer: CALayer? { get } - #endif -} - -extension Nuke_ImageDisplaying { - func display(_ container: ImageContainer) { - nuke_display(image: container.image, data: container.data) - } -} - -#if os(macOS) -extension Nuke_ImageDisplaying { - var layer: CALayer? { nil } -} -#endif - -#if os(iOS) || os(tvOS) -import UIKit -/// A `UIView` that implements `ImageDisplaying` protocol. -typealias ImageDisplayingView = UIView & Nuke_ImageDisplaying - -extension UIImageView: Nuke_ImageDisplaying { - /// Displays an image. - func nuke_display(image: UIImage?, data: Data? = nil) { - self.image = image - } -} -#elseif os(macOS) -import Cocoa -/// An `NSObject` that implements `ImageDisplaying` and `Animating` protocols. -/// Can support `NSView` and `NSCell`. The latter can return nil for layer. -typealias ImageDisplayingView = NSObject & Nuke_ImageDisplaying - -extension NSImageView: Nuke_ImageDisplaying { - /// Displays an image. - func nuke_display(image: NSImage?, data: Data? = nil) { - self.image = image - } -} -#elseif os(watchOS) -import WatchKit - -/// A `WKInterfaceObject` that implements `ImageDisplaying` protocol. -typealias ImageDisplayingView = WKInterfaceObject & Nuke_ImageDisplaying - -extension WKInterfaceImage: Nuke_ImageDisplaying { - /// Displays an image. - func nuke_display(image: UIImage?, data: Data? = nil) { - self.setImage(image) - } -} -#endif - -// MARK: - ImageView Extensions - -/// Loads an image with the given request and displays it in the view. -/// -/// See the complete method signature for more information. -@discardableResult -func loadImage( - with request: ImageRequestConvertible?, - options: ImageLoadingOptions = ImageLoadingOptions.shared, - into view: ImageDisplayingView, - completion: @escaping (_ result: Result) -> Void -) -> ImageTask? { - loadImage(with: request, options: options, into: view, progress: nil, completion: completion) -} - -/// Loads an image with the given request and displays it in the view. -/// -/// Before loading a new image, the view is prepared for reuse by canceling any -/// outstanding requests and removing a previously displayed image. -/// -/// If the image is stored in the memory cache, it is displayed immediately with -/// no animations. If not, the image is loaded using an image pipeline. When the -/// image is loading, the `placeholder` is displayed. When the request -/// completes the loaded image is displayed (or `failureImage` in case of an error) -/// with the selected animation. -/// -/// - parameter request: The image request. If `nil`, it's handled as a failure -/// scenario. -/// - parameter options: `ImageLoadingOptions.shared` by default. -/// - parameter view: Nuke keeps a weak reference to the view. If the view is deallocated -/// the associated request automatically gets canceled. -/// - parameter progress: A closure to be called periodically on the main thread -/// when the progress is updated. `nil` by default. -/// - parameter completion: A closure to be called on the main thread when the -/// request is finished. Gets called synchronously if the response was found in -/// the memory cache. `nil` by default. -/// - returns: An image task or `nil` if the image was found in the memory cache. -@discardableResult -func loadImage( - with request: ImageRequestConvertible?, - options: ImageLoadingOptions = ImageLoadingOptions.shared, - into view: ImageDisplayingView, - progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? = nil, - completion: ((_ result: Result) -> Void)? = nil -) -> ImageTask? { - assert(Thread.isMainThread) - let controller = ImageViewController.controller(for: view) - return controller.loadImage(with: request?.asImageRequest(), options: options, progress: progress, completion: completion) -} - -/// Cancels an outstanding request associated with the view. -func cancelRequest(for view: ImageDisplayingView) { - assert(Thread.isMainThread) - ImageViewController.controller(for: view).cancelOutstandingTask() -} - -// MARK: - ImageLoadingOptions - -/// A set of options that control how the image is loaded and displayed. -struct ImageLoadingOptions { - /// Shared options. - static var shared = ImageLoadingOptions() - - /// Placeholder to be displayed when the image is loading. `nil` by default. - var placeholder: PlatformImage? - - /// Image to be displayed when the request fails. `nil` by default. - var failureImage: PlatformImage? - - #if os(iOS) || os(tvOS) || os(macOS) - - /// The image transition animation performed when displaying a loaded image. - /// Only runs when the image was not found in memory cache. `nil` by default. - var transition: Transition? - - /// The image transition animation performed when displaying a failure image. - /// `nil` by default. - var failureImageTransition: Transition? - - /// If true, the requested image will always appear with transition, even - /// when loaded from cache. - var alwaysTransition = false - - func transition(for response: ResponseType) -> Transition? { - switch response { - case .success: return transition - case .failure: return failureImageTransition - case .placeholder: return nil - } - } - - #endif - - /// If true, every time you request a new image for a view, the view will be - /// automatically prepared for reuse: image will be set to `nil`, and animations - /// will be removed. `true` by default. - var isPrepareForReuseEnabled = true - - /// If `true`, every progressively generated preview produced by the pipeline - /// is going to be displayed. `true` by default. - /// - /// - note: To enable progressive decoding, see `ImagePipeline.Configuration`, - /// `isProgressiveDecodingEnabled` option. - var isProgressiveRenderingEnabled = true - - /// Custom pipeline to be used. `nil` by default. - var pipeline: ImagePipeline? - - /// The default processors to be applied to the images. `nil` by default. - var processors: [ImageProcessing]? - - #if os(iOS) || os(tvOS) - - /// Content modes to be used for each image type (placeholder, success, - /// failure). `nil` by default (don't change content mode). - var contentModes: ContentModes? - - /// Custom content modes to be used for each image type (placeholder, success, - /// failure). - struct ContentModes { - /// Content mode to be used for the loaded image. - var success: UIView.ContentMode - /// Content mode to be used when displaying a `failureImage`. - var failure: UIView.ContentMode - /// Content mode to be used when displaying a `placeholder`. - var placeholder: UIView.ContentMode - - /// - parameter success: A content mode to be used with a loaded image. - /// - parameter failure: A content mode to be used with a `failureImage`. - /// - parameter placeholder: A content mode to be used with a `placeholder`. - init(success: UIView.ContentMode, failure: UIView.ContentMode, placeholder: UIView.ContentMode) { - self.success = success; self.failure = failure; self.placeholder = placeholder - } - } - - func contentMode(for response: ResponseType) -> UIView.ContentMode? { - switch response { - case .success: return contentModes?.success - case .placeholder: return contentModes?.placeholder - case .failure: return contentModes?.failure - } - } - - /// Tint colors to be used for each image type (placeholder, success, - /// failure). `nil` by default (don't change tint color or rendering mode). - var tintColors: TintColors? - - /// Custom tint color to be used for each image type (placeholder, success, - /// failure). - struct TintColors { - /// Tint color to be used for the loaded image. - var success: UIColor? - /// Tint color to be used when displaying a `failureImage`. - var failure: UIColor? - /// Tint color to be used when displaying a `placeholder`. - var placeholder: UIColor? - - /// - parameter success: A tint color to be used with a loaded image. - /// - parameter failure: A tint color to be used with a `failureImage`. - /// - parameter placeholder: A tint color to be used with a `placeholder`. - init(success: UIColor?, failure: UIColor?, placeholder: UIColor?) { - self.success = success; self.failure = failure; self.placeholder = placeholder - } - } - - func tintColor(for response: ResponseType) -> UIColor? { - switch response { - case .success: return tintColors?.success - case .placeholder: return tintColors?.placeholder - case .failure: return tintColors?.failure - } - } - - #endif - - #if os(iOS) || os(tvOS) - - /// - parameter placeholder: Placeholder to be displayed when the image is - /// loading . `nil` by default. - /// - parameter transition: The image transition animation performed when - /// displaying a loaded image. Only runs when the image was not found in - /// memory cache. `nil` by default (no animations). - /// - parameter failureImage: Image to be displayd when request fails. - /// `nil` by default. - /// - parameter failureImageTransition: The image transition animation - /// performed when displaying a failure image. `nil` by default. - /// - parameter contentModes: Content modes to be used for each image type - /// (placeholder, success, failure). `nil` by default (don't change content mode). - init(placeholder: UIImage? = nil, transition: Transition? = nil, failureImage: UIImage? = nil, failureImageTransition: Transition? = nil, contentModes: ContentModes? = nil, tintColors: TintColors? = nil) { - self.placeholder = placeholder - self.transition = transition - self.failureImage = failureImage - self.failureImageTransition = failureImageTransition - self.contentModes = contentModes - self.tintColors = tintColors - } - - #elseif os(macOS) - - init(placeholder: NSImage? = nil, transition: Transition? = nil, failureImage: NSImage? = nil, failureImageTransition: Transition? = nil) { - self.placeholder = placeholder - self.transition = transition - self.failureImage = failureImage - self.failureImageTransition = failureImageTransition - } - - #elseif os(watchOS) - - init(placeholder: UIImage? = nil, failureImage: UIImage? = nil) { - self.placeholder = placeholder - self.failureImage = failureImage - } - - #endif - - /// An animated image transition. - struct Transition { - var style: Style - - #if os(iOS) || os(tvOS) - enum Style { // internal representation - case fadeIn(parameters: Parameters) - case custom((ImageDisplayingView, UIImage) -> Void) - } - - struct Parameters { // internal representation - let duration: TimeInterval - let options: UIView.AnimationOptions - } - - /// Fade-in transition (cross-fade in case the image view is already - /// displaying an image). - static func fadeIn(duration: TimeInterval, options: UIView.AnimationOptions = .allowUserInteraction) -> Transition { - Transition(style: .fadeIn(parameters: Parameters(duration: duration, options: options))) - } - - /// Custom transition. Only runs when the image was not found in memory cache. - static func custom(_ closure: @escaping (ImageDisplayingView, UIImage) -> Void) -> Transition { - Transition(style: .custom(closure)) - } - #elseif os(macOS) - enum Style { // internal representation - case fadeIn(parameters: Parameters) - case custom((ImageDisplayingView, NSImage) -> Void) - } - - struct Parameters { // internal representation - let duration: TimeInterval - } - - /// Fade-in transition. - static func fadeIn(duration: TimeInterval) -> Transition { - Transition(style: .fadeIn(parameters: Parameters(duration: duration))) - } - - /// Custom transition. Only runs when the image was not found in memory cache. - static func custom(_ closure: @escaping (ImageDisplayingView, NSImage) -> Void) -> Transition { - Transition(style: .custom(closure)) - } - #else - enum Style {} - #endif - } - - init() {} - - enum ResponseType { - case success, failure, placeholder - } -} - -// MARK: - ImageViewController - -/// Manages image requests on behalf of an image view. -/// -/// - note: With a few modifications this might become at some point, -/// however as it stands today `ImageViewController` is just a helper class, -/// making it wouldn't expose any additional functionality to the users. -private final class ImageViewController { - private weak var imageView: ImageDisplayingView? - private var task: ImageTask? - private var options: ImageLoadingOptions - - #if os(iOS) || os(tvOS) - // Image view used for cross-fade transition between images with different - // content modes. - private lazy var transitionImageView = UIImageView() - #endif - - // Automatically cancel the request when the view is deallocated. - deinit { - cancelOutstandingTask() - } - - init(view: /* weak */ ImageDisplayingView) { - self.imageView = view - self.options = .shared - } - - // MARK: - Associating Controller - - static var controllerAK = "ImageViewController.AssociatedKey" - - // Lazily create a controller for a given view and associate it with a view. - static func controller(for view: ImageDisplayingView) -> ImageViewController { - if let controller = objc_getAssociatedObject(view, &ImageViewController.controllerAK) as? ImageViewController { - return controller - } - let controller = ImageViewController(view: view) - objc_setAssociatedObject(view, &ImageViewController.controllerAK, controller, .OBJC_ASSOCIATION_RETAIN) - return controller - } - - // MARK: - Loading Images - - func loadImage( - with request: ImageRequest?, - options: ImageLoadingOptions, - progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? = nil, - completion: ((_ result: Result) -> Void)? = nil - ) -> ImageTask? { - cancelOutstandingTask() - - guard let imageView = imageView else { - return nil - } - - self.options = options - - if options.isPrepareForReuseEnabled { // enabled by default - #if os(iOS) || os(tvOS) - imageView.layer.removeAllAnimations() - #elseif os(macOS) - let layer = (imageView as? NSView)?.layer ?? imageView.layer - layer?.removeAllAnimations() - #endif - } - - // Handle a scenario where request is `nil` (in the same way as a failure) - guard let unwrappedRequest = request else { - if options.isPrepareForReuseEnabled { - imageView.nuke_display(image: nil, data: nil) - } - let result: Result = .failure(.dataLoadingFailed(URLError(.unknown))) - handle(result: result, isFromMemory: true) - completion?(result) - return nil - } - - let pipeline = options.pipeline ?? ImagePipeline.shared - var request = pipeline.configuration.inheritOptions(unwrappedRequest) - if let processors = options.processors, request.processors.isEmpty { - request.processors = processors - } - - // Quick synchronous memory cache lookup. - if let image = pipeline.cache[request] { - display(image, true, .success) - if !image.isPreview { // Final image was downloaded - completion?(.success(ImageResponse(container: image, cacheType: .memory))) - return nil // No task to perform - } - } - - // Display a placeholder. - if let placeholder = options.placeholder { - display(ImageContainer(image: placeholder), true, .placeholder) - } else if options.isPrepareForReuseEnabled { - imageView.nuke_display(image: nil, data: nil) // Remove previously displayed images (if any) - } - - task = pipeline.loadImage(with: request, queue: .main, progress: { [weak self] response, completedCount, totalCount in - if let response = response, options.isProgressiveRenderingEnabled { - self?.handle(partialImage: response) - } - progress?(response, completedCount, totalCount) - }, completion: { [weak self] result in - self?.handle(result: result, isFromMemory: false) - completion?(result) - }) - return task - } - - func cancelOutstandingTask() { - task?.cancel() // The pipeline guarantees no callbacks to be deliver after cancellation - task = nil - } - - // MARK: - Handling Responses - - private func handle(result: Result, isFromMemory: Bool) { - switch result { - case let .success(response): - display(response.container, isFromMemory, .success) - case .failure: - if let failureImage = options.failureImage { - display(ImageContainer(image: failureImage), isFromMemory, .failure) - } - } - self.task = nil - } - - private func handle(partialImage response: ImageResponse) { - display(response.container, false, .success) - } - - #if os(iOS) || os(tvOS) || os(macOS) - - private func display(_ image: ImageContainer, _ isFromMemory: Bool, _ response: ImageLoadingOptions.ResponseType) { - guard let imageView = imageView else { - return - } - - var image = image - - #if os(iOS) || os(tvOS) - if let tintColor = options.tintColor(for: response) { - image = image.map { $0.withRenderingMode(.alwaysTemplate) } ?? image - imageView.tintColor = tintColor - } - #endif - - if !isFromMemory || options.alwaysTransition, let transition = options.transition(for: response) { - switch transition.style { - case let .fadeIn(params): - runFadeInTransition(image: image, params: params, response: response) - case let .custom(closure): - // The user is reponsible for both displaying an image and performing - // animations. - closure(imageView, image.image) - } - } else { - imageView.display(image) - } - - #if os(iOS) || os(tvOS) - if let contentMode = options.contentMode(for: response) { - imageView.contentMode = contentMode - } - #endif - } - - #elseif os(watchOS) - - private func display(_ image: ImageContainer, _ isFromMemory: Bool, _ response: ImageLoadingOptions.ResponseType) { - imageView?.display(image) - } - - #endif -} - -// MARK: - ImageViewController (Transitions) - -private extension ImageViewController { - #if os(iOS) || os(tvOS) - - private func runFadeInTransition(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters, response: ImageLoadingOptions.ResponseType) { - guard let imageView = imageView else { - return - } - - // Special case where it animates between content modes, only works - // on imageView subclasses. - if let contentMode = options.contentMode(for: response), imageView.contentMode != contentMode, let imageView = imageView as? UIImageView, imageView.image != nil { - runCrossDissolveWithContentMode(imageView: imageView, image: image, params: params) - } else { - runSimpleFadeIn(image: image, params: params) - } - } - - private func runSimpleFadeIn(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters) { - guard let imageView = imageView else { - return - } - - UIView.transition( - with: imageView, - duration: params.duration, - options: params.options.union(.transitionCrossDissolve), - animations: { - imageView.nuke_display(image: image.image, data: image.data) - }, - completion: nil - ) - } - - /// Performs cross-dissolve animation alonside transition to a new content - /// mode. This isn't natively supported feature and it requires a second - /// image view. There might be better ways to implement it. - private func runCrossDissolveWithContentMode(imageView: UIImageView, image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters) { - // Lazily create a transition view. - let transitionView = self.transitionImageView - - // Create a transition view which mimics current view's contents. - transitionView.image = imageView.image - transitionView.contentMode = imageView.contentMode - imageView.addSubview(transitionView) - transitionView.frame = imageView.bounds - - // "Manual" cross-fade. - transitionView.alpha = 1 - imageView.alpha = 0 - imageView.display(image) // Display new image in current view - - UIView.animate( - withDuration: params.duration, - delay: 0, - options: params.options, - animations: { - transitionView.alpha = 0 - imageView.alpha = 1 - }, - completion: { isCompleted in - if isCompleted { - transitionView.removeFromSuperview() - } - } - ) - } - - #elseif os(macOS) - - private func runFadeInTransition(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters, response: ImageLoadingOptions.ResponseType) { - let animation = CABasicAnimation(keyPath: "opacity") - animation.duration = params.duration - animation.fromValue = 0 - animation.toValue = 1 - imageView?.layer?.add(animation, forKey: "imageTransition") - - imageView?.display(image) - } - - #endif -} diff --git a/Sources/StreamChatUI/StreamSwiftyGif/SwiftyGifManager.swift b/Sources/StreamChatUI/StreamSwiftyGif/SwiftyGifManager.swift index e2a4c86c6df..8e39f234303 100755 --- a/Sources/StreamChatUI/StreamSwiftyGif/SwiftyGifManager.swift +++ b/Sources/StreamChatUI/StreamSwiftyGif/SwiftyGifManager.swift @@ -17,10 +17,10 @@ typealias PlatformImageView = NSImageView typealias PlatformImageView = UIImageView #endif -class SwiftyGifManager { +class SwiftyGifManager: @unchecked Sendable { // A convenient default manager if we only have one gif to display here and there - static var defaultManager = SwiftyGifManager(memoryLimit: 50) + nonisolated(unsafe) static var defaultManager = SwiftyGifManager(memoryLimit: 50) #if os(macOS) fileprivate var timer: CVDisplayLink? @@ -161,7 +161,7 @@ class SwiftyGifManager { #endif for imageView in displayViews { - queue.sync { + StreamConcurrency.onMain { imageView.image = imageView.currentImage } diff --git a/Sources/StreamChatUI/StreamSwiftyGif/UIImage+SwiftyGif.swift b/Sources/StreamChatUI/StreamSwiftyGif/UIImage+SwiftyGif.swift index 96fe8fe2d61..e341cc0878b 100755 --- a/Sources/StreamChatUI/StreamSwiftyGif/UIImage+SwiftyGif.swift +++ b/Sources/StreamChatUI/StreamSwiftyGif/UIImage+SwiftyGif.swift @@ -41,7 +41,7 @@ extension UIImage { /// /// - Parameter imageData: The actual image data, can be GIF or some other format /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping - convenience init?(imageData:Data, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { + @MainActor convenience init?(imageData:Data, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { do { try self.init(gifData: imageData, levelOfIntegrity: levelOfIntegrity) } catch { @@ -53,7 +53,7 @@ extension UIImage { /// /// - Parameter imageName: Filename /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping - convenience init?(imageName: String, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { + @MainActor convenience init?(imageName: String, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { self.init() do { @@ -72,7 +72,7 @@ extension UIImage { /// /// - Parameter gifData: The actual gif data /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping - convenience init(gifData:Data, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { + @MainActor convenience init(gifData:Data, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { self.init() try setGifFromData(gifData, levelOfIntegrity: levelOfIntegrity) } @@ -81,7 +81,7 @@ extension UIImage { /// /// - Parameter gifName: Filename /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping - convenience init(gifName: String, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { + @MainActor convenience init(gifName: String, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { self.init() try setGif(gifName, levelOfIntegrity: levelOfIntegrity) } @@ -90,7 +90,7 @@ extension UIImage { /// /// - Parameter data: The actual gif data /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping - func setGifFromData(_ data: Data, levelOfIntegrity: GifLevelOfIntegrity) throws { + @MainActor func setGifFromData(_ data: Data, levelOfIntegrity: GifLevelOfIntegrity) throws { guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else { return } self.imageSource = imageSource imageData = data @@ -102,7 +102,7 @@ extension UIImage { /// Set backing data for this gif. Overwrites any existing data. /// /// - Parameter name: Filename - func setGif(_ name: String) throws { + @MainActor func setGif(_ name: String) throws { try setGif(name, levelOfIntegrity: .default) } @@ -117,7 +117,7 @@ extension UIImage { /// /// - Parameter name: Filename /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping - func setGif(_ name: String, levelOfIntegrity: GifLevelOfIntegrity) throws { + @MainActor func setGif(_ name: String, levelOfIntegrity: GifLevelOfIntegrity) throws { if let url = Bundle.main.url(forResource: name, withExtension: name.pathExtension() == "gif" ? "" : "gif") { if let data = try? Data(contentsOf: url) { @@ -206,7 +206,7 @@ extension UIImage { /// /// - Parameter delaysArray: decoded delay times for this gif /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping - private func calculateFrameDelay(_ delaysArray: [Float], levelOfIntegrity: GifLevelOfIntegrity) { + @MainActor private func calculateFrameDelay(_ delaysArray: [Float], levelOfIntegrity: GifLevelOfIntegrity) { let levelOfIntegrity = max(0, min(1, levelOfIntegrity)) var delays = delaysArray @@ -288,12 +288,12 @@ extension UIImage { // MARK: - Properties -private let _imageSourceKey = malloc(4) -private let _displayRefreshFactorKey = malloc(4) -private let _imageSizeKey = malloc(4) -private let _imageCountKey = malloc(4) -private let _displayOrderKey = malloc(4) -private let _imageDataKey = malloc(4) +nonisolated(unsafe) private let _imageSourceKey = malloc(4) +nonisolated(unsafe) private let _displayRefreshFactorKey = malloc(4) +nonisolated(unsafe) private let _imageSizeKey = malloc(4) +nonisolated(unsafe) private let _imageCountKey = malloc(4) +nonisolated(unsafe) private let _displayOrderKey = malloc(4) +nonisolated(unsafe) private let _imageDataKey = malloc(4) extension UIImage { diff --git a/Sources/StreamChatUI/StreamSwiftyGif/UIImageView+SwiftyGif.swift b/Sources/StreamChatUI/StreamSwiftyGif/UIImageView+SwiftyGif.swift index 26f3a05ff8b..9c0b78f7894 100755 --- a/Sources/StreamChatUI/StreamSwiftyGif/UIImageView+SwiftyGif.swift +++ b/Sources/StreamChatUI/StreamSwiftyGif/UIImageView+SwiftyGif.swift @@ -121,7 +121,7 @@ extension UIImageView { let loader: UIView? = showLoader ? createLoader(from: customLoader) : nil let task = session.dataTask(with: url) { [weak self] data, _, error in - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in loader?.removeFromSuperview() self?.parseDownloadedGif(url: url, data: data, @@ -197,13 +197,13 @@ extension UIImageView { extension UIImageView { /// Start displaying the gif for this UIImageView. - private func startDisplay() { + nonisolated private func startDisplay() { displaying = true updateCache() } /// Stop displaying the gif for this UIImageView. - private func stopDisplay() { + nonisolated private func stopDisplay() { displaying = false updateCache() } @@ -221,7 +221,7 @@ extension UIImageView { /// Check if this imageView is currently playing a gif /// /// - Returns whether the gif is currently playing - func isAnimatingGif() -> Bool{ + nonisolated func isAnimatingGif() -> Bool{ return isPlaying } @@ -252,7 +252,7 @@ extension UIImageView { } /// Update cache for the current imageView. - func updateCache() { + nonisolated func updateCache() { guard let animationManager = animationManager else { return } if animationManager.hasCache(self) && !haveCache { @@ -265,7 +265,7 @@ extension UIImageView { } /// Update current image displayed. This method is called by the manager. - func updateCurrentImage() { + nonisolated func updateCurrentImage() { if displaying { updateFrame() updateIndex() @@ -285,7 +285,7 @@ extension UIImageView { } /// Force update frame - private func updateFrame() { + nonisolated private func updateFrame() { if haveCache, let image = cache?.object(forKey: displayOrderIndex as AnyObject) as? UIImage { currentImage = image } else { @@ -294,12 +294,12 @@ extension UIImageView { } /// Get current frame index - func currentFrameIndex() -> Int{ + nonisolated func currentFrameIndex() -> Int{ return displayOrderIndex } /// Get frame at specific index - func frameAtIndex(index: Int) -> UIImage { + nonisolated func frameAtIndex(index: Int) -> UIImage { guard let gifImage = gifImage, let imageSource = gifImage.imageSource, let displayOrder = gifImage.displayOrder, index < displayOrder.count, @@ -313,26 +313,30 @@ extension UIImageView { /// Check if the imageView has been discarded and is not in the view hierarchy anymore. /// /// - Returns : A boolean for weather the imageView was discarded - func isDiscarded(_ imageView: UIView?) -> Bool { - return imageView?.superview == nil + nonisolated func isDiscarded(_ imageView: UIView?) -> Bool { + StreamConcurrency.onMain { + return imageView?.superview == nil + } } /// Check if the imageView is displayed. /// /// - Returns : A boolean for weather the imageView is displayed - func isDisplayedInScreen(_ imageView: UIView?) -> Bool { - guard !isHidden, let imageView = imageView else { - return false + nonisolated func isDisplayedInScreen(_ imageView: UIView?) -> Bool { + StreamConcurrency.onMain { + guard !isHidden, let imageView = imageView else { + return false + } + + let screenRect = UIScreen.main.bounds + let viewRect = imageView.convert(bounds, to:nil) + let intersectionRect = viewRect.intersection(screenRect) + + return window != nil && !intersectionRect.isEmpty && !intersectionRect.isNull } - - let screenRect = UIScreen.main.bounds - let viewRect = imageView.convert(bounds, to:nil) - let intersectionRect = viewRect.intersection(screenRect) - - return window != nil && !intersectionRect.isEmpty && !intersectionRect.isNull } - func clear() { + nonisolated func clear() { if let gifImage = gifImage { gifImage.clear() } @@ -341,11 +345,13 @@ extension UIImageView { currentImage = nil cache?.removeAllObjects() animationManager = nil - image = nil + StreamConcurrency.onMain { + image = nil + } } /// Update loop count and sync factor. - private func updateIndex() { + nonisolated private func updateIndex() { guard let gif = self.gifImage, let displayRefreshFactor = gif.displayRefreshFactor, displayRefreshFactor > 0 else { @@ -372,7 +378,7 @@ extension UIImageView { } /// Prepare the cache by adding every images of the gif to an NSCache object. - private func prepareCache() { + nonisolated private func prepareCache() { guard let cache = self.cache else { return } cache.removeAllObjects() @@ -391,66 +397,66 @@ extension UIImageView { // MARK: - Dynamic properties -private let _gifImageKey = malloc(4) -private let _cacheKey = malloc(4) -private let _currentImageKey = malloc(4) -private let _displayOrderIndexKey = malloc(4) -private let _syncFactorKey = malloc(4) -private let _haveCacheKey = malloc(4) -private let _loopCountKey = malloc(4) -private let _displayingKey = malloc(4) -private let _isPlayingKey = malloc(4) -private let _animationManagerKey = malloc(4) -private let _delegateKey = malloc(4) +nonisolated(unsafe) private let _gifImageKey = malloc(4) +nonisolated(unsafe) private let _cacheKey = malloc(4) +nonisolated(unsafe) private let _currentImageKey = malloc(4) +nonisolated(unsafe) private let _displayOrderIndexKey = malloc(4) +nonisolated(unsafe) private let _syncFactorKey = malloc(4) +nonisolated(unsafe) private let _haveCacheKey = malloc(4) +nonisolated(unsafe) private let _loopCountKey = malloc(4) +nonisolated(unsafe) private let _displayingKey = malloc(4) +nonisolated(unsafe) private let _isPlayingKey = malloc(4) +nonisolated(unsafe) private let _animationManagerKey = malloc(4) +nonisolated(unsafe) private let _delegateKey = malloc(4) extension UIImageView { - var gifImage: UIImage? { + nonisolated var gifImage: UIImage? { get { return possiblyNil(_gifImageKey) } set { objc_setAssociatedObject(self, _gifImageKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - var currentImage: UIImage? { + nonisolated var currentImage: UIImage? { get { return possiblyNil(_currentImageKey) } set { objc_setAssociatedObject(self, _currentImageKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - private var displayOrderIndex: Int { + nonisolated private var displayOrderIndex: Int { get { return value(_displayOrderIndexKey, 0) } set { objc_setAssociatedObject(self, _displayOrderIndexKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - private var syncFactor: Int { + nonisolated private var syncFactor: Int { get { return value(_syncFactorKey, 0) } set { objc_setAssociatedObject(self, _syncFactorKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - var loopCount: Int { + nonisolated var loopCount: Int { get { return value(_loopCountKey, 0) } set { objc_setAssociatedObject(self, _loopCountKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - var animationManager: SwiftyGifManager? { + nonisolated var animationManager: SwiftyGifManager? { get { return (objc_getAssociatedObject(self, _animationManagerKey!) as? SwiftyGifManager) } set { objc_setAssociatedObject(self, _animationManagerKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - var delegate: SwiftyGifDelegate? { + nonisolated var delegate: SwiftyGifDelegate? { get { return (objc_getAssociatedWeakObject(self, _delegateKey!) as? SwiftyGifDelegate) } set { objc_setAssociatedWeakObject(self, _delegateKey!, newValue) } } - private var haveCache: Bool { + nonisolated private var haveCache: Bool { get { return value(_haveCacheKey, false) } set { objc_setAssociatedObject(self, _haveCacheKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - var displaying: Bool { + nonisolated var displaying: Bool { get { return value(_displayingKey, false) } set { objc_setAssociatedObject(self, _displayingKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - private var isPlaying: Bool { + nonisolated private var isPlaying: Bool { get { return value(_isPlayingKey, false) } @@ -463,16 +469,16 @@ extension UIImageView { } } - private var cache: NSCache? { + nonisolated private var cache: NSCache? { get { return (objc_getAssociatedObject(self, _cacheKey!) as? NSCache) } set { objc_setAssociatedObject(self, _cacheKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - private func value(_ key:UnsafeMutableRawPointer?, _ defaultValue:T) -> T { + nonisolated private func value(_ key:UnsafeMutableRawPointer?, _ defaultValue:T) -> T { return (objc_getAssociatedObject(self, key!) as? T) ?? defaultValue } - private func possiblyNil(_ key:UnsafeMutableRawPointer?) -> T? { + nonisolated private func possiblyNil(_ key:UnsafeMutableRawPointer?) -> T? { let result = objc_getAssociatedObject(self, key!) if result == nil { diff --git a/Sources/StreamChatUI/Utils/Animation.swift b/Sources/StreamChatUI/Utils/Animation.swift index ab2f92b7f70..3ae4a366226 100644 --- a/Sources/StreamChatUI/Utils/Animation.swift +++ b/Sources/StreamChatUI/Utils/Animation.swift @@ -4,7 +4,7 @@ import UIKit -public func Animate( +@MainActor public func Animate( duration: TimeInterval = 0.25, delay: TimeInterval = 0, _ actions: @escaping () -> Void, @@ -19,7 +19,7 @@ public func Animate( ) } -func Animate( +@MainActor func Animate( duration: TimeInterval = 0.25, delay: TimeInterval = 0, isAnimated: Bool = true, diff --git a/Sources/StreamChatUI/Utils/Extensions/UIView+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/UIView+Extensions.swift index 442b159139e..a99a2670d23 100644 --- a/Sources/StreamChatUI/Utils/Extensions/UIView+Extensions.swift +++ b/Sources/StreamChatUI/Utils/Extensions/UIView+Extensions.swift @@ -80,7 +80,7 @@ enum LayoutAnchorName { case trailing case width - func makeConstraint(fromView: UIView, toView: UIView, constant: CGFloat = 0) -> NSLayoutConstraint { + @MainActor func makeConstraint(fromView: UIView, toView: UIView, constant: CGFloat = 0) -> NSLayoutConstraint { switch self { case .bottom: return fromView.bottomAnchor.pin(equalTo: toView.bottomAnchor, constant: constant) @@ -109,7 +109,7 @@ enum LayoutAnchorName { } } - func makeConstraint(fromView: UIView, toLayoutGuide: UILayoutGuide, constant: CGFloat = 0) -> NSLayoutConstraint? { + @MainActor func makeConstraint(fromView: UIView, toLayoutGuide: UILayoutGuide, constant: CGFloat = 0) -> NSLayoutConstraint? { switch self { case .bottom: return fromView.bottomAnchor.pin(equalTo: toLayoutGuide.bottomAnchor, constant: constant) @@ -137,7 +137,7 @@ enum LayoutAnchorName { } } - func makeConstraint(fromView: UIView, constant: CGFloat) -> NSLayoutConstraint? { + @MainActor func makeConstraint(fromView: UIView, constant: CGFloat) -> NSLayoutConstraint? { switch self { case .height: return fromView.heightAnchor.pin(equalToConstant: constant) diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/ImageCDN.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/ImageCDN.swift index 169c07ed268..2c16643a77c 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/ImageCDN.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/ImageCDN.swift @@ -5,7 +5,7 @@ import UIKit /// A protocol responsible to configure the image CDN by intercepting the image url requests. -public protocol ImageCDN { +public protocol ImageCDN: Sendable { /// Intercept the image url request. /// /// This can be used to change the host of the CDN, adding HTTP Headers etc. diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/StreamCDN.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/StreamCDN.swift index 9f5b782c82d..7000fd706c9 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/StreamCDN.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageCDN/StreamCDN.swift @@ -4,8 +4,8 @@ import UIKit -open class StreamImageCDN: ImageCDN { - public static var streamCDNURL = "stream-io-cdn.com" +open class StreamImageCDN: ImageCDN, @unchecked Sendable { + nonisolated(unsafe) public static var streamCDNURL = "stream-io-cdn.com" public init() {} @@ -21,7 +21,7 @@ open class StreamImageCDN: ImageCDN { return URLRequest(url: url) } - let scale = UIScreen.main.scale + let scale = Screen.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), diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift index e392dd9cb79..17bea74e0ae 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoaderOptions.swift @@ -5,7 +5,7 @@ import UIKit /// The options for loading an image into a view. -public struct ImageLoaderOptions { +public struct ImageLoaderOptions: Sendable { // Ideally, the name would be `ImageLoadingOptions`, but this would conflict with Nuke. /// The resize information when loading an image. `Nil` if you want the full resolution of the image. diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift index 985ed1fc013..8bc16084eb5 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageLoading.swift @@ -19,7 +19,7 @@ public protocol ImageLoading: AnyObject { into imageView: UIImageView, from url: URL?, with options: ImageLoaderOptions, - completion: ((_ result: Result) -> Void)? + completion: (@Sendable(_ result: Result) -> Void)? ) -> Cancellable? /// Load an image into an imageView from a given `ImageAttachmentPayload`. @@ -34,7 +34,7 @@ public protocol ImageLoading: AnyObject { into imageView: UIImageView, from attachmentPayload: ImageAttachmentPayload?, maxResolutionInPixels: Double, - completion: ((_ result: Result) -> Void)? + completion: (@Sendable(_ result: Result) -> Void)? ) -> Cancellable? /// Download an image from the given `URL`. @@ -45,7 +45,7 @@ public protocol ImageLoading: AnyObject { @discardableResult func downloadImage( with request: ImageDownloadRequest, - completion: @escaping ((_ result: Result) -> Void) + completion: @escaping (@Sendable(_ result: Result) -> Void) ) -> Cancellable? /// Load a batch of images and get notified when all of them complete loading. @@ -55,7 +55,7 @@ public protocol ImageLoading: AnyObject { /// It returns an array of image and errors in case the image failed to load. func downloadMultipleImages( with requests: [ImageDownloadRequest], - completion: @escaping (([Result]) -> Void) + completion: @escaping (@Sendable([Result]) -> Void) ) // MARK: - Deprecations @@ -65,7 +65,7 @@ public protocol ImageLoading: AnyObject { func loadImage( using urlRequest: URLRequest, cachingKey: String?, - completion: @escaping ((_ result: Result) -> Void) + completion: @escaping (@Sendable(_ result: Result) -> Void) ) -> Cancellable? @available(*, deprecated, message: "use loadImage(into:from:with:) instead.") @@ -77,7 +77,7 @@ public protocol ImageLoading: AnyObject { placeholder: UIImage?, resize: Bool, preferredSize: CGSize?, - completion: ((_ result: Result) -> Void)? + completion: (@Sendable(_ result: Result) -> Void)? ) -> Cancellable? @available(*, deprecated, message: "use loadMultipleImages() instead.") @@ -87,7 +87,7 @@ public protocol ImageLoading: AnyObject { loadThumbnails: Bool, thumbnailSize: CGSize, imageCDN: ImageCDN, - completion: @escaping (([UIImage]) -> Void) + completion: @escaping (@Sendable([UIImage]) -> Void) ) } @@ -99,7 +99,7 @@ public extension ImageLoading { into imageView: UIImageView, from attachmentPayload: ImageAttachmentPayload?, maxResolutionInPixels: Double, - completion: ((_ result: Result) -> Void)? + completion: (@Sendable(_ result: Result) -> Void)? ) -> Cancellable? { guard let originalWidth = attachmentPayload?.originalWidth, let originalHeight = attachmentPayload?.originalHeight else { @@ -168,7 +168,7 @@ public extension ImageLoading { @available(*, deprecated, message: "use downloadImage() instead.") func loadImage( using urlRequest: URLRequest, - cachingKey: String?, completion: @escaping ((Result) -> Void) + cachingKey: String?, completion: @escaping @Sendable(Result) -> Void ) -> Cancellable? { guard let url = urlRequest.url else { completion(.failure(NSError(domain: "io.getstream.imageDeprecation.invalidUrl", code: 1))) @@ -190,7 +190,7 @@ public extension ImageLoading { placeholder: UIImage? = nil, resize: Bool = true, preferredSize: CGSize? = nil, - completion: ((_ result: Result) -> Void)? = nil + completion: (@Sendable(_ result: Result) -> Void)? = nil ) -> Cancellable? { loadImage( into: imageView, @@ -210,7 +210,7 @@ public extension ImageLoading { loadThumbnails: Bool = true, thumbnailSize: CGSize = Components.default.avatarThumbnailSize, imageCDN: ImageCDN, - completion: @escaping (([UIImage]) -> Void) + completion: @escaping @Sendable([UIImage]) -> Void ) { let requests = urls.map { url in ImageDownloadRequest(url: url, options: .init(resize: .init(thumbnailSize))) diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageMerger.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageMerger.swift index 25ee2f4161c..ac6a8a20073 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageMerger.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageMerger.swift @@ -50,7 +50,7 @@ open class DefaultImageMerger: ImageMerging { dimensions.height += image.size.height } - UIGraphicsBeginImageContextWithOptions(dimensions, true, UIScreen.main.scale) + UIGraphicsBeginImageContextWithOptions(dimensions, true, Screen.scale) var lastY: CGFloat = 0 for image in images { @@ -75,7 +75,7 @@ open class DefaultImageMerger: ImageMerging { dimensions.height = max(dimensions.height, image.size.height) } - UIGraphicsBeginImageContextWithOptions(dimensions, true, UIScreen.main.scale) + UIGraphicsBeginImageContextWithOptions(dimensions, true, Screen.scale) var lastX: CGFloat = 0 for image in images { diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift index 51916071943..bb77f15ad43 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageResize.swift @@ -5,7 +5,7 @@ import UIKit /// The resize information when loading an image. -public struct ImageResize { +public struct ImageResize: Sendable { /// The new width of the image in points (not pixels). public var width: CGFloat /// The new height of the image in points (not pixels). @@ -33,7 +33,7 @@ extension ImageResize { /// - `crop` /// - `fill` /// - `scale` - public struct Mode { + public struct Mode: Sendable { public var value: String public var cropValue: String? @@ -44,7 +44,7 @@ extension ImageResize { /// 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") + public static let 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 { @@ -53,10 +53,10 @@ extension ImageResize { /// 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") + public static let fill = Mode(value: "fill") /// Ignore aspect ratio, and resize the image to the given height and width. - public static var scale = Mode(value: "scale") + public static let scale = Mode(value: "scale") /// The crop position of the image. public enum Crop: String { diff --git a/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift b/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift index 4c88daf0d1b..57f2bcf326c 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/ImageSizeCalculator.swift @@ -23,7 +23,7 @@ struct ImageSizeCalculator { originalHeightInPixels: Double, maxResolutionTotalPixels: Double ) -> CGSize { - let scale = UIScreen.main.scale + let scale = Screen.scale let originalResolutionTotalPixels = originalWidthInPixels * originalHeightInPixels guard originalResolutionTotalPixels > maxResolutionTotalPixels else { diff --git a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift index 0663354cb22..dabb7841217 100644 --- a/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift +++ b/Sources/StreamChatUI/Utils/ImageLoading/NukeImageLoader.swift @@ -28,45 +28,47 @@ open class NukeImageLoader: ImageLoading { into imageView: UIImageView, from url: URL?, with options: ImageLoaderOptions, - completion: ((Result) -> Void)? + completion: (@Sendable(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 nukeOptions = ImageLoadingOptions(placeholder: options.placeholder) - imageView.currentImageLoadingTask = StreamChatUI.loadImage( - with: request, - options: nukeOptions, - into: imageView - ) { result in - switch result { - case let .success(imageResponse): - completion?(.success(imageResponse.image)) - case let .failure(error): - completion?(.failure(error)) + let imageTask: ImageTask? = StreamConcurrency.onMain { [imageCDN] in + 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 nukeOptions = ImageLoadingOptions(placeholder: options.placeholder) + imageView.currentImageLoadingTask = StreamChatUI.loadImage( + with: request, + options: nukeOptions, + into: imageView + ) { result in + switch result { + case let .success(imageResponse): + completion?(.success(imageResponse.image)) + case let .failure(error): + completion?(.failure(error)) + } + } + return imageView.currentImageLoadingTask } - - return imageView.currentImageLoadingTask + return imageTask } @discardableResult diff --git a/Sources/StreamChatUI/Utils/ImageProcessor/NukeImageProcessor.swift b/Sources/StreamChatUI/Utils/ImageProcessor/NukeImageProcessor.swift index c8fde143b0a..d7407531940 100644 --- a/Sources/StreamChatUI/Utils/ImageProcessor/NukeImageProcessor.swift +++ b/Sources/StreamChatUI/Utils/ImageProcessor/NukeImageProcessor.swift @@ -4,7 +4,7 @@ import UIKit -public protocol ImageProcessor { +public protocol ImageProcessor: Sendable { /// Crop the image to a given size. The image is center-cropped /// - Parameters: /// - image: The image to crop @@ -21,7 +21,7 @@ public protocol ImageProcessor { } /// This class provides resizing operations for `UIImage`. It internally uses `Nuke` porcessors to implement operations on images. -open class NukeImageProcessor: ImageProcessor { +open class NukeImageProcessor: ImageProcessor, @unchecked Sendable { open func crop(image: UIImage, to size: CGSize) -> UIImage? { let imageProccessor = ImageProcessors.Resize(size: size, crop: true) return imageProccessor.process(image) @@ -64,12 +64,12 @@ extension ImageProcessors { } private let id: String - private let sizeProvider: () -> CGSize + private let sizeProvider: @Sendable() -> CGSize /// Initializes the processor with size providing closure. /// - Parameter sizeProvider: Closure to obtain size after the image is loaded. @available(*, deprecated, message: "Use init(id:sizeProvider:) instead") - public init(sizeProvider: @escaping () -> CGSize) { + public init(sizeProvider: @escaping @Sendable() -> CGSize) { // Backwards compatible init self.init(id: "", sizeProvider: sizeProvider) } @@ -78,7 +78,7 @@ extension ImageProcessors { /// - Parameters: /// - id: Image identifier. /// - sizeProvider: Closure to obtain size after the image is loaded. - public init(id: String, sizeProvider: @escaping () -> CGSize) { + public init(id: String, sizeProvider: @escaping @Sendable() -> CGSize) { self.id = id self.sizeProvider = sizeProvider } diff --git a/Sources/StreamChatUI/Utils/KeyboardHandler/KeyboardHandler.swift b/Sources/StreamChatUI/Utils/KeyboardHandler/KeyboardHandler.swift index cc5423a28fc..08142ccca15 100644 --- a/Sources/StreamChatUI/Utils/KeyboardHandler/KeyboardHandler.swift +++ b/Sources/StreamChatUI/Utils/KeyboardHandler/KeyboardHandler.swift @@ -5,7 +5,7 @@ import Foundation /// A component responsible to handle keyboard events and act on them. -public protocol KeyboardHandler { +@preconcurrency @MainActor public protocol KeyboardHandler { /// Start handling events. func start() /// Stop handling events. diff --git a/Sources/StreamChatUI/Utils/Cache.swift b/Sources/StreamChatUI/Utils/NSCacheWrapper.swift similarity index 94% rename from Sources/StreamChatUI/Utils/Cache.swift rename to Sources/StreamChatUI/Utils/NSCacheWrapper.swift index 887baa429f2..a95ff2cf8eb 100644 --- a/Sources/StreamChatUI/Utils/Cache.swift +++ b/Sources/StreamChatUI/Utils/NSCacheWrapper.swift @@ -4,7 +4,7 @@ import Foundation -final class Cache { +final class NSCacheWrapper { private let wrapped: NSCache init(countLimit: Int = 0) { @@ -40,7 +40,7 @@ final class Cache { } } -private extension Cache { +private extension NSCacheWrapper { final class WrappedKey: NSObject { let key: Key diff --git a/Sources/StreamChatUI/Utils/StreamConcurrency.swift b/Sources/StreamChatUI/Utils/StreamConcurrency.swift new file mode 100644 index 00000000000..e2e47defcda --- /dev/null +++ b/Sources/StreamChatUI/Utils/StreamConcurrency.swift @@ -0,0 +1,28 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +enum StreamConcurrency { + /// Synchronously performs the provided action on the main thread. + /// + /// Used for ensuring that we are on the main thread when compiler can't know it. For example, + /// controller completion handlers by default are called from main thread, but one can + /// configure controller to use background thread for completions instead. + /// + /// - Important: It is safe to call from any thread. It does not deadlock if we are already on the main thread. + /// - Important: Prefer Task { @MainActor if possible. + static func onMain(_ action: @MainActor @Sendable() throws -> T) rethrows -> T where T: Sendable { + if Thread.current.isMainThread { + return try MainActor.assumeIsolated { + try action() + } + } else { + // We use sync here, because this function supports returning a value. + return try DispatchQueue.main.sync { + try action() + } + } + } +} diff --git a/Sources/StreamChatUI/Utils/TextViewMentionedUsersHandler.swift b/Sources/StreamChatUI/Utils/TextViewMentionedUsersHandler.swift index 46b817fb3ad..513b83ff822 100644 --- a/Sources/StreamChatUI/Utils/TextViewMentionedUsersHandler.swift +++ b/Sources/StreamChatUI/Utils/TextViewMentionedUsersHandler.swift @@ -13,7 +13,7 @@ class TextViewMentionedUsersHandler { /// - characterRange: The location where the tap was performed. /// - mentionedUsers: The current mentioned users in the message. /// - Returns: The `ChatUser` in case it tapped a mentioned user. - func mentionedUserTapped( + @MainActor func mentionedUserTapped( on textView: UITextView, in characterRange: NSRange, with mentionedUsers: Set diff --git a/Sources/StreamChatUI/Utils/VideoLoading/VideoLoading.swift b/Sources/StreamChatUI/Utils/VideoLoading/VideoLoading.swift index db2c427b671..3f9d3ad7069 100644 --- a/Sources/StreamChatUI/Utils/VideoLoading/VideoLoading.swift +++ b/Sources/StreamChatUI/Utils/VideoLoading/VideoLoading.swift @@ -12,7 +12,7 @@ public protocol VideoLoading: AnyObject { /// - Parameters: /// - url: A video URL. /// - completion: A completion that is called when a preview is loaded. Must be invoked on main queue. - func loadPreviewForVideo(at url: URL, completion: @escaping (Result) -> Void) + func loadPreviewForVideo(at url: URL, completion: @escaping @Sendable(Result) -> Void) /// Returns a video asset with the given URL. /// @@ -27,8 +27,8 @@ public extension VideoLoading { } /// The default `VideoLoading` implementation. -open class StreamVideoLoader: VideoLoading { - private let cache: Cache +open class StreamVideoLoader: VideoLoading, @unchecked Sendable { + private let cache: NSCacheWrapper public init(cachedVideoPreviewsCountLimit: Int = 50) { cache = .init(countLimit: cachedVideoPreviewsCountLimit) @@ -45,7 +45,7 @@ open class StreamVideoLoader: VideoLoading { NotificationCenter.default.removeObserver(self) } - open func loadPreviewForVideo(at url: URL, completion: @escaping (Result) -> Void) { + open func loadPreviewForVideo(at url: URL, completion: @escaping @Sendable(Result) -> Void) { if let cached = cache[url] { return call(completion, with: .success(cached)) } @@ -77,7 +77,7 @@ open class StreamVideoLoader: VideoLoading { .init(url: url) } - private func call(_ completion: @escaping (Result) -> Void, with result: Result) { + private func call(_ completion: @escaping @Sendable(Result) -> Void, with result: Result) { if Thread.current.isMainThread { completion(result) } else { diff --git a/Sources/StreamChatUI/Utils/ViewPaginationHandling/ScrollViewPaginationHandler.swift b/Sources/StreamChatUI/Utils/ViewPaginationHandling/ScrollViewPaginationHandler.swift index d1a398ed39e..11b32f2b721 100644 --- a/Sources/StreamChatUI/Utils/ViewPaginationHandling/ScrollViewPaginationHandler.swift +++ b/Sources/StreamChatUI/Utils/ViewPaginationHandling/ScrollViewPaginationHandler.swift @@ -5,7 +5,7 @@ import UIKit /// A component responsible to handle when to load new pages in a scrollView. -final class ScrollViewPaginationHandler: ViewPaginationHandling { +@MainActor final class ScrollViewPaginationHandler: ViewPaginationHandling { private weak var scrollView: UIScrollView? private var observation: NSKeyValueObservation? private var previousPosition: CGFloat = 0.0 @@ -20,7 +20,9 @@ final class ScrollViewPaginationHandler: ViewPaginationHandling { self.scrollView = scrollView observation = self.scrollView?.observe(\.contentOffset, changeHandler: { [weak self] scrollView, _ in - self?.onChanged(scrollView) + StreamConcurrency.onMain { [weak self] in + self?.onChanged(scrollView) + } }) } diff --git a/Sources/StreamChatUI/Utils/ViewPaginationHandling/StatefulScrollViewPaginationHandler.swift b/Sources/StreamChatUI/Utils/ViewPaginationHandling/StatefulScrollViewPaginationHandler.swift index 28a3a20245f..c81bdd9f978 100644 --- a/Sources/StreamChatUI/Utils/ViewPaginationHandling/StatefulScrollViewPaginationHandler.swift +++ b/Sources/StreamChatUI/Utils/ViewPaginationHandling/StatefulScrollViewPaginationHandler.swift @@ -5,7 +5,7 @@ import UIKit /// A component responsible to handle when to load new pages in a scrollView holding state associated to the content view -final class StatefulScrollViewPaginationHandler: StatefulViewPaginationHandling { +@MainActor final class StatefulScrollViewPaginationHandler: StatefulViewPaginationHandling { private var bottomPageRequestItemCount: Int? private var topPageRequestItemCount: Int? private var canRequestNewTopPage: Bool { @@ -30,7 +30,9 @@ final class StatefulScrollViewPaginationHandler: StatefulViewPaginationHandling self.scrollView = scrollView observation = self.scrollView?.observe(\.contentOffset, changeHandler: { [weak self] scrollView, _ in - self?.onChanged(scrollView) + StreamConcurrency.onMain { [weak self] in + self?.onChanged(scrollView) + } }) } @@ -44,7 +46,9 @@ final class StatefulScrollViewPaginationHandler: StatefulViewPaginationHandling if canRequestNewBottomPage, position > scrollView.contentSize.height - bottomThreshold - scrollView.frame.size.height { onNewBottomPage?({ bottomPageRequestItemCount = $0 }, { [weak self] error in if error != nil { - self?.bottomPageRequestItemCount = nil + StreamConcurrency.onMain { [weak self] in + self?.bottomPageRequestItemCount = nil + } } }) } @@ -53,7 +57,9 @@ final class StatefulScrollViewPaginationHandler: StatefulViewPaginationHandling if canRequestNewTopPage, position >= 0 && position <= topThreshold && position <= max(0, previousPosition) { onNewTopPage?({ topPageRequestItemCount = $0 }, { [weak self] error in if error != nil { - self?.topPageRequestItemCount = nil + StreamConcurrency.onMain { [weak self] in + self?.topPageRequestItemCount = nil + } } }) } diff --git a/Sources/StreamChatUI/Utils/ViewPaginationHandling/ViewPaginationHandling.swift b/Sources/StreamChatUI/Utils/ViewPaginationHandling/ViewPaginationHandling.swift index b503a70213a..828d5922b81 100644 --- a/Sources/StreamChatUI/Utils/ViewPaginationHandling/ViewPaginationHandling.swift +++ b/Sources/StreamChatUI/Utils/ViewPaginationHandling/ViewPaginationHandling.swift @@ -4,7 +4,7 @@ import Foundation -protocol ViewPaginationHandling { +@MainActor protocol ViewPaginationHandling { var topThreshold: Int { get set } var bottomThreshold: Int { get set } @@ -12,8 +12,8 @@ protocol ViewPaginationHandling { var onNewBottomPage: (() -> Void)? { get set } } -typealias StatefulViewPaginationHandlingBlock = ((_ notifyItemCount: (Int) -> Void, _ completion: @escaping (Error?) -> Void) -> Void) -protocol StatefulViewPaginationHandling { +typealias StatefulViewPaginationHandlingBlock = ((_ notifyItemCount: (Int) -> Void, _ completion: @escaping @Sendable(Error?) -> Void) -> Void) +@MainActor protocol StatefulViewPaginationHandling { var topThreshold: Int { get set } var bottomThreshold: Int { get set } diff --git a/Sources/StreamChatUI/ViewContainerBuilder/ViewContainerBuilder.swift b/Sources/StreamChatUI/ViewContainerBuilder/ViewContainerBuilder.swift index 4872bc6a12f..3d24d8eb6ca 100644 --- a/Sources/StreamChatUI/ViewContainerBuilder/ViewContainerBuilder.swift +++ b/Sources/StreamChatUI/ViewContainerBuilder/ViewContainerBuilder.swift @@ -8,6 +8,7 @@ import UIKit /// A result builder to create a stack view given an array of views. /// The goal is to build UIKit layout similar to SwiftUI so that it easier to create and re-layout views. +@preconcurrency @MainActor @resultBuilder public struct ViewContainerBuilder { init() {} @@ -72,11 +73,12 @@ public struct ViewContainerBuilder { /// - parameter distribution: The stack view distribution, by default it is `.fill`. /// - parameter alignment: The stack view alignment, by default it is `.fill`. /// - parameter content: The result builder responsible to return the stack view with the arranged views. +@preconcurrency @MainActor public func VContainer( spacing: CGFloat = 0, distribution: UIStackView.Distribution = .fill, alignment: UIStackView.Alignment = .fill, - @ViewContainerBuilder content: () -> UIStackView = { UIStackView() } + @ViewContainerBuilder content: @MainActor() -> UIStackView = { UIStackView() } ) -> UIStackView { let stack = content() stack.translatesAutoresizingMaskIntoConstraints = false @@ -93,11 +95,12 @@ public func VContainer( /// - parameter distribution: The stack view distribution, by default it is `.fill`. /// - parameter alignment: The stack view alignment. /// - parameter content: The result builder responsible to return the stack view with the arranged views. +@preconcurrency @MainActor public func HContainer( spacing: CGFloat = 0, distribution: UIStackView.Distribution = .fill, alignment: UIStackView.Alignment = .fill, - @ViewContainerBuilder content: () -> UIStackView = { UIStackView() } + @ViewContainerBuilder content: @MainActor() -> UIStackView = { UIStackView() } ) -> UIStackView { let stack = content() stack.translatesAutoresizingMaskIntoConstraints = false @@ -109,6 +112,7 @@ public func HContainer( } /// A flexible space that expands along the major axis of its containing stack layout. +@preconcurrency @MainActor public func Spacer() -> UIView { let view = UIStackView() view.translatesAutoresizingMaskIntoConstraints = false diff --git a/Sources/StreamChatUI/VoiceRecording/Components/AudioQueuePlayerNextItemProvider.swift b/Sources/StreamChatUI/VoiceRecording/Components/AudioQueuePlayerNextItemProvider.swift index 0fc2839c692..351d5ed04db 100644 --- a/Sources/StreamChatUI/VoiceRecording/Components/AudioQueuePlayerNextItemProvider.swift +++ b/Sources/StreamChatUI/VoiceRecording/Components/AudioQueuePlayerNextItemProvider.swift @@ -8,7 +8,7 @@ import StreamChat /// An object responsible for providing the next VoiceRecording to play. open class AudioQueuePlayerNextItemProvider { /// Describes the lookUp scope in which the Provider will look into for the next available VoiceRecording. - public struct LookUpScope: RawRepresentable, Equatable { + public struct LookUpScope: RawRepresentable, Equatable, Sendable { public var rawValue: String public init(rawValue: String) { self.rawValue = rawValue } diff --git a/Sources/StreamChatUI/VoiceRecording/Components/AudioSessionFeedbackGenerator.swift b/Sources/StreamChatUI/VoiceRecording/Components/AudioSessionFeedbackGenerator.swift index aae6daca754..5b4c5752f1d 100644 --- a/Sources/StreamChatUI/VoiceRecording/Components/AudioSessionFeedbackGenerator.swift +++ b/Sources/StreamChatUI/VoiceRecording/Components/AudioSessionFeedbackGenerator.swift @@ -7,7 +7,7 @@ import UIKit.UIImpactFeedbackGenerator import UIKit.UISelectionFeedbackGenerator /// A protocol that defines the required methods for providing haptic feedback for different events in an audio session -public protocol AudioSessionFeedbackGenerator { +@preconcurrency @MainActor public protocol AudioSessionFeedbackGenerator { /// Initialises an instance of the conforming type init() diff --git a/Sources/StreamChatUI/VoiceRecording/Views/LiveRecordingView.swift b/Sources/StreamChatUI/VoiceRecording/Views/LiveRecordingView.swift index cfda2a11477..5345911fa23 100644 --- a/Sources/StreamChatUI/VoiceRecording/Views/LiveRecordingView.swift +++ b/Sources/StreamChatUI/VoiceRecording/Views/LiveRecordingView.swift @@ -38,13 +38,15 @@ open class LiveRecordingView: _View, ThemeProvider { self.waveform = waveform } - static var initial = Content( - isRecording: false, - isPlaying: false, - duration: 0, - currentTime: 0, - waveform: [] - ) + static var initial: Content { + Content( + isRecording: false, + isPlaying: false, + duration: 0, + currentTime: 0, + waveform: [] + ) + } } public var content: Content = .initial { diff --git a/Sources/StreamChatUI/VoiceRecording/Views/WaveformView.swift b/Sources/StreamChatUI/VoiceRecording/Views/WaveformView.swift index 9f52ac07126..e17c08e9c0f 100644 --- a/Sources/StreamChatUI/VoiceRecording/Views/WaveformView.swift +++ b/Sources/StreamChatUI/VoiceRecording/Views/WaveformView.swift @@ -7,7 +7,7 @@ import UIKit /// Displays an interactive waveform visualisation of an audio file. open class WaveformView: _View, ThemeProvider { - public struct Content: Equatable { + public struct Content: Equatable, Sendable { /// When set to `true` the waveform will be updating with the data live (scrolling to the trailing side /// as new data arrive). public var isRecording: Bool diff --git a/Sources/StreamChatUI/VoiceRecording/VoiceRecordingVC.swift b/Sources/StreamChatUI/VoiceRecording/VoiceRecordingVC.swift index 4ed104f54f4..e2f33439d2a 100644 --- a/Sources/StreamChatUI/VoiceRecording/VoiceRecordingVC.swift +++ b/Sources/StreamChatUI/VoiceRecording/VoiceRecordingVC.swift @@ -15,41 +15,41 @@ import UIKit open class VoiceRecordingVC: _ViewController, ComponentsProvider, AppearanceProvider, AudioRecordingDelegate, AudioPlayingDelegate, UIGestureRecognizerDelegate { // MARK: - Nested Types - public struct State: RawRepresentable, Equatable { + public struct State: RawRepresentable, Equatable, Sendable { public typealias RawValue = String public let rawValue: String public var description: String { rawValue.uppercased() } public init(rawValue: RawValue) { self.rawValue = rawValue } - public static var idle = State(rawValue: "idle") - public static var showingTip = State(rawValue: "showingTip") - public static var recording = State(rawValue: "recording") - public static var locked = State(rawValue: "locked") - public static var preview = State(rawValue: "preview") + public static let idle = State(rawValue: "idle") + public static let showingTip = State(rawValue: "showingTip") + public static let recording = State(rawValue: "recording") + public static let locked = State(rawValue: "locked") + public static let preview = State(rawValue: "preview") } - public struct Action: RawRepresentable, Equatable { + public struct Action: RawRepresentable, Equatable, Sendable { public typealias RawValue = String public let rawValue: String public var description: String { rawValue.uppercased() } public init(rawValue: RawValue) { self.rawValue = rawValue } - public static var tapRecord = Action(rawValue: "tapRecord") - public static var showTip = Action(rawValue: "showTip") - public static var beginRecording = Action(rawValue: "beginRecording") - public static var touchUp = Action(rawValue: "touchUp") - public static var cancel = Action(rawValue: "cancel") - public static var lock = Action(rawValue: "lock") - public static var stop = Action(rawValue: "stop") - public static var discard = Action(rawValue: "discard") - public static var send = Action(rawValue: "send") - public static var confirm = Action(rawValue: "confirm") - public static var publishMessage = Action(rawValue: "publishMessage") - public static var play = Action(rawValue: "play") - public static var pause = Action(rawValue: "pause") + public static let tapRecord = Action(rawValue: "tapRecord") + public static let showTip = Action(rawValue: "showTip") + public static let beginRecording = Action(rawValue: "beginRecording") + public static let touchUp = Action(rawValue: "touchUp") + public static let cancel = Action(rawValue: "cancel") + public static let lock = Action(rawValue: "lock") + public static let stop = Action(rawValue: "stop") + public static let discard = Action(rawValue: "discard") + public static let send = Action(rawValue: "send") + public static let confirm = Action(rawValue: "confirm") + public static let publishMessage = Action(rawValue: "publishMessage") + public static let play = Action(rawValue: "play") + public static let pause = Action(rawValue: "pause") } - public struct Content: Equatable { + public struct Content: Equatable, Sendable { /// The current state of the recording flow. public var state: State @@ -463,7 +463,11 @@ open class VoiceRecordingVC: _ViewController, ComponentsProvider, AppearanceProv audioSessionFeedbackGenerator.feedbackForBeginRecording() delegate?.voiceRecordingWillBeginRecording(self) audioPlayer?.stop() - audioRecorder.beginRecording { [weak self] in self?.content = .beginRecording } + audioRecorder.beginRecording { [weak self] in + StreamConcurrency.onMain { [weak self] in + self?.content = .beginRecording + } + } case .touchUp where content.state == .recording: sendImmediately = true @@ -565,109 +569,117 @@ open class VoiceRecordingVC: _ViewController, ComponentsProvider, AppearanceProv // MARK: - AudioRecordingDelegate - open func audioRecorder( + nonisolated open func audioRecorder( _ audioRecorder: AudioRecording, didUpdateContext context: AudioRecordingContext ) { - content.updatedWithDuration(context.duration) - content.updatedWithWaveform(context.averagePower) - - recordingIndicatorView.content = context.duration - - liveRecordingView.content = .init( - isRecording: true, - isPlaying: false, - duration: context.duration, - currentTime: context.duration, - waveform: content.waveform - ) + StreamConcurrency.onMain { + content.updatedWithDuration(context.duration) + content.updatedWithWaveform(context.averagePower) + + recordingIndicatorView.content = context.duration + + liveRecordingView.content = .init( + isRecording: true, + isPlaying: false, + duration: context.duration, + currentTime: context.duration, + waveform: content.waveform + ) + } } - open func audioRecorder( + nonisolated open func audioRecorder( _ audioRecorder: AudioRecording, didFinishRecordingAtURL location: URL ) { - guard content != .idle else { return } - - audioAnalysisFactory?.waveformVisualisation( - fromAudioURL: location, - for: waveformTargetSamples - ) { [weak self] result in - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - switch result { - case let .success(waveform): - self.content.waveform = waveform - self.content.updatedWithLocation(location) - - self.liveRecordingView.content = .init( - isRecording: false, - isPlaying: false, - duration: self.content.duration, - currentTime: 0, - waveform: self.content.waveform - ) - self.stopRecordingButton.isHidden = true - - if self.sendImmediately { - if self.components.isVoiceRecordingConfirmationRequiredEnabled { - self.updateContentByApplyingAction(.confirm) - } else { - self.updateContentByApplyingAction(.send) + StreamConcurrency.onMain { + guard content != .idle else { return } + + audioAnalysisFactory?.waveformVisualisation( + fromAudioURL: location, + for: waveformTargetSamples + ) { [weak self] result in + StreamConcurrency.onMain { [weak self] in + guard let self = self else { return } + switch result { + case let .success(waveform): + self.content.waveform = waveform + self.content.updatedWithLocation(location) + + self.liveRecordingView.content = .init( + isRecording: false, + isPlaying: false, + duration: self.content.duration, + currentTime: 0, + waveform: self.content.waveform + ) + self.stopRecordingButton.isHidden = true + + if self.sendImmediately { + if self.components.isVoiceRecordingConfirmationRequiredEnabled { + self.updateContentByApplyingAction(.confirm) + } else { + self.updateContentByApplyingAction(.send) + } + self.sendImmediately = false + self.content = .idle } - self.sendImmediately = false - self.content = .idle + case let .failure(error): + log.error(error) } - case let .failure(error): - log.error(error) } } } } - open func audioRecorder( + nonisolated open func audioRecorder( _ audioRecorder: AudioRecording, didFailWithError error: Error ) { - log.error(error) - content = .idle + StreamConcurrency.onMain { + log.error(error) + content = .idle + } } // MARK: - AudioPlayingDelegate - open func audioPlayer( + nonisolated open func audioPlayer( _ audioPlayer: AudioPlaying, didUpdateContext context: AudioPlaybackContext ) { - let isActive = context.assetLocation == content.location - - switch (isActive, context.state) { - case (true, .playing), (true, .paused): - liveRecordingView.content = .init( - isRecording: false, - isPlaying: context.state == .playing, - duration: context.duration, - currentTime: context.currentTime, - waveform: liveRecordingView.content.waveform - ) - case (true, .stopped): - liveRecordingView.content = .init( - isRecording: false, - isPlaying: false, - duration: context.duration, - currentTime: 0, - waveform: liveRecordingView.content.waveform - ) - case (false, _): - liveRecordingView.content = .init( - isRecording: false, - isPlaying: false, - duration: content.duration, - currentTime: 0, - waveform: liveRecordingView.content.waveform - ) - default: - break + StreamConcurrency.onMain { + let isActive = context.assetLocation == content.location + + switch (isActive, context.state) { + case (true, .playing), (true, .paused): + liveRecordingView.content = .init( + isRecording: false, + isPlaying: context.state == .playing, + duration: context.duration, + currentTime: context.currentTime, + waveform: liveRecordingView.content.waveform + ) + case (true, .stopped): + liveRecordingView.content = .init( + isRecording: false, + isPlaying: false, + duration: context.duration, + currentTime: 0, + waveform: liveRecordingView.content.waveform + ) + case (false, _): + liveRecordingView.content = .init( + isRecording: false, + isPlaying: false, + duration: content.duration, + currentTime: 0, + waveform: liveRecordingView.content.waveform + ) + default: + break + } } } @@ -710,7 +722,7 @@ open class VoiceRecordingVC: _ViewController, ComponentsProvider, AppearanceProv // MARK: - Delegate /// A delegate that the VoiceRecordingVC will use to post information or ask for support. -public protocol VoiceRecordingDelegate: AnyObject { +@preconcurrency @MainActor public protocol VoiceRecordingDelegate: AnyObject { /// Creates and attaches a VoiceRecording attachment on the message. /// - Parameters: /// - voiceRecordingVC: The VoiceRecordingVC responsible for this action. diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 521e7fabbf9..e6e92266ca4 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -318,8 +318,124 @@ 4F97F2782BA87E30001C4D66 /* MessageSearchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F97F2762BA87E30001C4D66 /* MessageSearchState.swift */; }; 4F97F27A2BA88936001C4D66 /* MessageSearchState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F97F2792BA88936001C4D66 /* MessageSearchState+Observer.swift */; }; 4F97F27B2BA88936001C4D66 /* MessageSearchState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F97F2792BA88936001C4D66 /* MessageSearchState+Observer.swift */; }; + 4F9E5EE42D8C439700047754 /* StreamConcurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F9E5EE32D8C438900047754 /* StreamConcurrency.swift */; }; + 4F9E5EE52D8C439700047754 /* StreamConcurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F9E5EE32D8C438900047754 /* StreamConcurrency.swift */; }; 4FB4AB9F2BAD6DBD00712C4E /* Chat_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FB4AB9E2BAD6DBD00712C4E /* Chat_Tests.swift */; }; 4FBD840B2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FBD840A2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift */; }; + 4FC743EC2D8D9A2600E314EB /* ImageDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B22D8D9A2600E314EB /* ImageDecoding.swift */; }; + 4FC743ED2D8D9A2600E314EB /* AssetType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743AE2D8D9A2600E314EB /* AssetType.swift */; }; + 4FC743EE2D8D9A2600E314EB /* TaskFetchOriginalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743DE2D8D9A2600E314EB /* TaskFetchOriginalData.swift */; }; + 4FC743EF2D8D9A2600E314EB /* ImageProcessors+Anonymous.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D42D8D9A2600E314EB /* ImageProcessors+Anonymous.swift */; }; + 4FC743F02D8D9A2600E314EB /* ImageEncoders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B42D8D9A2600E314EB /* ImageEncoders.swift */; }; + 4FC743F12D8D9A2600E314EB /* TaskLoadImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E22D8D9A2600E314EB /* TaskLoadImage.swift */; }; + 4FC743F22D8D9A2600E314EB /* ImageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E62D8D9A2600E314EB /* ImageResponse.swift */; }; + 4FC743F32D8D9A2600E314EB /* ImageLoadingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E92D8D9A2600E314EB /* ImageLoadingOptions.swift */; }; + 4FC743F42D8D9A2600E314EB /* LinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BF2D8D9A2600E314EB /* LinkedList.swift */; }; + 4FC743F52D8D9A2600E314EB /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C02D8D9A2600E314EB /* Log.swift */; }; + 4FC743F62D8D9A2600E314EB /* ImageRequestKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BE2D8D9A2600E314EB /* ImageRequestKeys.swift */; }; + 4FC743F72D8D9A2600E314EB /* ImagePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C82D8D9A2600E314EB /* ImagePipeline.swift */; }; + 4FC743F82D8D9A2600E314EB /* ImageDecoderRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743AF2D8D9A2600E314EB /* ImageDecoderRegistry.swift */; }; + 4FC743F92D8D9A2600E314EB /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D12D8D9A2600E314EB /* ImageProcessing.swift */; }; + 4FC743FA2D8D9A2600E314EB /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743AB2D8D9A2600E314EB /* ImageCache.swift */; }; + 4FC743FB2D8D9A2600E314EB /* ImagePipeline+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C92D8D9A2600E314EB /* ImagePipeline+Cache.swift */; }; + 4FC743FC2D8D9A2600E314EB /* ImagePipeline+Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743CB2D8D9A2600E314EB /* ImagePipeline+Delegate.swift */; }; + 4FC743FD2D8D9A2600E314EB /* ImageDecoders+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B02D8D9A2600E314EB /* ImageDecoders+Default.swift */; }; + 4FC743FE2D8D9A2600E314EB /* ResumableData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C32D8D9A2600E314EB /* ResumableData.swift */; }; + 4FC743FF2D8D9A2600E314EB /* ImageProcessors+RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743DA2D8D9A2600E314EB /* ImageProcessors+RoundedCorners.swift */; }; + 4FC744002D8D9A2600E314EB /* ImageProcessingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D22D8D9A2600E314EB /* ImageProcessingOptions.swift */; }; + 4FC744012D8D9A2600E314EB /* ImageContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E42D8D9A2600E314EB /* ImageContainer.swift */; }; + 4FC744022D8D9A2600E314EB /* ImageEncoders+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B52D8D9A2600E314EB /* ImageEncoders+Default.swift */; }; + 4FC744032D8D9A2600E314EB /* TaskFetchWithPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E02D8D9A2600E314EB /* TaskFetchWithPublisher.swift */; }; + 4FC744042D8D9A2600E314EB /* RateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C22D8D9A2600E314EB /* RateLimiter.swift */; }; + 4FC744052D8D9A2600E314EB /* DataLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C62D8D9A2600E314EB /* DataLoading.swift */; }; + 4FC744062D8D9A2600E314EB /* ImageTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E72D8D9A2600E314EB /* ImageTask.swift */; }; + 4FC744072D8D9A2600E314EB /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743A82D8D9A2600E314EB /* Cache.swift */; }; + 4FC744082D8D9A2600E314EB /* AsyncPipelineTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743DC2D8D9A2600E314EB /* AsyncPipelineTask.swift */; }; + 4FC744092D8D9A2600E314EB /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B92D8D9A2600E314EB /* Atomic.swift */; }; + 4FC7440A2D8D9A2600E314EB /* DataCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743AA2D8D9A2600E314EB /* DataCaching.swift */; }; + 4FC7440B2D8D9A2600E314EB /* ImageProcessors+CoreImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D72D8D9A2600E314EB /* ImageProcessors+CoreImage.swift */; }; + 4FC7440C2D8D9A2600E314EB /* ImageProcessors+Composition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D62D8D9A2600E314EB /* ImageProcessors+Composition.swift */; }; + 4FC7440D2D8D9A2600E314EB /* ImagePipeline+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743CC2D8D9A2600E314EB /* ImagePipeline+Error.swift */; }; + 4FC7440E2D8D9A2600E314EB /* ImagePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BD2D8D9A2600E314EB /* ImagePublisher.swift */; }; + 4FC7440F2D8D9A2600E314EB /* ImageProcessors+Circle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D52D8D9A2600E314EB /* ImageProcessors+Circle.swift */; }; + 4FC744102D8D9A2600E314EB /* TaskFetchOriginalImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743DF2D8D9A2600E314EB /* TaskFetchOriginalImage.swift */; }; + 4FC744112D8D9A2600E314EB /* DataPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BA2D8D9A2600E314EB /* DataPublisher.swift */; }; + 4FC744122D8D9A2600E314EB /* ImagePipeline+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743CA2D8D9A2600E314EB /* ImagePipeline+Configuration.swift */; }; + 4FC744132D8D9A2600E314EB /* AsyncTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743DD2D8D9A2600E314EB /* AsyncTask.swift */; }; + 4FC744142D8D9A2600E314EB /* ImageCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743AC2D8D9A2600E314EB /* ImageCaching.swift */; }; + 4FC744152D8D9A2600E314EB /* ImageEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B72D8D9A2600E314EB /* ImageEncoding.swift */; }; + 4FC744162D8D9A2600E314EB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BB2D8D9A2600E314EB /* Extensions.swift */; }; + 4FC744172D8D9A2600E314EB /* ImagePrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743CE2D8D9A2600E314EB /* ImagePrefetcher.swift */; }; + 4FC744182D8D9A2600E314EB /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743A92D8D9A2600E314EB /* DataCache.swift */; }; + 4FC744192D8D9A2600E314EB /* ImageEncoders+ImageIO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B62D8D9A2600E314EB /* ImageEncoders+ImageIO.swift */; }; + 4FC7441A2D8D9A2600E314EB /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C52D8D9A2600E314EB /* DataLoader.swift */; }; + 4FC7441B2D8D9A2600E314EB /* ImageProcessors+GaussianBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D82D8D9A2600E314EB /* ImageProcessors+GaussianBlur.swift */; }; + 4FC7441C2D8D9A2600E314EB /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C12D8D9A2600E314EB /* Operation.swift */; }; + 4FC7441D2D8D9A2600E314EB /* TaskLoadData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E12D8D9A2600E314EB /* TaskLoadData.swift */; }; + 4FC7441E2D8D9A2600E314EB /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E52D8D9A2600E314EB /* ImageRequest.swift */; }; + 4FC7441F2D8D9A2600E314EB /* ImageDecoders+Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B12D8D9A2600E314EB /* ImageDecoders+Empty.swift */; }; + 4FC744202D8D9A2600E314EB /* ImageViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743EA2D8D9A2600E314EB /* ImageViewExtensions.swift */; }; + 4FC744212D8D9A2600E314EB /* ImageProcessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D32D8D9A2600E314EB /* ImageProcessors.swift */; }; + 4FC744222D8D9A2600E314EB /* ImageProcessors+Resize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D92D8D9A2600E314EB /* ImageProcessors+Resize.swift */; }; + 4FC744232D8D9A2600E314EB /* ImageDecompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D02D8D9A2600E314EB /* ImageDecompression.swift */; }; + 4FC744242D8D9A2600E314EB /* Graphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BC2D8D9A2600E314EB /* Graphics.swift */; }; + 4FC744252D8D9A2600E314EB /* ImageDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B22D8D9A2600E314EB /* ImageDecoding.swift */; }; + 4FC744262D8D9A2600E314EB /* AssetType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743AE2D8D9A2600E314EB /* AssetType.swift */; }; + 4FC744272D8D9A2600E314EB /* TaskFetchOriginalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743DE2D8D9A2600E314EB /* TaskFetchOriginalData.swift */; }; + 4FC744282D8D9A2600E314EB /* ImageProcessors+Anonymous.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D42D8D9A2600E314EB /* ImageProcessors+Anonymous.swift */; }; + 4FC744292D8D9A2600E314EB /* ImageEncoders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B42D8D9A2600E314EB /* ImageEncoders.swift */; }; + 4FC7442A2D8D9A2600E314EB /* TaskLoadImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E22D8D9A2600E314EB /* TaskLoadImage.swift */; }; + 4FC7442B2D8D9A2600E314EB /* ImageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E62D8D9A2600E314EB /* ImageResponse.swift */; }; + 4FC7442C2D8D9A2600E314EB /* ImageLoadingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E92D8D9A2600E314EB /* ImageLoadingOptions.swift */; }; + 4FC7442D2D8D9A2600E314EB /* LinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BF2D8D9A2600E314EB /* LinkedList.swift */; }; + 4FC7442E2D8D9A2600E314EB /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C02D8D9A2600E314EB /* Log.swift */; }; + 4FC7442F2D8D9A2600E314EB /* ImageRequestKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BE2D8D9A2600E314EB /* ImageRequestKeys.swift */; }; + 4FC744302D8D9A2600E314EB /* ImagePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C82D8D9A2600E314EB /* ImagePipeline.swift */; }; + 4FC744312D8D9A2600E314EB /* ImageDecoderRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743AF2D8D9A2600E314EB /* ImageDecoderRegistry.swift */; }; + 4FC744322D8D9A2600E314EB /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D12D8D9A2600E314EB /* ImageProcessing.swift */; }; + 4FC744332D8D9A2600E314EB /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743AB2D8D9A2600E314EB /* ImageCache.swift */; }; + 4FC744342D8D9A2600E314EB /* ImagePipeline+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C92D8D9A2600E314EB /* ImagePipeline+Cache.swift */; }; + 4FC744352D8D9A2600E314EB /* ImagePipeline+Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743CB2D8D9A2600E314EB /* ImagePipeline+Delegate.swift */; }; + 4FC744362D8D9A2600E314EB /* ImageDecoders+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B02D8D9A2600E314EB /* ImageDecoders+Default.swift */; }; + 4FC744372D8D9A2600E314EB /* ResumableData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C32D8D9A2600E314EB /* ResumableData.swift */; }; + 4FC744382D8D9A2600E314EB /* ImageProcessors+RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743DA2D8D9A2600E314EB /* ImageProcessors+RoundedCorners.swift */; }; + 4FC744392D8D9A2600E314EB /* ImageProcessingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D22D8D9A2600E314EB /* ImageProcessingOptions.swift */; }; + 4FC7443A2D8D9A2600E314EB /* ImageContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E42D8D9A2600E314EB /* ImageContainer.swift */; }; + 4FC7443B2D8D9A2600E314EB /* ImageEncoders+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B52D8D9A2600E314EB /* ImageEncoders+Default.swift */; }; + 4FC7443C2D8D9A2600E314EB /* TaskFetchWithPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E02D8D9A2600E314EB /* TaskFetchWithPublisher.swift */; }; + 4FC7443D2D8D9A2600E314EB /* RateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C22D8D9A2600E314EB /* RateLimiter.swift */; }; + 4FC7443E2D8D9A2600E314EB /* DataLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C62D8D9A2600E314EB /* DataLoading.swift */; }; + 4FC7443F2D8D9A2600E314EB /* ImageTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E72D8D9A2600E314EB /* ImageTask.swift */; }; + 4FC744402D8D9A2600E314EB /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743A82D8D9A2600E314EB /* Cache.swift */; }; + 4FC744412D8D9A2600E314EB /* AsyncPipelineTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743DC2D8D9A2600E314EB /* AsyncPipelineTask.swift */; }; + 4FC744422D8D9A2600E314EB /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B92D8D9A2600E314EB /* Atomic.swift */; }; + 4FC744432D8D9A2600E314EB /* DataCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743AA2D8D9A2600E314EB /* DataCaching.swift */; }; + 4FC744442D8D9A2600E314EB /* ImageProcessors+CoreImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D72D8D9A2600E314EB /* ImageProcessors+CoreImage.swift */; }; + 4FC744452D8D9A2600E314EB /* ImageProcessors+Composition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D62D8D9A2600E314EB /* ImageProcessors+Composition.swift */; }; + 4FC744462D8D9A2600E314EB /* ImagePipeline+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743CC2D8D9A2600E314EB /* ImagePipeline+Error.swift */; }; + 4FC744472D8D9A2600E314EB /* ImagePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BD2D8D9A2600E314EB /* ImagePublisher.swift */; }; + 4FC744482D8D9A2600E314EB /* ImageProcessors+Circle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D52D8D9A2600E314EB /* ImageProcessors+Circle.swift */; }; + 4FC744492D8D9A2600E314EB /* TaskFetchOriginalImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743DF2D8D9A2600E314EB /* TaskFetchOriginalImage.swift */; }; + 4FC7444A2D8D9A2600E314EB /* DataPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BA2D8D9A2600E314EB /* DataPublisher.swift */; }; + 4FC7444B2D8D9A2600E314EB /* ImagePipeline+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743CA2D8D9A2600E314EB /* ImagePipeline+Configuration.swift */; }; + 4FC7444C2D8D9A2600E314EB /* AsyncTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743DD2D8D9A2600E314EB /* AsyncTask.swift */; }; + 4FC7444D2D8D9A2600E314EB /* ImageCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743AC2D8D9A2600E314EB /* ImageCaching.swift */; }; + 4FC7444E2D8D9A2600E314EB /* ImageEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B72D8D9A2600E314EB /* ImageEncoding.swift */; }; + 4FC7444F2D8D9A2600E314EB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BB2D8D9A2600E314EB /* Extensions.swift */; }; + 4FC744502D8D9A2600E314EB /* ImagePrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743CE2D8D9A2600E314EB /* ImagePrefetcher.swift */; }; + 4FC744512D8D9A2600E314EB /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743A92D8D9A2600E314EB /* DataCache.swift */; }; + 4FC744522D8D9A2600E314EB /* ImageEncoders+ImageIO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B62D8D9A2600E314EB /* ImageEncoders+ImageIO.swift */; }; + 4FC744532D8D9A2600E314EB /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C52D8D9A2600E314EB /* DataLoader.swift */; }; + 4FC744542D8D9A2600E314EB /* ImageProcessors+GaussianBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D82D8D9A2600E314EB /* ImageProcessors+GaussianBlur.swift */; }; + 4FC744552D8D9A2600E314EB /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743C12D8D9A2600E314EB /* Operation.swift */; }; + 4FC744562D8D9A2600E314EB /* TaskLoadData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E12D8D9A2600E314EB /* TaskLoadData.swift */; }; + 4FC744572D8D9A2600E314EB /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743E52D8D9A2600E314EB /* ImageRequest.swift */; }; + 4FC744582D8D9A2600E314EB /* ImageDecoders+Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B12D8D9A2600E314EB /* ImageDecoders+Empty.swift */; }; + 4FC744592D8D9A2600E314EB /* ImageViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743EA2D8D9A2600E314EB /* ImageViewExtensions.swift */; }; + 4FC7445A2D8D9A2600E314EB /* ImageProcessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D32D8D9A2600E314EB /* ImageProcessors.swift */; }; + 4FC7445B2D8D9A2600E314EB /* ImageProcessors+Resize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D92D8D9A2600E314EB /* ImageProcessors+Resize.swift */; }; + 4FC7445C2D8D9A2600E314EB /* ImageDecompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743D02D8D9A2600E314EB /* ImageDecompression.swift */; }; + 4FC7445D2D8D9A2600E314EB /* Graphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BC2D8D9A2600E314EB /* Graphics.swift */; }; 4FCCACE42BC939EB009D23E1 /* MemberList_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCCACE32BC939EB009D23E1 /* MemberList_Tests.swift */; }; 4FD2BE502B99F68300FFC6F2 /* ReadStateHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD2BE4F2B99F68300FFC6F2 /* ReadStateHandler.swift */; }; 4FD2BE512B99F68300FFC6F2 /* ReadStateHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD2BE4F2B99F68300FFC6F2 /* ReadStateHandler.swift */; }; @@ -727,7 +843,7 @@ 843C53AB269370A900C7D8EA /* ImageAttachmentPayload_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843C53AA269370A900C7D8EA /* ImageAttachmentPayload_Tests.swift */; }; 843C53AD269373EA00C7D8EA /* VideoAttachmentPayload_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843C53AC269373EA00C7D8EA /* VideoAttachmentPayload_Tests.swift */; }; 843C53AF2693759E00C7D8EA /* FileAttachmentPayload_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843C53AE2693759E00C7D8EA /* FileAttachmentPayload_Tests.swift */; }; - 843F0BC326775CDB00B342CB /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F0BC226775CDB00B342CB /* Cache.swift */; }; + 843F0BC326775CDB00B342CB /* NSCacheWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F0BC226775CDB00B342CB /* NSCacheWrapper.swift */; }; 843F0BC526775D2D00B342CB /* VideoLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F0BC426775D2D00B342CB /* VideoLoading.swift */; }; 843F0BC72677640000B342CB /* VideoAttachmentGalleryPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F0BC62677640000B342CB /* VideoAttachmentGalleryPreview.swift */; }; 843F0BCD2677667000B342CB /* AttachmentActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F0BCC2677667000B342CB /* AttachmentActionButton.swift */; }; @@ -2137,7 +2253,7 @@ C121EB812746A1E700023E4C /* CGRect+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6E5E3462627A372007FA51F /* CGRect+Extensions.swift */; }; C121EB822746A1E700023E4C /* CGPoint+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DFA8B26282F8B00F7F6F9 /* CGPoint+Extensions.swift */; }; C121EB832746A1E700023E4C /* NavigationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88410ED026556B6F00525AA3 /* NavigationVC.swift */; }; - C121EB842746A1E700023E4C /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F0BC226775CDB00B342CB /* Cache.swift */; }; + C121EB842746A1E700023E4C /* NSCacheWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F0BC226775CDB00B342CB /* NSCacheWrapper.swift */; }; C121EB852746A1E700023E4C /* VideoLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F0BC426775D2D00B342CB /* VideoLoading.swift */; }; C121EB872746A1E700023E4C /* ImageMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD502A826BC0C670029FB7D /* ImageMerger.swift */; }; C121EB882746A1E700023E4C /* NukeImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACCA772D26C568D8007AE2ED /* NukeImageProcessor.swift */; }; @@ -2277,41 +2393,6 @@ C121EC0E2746A1EC00023E4C /* AlertsRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8825334B258CE82500B77352 /* AlertsRouter.swift */; }; C121EC0F2746A1EC00023E4C /* NavigationRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850FE90256558B200C8D534 /* NavigationRouter.swift */; }; C121EC102746A1EC00023E4C /* L10n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F0D733257E4EA500F4B050 /* L10n.swift */; }; - C121EC112746A1EC00023E4C /* ImageViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72762732CB7B006EB51E /* ImageViewExtensions.swift */; }; - C121EC122746A1EC00023E4C /* FetchImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72772732CB7B006EB51E /* FetchImage.swift */; }; - C121EC132746A1EC00023E4C /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72792732CB7B006EB51E /* ImageCache.swift */; }; - C121EC142746A1EC00023E4C /* ImageTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727A2732CB7B006EB51E /* ImageTask.swift */; }; - C121EC152746A1EC00023E4C /* ImagePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727B2732CB7B006EB51E /* ImagePipeline.swift */; }; - C121EC162746A1EC00023E4C /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727C2732CB7B006EB51E /* ImageProcessing.swift */; }; - C121EC172746A1EC00023E4C /* ImagePipelineConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727D2732CB7B006EB51E /* ImagePipelineConfiguration.swift */; }; - C121EC182746A1EC00023E4C /* ImageEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727E2732CB7B006EB51E /* ImageEncoding.swift */; }; - C121EC192746A1EC00023E4C /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727F2732CB7B006EB51E /* ImageRequest.swift */; }; - C121EC1A2746A1EC00023E4C /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72802732CB7B006EB51E /* DataCache.swift */; }; - C121EC1B2746A1EC00023E4C /* ImageDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72812732CB7B006EB51E /* ImageDecoding.swift */; }; - C121EC1C2746A1EC00023E4C /* ImagePipelineCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72822732CB7B006EB51E /* ImagePipelineCache.swift */; }; - C121EC1D2746A1EC00023E4C /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72832732CB7B006EB51E /* DataLoader.swift */; }; - C121EC1E2746A1EC00023E4C /* ImageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72842732CB7B006EB51E /* ImageResponse.swift */; }; - C121EC1F2746A1EC00023E4C /* ImagePipelineDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72852732CB7B006EB51E /* ImagePipelineDelegate.swift */; }; - C121EC202746A1EC00023E4C /* ImagePrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72862732CB7B006EB51E /* ImagePrefetcher.swift */; }; - C121EC212746A1EC00023E4C /* ImagePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72882732CB7B006EB51E /* ImagePublisher.swift */; }; - C121EC222746A1EC00023E4C /* ResumableData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE728A2732CB7B006EB51E /* ResumableData.swift */; }; - C121EC232746A1EC00023E4C /* Allocations.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE728B2732CB7B006EB51E /* Allocations.swift */; }; - C121EC242746A1ED00023E4C /* TaskFetchDecodedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE728D2732CB7B006EB51E /* TaskFetchDecodedImage.swift */; }; - C121EC252746A1ED00023E4C /* TaskLoadData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE728E2732CB7B006EB51E /* TaskLoadData.swift */; }; - C121EC262746A1ED00023E4C /* TaskFetchOriginalImageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE728F2732CB7B006EB51E /* TaskFetchOriginalImageData.swift */; }; - C121EC272746A1ED00023E4C /* ImagePipelineTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72902732CB7B006EB51E /* ImagePipelineTask.swift */; }; - C121EC282746A1ED00023E4C /* OperationTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72912732CB7B006EB51E /* OperationTask.swift */; }; - C121EC292746A1ED00023E4C /* TaskLoadImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72922732CB7B006EB51E /* TaskLoadImage.swift */; }; - C121EC2A2746A1ED00023E4C /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72932732CB7B006EB51E /* Task.swift */; }; - C121EC2B2746A1ED00023E4C /* TaskFetchWithPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72942732CB7B006EB51E /* TaskFetchWithPublisher.swift */; }; - C121EC2C2746A1ED00023E4C /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72952732CB7B006EB51E /* Log.swift */; }; - C121EC2D2746A1ED00023E4C /* RateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72962732CB7B006EB51E /* RateLimiter.swift */; }; - C121EC2E2746A1ED00023E4C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72972732CB7B006EB51E /* Extensions.swift */; }; - C121EC2F2746A1ED00023E4C /* Deprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72982732CB7B006EB51E /* Deprecated.swift */; }; - C121EC302746A1ED00023E4C /* Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72992732CB7B006EB51E /* Combine.swift */; }; - C121EC312746A1ED00023E4C /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE729A2732CB7B006EB51E /* Operation.swift */; }; - C121EC322746A1ED00023E4C /* ImageRequestKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE729B2732CB7B006EB51E /* ImageRequestKeys.swift */; }; - C121EC332746A1ED00023E4C /* LinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE729C2732CB7B006EB51E /* LinkedList.swift */; }; C121EC342746A1ED00023E4C /* UIImageView+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13C74D2273932D200F93B34 /* UIImageView+SwiftyGif.swift */; }; C121EC352746A1ED00023E4C /* NSImage+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13C74D3273932D200F93B34 /* NSImage+SwiftyGif.swift */; }; C121EC362746A1ED00023E4C /* UIImage+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13C74D4273932D200F93B34 /* UIImage+SwiftyGif.swift */; }; @@ -2418,41 +2499,6 @@ C1EFF3F3285E459C0057B91B /* IdentifiableModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EFF3F2285E459C0057B91B /* IdentifiableModel.swift */; }; C1EFF3F4285E459C0057B91B /* IdentifiableModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EFF3F2285E459C0057B91B /* IdentifiableModel.swift */; }; C1EFF3F828633B5D0057B91B /* IdentifiableModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EFF3F728633B5D0057B91B /* IdentifiableModel_Tests.swift */; }; - C1FC2F6727416E150062530F /* ResumableData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE728A2732CB7B006EB51E /* ResumableData.swift */; }; - C1FC2F6827416E150062530F /* ImagePipelineCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72822732CB7B006EB51E /* ImagePipelineCache.swift */; }; - C1FC2F6927416E150062530F /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72952732CB7B006EB51E /* Log.swift */; }; - C1FC2F6A27416E150062530F /* TaskFetchWithPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72942732CB7B006EB51E /* TaskFetchWithPublisher.swift */; }; - C1FC2F6B27416E150062530F /* ImagePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72882732CB7B006EB51E /* ImagePublisher.swift */; }; - C1FC2F6C27416E150062530F /* LinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE729C2732CB7B006EB51E /* LinkedList.swift */; }; - C1FC2F6D27416E150062530F /* TaskLoadData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE728E2732CB7B006EB51E /* TaskLoadData.swift */; }; - C1FC2F6E27416E150062530F /* ImagePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727B2732CB7B006EB51E /* ImagePipeline.swift */; }; - C1FC2F6F27416E150062530F /* ImagePipelineConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727D2732CB7B006EB51E /* ImagePipelineConfiguration.swift */; }; - C1FC2F7027416E150062530F /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72932732CB7B006EB51E /* Task.swift */; }; - C1FC2F7127416E150062530F /* ImageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72842732CB7B006EB51E /* ImageResponse.swift */; }; - C1FC2F7227416E150062530F /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72792732CB7B006EB51E /* ImageCache.swift */; }; - C1FC2F7327416E150062530F /* ImageViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72762732CB7B006EB51E /* ImageViewExtensions.swift */; }; - C1FC2F7427416E150062530F /* Deprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72982732CB7B006EB51E /* Deprecated.swift */; }; - C1FC2F7527416E150062530F /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE729A2732CB7B006EB51E /* Operation.swift */; }; - C1FC2F7627416E150062530F /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72972732CB7B006EB51E /* Extensions.swift */; }; - C1FC2F7727416E150062530F /* ImagePipelineDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72852732CB7B006EB51E /* ImagePipelineDelegate.swift */; }; - C1FC2F7827416E150062530F /* ImageDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72812732CB7B006EB51E /* ImageDecoding.swift */; }; - C1FC2F7927416E150062530F /* TaskLoadImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72922732CB7B006EB51E /* TaskLoadImage.swift */; }; - C1FC2F7A27416E150062530F /* ImageTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727A2732CB7B006EB51E /* ImageTask.swift */; }; - C1FC2F7B27416E150062530F /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72832732CB7B006EB51E /* DataLoader.swift */; }; - C1FC2F7C27416E150062530F /* Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72992732CB7B006EB51E /* Combine.swift */; }; - C1FC2F7D27416E150062530F /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727F2732CB7B006EB51E /* ImageRequest.swift */; }; - C1FC2F7E27416E150062530F /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727C2732CB7B006EB51E /* ImageProcessing.swift */; }; - C1FC2F7F27416E150062530F /* TaskFetchOriginalImageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE728F2732CB7B006EB51E /* TaskFetchOriginalImageData.swift */; }; - C1FC2F8027416E150062530F /* ImagePrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72862732CB7B006EB51E /* ImagePrefetcher.swift */; }; - C1FC2F8127416E150062530F /* RateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72962732CB7B006EB51E /* RateLimiter.swift */; }; - C1FC2F8227416E150062530F /* ImagePipelineTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72902732CB7B006EB51E /* ImagePipelineTask.swift */; }; - C1FC2F8327416E150062530F /* TaskFetchDecodedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE728D2732CB7B006EB51E /* TaskFetchDecodedImage.swift */; }; - C1FC2F8427416E150062530F /* FetchImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72772732CB7B006EB51E /* FetchImage.swift */; }; - C1FC2F8527416E150062530F /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72802732CB7B006EB51E /* DataCache.swift */; }; - C1FC2F8627416E150062530F /* Allocations.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE728B2732CB7B006EB51E /* Allocations.swift */; }; - C1FC2F8727416E150062530F /* OperationTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE72912732CB7B006EB51E /* OperationTask.swift */; }; - C1FC2F8827416E150062530F /* ImageRequestKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE729B2732CB7B006EB51E /* ImageRequestKeys.swift */; }; - C1FC2F8927416E150062530F /* ImageEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE727E2732CB7B006EB51E /* ImageEncoding.swift */; }; C1FC2F8A27416E1F0062530F /* UIImageView+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13C74D2273932D200F93B34 /* UIImageView+SwiftyGif.swift */; }; C1FC2F8B27416E1F0062530F /* SwiftyGifManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13C74D5273932D200F93B34 /* SwiftyGifManager.swift */; }; C1FC2F8C27416E1F0062530F /* UIImage+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13C74D4273932D200F93B34 /* UIImage+SwiftyGif.swift */; }; @@ -3244,8 +3290,66 @@ 4F97F2732BA87C41001C4D66 /* MessageSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSearch.swift; sourceTree = ""; }; 4F97F2762BA87E30001C4D66 /* MessageSearchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSearchState.swift; sourceTree = ""; }; 4F97F2792BA88936001C4D66 /* MessageSearchState+Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSearchState+Observer.swift"; sourceTree = ""; }; + 4F9E5EE32D8C438900047754 /* StreamConcurrency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamConcurrency.swift; sourceTree = ""; }; 4FB4AB9E2BAD6DBD00712C4E /* Chat_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chat_Tests.swift; sourceTree = ""; }; 4FBD840A2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDownloader_Spy.swift; sourceTree = ""; }; + 4FC743A82D8D9A2600E314EB /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = ""; }; + 4FC743A92D8D9A2600E314EB /* DataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = ""; }; + 4FC743AA2D8D9A2600E314EB /* DataCaching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCaching.swift; sourceTree = ""; }; + 4FC743AB2D8D9A2600E314EB /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; + 4FC743AC2D8D9A2600E314EB /* ImageCaching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCaching.swift; sourceTree = ""; }; + 4FC743AE2D8D9A2600E314EB /* AssetType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetType.swift; sourceTree = ""; }; + 4FC743AF2D8D9A2600E314EB /* ImageDecoderRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecoderRegistry.swift; sourceTree = ""; }; + 4FC743B02D8D9A2600E314EB /* ImageDecoders+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+Default.swift"; sourceTree = ""; }; + 4FC743B12D8D9A2600E314EB /* ImageDecoders+Empty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+Empty.swift"; sourceTree = ""; }; + 4FC743B22D8D9A2600E314EB /* ImageDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecoding.swift; sourceTree = ""; }; + 4FC743B42D8D9A2600E314EB /* ImageEncoders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEncoders.swift; sourceTree = ""; }; + 4FC743B52D8D9A2600E314EB /* ImageEncoders+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageEncoders+Default.swift"; sourceTree = ""; }; + 4FC743B62D8D9A2600E314EB /* ImageEncoders+ImageIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageEncoders+ImageIO.swift"; sourceTree = ""; }; + 4FC743B72D8D9A2600E314EB /* ImageEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEncoding.swift; sourceTree = ""; }; + 4FC743B92D8D9A2600E314EB /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; + 4FC743BA2D8D9A2600E314EB /* DataPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPublisher.swift; sourceTree = ""; }; + 4FC743BB2D8D9A2600E314EB /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + 4FC743BC2D8D9A2600E314EB /* Graphics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Graphics.swift; sourceTree = ""; }; + 4FC743BD2D8D9A2600E314EB /* ImagePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePublisher.swift; sourceTree = ""; }; + 4FC743BE2D8D9A2600E314EB /* ImageRequestKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequestKeys.swift; sourceTree = ""; }; + 4FC743BF2D8D9A2600E314EB /* LinkedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedList.swift; sourceTree = ""; }; + 4FC743C02D8D9A2600E314EB /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; + 4FC743C12D8D9A2600E314EB /* Operation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = ""; }; + 4FC743C22D8D9A2600E314EB /* RateLimiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimiter.swift; sourceTree = ""; }; + 4FC743C32D8D9A2600E314EB /* ResumableData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumableData.swift; sourceTree = ""; }; + 4FC743C52D8D9A2600E314EB /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = ""; }; + 4FC743C62D8D9A2600E314EB /* DataLoading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoading.swift; sourceTree = ""; }; + 4FC743C82D8D9A2600E314EB /* ImagePipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipeline.swift; sourceTree = ""; }; + 4FC743C92D8D9A2600E314EB /* ImagePipeline+Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Cache.swift"; sourceTree = ""; }; + 4FC743CA2D8D9A2600E314EB /* ImagePipeline+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Configuration.swift"; sourceTree = ""; }; + 4FC743CB2D8D9A2600E314EB /* ImagePipeline+Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Delegate.swift"; sourceTree = ""; }; + 4FC743CC2D8D9A2600E314EB /* ImagePipeline+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Error.swift"; sourceTree = ""; }; + 4FC743CE2D8D9A2600E314EB /* ImagePrefetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePrefetcher.swift; sourceTree = ""; }; + 4FC743D02D8D9A2600E314EB /* ImageDecompression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecompression.swift; sourceTree = ""; }; + 4FC743D12D8D9A2600E314EB /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = ""; }; + 4FC743D22D8D9A2600E314EB /* ImageProcessingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessingOptions.swift; sourceTree = ""; }; + 4FC743D32D8D9A2600E314EB /* ImageProcessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessors.swift; sourceTree = ""; }; + 4FC743D42D8D9A2600E314EB /* ImageProcessors+Anonymous.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Anonymous.swift"; sourceTree = ""; }; + 4FC743D52D8D9A2600E314EB /* ImageProcessors+Circle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Circle.swift"; sourceTree = ""; }; + 4FC743D62D8D9A2600E314EB /* ImageProcessors+Composition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Composition.swift"; sourceTree = ""; }; + 4FC743D72D8D9A2600E314EB /* ImageProcessors+CoreImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+CoreImage.swift"; sourceTree = ""; }; + 4FC743D82D8D9A2600E314EB /* ImageProcessors+GaussianBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+GaussianBlur.swift"; sourceTree = ""; }; + 4FC743D92D8D9A2600E314EB /* ImageProcessors+Resize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Resize.swift"; sourceTree = ""; }; + 4FC743DA2D8D9A2600E314EB /* ImageProcessors+RoundedCorners.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+RoundedCorners.swift"; sourceTree = ""; }; + 4FC743DC2D8D9A2600E314EB /* AsyncPipelineTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPipelineTask.swift; sourceTree = ""; }; + 4FC743DD2D8D9A2600E314EB /* AsyncTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTask.swift; sourceTree = ""; }; + 4FC743DE2D8D9A2600E314EB /* TaskFetchOriginalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchOriginalData.swift; sourceTree = ""; }; + 4FC743DF2D8D9A2600E314EB /* TaskFetchOriginalImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchOriginalImage.swift; sourceTree = ""; }; + 4FC743E02D8D9A2600E314EB /* TaskFetchWithPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchWithPublisher.swift; sourceTree = ""; }; + 4FC743E12D8D9A2600E314EB /* TaskLoadData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLoadData.swift; sourceTree = ""; }; + 4FC743E22D8D9A2600E314EB /* TaskLoadImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLoadImage.swift; sourceTree = ""; }; + 4FC743E42D8D9A2600E314EB /* ImageContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContainer.swift; sourceTree = ""; }; + 4FC743E52D8D9A2600E314EB /* ImageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = ""; }; + 4FC743E62D8D9A2600E314EB /* ImageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageResponse.swift; sourceTree = ""; }; + 4FC743E72D8D9A2600E314EB /* ImageTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTask.swift; sourceTree = ""; }; + 4FC743E92D8D9A2600E314EB /* ImageLoadingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoadingOptions.swift; sourceTree = ""; }; + 4FC743EA2D8D9A2600E314EB /* ImageViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewExtensions.swift; sourceTree = ""; }; 4FCCACE32BC939EB009D23E1 /* MemberList_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberList_Tests.swift; sourceTree = ""; }; 4FD2BE4F2B99F68300FFC6F2 /* ReadStateHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadStateHandler.swift; sourceTree = ""; }; 4FD2BE522B9AEE3500FFC6F2 /* StreamCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamCollection.swift; sourceTree = ""; }; @@ -3656,7 +3760,7 @@ 843C53AA269370A900C7D8EA /* ImageAttachmentPayload_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageAttachmentPayload_Tests.swift; sourceTree = ""; }; 843C53AC269373EA00C7D8EA /* VideoAttachmentPayload_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAttachmentPayload_Tests.swift; sourceTree = ""; }; 843C53AE2693759E00C7D8EA /* FileAttachmentPayload_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAttachmentPayload_Tests.swift; sourceTree = ""; }; - 843F0BC226775CDB00B342CB /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = ""; }; + 843F0BC226775CDB00B342CB /* NSCacheWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSCacheWrapper.swift; sourceTree = ""; }; 843F0BC426775D2D00B342CB /* VideoLoading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoLoading.swift; sourceTree = ""; }; 843F0BC62677640000B342CB /* VideoAttachmentGalleryPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAttachmentGalleryPreview.swift; sourceTree = ""; }; 843F0BCC2677667000B342CB /* AttachmentActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentActionButton.swift; sourceTree = ""; }; @@ -4592,41 +4696,6 @@ C1B15A1329115E8D00C9CD80 /* Token+Development.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Token+Development.swift"; sourceTree = ""; }; C1B49B3C2822A7AD00F4E89E /* StreamRuntimeCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamRuntimeCheck.swift; sourceTree = ""; }; C1B49B3F2822C01C00F4E89E /* NSManagedObject+Validation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSManagedObject+Validation.swift"; sourceTree = ""; }; - C1BE72762732CB7B006EB51E /* ImageViewExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageViewExtensions.swift; sourceTree = ""; }; - C1BE72772732CB7B006EB51E /* FetchImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchImage.swift; sourceTree = ""; }; - C1BE72792732CB7B006EB51E /* ImageCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; - C1BE727A2732CB7B006EB51E /* ImageTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageTask.swift; sourceTree = ""; }; - C1BE727B2732CB7B006EB51E /* ImagePipeline.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipeline.swift; sourceTree = ""; }; - C1BE727C2732CB7B006EB51E /* ImageProcessing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = ""; }; - C1BE727D2732CB7B006EB51E /* ImagePipelineConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipelineConfiguration.swift; sourceTree = ""; }; - C1BE727E2732CB7B006EB51E /* ImageEncoding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEncoding.swift; sourceTree = ""; }; - C1BE727F2732CB7B006EB51E /* ImageRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = ""; }; - C1BE72802732CB7B006EB51E /* DataCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = ""; }; - C1BE72812732CB7B006EB51E /* ImageDecoding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageDecoding.swift; sourceTree = ""; }; - C1BE72822732CB7B006EB51E /* ImagePipelineCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipelineCache.swift; sourceTree = ""; }; - C1BE72832732CB7B006EB51E /* DataLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = ""; }; - C1BE72842732CB7B006EB51E /* ImageResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageResponse.swift; sourceTree = ""; }; - C1BE72852732CB7B006EB51E /* ImagePipelineDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipelineDelegate.swift; sourceTree = ""; }; - C1BE72862732CB7B006EB51E /* ImagePrefetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePrefetcher.swift; sourceTree = ""; }; - C1BE72882732CB7B006EB51E /* ImagePublisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePublisher.swift; sourceTree = ""; }; - C1BE728A2732CB7B006EB51E /* ResumableData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResumableData.swift; sourceTree = ""; }; - C1BE728B2732CB7B006EB51E /* Allocations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Allocations.swift; sourceTree = ""; }; - C1BE728D2732CB7B006EB51E /* TaskFetchDecodedImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TaskFetchDecodedImage.swift; sourceTree = ""; }; - C1BE728E2732CB7B006EB51E /* TaskLoadData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TaskLoadData.swift; sourceTree = ""; }; - C1BE728F2732CB7B006EB51E /* TaskFetchOriginalImageData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TaskFetchOriginalImageData.swift; sourceTree = ""; }; - C1BE72902732CB7B006EB51E /* ImagePipelineTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipelineTask.swift; sourceTree = ""; }; - C1BE72912732CB7B006EB51E /* OperationTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationTask.swift; sourceTree = ""; }; - C1BE72922732CB7B006EB51E /* TaskLoadImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TaskLoadImage.swift; sourceTree = ""; }; - C1BE72932732CB7B006EB51E /* Task.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = ""; }; - C1BE72942732CB7B006EB51E /* TaskFetchWithPublisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TaskFetchWithPublisher.swift; sourceTree = ""; }; - C1BE72952732CB7B006EB51E /* Log.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; - C1BE72962732CB7B006EB51E /* RateLimiter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RateLimiter.swift; sourceTree = ""; }; - C1BE72972732CB7B006EB51E /* Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; - C1BE72982732CB7B006EB51E /* Deprecated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Deprecated.swift; sourceTree = ""; }; - C1BE72992732CB7B006EB51E /* Combine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = ""; }; - C1BE729A2732CB7B006EB51E /* Operation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = ""; }; - C1BE729B2732CB7B006EB51E /* ImageRequestKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRequestKeys.swift; sourceTree = ""; }; - C1BE729C2732CB7B006EB51E /* LinkedList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkedList.swift; sourceTree = ""; }; C1BFBABD29CB2A7500FC82A2 /* JumpToUnreadMessagesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadMessagesButton.swift; sourceTree = ""; }; C1BFBAC029CC42CE00FC82A2 /* JumpToUnreadMessagesButton_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadMessagesButton_Tests.swift; sourceTree = ""; }; C1C5345929AFDDAE006F9AF4 /* ChannelRepository_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRepository_Mock.swift; sourceTree = ""; }; @@ -5354,6 +5423,149 @@ path = StateLayer; sourceTree = ""; }; + 4FC743AD2D8D9A2600E314EB /* Caching */ = { + isa = PBXGroup; + children = ( + 4FC743A82D8D9A2600E314EB /* Cache.swift */, + 4FC743A92D8D9A2600E314EB /* DataCache.swift */, + 4FC743AA2D8D9A2600E314EB /* DataCaching.swift */, + 4FC743AB2D8D9A2600E314EB /* ImageCache.swift */, + 4FC743AC2D8D9A2600E314EB /* ImageCaching.swift */, + ); + path = Caching; + sourceTree = ""; + }; + 4FC743B32D8D9A2600E314EB /* Decoding */ = { + isa = PBXGroup; + children = ( + 4FC743AE2D8D9A2600E314EB /* AssetType.swift */, + 4FC743AF2D8D9A2600E314EB /* ImageDecoderRegistry.swift */, + 4FC743B02D8D9A2600E314EB /* ImageDecoders+Default.swift */, + 4FC743B12D8D9A2600E314EB /* ImageDecoders+Empty.swift */, + 4FC743B22D8D9A2600E314EB /* ImageDecoding.swift */, + ); + path = Decoding; + sourceTree = ""; + }; + 4FC743B82D8D9A2600E314EB /* Encoding */ = { + isa = PBXGroup; + children = ( + 4FC743B42D8D9A2600E314EB /* ImageEncoders.swift */, + 4FC743B52D8D9A2600E314EB /* ImageEncoders+Default.swift */, + 4FC743B62D8D9A2600E314EB /* ImageEncoders+ImageIO.swift */, + 4FC743B72D8D9A2600E314EB /* ImageEncoding.swift */, + ); + path = Encoding; + sourceTree = ""; + }; + 4FC743C42D8D9A2600E314EB /* Internal */ = { + isa = PBXGroup; + children = ( + 4FC743B92D8D9A2600E314EB /* Atomic.swift */, + 4FC743BA2D8D9A2600E314EB /* DataPublisher.swift */, + 4FC743BB2D8D9A2600E314EB /* Extensions.swift */, + 4FC743BC2D8D9A2600E314EB /* Graphics.swift */, + 4FC743BD2D8D9A2600E314EB /* ImagePublisher.swift */, + 4FC743BE2D8D9A2600E314EB /* ImageRequestKeys.swift */, + 4FC743BF2D8D9A2600E314EB /* LinkedList.swift */, + 4FC743C02D8D9A2600E314EB /* Log.swift */, + 4FC743C12D8D9A2600E314EB /* Operation.swift */, + 4FC743C22D8D9A2600E314EB /* RateLimiter.swift */, + 4FC743C32D8D9A2600E314EB /* ResumableData.swift */, + ); + path = Internal; + sourceTree = ""; + }; + 4FC743C72D8D9A2600E314EB /* Loading */ = { + isa = PBXGroup; + children = ( + 4FC743C52D8D9A2600E314EB /* DataLoader.swift */, + 4FC743C62D8D9A2600E314EB /* DataLoading.swift */, + ); + path = Loading; + sourceTree = ""; + }; + 4FC743CD2D8D9A2600E314EB /* Pipeline */ = { + isa = PBXGroup; + children = ( + 4FC743C82D8D9A2600E314EB /* ImagePipeline.swift */, + 4FC743C92D8D9A2600E314EB /* ImagePipeline+Cache.swift */, + 4FC743CA2D8D9A2600E314EB /* ImagePipeline+Configuration.swift */, + 4FC743CB2D8D9A2600E314EB /* ImagePipeline+Delegate.swift */, + 4FC743CC2D8D9A2600E314EB /* ImagePipeline+Error.swift */, + ); + path = Pipeline; + sourceTree = ""; + }; + 4FC743CF2D8D9A2600E314EB /* Prefetching */ = { + isa = PBXGroup; + children = ( + 4FC743CE2D8D9A2600E314EB /* ImagePrefetcher.swift */, + ); + path = Prefetching; + sourceTree = ""; + }; + 4FC743DB2D8D9A2600E314EB /* Processing */ = { + isa = PBXGroup; + children = ( + 4FC743D02D8D9A2600E314EB /* ImageDecompression.swift */, + 4FC743D12D8D9A2600E314EB /* ImageProcessing.swift */, + 4FC743D22D8D9A2600E314EB /* ImageProcessingOptions.swift */, + 4FC743D32D8D9A2600E314EB /* ImageProcessors.swift */, + 4FC743D42D8D9A2600E314EB /* ImageProcessors+Anonymous.swift */, + 4FC743D52D8D9A2600E314EB /* ImageProcessors+Circle.swift */, + 4FC743D62D8D9A2600E314EB /* ImageProcessors+Composition.swift */, + 4FC743D72D8D9A2600E314EB /* ImageProcessors+CoreImage.swift */, + 4FC743D82D8D9A2600E314EB /* ImageProcessors+GaussianBlur.swift */, + 4FC743D92D8D9A2600E314EB /* ImageProcessors+Resize.swift */, + 4FC743DA2D8D9A2600E314EB /* ImageProcessors+RoundedCorners.swift */, + ); + path = Processing; + sourceTree = ""; + }; + 4FC743E32D8D9A2600E314EB /* Tasks */ = { + isa = PBXGroup; + children = ( + 4FC743DC2D8D9A2600E314EB /* AsyncPipelineTask.swift */, + 4FC743DD2D8D9A2600E314EB /* AsyncTask.swift */, + 4FC743DE2D8D9A2600E314EB /* TaskFetchOriginalData.swift */, + 4FC743DF2D8D9A2600E314EB /* TaskFetchOriginalImage.swift */, + 4FC743E02D8D9A2600E314EB /* TaskFetchWithPublisher.swift */, + 4FC743E12D8D9A2600E314EB /* TaskLoadData.swift */, + 4FC743E22D8D9A2600E314EB /* TaskLoadImage.swift */, + ); + path = Tasks; + sourceTree = ""; + }; + 4FC743E82D8D9A2600E314EB /* Nuke */ = { + isa = PBXGroup; + children = ( + 4FC743AD2D8D9A2600E314EB /* Caching */, + 4FC743B32D8D9A2600E314EB /* Decoding */, + 4FC743B82D8D9A2600E314EB /* Encoding */, + 4FC743C42D8D9A2600E314EB /* Internal */, + 4FC743C72D8D9A2600E314EB /* Loading */, + 4FC743CD2D8D9A2600E314EB /* Pipeline */, + 4FC743CF2D8D9A2600E314EB /* Prefetching */, + 4FC743DB2D8D9A2600E314EB /* Processing */, + 4FC743E32D8D9A2600E314EB /* Tasks */, + 4FC743E42D8D9A2600E314EB /* ImageContainer.swift */, + 4FC743E52D8D9A2600E314EB /* ImageRequest.swift */, + 4FC743E62D8D9A2600E314EB /* ImageResponse.swift */, + 4FC743E72D8D9A2600E314EB /* ImageTask.swift */, + ); + path = Nuke; + sourceTree = ""; + }; + 4FC743EB2D8D9A2600E314EB /* NukeExtensions */ = { + isa = PBXGroup; + children = ( + 4FC743E92D8D9A2600E314EB /* ImageLoadingOptions.swift */, + 4FC743EA2D8D9A2600E314EB /* ImageViewExtensions.swift */, + ); + path = NukeExtensions; + sourceTree = ""; + }; 4FF9B2662C6F695C00A3B711 /* AttachmentDownloader */ = { isa = PBXGroup; children = ( @@ -6363,10 +6575,11 @@ isa = PBXGroup; children = ( 22C23599259CA87B00DC805A /* Animation.swift */, - 843F0BC226775CDB00B342CB /* Cache.swift */, + 843F0BC226775CDB00B342CB /* NSCacheWrapper.swift */, ACF73D7726CFE07900372DC0 /* Cancellable.swift */, 88A11B092590AFBB0000AC24 /* ChatMessage+Extensions.swift */, ACA3C98526CA23F300EB8B07 /* DateUtils.swift */, + 4F9E5EE32D8C438900047754 /* StreamConcurrency.swift */, 79F691B12604C10A000AE89B /* SystemEnvironment.swift */, CF7B2A2528BEAA93006BE124 /* TextViewMentionedUsersHandler.swift */, C12297D22AC57A3200C5FF04 /* Throttler.swift */, @@ -9192,85 +9405,12 @@ C1BE72742732CB7B006EB51E /* StreamNuke */ = { isa = PBXGroup; children = ( - C1BE72752732CB7B006EB51E /* UI */, - C1BE72782732CB7B006EB51E /* Core */, - C1BE72872732CB7B006EB51E /* Combine */, - C1BE72892732CB7B006EB51E /* Internal */, + 4FC743E82D8D9A2600E314EB /* Nuke */, + 4FC743EB2D8D9A2600E314EB /* NukeExtensions */, ); path = StreamNuke; sourceTree = ""; }; - C1BE72752732CB7B006EB51E /* UI */ = { - isa = PBXGroup; - children = ( - C1BE72762732CB7B006EB51E /* ImageViewExtensions.swift */, - C1BE72772732CB7B006EB51E /* FetchImage.swift */, - ); - path = UI; - sourceTree = ""; - }; - C1BE72782732CB7B006EB51E /* Core */ = { - isa = PBXGroup; - children = ( - C1BE72792732CB7B006EB51E /* ImageCache.swift */, - C1BE727A2732CB7B006EB51E /* ImageTask.swift */, - C1BE727B2732CB7B006EB51E /* ImagePipeline.swift */, - C1BE727C2732CB7B006EB51E /* ImageProcessing.swift */, - C1BE727D2732CB7B006EB51E /* ImagePipelineConfiguration.swift */, - C1BE727E2732CB7B006EB51E /* ImageEncoding.swift */, - C1BE727F2732CB7B006EB51E /* ImageRequest.swift */, - C1BE72802732CB7B006EB51E /* DataCache.swift */, - C1BE72812732CB7B006EB51E /* ImageDecoding.swift */, - C1BE72822732CB7B006EB51E /* ImagePipelineCache.swift */, - C1BE72832732CB7B006EB51E /* DataLoader.swift */, - C1BE72842732CB7B006EB51E /* ImageResponse.swift */, - C1BE72852732CB7B006EB51E /* ImagePipelineDelegate.swift */, - C1BE72862732CB7B006EB51E /* ImagePrefetcher.swift */, - ); - path = Core; - sourceTree = ""; - }; - C1BE72872732CB7B006EB51E /* Combine */ = { - isa = PBXGroup; - children = ( - C1BE72882732CB7B006EB51E /* ImagePublisher.swift */, - ); - path = Combine; - sourceTree = ""; - }; - C1BE72892732CB7B006EB51E /* Internal */ = { - isa = PBXGroup; - children = ( - C1BE728A2732CB7B006EB51E /* ResumableData.swift */, - C1BE728B2732CB7B006EB51E /* Allocations.swift */, - C1BE728C2732CB7B006EB51E /* Tasks */, - C1BE72952732CB7B006EB51E /* Log.swift */, - C1BE72962732CB7B006EB51E /* RateLimiter.swift */, - C1BE72972732CB7B006EB51E /* Extensions.swift */, - C1BE72982732CB7B006EB51E /* Deprecated.swift */, - C1BE72992732CB7B006EB51E /* Combine.swift */, - C1BE729A2732CB7B006EB51E /* Operation.swift */, - C1BE729B2732CB7B006EB51E /* ImageRequestKeys.swift */, - C1BE729C2732CB7B006EB51E /* LinkedList.swift */, - ); - path = Internal; - sourceTree = ""; - }; - C1BE728C2732CB7B006EB51E /* Tasks */ = { - isa = PBXGroup; - children = ( - C1BE728D2732CB7B006EB51E /* TaskFetchDecodedImage.swift */, - C1BE728E2732CB7B006EB51E /* TaskLoadData.swift */, - C1BE728F2732CB7B006EB51E /* TaskFetchOriginalImageData.swift */, - C1BE72902732CB7B006EB51E /* ImagePipelineTask.swift */, - C1BE72912732CB7B006EB51E /* OperationTask.swift */, - C1BE72922732CB7B006EB51E /* TaskLoadImage.swift */, - C1BE72932732CB7B006EB51E /* Task.swift */, - C1BE72942732CB7B006EB51E /* TaskFetchWithPublisher.swift */, - ); - path = Tasks; - sourceTree = ""; - }; C1E8AD59278DDC500041B775 /* Repositories */ = { isa = PBXGroup; children = ( @@ -10568,7 +10708,6 @@ E798D6D325FF69120002C3B9 /* SwipeableView.swift in Sources */, ADD328762C07E9B200BAD0E9 /* ViewContainerBuilder.swift in Sources */, E701201E2583EBD50036DACD /* CALayer+Extensions.swift in Sources */, - C1FC2F7C27416E150062530F /* Combine.swift in Sources */, 84C11BE527FB459900000A9E /* ChatMessageDeliveryStatusView.swift in Sources */, A3BB3FFF261DA74D00365496 /* ContainerStackView.swift in Sources */, ADBBDA22279F0CFA00E47B1C /* UploadingProgressFormatter.swift in Sources */, @@ -10578,8 +10717,6 @@ F87A4956260C6F38001653A8 /* ChatMessageContentView+SwiftUI.swift in Sources */, 88CABC4325933EE70061BB67 /* ChatMessageReactionsPickerVC.swift in Sources */, DB9A3D562582689A00555D36 /* ChatMessageListRouter.swift in Sources */, - C1FC2F7327416E150062530F /* ImageViewExtensions.swift in Sources */, - C1FC2F8327416E150062530F /* TaskFetchDecodedImage.swift in Sources */, 22411680258A91280034184D /* String+Extensions.swift in Sources */, 228C7EE52583AF4800AAE9E3 /* UITextView+Extensions.swift in Sources */, 8803E9E726398F4E002B2A7B /* ChatMessageBubbleView.swift in Sources */, @@ -10605,7 +10742,6 @@ CF62AD9928944D4700392893 /* SkeletonLoadable.swift in Sources */, 847DD2D82670F75F0084E14B /* VideoAttachmentComposerPreview.swift in Sources */, 847D60292679EDD300FB701D /* GalleryCollectionViewCell.swift in Sources */, - C1FC2F7B27416E150062530F /* DataLoader.swift in Sources */, E7166CB225BED22B00B03B07 /* Appearance+ColorPalette.swift in Sources */, 88A11B0A2590AFBB0000AC24 /* ChatMessage+Extensions.swift in Sources */, 4011052B2A12734800F877C7 /* AudioVisualizationView.swift in Sources */, @@ -10616,16 +10752,13 @@ 40824D232A1271B9003B61FD /* PlayPauseButton.swift in Sources */, ACCA772E26C568D8007AE2ED /* NukeImageProcessor.swift in Sources */, 4067764F2A14CB550079B05C /* MediaButton.swift in Sources */, - C1FC2F6B27416E150062530F /* ImagePublisher.swift in Sources */, 88A8CF16256E7BDA004EA4C7 /* ChatMessageContentView.swift in Sources */, BDEB9417268211EC00928AF1 /* ChatMessageListUnreadCountView.swift in Sources */, 226C438D25802AAD008B3648 /* InputTextView.swift in Sources */, - C1FC2F7127416E150062530F /* ImageResponse.swift in Sources */, ADD4C0E12B30A98300F230FF /* UnsupportedAttachmentViewInjector.swift in Sources */, ACF73D7826CFE07900372DC0 /* Cancellable.swift in Sources */, 847D602B2679EED400FB701D /* VideoAttachmentGalleryCell.swift in Sources */, AD87D097263C7783008B466C /* CommandButton.swift in Sources */, - C1FC2F8127416E150062530F /* RateLimiter.swift in Sources */, 224165A825910A2C00ED7F78 /* CheckboxControl.swift in Sources */, AD96106F2C2DD874004F543C /* BannerView.swift in Sources */, 79FA4A84263BFD1100EC33DA /* GalleryAttachmentViewInjector.swift in Sources */, @@ -10634,7 +10767,6 @@ ADCB577928A42D7700B81AE8 /* Algorithm.swift in Sources */, 796CBD1C25FF9552003299B0 /* UIStackView+Extensions.swift in Sources */, AD169DF72C9B33B500F58FAC /* PollCreationSectionHeaderView.swift in Sources */, - C1FC2F8627416E150062530F /* Allocations.swift in Sources */, C1FC2F8C27416E1F0062530F /* UIImage+SwiftyGif.swift in Sources */, AD9632DC2C09E0350073B814 /* ChatThreadListRouter.swift in Sources */, AD4118842D5E1368000EF88E /* UILabel+highlightText.swift in Sources */, @@ -10642,13 +10774,69 @@ ADC4AAB02788C8850004BB35 /* Appearance+Formatters.swift in Sources */, AD6F531927175FDB00D428B4 /* ChatMessageGiphyView+GiphyBadge.swift in Sources */, ACA3C98726CA23F300EB8B07 /* DateUtils.swift in Sources */, + 4FC744252D8D9A2600E314EB /* ImageDecoding.swift in Sources */, + 4FC744262D8D9A2600E314EB /* AssetType.swift in Sources */, + 4FC744272D8D9A2600E314EB /* TaskFetchOriginalData.swift in Sources */, + 4FC744282D8D9A2600E314EB /* ImageProcessors+Anonymous.swift in Sources */, + 4FC744292D8D9A2600E314EB /* ImageEncoders.swift in Sources */, + 4FC7442A2D8D9A2600E314EB /* TaskLoadImage.swift in Sources */, + 4FC7442B2D8D9A2600E314EB /* ImageResponse.swift in Sources */, + 4FC7442C2D8D9A2600E314EB /* ImageLoadingOptions.swift in Sources */, + 4FC7442D2D8D9A2600E314EB /* LinkedList.swift in Sources */, + 4FC7442E2D8D9A2600E314EB /* Log.swift in Sources */, + 4FC7442F2D8D9A2600E314EB /* ImageRequestKeys.swift in Sources */, + 4FC744302D8D9A2600E314EB /* ImagePipeline.swift in Sources */, + 4FC744312D8D9A2600E314EB /* ImageDecoderRegistry.swift in Sources */, + 4FC744322D8D9A2600E314EB /* ImageProcessing.swift in Sources */, + 4FC744332D8D9A2600E314EB /* ImageCache.swift in Sources */, + 4FC744342D8D9A2600E314EB /* ImagePipeline+Cache.swift in Sources */, + 4FC744352D8D9A2600E314EB /* ImagePipeline+Delegate.swift in Sources */, + 4FC744362D8D9A2600E314EB /* ImageDecoders+Default.swift in Sources */, + 4FC744372D8D9A2600E314EB /* ResumableData.swift in Sources */, + 4FC744382D8D9A2600E314EB /* ImageProcessors+RoundedCorners.swift in Sources */, + 4FC744392D8D9A2600E314EB /* ImageProcessingOptions.swift in Sources */, + 4FC7443A2D8D9A2600E314EB /* ImageContainer.swift in Sources */, + 4FC7443B2D8D9A2600E314EB /* ImageEncoders+Default.swift in Sources */, + 4FC7443C2D8D9A2600E314EB /* TaskFetchWithPublisher.swift in Sources */, + 4FC7443D2D8D9A2600E314EB /* RateLimiter.swift in Sources */, + 4FC7443E2D8D9A2600E314EB /* DataLoading.swift in Sources */, + 4FC7443F2D8D9A2600E314EB /* ImageTask.swift in Sources */, + 4FC744402D8D9A2600E314EB /* Cache.swift in Sources */, + 4FC744412D8D9A2600E314EB /* AsyncPipelineTask.swift in Sources */, + 4FC744422D8D9A2600E314EB /* Atomic.swift in Sources */, + 4FC744432D8D9A2600E314EB /* DataCaching.swift in Sources */, + 4FC744442D8D9A2600E314EB /* ImageProcessors+CoreImage.swift in Sources */, + 4FC744452D8D9A2600E314EB /* ImageProcessors+Composition.swift in Sources */, + 4FC744462D8D9A2600E314EB /* ImagePipeline+Error.swift in Sources */, + 4FC744472D8D9A2600E314EB /* ImagePublisher.swift in Sources */, + 4FC744482D8D9A2600E314EB /* ImageProcessors+Circle.swift in Sources */, + 4FC744492D8D9A2600E314EB /* TaskFetchOriginalImage.swift in Sources */, + 4FC7444A2D8D9A2600E314EB /* DataPublisher.swift in Sources */, + 4FC7444B2D8D9A2600E314EB /* ImagePipeline+Configuration.swift in Sources */, + 4FC7444C2D8D9A2600E314EB /* AsyncTask.swift in Sources */, + 4FC7444D2D8D9A2600E314EB /* ImageCaching.swift in Sources */, + 4FC7444E2D8D9A2600E314EB /* ImageEncoding.swift in Sources */, + 4FC7444F2D8D9A2600E314EB /* Extensions.swift in Sources */, + 4FC744502D8D9A2600E314EB /* ImagePrefetcher.swift in Sources */, + 4FC744512D8D9A2600E314EB /* DataCache.swift in Sources */, + 4FC744522D8D9A2600E314EB /* ImageEncoders+ImageIO.swift in Sources */, + 4FC744532D8D9A2600E314EB /* DataLoader.swift in Sources */, + 4FC744542D8D9A2600E314EB /* ImageProcessors+GaussianBlur.swift in Sources */, + 4FC744552D8D9A2600E314EB /* Operation.swift in Sources */, + 4FC744562D8D9A2600E314EB /* TaskLoadData.swift in Sources */, + 4FC744572D8D9A2600E314EB /* ImageRequest.swift in Sources */, + 4FC744582D8D9A2600E314EB /* ImageDecoders+Empty.swift in Sources */, + 4FC744592D8D9A2600E314EB /* ImageViewExtensions.swift in Sources */, + 4FC7445A2D8D9A2600E314EB /* ImageProcessors.swift in Sources */, + 4FC7445B2D8D9A2600E314EB /* ImageProcessors+Resize.swift in Sources */, + 4FC7445C2D8D9A2600E314EB /* ImageDecompression.swift in Sources */, + 4FC7445D2D8D9A2600E314EB /* Graphics.swift in Sources */, ADCB578128A42D7700B81AE8 /* AnyDifferentiable.swift in Sources */, AD87D0BD263C7C09008B466C /* CircularCloseButton.swift in Sources */, BDDD1EAA2632CE3C00BA007B /* Appearance+SwiftUI.swift in Sources */, 8850FE87256558A200C8D534 /* ChatChannelListRouter.swift in Sources */, 888ABA072594FDE30015937E /* ChatMessageInteractiveAttachmentView.swift in Sources */, ACCA772C26C40D43007AE2ED /* NukeImageLoader.swift in Sources */, - C1FC2F6D27416E150062530F /* TaskLoadData.swift in Sources */, 88BD82B02549D18F00369074 /* ChatChannelListItemView.swift in Sources */, A39A8AE7263825F4003453D9 /* ChatMessageLayoutOptionsResolver.swift in Sources */, AD169DEA2C99CB8200F58FAC /* PollFeature.swift in Sources */, @@ -10663,17 +10851,14 @@ 8806570D259A51C200E31D23 /* ChatMessageInteractiveAttachmentView+ActionButton.swift in Sources */, AD4F89D62C666471006DF7E5 /* PollResultsVoteItemCell.swift in Sources */, E79AC10C25831A1500C3CE5D /* ChatSuggestionsCollectionView.swift in Sources */, - C1FC2F8527416E150062530F /* DataCache.swift in Sources */, 40824D0E2A1270CB003B61FD /* ChatMessageVoiceRecordingAttachmentListView.swift in Sources */, 88D66E762599DF1400CFC102 /* ChatMessageReactionAppearance.swift in Sources */, 4F05ECB82B6CCA4900641820 /* DifferenceKit+Stream.swift in Sources */, C1FC2F8D27416E1F0062530F /* NSImageView+SwiftyGif.swift in Sources */, AD169E022C9F969C00F58FAC /* DefaultScrollViewKeyboardHandler.swift in Sources */, F80BCA0A263011F500F2107B /* ImageAttachmentGalleryCell.swift in Sources */, - 843F0BC326775CDB00B342CB /* Cache.swift in Sources */, + 843F0BC326775CDB00B342CB /* NSCacheWrapper.swift in Sources */, 8830513E263031C40069D731 /* ChatMessageLayoutOptions.swift in Sources */, - C1FC2F6A27416E150062530F /* TaskFetchWithPublisher.swift in Sources */, - C1FC2F6827416E150062530F /* ImagePipelineCache.swift in Sources */, 792DD9D9256BC542001DB91B /* BaseViews.swift in Sources */, AD7B51D327EDECA80068CBD1 /* MixedAttachmentViewInjector.swift in Sources */, ADA3572F269C807A004AD8E9 /* ChatChannelHeaderView.swift in Sources */, @@ -10683,7 +10868,6 @@ F80BCA1426304F7800F2107B /* ShareButton.swift in Sources */, CF38F5AF287DB53E00E24D10 /* ChatChannelListErrorView.swift in Sources */, ADF509752C5A80EE008F95CD /* PollAttachmentViewInjector.swift in Sources */, - C1FC2F6F27416E150062530F /* ImagePipelineConfiguration.swift in Sources */, ADE57B792C36DB2000DD6B88 /* ChatThreadListErrorView.swift in Sources */, AD169DFF2C9F513000F58FAC /* PollCreationVC.swift in Sources */, AD7EFDA72C7796D400625FC5 /* PollCommentListVC.swift in Sources */, @@ -10749,15 +10933,12 @@ C1788F5F29C33A1000149883 /* ChatThreadRepliesCountDecorationView.swift in Sources */, 40824D092A1270BF003B61FD /* VoiceRecordingAttachmentViewInjector.swift in Sources */, 224FF6812562F2E900725DD1 /* ChatChannelUnreadCountView.swift in Sources */, - C1FC2F7827416E150062530F /* ImageDecoding.swift in Sources */, CFBF8D512847C57700EEB7D3 /* MarkdownFormatter.swift in Sources */, 40FA4DD32A12A0C300DA21D2 /* LockIndicatorView.swift in Sources */, - C1FC2F8227416E150062530F /* ImagePipelineTask.swift in Sources */, A3EA3328276C904700C84A52 /* ObjcAssociatedWeakObject.swift in Sources */, 40FA4DDC2A12A0D200DA21D2 /* BidirectionalPanGestureRecogniser.swift in Sources */, F6E5E3472627A372007FA51F /* CGRect+Extensions.swift in Sources */, AD2DDA552CAA2B600040B8D4 /* PollAllOptionsListItemCell.swift in Sources */, - C1FC2F7E27416E150062530F /* ImageProcessing.swift in Sources */, E7DF7E2425C2C67E00AE9D21 /* ChatAvatarView.swift in Sources */, ADDC08142C82A81F00EA0E5F /* TextFieldView.swift in Sources */, 79FA4A7A263BFCE200EC33DA /* AttachmentViewInjector.swift in Sources */, @@ -10767,18 +10948,14 @@ 7844B10C25EF92B600B87E89 /* ChatChannelListItemView+SwiftUI.swift in Sources */, 224FF67B2562F1EA00725DD1 /* ChatMessageDeliveryStatusCheckmarkView.swift in Sources */, DB70CFFB25702EB900DDF436 /* ChatMessagePopupVC.swift in Sources */, - C1FC2F7727416E150062530F /* ImagePipelineDelegate.swift in Sources */, - C1FC2F7027416E150062530F /* Task.swift in Sources */, BD837AF02652D23600A99AB5 /* AttachmentPreviewContainer.swift in Sources */, 40FA4DD72A12A0C300DA21D2 /* RecordingIndicatorView.swift in Sources */, ADDC08082C828FDB00EA0E5F /* PollCreationFeatureCell.swift in Sources */, - C1FC2F6727416E150062530F /* ResumableData.swift in Sources */, BD4016362638411D00F09774 /* Deprecations.swift in Sources */, 7844B14E25EF9F5700B87E89 /* ChatChannelAvatarView+SwiftUI.swift in Sources */, ADA5A0F8276790C100E1C465 /* ChatMessageListDateSeparatorView.swift in Sources */, BDC80CB5265CF4B800F62CE2 /* ImageCDN.swift in Sources */, 843F0BCD2677667000B342CB /* AttachmentActionButton.swift in Sources */, - C1FC2F6927416E150062530F /* Log.swift in Sources */, AD7EFDB52C78DC1800625FC5 /* PollCommentListItemView.swift in Sources */, CF143980288637AD00898ECA /* ChatChannelListLoadingViewCellContentView.swift in Sources */, 843F0BC72677640000B342CB /* VideoAttachmentGalleryPreview.swift in Sources */, @@ -10787,8 +10964,6 @@ AD95FD1128FA038900DBDF41 /* ImageDownloadOptions.swift in Sources */, AD99C904279B073B009DD9C5 /* MessageTimestampFormatter.swift in Sources */, 2245B2B625602465006A612D /* ChatChannelAvatarView.swift in Sources */, - C1FC2F6E27416E150062530F /* ImagePipeline.swift in Sources */, - C1FC2F8027416E150062530F /* ImagePrefetcher.swift in Sources */, 88EF29FF2571288600B06EF1 /* Array+Extensions.swift in Sources */, AD43DE6D2A712B0F0040C0FD /* ChatChannelListSearchVC.swift in Sources */, 40824D102A1270CB003B61FD /* ChatMessageVoiceRecordingAttachmentListView+ItemView.swift in Sources */, @@ -10798,11 +10973,9 @@ 40FA4DD62A12A0C300DA21D2 /* LiveRecordingView.swift in Sources */, BDDD1EA62632C6D600BA007B /* AppearanceProvider.swift in Sources */, A35757C72613081B00DC914C /* ComposerKeyboardHandler.swift in Sources */, - C1FC2F7D27416E150062530F /* ImageRequest.swift in Sources */, 79205857264C2D6C002B145B /* TitleContainerView.swift in Sources */, AD793F49270B767500B05456 /* ChatMessageReactionAuthorsVC.swift in Sources */, C12297D32AC57A3200C5FF04 /* Throttler.swift in Sources */, - C1FC2F7527416E150062530F /* Operation.swift in Sources */, AD447443263AC6A10030E583 /* ChatMentionSuggestionView.swift in Sources */, ADCB578728A42D7700B81AE8 /* DifferentiableSection.swift in Sources */, AD4F89E32C6A7B81006DF7E5 /* PollResultsVoteListVC.swift in Sources */, @@ -10813,6 +10986,7 @@ E7073A6325DD67B3003896B9 /* UILabel+Extensions.swift in Sources */, C171041E2768C34E008FB3F2 /* Array+SafeSubscript.swift in Sources */, ADD3285E2C05447200BAD0E9 /* ChatThreadListVC.swift in Sources */, + 4F9E5EE52D8C439700047754 /* StreamConcurrency.swift in Sources */, 843F0BC526775D2D00B342CB /* VideoLoading.swift in Sources */, 88CABC4625933EE70061BB67 /* ChatReactionPickerBubbleView.swift in Sources */, AD7EFDB32C78DBF600625FC5 /* PollCommentListSectionHeaderView.swift in Sources */, @@ -10824,7 +10998,6 @@ ADF509732C5A80EE008F95CD /* PollAttachmentView.swift in Sources */, F880DEA32628528B0025AD64 /* GalleryVC.swift in Sources */, ADCB577728A42D7700B81AE8 /* ArraySection.swift in Sources */, - C1FC2F8727416E150062530F /* OperationTask.swift in Sources */, 78C8473825FA0EF000A5D1D0 /* ChatChannelUnreadCountView+SwiftUI.swift in Sources */, ACCA772A26C40C96007AE2ED /* ImageLoading.swift in Sources */, DB3CCF3F258CF7ED009D5E99 /* ChatMessageLinkPreviewView.swift in Sources */, @@ -10832,10 +11005,8 @@ 43F4750E26FB247C0009487D /* ChatReactionPickerReactionsView.swift in Sources */, E7516B162642E11A00DE778C /* GiphyAttachmentViewInjector.swift in Sources */, E79AC10D25831A1500C3CE5D /* ChatSuggestionsCollectionViewLayout.swift in Sources */, - C1FC2F7427416E150062530F /* Deprecated.swift in Sources */, ADCB578928A42D7700B81AE8 /* Changeset.swift in Sources */, C1FC2F8B27416E1F0062530F /* SwiftyGifManager.swift in Sources */, - C1FC2F8427416E150062530F /* FetchImage.swift in Sources */, E73A8B2B2578EB2B00FBDC56 /* ComposerVC.swift in Sources */, ADFA09C926A99E0A002A6EFA /* ChatThreadHeaderView.swift in Sources */, 401105292A12734800F877C7 /* WaveformView.swift in Sources */, @@ -10843,7 +11014,6 @@ 847DD2DB267233DB0084E14B /* GradientView.swift in Sources */, 40824D1F2A1271B9003B61FD /* ClampedView.swift in Sources */, AD43F90926153BAD00F2D4BB /* QuotedChatMessageView.swift in Sources */, - C1FC2F8927416E150062530F /* ImageEncoding.swift in Sources */, 847F3CEA2689FDEB00D240E0 /* ChatMessageCell.swift in Sources */, 885B3D7725642B3D003E6BDF /* CurrentChatUserAvatarView.swift in Sources */, C14A46582846636900EF498E /* SDKIdentifier.swift in Sources */, @@ -10852,7 +11022,6 @@ CF7B2A2628BEAA93006BE124 /* TextViewMentionedUsersHandler.swift in Sources */, 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 */, AD7BE1702C234798000A5756 /* ChatThreadListLoadingView.swift in Sources */, @@ -10871,8 +11040,6 @@ ADDC080B2C82905300EA0E5F /* PollCreationFeatureSwitchView.swift in Sources */, AD87D0AB263C7A7E008B466C /* ShrinkInputButton.swift in Sources */, AD4F89E92C6B89B3006DF7E5 /* PollTimestampFormatter.swift in Sources */, - C1FC2F6C27416E150062530F /* LinkedList.swift in Sources */, - C1FC2F7A27416E150062530F /* ImageTask.swift in Sources */, CF33B3AC28171BE500C84CDB /* CooldownView.swift in Sources */, 883998212576397900294DB9 /* ChatMessageGalleryView.swift in Sources */, F64DFA8C26282F8B00F7F6F9 /* CGPoint+Extensions.swift in Sources */, @@ -10884,10 +11051,8 @@ AD29395D2A2E36FE00533CA7 /* SwipeToReplyGestureHandler.swift in Sources */, AD7112F325F12AA800932AEE /* ChatUserAvatarView.swift in Sources */, 40D396252A0905560020DDC9 /* AudioPlaybackRateFormatter.swift in Sources */, - C1FC2F8827416E150062530F /* ImageRequestKeys.swift in Sources */, 882AE124257A7FFE004095B3 /* UIViewController+Extensions.swift in Sources */, E768AA892625C18D00328E6E /* TypingIndicatorView.swift in Sources */, - C1FC2F7227416E150062530F /* ImageCache.swift in Sources */, C17546A72B1652C100129DF7 /* StatefulScrollViewPaginationHandler.swift in Sources */, 224FF6972562F5AE00725DD1 /* Bundle+Extensions.swift in Sources */, C1FC2F8A27416E1F0062530F /* UIImageView+SwiftyGif.swift in Sources */, @@ -10900,9 +11065,7 @@ AD050B9E265D5E12006649A5 /* QuotedChatMessageView+SwiftUI.swift in Sources */, 224FF69D2562F5D100725DD1 /* UIImage+Extensions.swift in Sources */, AD76CE342A5F112D003CA182 /* ChatChannelSearchVC.swift in Sources */, - C1FC2F7F27416E150062530F /* TaskFetchOriginalImageData.swift in Sources */, 88F0D734257E4EA500F4B050 /* L10n.swift in Sources */, - C1FC2F7927416E150062530F /* TaskLoadImage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -12738,7 +12901,7 @@ 40FA4DEB2A12A46D00DA21D2 /* VoiceRecordingAttachmentComposerPreview_Tests.swift in Sources */, C121EB832746A1E700023E4C /* NavigationVC.swift in Sources */, ADD3286E2C07CCCA00BAD0E9 /* BadgeView.swift in Sources */, - C121EB842746A1E700023E4C /* Cache.swift in Sources */, + C121EB842746A1E700023E4C /* NSCacheWrapper.swift in Sources */, AD169E032C9F969C00F58FAC /* DefaultScrollViewKeyboardHandler.swift in Sources */, AD7BE1742C2347A3000A5756 /* ChatThreadListEmptyView.swift in Sources */, C121EB852746A1E700023E4C /* VideoLoading.swift in Sources */, @@ -12858,6 +13021,63 @@ C121EBCB2746A1E900023E4C /* ChatChannelUnreadCountView.swift in Sources */, C121EBCC2746A1E900023E4C /* ChatChannelUnreadCountView+SwiftUI.swift in Sources */, C121EBCD2746A1EA00023E4C /* ChatChannelVC.swift in Sources */, + 4FC743EC2D8D9A2600E314EB /* ImageDecoding.swift in Sources */, + 4FC743ED2D8D9A2600E314EB /* AssetType.swift in Sources */, + 4FC743EE2D8D9A2600E314EB /* TaskFetchOriginalData.swift in Sources */, + 4FC743EF2D8D9A2600E314EB /* ImageProcessors+Anonymous.swift in Sources */, + 4FC743F02D8D9A2600E314EB /* ImageEncoders.swift in Sources */, + 4FC743F12D8D9A2600E314EB /* TaskLoadImage.swift in Sources */, + 4FC743F22D8D9A2600E314EB /* ImageResponse.swift in Sources */, + 4FC743F32D8D9A2600E314EB /* ImageLoadingOptions.swift in Sources */, + 4FC743F42D8D9A2600E314EB /* LinkedList.swift in Sources */, + 4FC743F52D8D9A2600E314EB /* Log.swift in Sources */, + 4FC743F62D8D9A2600E314EB /* ImageRequestKeys.swift in Sources */, + 4FC743F72D8D9A2600E314EB /* ImagePipeline.swift in Sources */, + 4FC743F82D8D9A2600E314EB /* ImageDecoderRegistry.swift in Sources */, + 4FC743F92D8D9A2600E314EB /* ImageProcessing.swift in Sources */, + 4FC743FA2D8D9A2600E314EB /* ImageCache.swift in Sources */, + 4FC743FB2D8D9A2600E314EB /* ImagePipeline+Cache.swift in Sources */, + 4FC743FC2D8D9A2600E314EB /* ImagePipeline+Delegate.swift in Sources */, + 4FC743FD2D8D9A2600E314EB /* ImageDecoders+Default.swift in Sources */, + 4FC743FE2D8D9A2600E314EB /* ResumableData.swift in Sources */, + 4FC743FF2D8D9A2600E314EB /* ImageProcessors+RoundedCorners.swift in Sources */, + 4FC744002D8D9A2600E314EB /* ImageProcessingOptions.swift in Sources */, + 4FC744012D8D9A2600E314EB /* ImageContainer.swift in Sources */, + 4FC744022D8D9A2600E314EB /* ImageEncoders+Default.swift in Sources */, + 4FC744032D8D9A2600E314EB /* TaskFetchWithPublisher.swift in Sources */, + 4FC744042D8D9A2600E314EB /* RateLimiter.swift in Sources */, + 4FC744052D8D9A2600E314EB /* DataLoading.swift in Sources */, + 4FC744062D8D9A2600E314EB /* ImageTask.swift in Sources */, + 4FC744072D8D9A2600E314EB /* Cache.swift in Sources */, + 4FC744082D8D9A2600E314EB /* AsyncPipelineTask.swift in Sources */, + 4FC744092D8D9A2600E314EB /* Atomic.swift in Sources */, + 4FC7440A2D8D9A2600E314EB /* DataCaching.swift in Sources */, + 4FC7440B2D8D9A2600E314EB /* ImageProcessors+CoreImage.swift in Sources */, + 4FC7440C2D8D9A2600E314EB /* ImageProcessors+Composition.swift in Sources */, + 4FC7440D2D8D9A2600E314EB /* ImagePipeline+Error.swift in Sources */, + 4FC7440E2D8D9A2600E314EB /* ImagePublisher.swift in Sources */, + 4FC7440F2D8D9A2600E314EB /* ImageProcessors+Circle.swift in Sources */, + 4FC744102D8D9A2600E314EB /* TaskFetchOriginalImage.swift in Sources */, + 4FC744112D8D9A2600E314EB /* DataPublisher.swift in Sources */, + 4FC744122D8D9A2600E314EB /* ImagePipeline+Configuration.swift in Sources */, + 4FC744132D8D9A2600E314EB /* AsyncTask.swift in Sources */, + 4FC744142D8D9A2600E314EB /* ImageCaching.swift in Sources */, + 4FC744152D8D9A2600E314EB /* ImageEncoding.swift in Sources */, + 4FC744162D8D9A2600E314EB /* Extensions.swift in Sources */, + 4FC744172D8D9A2600E314EB /* ImagePrefetcher.swift in Sources */, + 4FC744182D8D9A2600E314EB /* DataCache.swift in Sources */, + 4FC744192D8D9A2600E314EB /* ImageEncoders+ImageIO.swift in Sources */, + 4FC7441A2D8D9A2600E314EB /* DataLoader.swift in Sources */, + 4FC7441B2D8D9A2600E314EB /* ImageProcessors+GaussianBlur.swift in Sources */, + 4FC7441C2D8D9A2600E314EB /* Operation.swift in Sources */, + 4FC7441D2D8D9A2600E314EB /* TaskLoadData.swift in Sources */, + 4FC7441E2D8D9A2600E314EB /* ImageRequest.swift in Sources */, + 4FC7441F2D8D9A2600E314EB /* ImageDecoders+Empty.swift in Sources */, + 4FC744202D8D9A2600E314EB /* ImageViewExtensions.swift in Sources */, + 4FC744212D8D9A2600E314EB /* ImageProcessors.swift in Sources */, + 4FC744222D8D9A2600E314EB /* ImageProcessors+Resize.swift in Sources */, + 4FC744232D8D9A2600E314EB /* ImageDecompression.swift in Sources */, + 4FC744242D8D9A2600E314EB /* Graphics.swift in Sources */, C121EBCE2746A1EA00023E4C /* ChatChannelVC+SwiftUI.swift in Sources */, C121EBCF2746A1EA00023E4C /* ChatChannelHeaderView.swift in Sources */, ADCB578C28A42D7700B81AE8 /* Differentiable.swift in Sources */, @@ -12866,6 +13086,7 @@ C121EBD02746A1EA00023E4C /* ChatThreadVC.swift in Sources */, C121EBD12746A1EA00023E4C /* ChatThreadVC+SwiftUI.swift in Sources */, C121EBD22746A1EA00023E4C /* ChatThreadHeaderView.swift in Sources */, + 4F9E5EE42D8C439700047754 /* StreamConcurrency.swift in Sources */, C121EBD32746A1EA00023E4C /* ChatMessageReactionAuthorsVC.swift in Sources */, ADD2A99B28FF4F4B00A83305 /* StreamCDN.swift in Sources */, ADCB578A28A42D7700B81AE8 /* Changeset.swift in Sources */, @@ -12972,63 +13193,28 @@ C121EC0F2746A1EC00023E4C /* NavigationRouter.swift in Sources */, ADCB577828A42D7700B81AE8 /* ArraySection.swift in Sources */, C121EC102746A1EC00023E4C /* L10n.swift in Sources */, - C121EC112746A1EC00023E4C /* ImageViewExtensions.swift in Sources */, - C121EC122746A1EC00023E4C /* FetchImage.swift in Sources */, 40824D202A1271B9003B61FD /* ClampedView.swift in Sources */, AD9610702C2DD874004F543C /* BannerView.swift in Sources */, - C121EC132746A1EC00023E4C /* ImageCache.swift in Sources */, - C121EC142746A1EC00023E4C /* ImageTask.swift in Sources */, - C121EC152746A1EC00023E4C /* ImagePipeline.swift in Sources */, - C121EC162746A1EC00023E4C /* ImageProcessing.swift in Sources */, AD7EFDB82C78DC6700625FC5 /* PollCommentListSectionFooterView.swift in Sources */, CF143981288637AD00898ECA /* ChatChannelListLoadingViewCellContentView.swift in Sources */, - C121EC172746A1EC00023E4C /* ImagePipelineConfiguration.swift in Sources */, AD7EFDB62C78DC1A00625FC5 /* PollCommentListItemView.swift in Sources */, - C121EC182746A1EC00023E4C /* ImageEncoding.swift in Sources */, 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 */, ADCB578828A42D7700B81AE8 /* DifferentiableSection.swift in Sources */, 40824D222A1271B9003B61FD /* RecordButton.swift in Sources */, - C121EC1C2746A1EC00023E4C /* ImagePipelineCache.swift in Sources */, AD9632DD2C09E0350073B814 /* ChatThreadListRouter.swift in Sources */, 400F063229A63A0B00242A86 /* ChatMessageDecorationView.swift in Sources */, AD4F89ED2C6B935A006DF7E5 /* Calendar+StreamCalendar.swift in Sources */, - C121EC1D2746A1EC00023E4C /* DataLoader.swift in Sources */, - C121EC1E2746A1EC00023E4C /* ImageResponse.swift in Sources */, 4011052A2A12734800F877C7 /* WaveformView.swift in Sources */, 40824D302A1271D7003B61FD /* RecordButton_Tests.swift in Sources */, - C121EC1F2746A1EC00023E4C /* ImagePipelineDelegate.swift in Sources */, - C121EC202746A1EC00023E4C /* ImagePrefetcher.swift in Sources */, - C121EC212746A1EC00023E4C /* ImagePublisher.swift in Sources */, - C121EC222746A1EC00023E4C /* ResumableData.swift in Sources */, - C121EC232746A1EC00023E4C /* Allocations.swift in Sources */, - C121EC242746A1ED00023E4C /* TaskFetchDecodedImage.swift in Sources */, - C121EC252746A1ED00023E4C /* TaskLoadData.swift in Sources */, C1788F6329C33A2C00149883 /* ChatUnreadMessagesCountDecorationView.swift in Sources */, - C121EC262746A1ED00023E4C /* TaskFetchOriginalImageData.swift in Sources */, - C121EC272746A1ED00023E4C /* ImagePipelineTask.swift in Sources */, 4011052C2A12734800F877C7 /* AudioVisualizationView.swift in Sources */, - C121EC282746A1ED00023E4C /* OperationTask.swift in Sources */, 40D396262A0905560020DDC9 /* AudioPlaybackRateFormatter.swift in Sources */, - C121EC292746A1ED00023E4C /* TaskLoadImage.swift in Sources */, ADCB577628A42D7700B81AE8 /* ContentEquatable.swift in Sources */, - C121EC2A2746A1ED00023E4C /* Task.swift in Sources */, - C121EC2B2746A1ED00023E4C /* TaskFetchWithPublisher.swift in Sources */, - C121EC2C2746A1ED00023E4C /* Log.swift in Sources */, - C121EC2D2746A1ED00023E4C /* RateLimiter.swift in Sources */, ADCB578028A42D7700B81AE8 /* ContentIdentifiable.swift in Sources */, - C121EC2E2746A1ED00023E4C /* Extensions.swift in Sources */, - C121EC2F2746A1ED00023E4C /* Deprecated.swift in Sources */, - C121EC302746A1ED00023E4C /* Combine.swift in Sources */, ADDC08062C828F9B00EA0E5F /* PollCreationTextFieldView.swift in Sources */, - C121EC312746A1ED00023E4C /* Operation.swift in Sources */, - C121EC322746A1ED00023E4C /* ImageRequestKeys.swift in Sources */, - C121EC332746A1ED00023E4C /* LinkedList.swift in Sources */, ADD328672C06B3A900BAD0E9 /* ChatThreadListItemCell.swift in Sources */, C121EC342746A1ED00023E4C /* UIImageView+SwiftyGif.swift in Sources */, C121EC352746A1ED00023E4C /* NSImage+SwiftyGif.swift in Sources */, @@ -13495,7 +13681,8 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -13523,7 +13710,8 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -13551,7 +13739,8 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Profile; diff --git a/Tests/StreamChatUITests/Extensions/ChatMessage+Equatable_Tests.swift b/Tests/StreamChatUITests/Extensions/ChatMessage+Equatable_Tests.swift index 65bb282b768..fb7adcce4a4 100644 --- a/Tests/StreamChatUITests/Extensions/ChatMessage+Equatable_Tests.swift +++ b/Tests/StreamChatUITests/Extensions/ChatMessage+Equatable_Tests.swift @@ -96,7 +96,7 @@ final class ChatMessage_Equatable_Tests: XCTestCase { let numberOfThreadParticipants = 4 let numberOfMentionedUsers = 2 - var messages: [ChatMessage] = [] + nonisolated(unsafe) var messages: [ChatMessage] = [] try database.writeSynchronously { session in var previousMessages: [MessageDTO] = [] diff --git a/Tests/StreamChatUITests/Localization_Tests.swift b/Tests/StreamChatUITests/Localization_Tests.swift index 7e2c29ae765..e6cad4fca25 100644 --- a/Tests/StreamChatUITests/Localization_Tests.swift +++ b/Tests/StreamChatUITests/Localization_Tests.swift @@ -10,9 +10,9 @@ import XCTest final class Localization_Tests: XCTestCase { /// Testing bundle which should be empty. private var testBundle: Bundle! - private var defaultLocalizationProvider: ((String, String) -> String)! + private var defaultLocalizationProvider: (@Sendable(String, String) -> String)! - override func setUp() { + @MainActor override func setUp() { super.setUp() testBundle = Bundle(for: Self.self) defaultLocalizationProvider = Appearance.default.localizationProvider @@ -22,12 +22,12 @@ final class Localization_Tests: XCTestCase { } } - override func tearDown() { + @MainActor override func tearDown() { Appearance.default.localizationProvider = defaultLocalizationProvider super.tearDown() } - func test_localizationProviderAssignment_ChangesLocalizationForBundle() { + @MainActor func test_localizationProviderAssignment_ChangesLocalizationForBundle() { // Setup some component which shows localization let channel: ChatChannel = .mock(cid: .unique) let itemView = ChatChannelListItemView() diff --git a/Tests/StreamChatUITests/Mocks/ChatMessageList/ChatMessageListVC_Mock.swift b/Tests/StreamChatUITests/Mocks/ChatMessageList/ChatMessageListVC_Mock.swift index b6c98a3c920..0e23f915016 100644 --- a/Tests/StreamChatUITests/Mocks/ChatMessageList/ChatMessageListVC_Mock.swift +++ b/Tests/StreamChatUITests/Mocks/ChatMessageList/ChatMessageListVC_Mock.swift @@ -20,7 +20,7 @@ class ChatMessageListVC_Mock: ChatMessageListVC { } var jumpToUnreadMessageCallCount = 0 - override func jumpToUnreadMessage(animated: Bool = true, onHighlight: ((IndexPath) -> Void)? = nil) { + override func jumpToUnreadMessage(animated: Bool = true, onHighlight: (@Sendable(IndexPath) -> Void)? = nil) { jumpToUnreadMessageCallCount += 1 } } diff --git a/Tests/StreamChatUITests/Mocks/Utils/CooldownTracker_Mock.swift b/Tests/StreamChatUITests/Mocks/Utils/CooldownTracker_Mock.swift index 863620a33b9..a0667704a47 100644 --- a/Tests/StreamChatUITests/Mocks/Utils/CooldownTracker_Mock.swift +++ b/Tests/StreamChatUITests/Mocks/Utils/CooldownTracker_Mock.swift @@ -5,7 +5,7 @@ import Foundation @testable import StreamChat -final class CooldownTracker_Mock: CooldownTracker { +final class CooldownTracker_Mock: CooldownTracker, @unchecked Sendable { var startCallCount = 0 override func start(with cooldown: Int) { diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC+SwiftUI_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC+SwiftUI_Tests.swift index dd47dcb6fd3..2781cf52185 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC+SwiftUI_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC+SwiftUI_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class ChatChannelView_Tests: iOS13TestCase { +@MainActor final class ChatChannelView_Tests: iOS13TestCase { var chatChannel: SwiftUIViewControllerRepresentable! fileprivate var mockComposer: ComposerVC_Mock! var mockedChannelController: ChatChannelController_Mock! diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift index 5f6ca55d7d4..9d2a117e081 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatChannelVC_Tests: XCTestCase { +@MainActor final class ChatChannelVC_Tests: XCTestCase { var vc: ChatChannelVC! fileprivate var mockComposer: ComposerVC_Mock! var channelControllerMock: ChatChannelController_Mock! @@ -779,12 +779,12 @@ final class ChatChannelVC_Tests: XCTestCase { parentId: .unique, showReplyInChannel: false ) - - try channelControllerMock.client.databaseContainer.writeSynchronously { session in - try session.saveChannel(payload: .dummy(channel: .dummy(cid: self.cid))) + let cid = self.cid! + try channelControllerMock.client.databaseContainer.writeSynchronously { [cid] session in + try session.saveChannel(payload: .dummy(channel: .dummy(cid: cid))) try session.saveMessage( payload: messageInsideThread, - for: self.cid, + for: cid, syncOwnReactions: false, cache: nil ) diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListCollectionViewCell_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListCollectionViewCell_Tests.swift index cb149eccabb..88d8a3a5f40 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListCollectionViewCell_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListCollectionViewCell_Tests.swift @@ -7,7 +7,7 @@ import StreamChat @testable import StreamChatUI import XCTest -final class ChatChannelListCollectionViewCell_Tests: XCTestCase { +@MainActor final class ChatChannelListCollectionViewCell_Tests: XCTestCase { // defaultAppearance() is called multiple times so backgroundColor is changed by // ChatChannelListItemView and snapshot test is not possible. func test_isHighlighted() { diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListEmptyView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListEmptyView_Tests.swift index d98753f8b40..fd388a291b8 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListEmptyView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListEmptyView_Tests.swift @@ -22,7 +22,7 @@ class ChatChannelListEmptyView_Tests: XCTestCase { AssertSnapshot(vc) } - func test_appearanceCustomization_usingAppearance() { + @MainActor func test_appearanceCustomization_usingAppearance() { var appearance = Appearance() appearance.fonts.bodyBold = .italicSystemFont(ofSize: 20) appearance.colorPalette.subtitleText = .cyan diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListErrorView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListErrorView_Tests.swift index c382ffe012d..6ca0ece6d54 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListErrorView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListErrorView_Tests.swift @@ -15,7 +15,7 @@ class ChatChannelListErrorView_Tests: XCTestCase { AssertSnapshot(view) } - func test_appearanceCustomization_usingAppearance() { + @MainActor func test_appearanceCustomization_usingAppearance() { var appearance = Appearance() appearance.fonts.bodyBold = .italicSystemFont(ofSize: 20) appearance.colorPalette.subtitleText = .blue diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView+SwiftUI_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView+SwiftUI_Tests.swift index f313bead791..1e6b55c2baf 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView+SwiftUI_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView+SwiftUI_Tests.swift @@ -9,9 +9,9 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class ChatChannelListItemView_SwiftUI_Tests: XCTestCase { +@MainActor final class ChatChannelListItemView_SwiftUI_Tests: XCTestCase { func test_injectedSwiftUIView() { - struct CustomChannelListItemView: ChatChannelListItemView.SwiftUIView { + @MainActor struct CustomChannelListItemView: ChatChannelListItemView.SwiftUIView { @ObservedObject var dataSource: ChatChannelListItemView.ObservedObject public init(dataSource: ChatChannelListItemView.ObservedObject) { diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift index a266234a4dd..e22f0d22927 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatChannelListItemView_Tests: XCTestCase { +@MainActor final class ChatChannelListItemView_Tests: XCTestCase { let currentUser: ChatUser = .mock( id: "yoda", name: "Yoda" diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListVC+SwiftUI_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListVC+SwiftUI_Tests.swift index a432347c73b..628dca327d5 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListVC+SwiftUI_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListVC+SwiftUI_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class ChatChannelListView_Tests: iOS13TestCase { +@MainActor final class ChatChannelListView_Tests: iOS13TestCase { var chatChannelList: SwiftUIViewControllerRepresentable! var mockedChannelListController: ChatChannelListController_Mock! diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListVC_Tests.swift index f0b15f2453d..2bdbca0b8a4 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListVC_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class ChatChannelListVC_Tests: XCTestCase { +@MainActor final class ChatChannelListVC_Tests: XCTestCase { var view: ChatChannelListItemView! var vc: ChatChannelListVC! var mockedChannelListController: ChatChannelListController_Mock! diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelUnreadCountView+SwiftUI_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelUnreadCountView+SwiftUI_Tests.swift index 5315a392646..45c75e53a58 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelUnreadCountView+SwiftUI_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelUnreadCountView+SwiftUI_Tests.swift @@ -8,9 +8,9 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class ChatChannelUnreadCountView_SwiftUI_Tests: XCTestCase { +@MainActor final class ChatChannelUnreadCountView_SwiftUI_Tests: XCTestCase { func test_injectedSwiftUIView() { - struct CustomUnreadCountView: ChatChannelUnreadCountView.SwiftUIView { + @MainActor struct CustomUnreadCountView: ChatChannelUnreadCountView.SwiftUIView { @ObservedObject var dataSource: ChatChannelUnreadCountView.ObservedObject public init(dataSource: ChatChannelUnreadCountView.ObservedObject) { diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelUnreadCountView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelUnreadCountView_Tests.swift index b677b986c63..2440dee8ff1 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelUnreadCountView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelUnreadCountView_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class ChatChannelUnreadCountView_Tests: XCTestCase { +@MainActor final class ChatChannelUnreadCountView_Tests: XCTestCase { func test_emptyAppearance() { let view = ChatChannelUnreadCountView().withoutAutoresizingMaskConstraints AssertSnapshot(view) diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChannelListSearchStrategy_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChannelListSearchStrategy_Tests.swift index 127aabe96ef..a95c221473b 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChannelListSearchStrategy_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChannelListSearchStrategy_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatUI import XCTest -class ChannelListSearchStrategy_Tests: XCTestCase { +@MainActor class ChannelListSearchStrategy_Tests: XCTestCase { var channelListVC: ChatChannelListVC { .make(with: ChatChannelListController_Mock.mock()) } diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChatChannelSearchVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChatChannelSearchVC_Tests.swift index 8375e359f10..5fd8fe883c2 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChatChannelSearchVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChatChannelSearchVC_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatChannelSearchVC_Tests: XCTestCase { +@MainActor final class ChatChannelSearchVC_Tests: XCTestCase { var mockedClient: ChatClient_Mock! var vc: ChatChannelSearchVC! var mockedChannelListController: ChatChannelListController_Mock! diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChatMessageSearchVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChatMessageSearchVC_Tests.swift index 0a902e731e2..9a11a1b1735 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChatMessageSearchVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/Search/ChatMessageSearchVC_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatMessageSearchVC_Tests: XCTestCase { +@MainActor final class ChatMessageSearchVC_Tests: XCTestCase { /// Static setUp() is only run once. Which is what we want in this case to preload the images. override class func setUp() { /// Dummy snapshot to preload the TestImages.yoda.url image diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatFileAttachmentListView+ItemView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatFileAttachmentListView+ItemView_Tests.swift index 8b8ef85007e..6554521f0dd 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatFileAttachmentListView+ItemView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatFileAttachmentListView+ItemView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import UIKit import XCTest -final class ChatFileAttachmentListViewItemView_Tests: XCTestCase { +@MainActor final class ChatFileAttachmentListViewItemView_Tests: XCTestCase { private var fileAttachmentView: ChatMessageFileAttachmentListView.ItemView! private var vc: UIViewController! diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageGalleryView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageGalleryView_Tests.swift index 0ebf29d3ad1..1f8e1da1e12 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageGalleryView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageGalleryView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import UIKit import XCTest -final class ChatMessageGalleryView_Tests: XCTestCase { +@MainActor final class ChatMessageGalleryView_Tests: XCTestCase { private var galleryView: ChatMessageGalleryView! override func setUp() { diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageLinkPreviewView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageLinkPreviewView_Tests.swift index 704967f798c..6e66a6dd843 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageLinkPreviewView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageLinkPreviewView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import UIKit import XCTest -final class ChatMessageLinkPreviewView_Tests: XCTestCase { +@MainActor final class ChatMessageLinkPreviewView_Tests: XCTestCase { private var linkPreviewView: ChatMessageLinkPreviewView! override func setUp() { diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListViewItemViewPresenter_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListViewItemViewPresenter_Tests.swift index 282d4094f88..87c53a513d6 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListViewItemViewPresenter_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListViewItemViewPresenter_Tests.swift @@ -8,7 +8,7 @@ import UIKit import XCTest -final class ChatMessageVoiceRecordingAttachmentListViewItemViewPresenter_Tests: XCTestCase { +@MainActor final class ChatMessageVoiceRecordingAttachmentListViewItemViewPresenter_Tests: XCTestCase { private lazy var view: ChatMessageVoiceRecordingAttachmentListView.ItemView! = .init() private lazy var spyView: SpyChatMessageVoiceRecordingAttachmentListViewItemView! = .init() private lazy var delegate: MockVoiceRecordingAttachmentPresentationViewDelegate! = .init() diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListViewItemView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListViewItemView_Tests.swift index ed890aaf50c..190afef658d 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListViewItemView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListViewItemView_Tests.swift @@ -8,7 +8,7 @@ import UIKit import XCTest -final class ChatMessageVoiceRecordingAttachmentListViewItemView_Tests: XCTestCase { +@MainActor final class ChatMessageVoiceRecordingAttachmentListViewItemView_Tests: XCTestCase { private lazy var presenter: SpyChatMessageVoiceRecordingAttachmentListViewItemViewViewPresenter! = .init(subject) private lazy var subject: ChatMessageVoiceRecordingAttachmentListView.ItemView! = .init() diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListView_Tests.swift index 87233e0af1b..cddf5b48eb5 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/ChatMessageVoiceRecordingAttachmentListView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import UIKit import XCTest -final class ChatMessageVoiceRecordingAttachmentListView_Tests: XCTestCase { +@MainActor final class ChatMessageVoiceRecordingAttachmentListView_Tests: XCTestCase { private lazy var playbackDelegate: MockVoiceRecordingAttachmentPresentationViewDelegate! = .init() private lazy var subject: ChatMessageVoiceRecordingAttachmentListView! = .init().withoutAutoresizingMaskConstraints private lazy var duration: TimeInterval! = 10 diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/MixedAttachmentViewInjector_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/MixedAttachmentViewInjector_Tests.swift index fb6dfd45a40..29cf003f986 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/MixedAttachmentViewInjector_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/MixedAttachmentViewInjector_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import UIKit import XCTest -final class MixedAttachmentViewInjector_Tests: XCTestCase { +@MainActor final class MixedAttachmentViewInjector_Tests: XCTestCase { func test_injectors_whenNoAttachments_isEmpty() { let message = ChatMessage.mock(attachments: []) let injectors = MixedAttachmentViewInjector.injectors(for: message) diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/VideoAttachmentGalleryPreview_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/VideoAttachmentGalleryPreview_Tests.swift index c504cae7f87..90f32a6edd9 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/VideoAttachmentGalleryPreview_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/VideoAttachmentGalleryPreview_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatUI import XCTest -final class VideoAttachmentGalleryPreview_Tests: XCTestCase { +@MainActor final class VideoAttachmentGalleryPreview_Tests: XCTestCase { func test_whenContentIsSet_videoLoadingComponentIsInvoked() throws { // Create mock components let components: Components = .mock diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/VoiceRecordingAttachmentViewInjector_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/VoiceRecordingAttachmentViewInjector_Tests.swift index a646975a584..ea7e410b838 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/VoiceRecordingAttachmentViewInjector_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Attachments/VoiceRecordingAttachmentViewInjector_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import UIKit import XCTest -final class VoiceRecordingAttachmentViewInjector_Tests: XCTestCase { +@MainActor final class VoiceRecordingAttachmentViewInjector_Tests: XCTestCase { private var contentView: ChatMessageContentView! = .init() private lazy var subject: VoiceRecordingAttachmentViewInjector! = .init(contentView) diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageBubbleView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageBubbleView_Tests.swift index e921057f08a..5463bec4436 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageBubbleView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageBubbleView_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatMessageBubbleView_Tests: XCTestCase { +@MainActor final class ChatMessageBubbleView_Tests: XCTestCase { private var bubbleContent = ChatMessageBubbleView.Content( backgroundColor: Appearance.default.colorPalette.background2, roundedCorners: CACornerMask.all.subtracting(.layerMaxXMinYCorner) diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift index 5055d0e5454..5da697e38a0 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatMessageContentView_Tests: XCTestCase { +@MainActor final class ChatMessageContentView_Tests: XCTestCase { /// Default content view width. private let contentViewWidth: CGFloat = 360 /// The current user. diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageErrorIndicator_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageErrorIndicator_Tests.swift index 37893da937c..e28551803b1 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageErrorIndicator_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageErrorIndicator_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class ChatMessageErrorIndicator_Tests: XCTestCase { +@MainActor final class ChatMessageErrorIndicator_Tests: XCTestCase { func test_appearanceCustomization_usingAppearance() { // Create custom appearance var appearance = Appearance() diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageMarkdown_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageMarkdown_Tests.swift index 8981cfb0983..edb032be5a7 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageMarkdown_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageMarkdown_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatMessageMarkdown_Tests: XCTestCase { +@MainActor final class ChatMessageMarkdown_Tests: XCTestCase { override func tearDownWithError() throws { Appearance.default = Appearance() } diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatReactionsBubbleView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatReactionsBubbleView_Tests.swift index cb864aa1f86..c01ec6495d3 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatReactionsBubbleView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatReactionsBubbleView_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatReactionsBubbleView_Tests: XCTestCase { +@MainActor final class ChatReactionsBubbleView_Tests: XCTestCase { func test_defaultAppearance_toLeadingTail() { // Create a bubble let bubble = ChatReactionsBubbleView().withFixedSize diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessageListVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessageListVC_Tests.swift index 5d17f9670c5..16a0967fe00 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessageListVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessageListVC_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatUI import XCTest -final class ChatMessageListVC_Tests: XCTestCase { +@MainActor final class ChatMessageListVC_Tests: XCTestCase { var sut: ChatMessageListVC! var config: ChatClientConfig! var mockedListView: ChatMessageListView_Mock { @@ -171,8 +171,8 @@ final class ChatMessageListVC_Tests: XCTestCase { sut.client = mockedClient // Make message without a CID - var messageDTOWithoutCid: MessageDTO! - var mockedMessageWithoutCid: ChatMessage! + nonisolated(unsafe) var messageDTOWithoutCid: MessageDTO! + nonisolated(unsafe) var mockedMessageWithoutCid: ChatMessage! try mockedClient.databaseContainer.writeSynchronously { session in let messagePayload = self.dummyMessagePayload(cid: nil) let channel = try session.saveChannel(payload: .dummy()) diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollAttachmentView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollAttachmentView_Tests.swift index f4221321045..bfc00b417a4 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollAttachmentView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollAttachmentView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import UIKit import XCTest -final class PollAttachmentView_Tests: XCTestCase { +@MainActor final class PollAttachmentView_Tests: XCTestCase { /// Static setUp() is only run once. Which is what we want in this case to preload the images. override class func setUp() { /// Dummy snapshot to preload the TestImages.yoda.url image diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollCreationVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollCreationVC_Tests.swift index 27f98af1e5b..cef0320c1a0 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollCreationVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollCreationVC_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import UIKit import XCTest -final class PollCreationVC_Tests: XCTestCase { +@MainActor final class PollCreationVC_Tests: XCTestCase { var mockChannelController = ChatChannelController_Spy(client: .mock) func test_appearance() { diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollResultsVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollResultsVC_Tests.swift index 1d5d96b95bf..e0b42fb20ed 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollResultsVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollResultsVC_Tests.swift @@ -62,7 +62,7 @@ class MockPollResultsVC: PollResultsVC { } } -class PollController_Mock: PollController { +class PollController_Mock: PollController, @unchecked Sendable { init() { super.init(client: .mock, messageId: .unique, pollId: .unique) } @@ -71,7 +71,7 @@ class PollController_Mock: PollController { super.init(client: client, messageId: .unique, pollId: .unique) } - var mockedPoll: Poll? + @Atomic var mockedPoll: Poll? override var poll: Poll? { mockedPoll } diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollResultsVoteListVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollResultsVoteListVC_Tests.swift index d77d105d011..bf248ae7a2a 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollResultsVoteListVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollResultsVoteListVC_Tests.swift @@ -37,7 +37,7 @@ final class PollResultsVoteListVC_Tests: XCTestCase { } } -class PollVoteListController_Mock: PollVoteListController { +class PollVoteListController_Mock: PollVoteListController, @unchecked Sendable { init() { super.init(query: .init(pollId: .unique), client: .mock) } diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorsVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorsVC_Tests.swift index 2c7486f31eb..ffcf69eed35 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorsVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionAuthorsVC/ChatMessageReactionAuthorsVC_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class ChatMessageReactionAuthorsVC_Tests: XCTestCase { +@MainActor final class ChatMessageReactionAuthorsVC_Tests: XCTestCase { var vc: ChatMessageReactionAuthorsVC! var messageControllerMock: ChatMessageController_Mock! diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionsView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionsView_Tests.swift index 9d6e4cf1419..8ca790a8fb2 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionsView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Reactions/ChatMessageReactionsView_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class ChatMessageReactionsView_Tests: XCTestCase { +@MainActor final class ChatMessageReactionsView_Tests: XCTestCase { var sut: ChatMessageReactionsView! var mockedReactionsData: [ChatMessageReactionData] { diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/TypingIndicatorView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/TypingIndicatorView_Tests.swift index 1556a762753..7da40c8b013 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/TypingIndicatorView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/TypingIndicatorView_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class TypingIndicatorViewTests: XCTestCase { +@MainActor final class TypingIndicatorViewTests: XCTestCase { override func setUp() { super.setUp() // Disable animations. diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC+SwiftUI_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC+SwiftUI_Tests.swift index cd331456cb9..69352fd3fb0 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC+SwiftUI_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC+SwiftUI_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class ChatThreadView_Tests: iOS13TestCase { +@MainActor final class ChatThreadView_Tests: iOS13TestCase { var chatThreadView: SwiftUIViewControllerRepresentable! var channelControllerMock: ChatChannelController_Mock! diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC_Tests.swift index 21cca01db2d..53a6b82ac05 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatThread/ChatThreadVC_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatThreadVC_Tests: XCTestCase { +@MainActor final class ChatThreadVC_Tests: XCTestCase { private var vc: ChatThreadVC! private var channelControllerMock: ChatChannelController_Mock! private var messageControllerMock: ChatMessageController_Mock! diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/ChatThreadListItemView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/ChatThreadListItemView_Tests.swift index 96377798d15..3488dc07130 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/ChatThreadListItemView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatThreadList/ChatThreadListItemView_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class ChatThreadListItemView_Tests: XCTestCase { +@MainActor final class ChatThreadListItemView_Tests: XCTestCase { var mockThread: ChatThread! var mockYoda = ChatUser.mock(id: .unique, name: "Yoda", imageURL: .localYodaImage) diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentPreviewProvider_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentPreviewProvider_Tests.swift index 5003d26d233..11b72113f81 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentPreviewProvider_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentPreviewProvider_Tests.swift @@ -7,7 +7,7 @@ import StreamChat import StreamChatUI import XCTest -final class AttachmentPreviewProvider_VoiceRecordingAttachmentPayload_Tests: XCTestCase { +@MainActor final class AttachmentPreviewProvider_VoiceRecordingAttachmentPayload_Tests: XCTestCase { // MARK: - preferredAxis func test_preferredAxis_returnsExpectedValue() { diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview_Tests.swift index 33ecc15b227..4178b804ea8 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Attachments/AttachmentViews/VideoAttachmentComposerPreview_Tests.swift @@ -7,7 +7,7 @@ import StreamChat @testable import StreamChatUI import XCTest -final class VideoAttachmentComposerPreview_Tests: XCTestCase { +@MainActor final class VideoAttachmentComposerPreview_Tests: XCTestCase { func test_whenContentIsSet_videoLoadingComponentIsInvoked() throws { // Create mock components let components: Components = .mock diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatChannelAvatarView+SwiftUI_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatChannelAvatarView+SwiftUI_Tests.swift index f0a45021a27..edbf3756537 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatChannelAvatarView+SwiftUI_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatChannelAvatarView+SwiftUI_Tests.swift @@ -9,9 +9,9 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class ChatChannelAvatarView_SwiftUI_Tests: XCTestCase { +@MainActor final class ChatChannelAvatarView_SwiftUI_Tests: XCTestCase { func test_injectedSwiftUIView() { - struct CustomChatChannelAvatarView: ChatChannelAvatarView.SwiftUIView { + @MainActor struct CustomChatChannelAvatarView: ChatChannelAvatarView.SwiftUIView { @ObservedObject var dataSource: ChatChannelAvatarView.ObservedObject init(dataSource: ChatChannelAvatarView.ObservedObject) { diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatChannelAvatarView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatChannelAvatarView_Tests.swift index d99e2c80514..03bf09df683 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatChannelAvatarView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatChannelAvatarView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class ChatChannelAvatarView_Tests: XCTestCase { +@MainActor final class ChatChannelAvatarView_Tests: XCTestCase { var currentUserId: UserId! var channel: ChatChannel! diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatUserAvatarView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatUserAvatarView_Tests.swift index ce9d550fabd..d35c24f3876 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatUserAvatarView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatUserAvatarView_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class ChatUserAvatarView_Tests: XCTestCase { +@MainActor final class ChatUserAvatarView_Tests: XCTestCase { var user: ChatUser! override func setUp() { diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/CurrentChatUserAvatarView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/CurrentChatUserAvatarView_Tests.swift index 13e61f3b2cf..1aad6d5c599 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/CurrentChatUserAvatarView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/CurrentChatUserAvatarView_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class CurrentChatUserAvatarView_Tests: XCTestCase { +@MainActor final class CurrentChatUserAvatarView_Tests: XCTestCase { var currentUserController: CurrentChatUserController_Mock! override func setUp() { diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/ChatMessageListDateSeparatorView/ChatMessageListDateSeparatorView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/ChatMessageListDateSeparatorView/ChatMessageListDateSeparatorView_Tests.swift index a61f9017463..159274c3a7b 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/ChatMessageListDateSeparatorView/ChatMessageListDateSeparatorView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/ChatMessageListDateSeparatorView/ChatMessageListDateSeparatorView_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatUI import XCTest -final class ChatMessageListDateSeparatorView_Tests: XCTestCase { +@MainActor final class ChatMessageListDateSeparatorView_Tests: XCTestCase { private lazy var subject: ChatMessageListDateSeparatorView! = .init() override func tearDownWithError() throws { diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/CommandLabelView/CommandLabelView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/CommandLabelView/CommandLabelView_Tests.swift index 7fe25c538ea..da3ba274479 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/CommandLabelView/CommandLabelView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/CommandLabelView/CommandLabelView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class CommandLabelView_Tests: XCTestCase { +@MainActor final class CommandLabelView_Tests: XCTestCase { func test_emptyAppearance() { let view = CommandLabelView().withoutAutoresizingMaskConstraints AssertSnapshot(view) @@ -22,7 +22,7 @@ final class CommandLabelView_Tests: XCTestCase { } } -final class CommandLabelView_SwiftUI_Tests: iOS13TestCase { +@MainActor final class CommandLabelView_SwiftUI_Tests: iOS13TestCase { func test_defaultAppearance_SwiftUI() { let view = CommandLabelView.asView( Command(name: "Giphy", description: "Send animated gifs", set: "", args: "") diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/ConfirmButton/ConfirmButton_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/ConfirmButton/ConfirmButton_Tests.swift index b644ecb5451..e18d7f5b4e4 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/ConfirmButton/ConfirmButton_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/ConfirmButton/ConfirmButton_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class ConfirmButton_Tests: XCTestCase { +@MainActor final class ConfirmButton_Tests: XCTestCase { private lazy var container = UIView().withoutAutoresizingMaskConstraints override func setUp() { diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/PillButton_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/PillButton_Tests.swift index a06672a0d28..c25bcd8a463 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/PillButton_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/PillButton_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class PillButton_Tests: XCTestCase { +@MainActor final class PillButton_Tests: XCTestCase { private var subject: PillButton! = .init().withoutAutoresizingMaskConstraints // MARK: - Lifecycle diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/PlayPauseButton/PlayPauseButton_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/PlayPauseButton/PlayPauseButton_Tests.swift index 81176373245..5643ec82c65 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/PlayPauseButton/PlayPauseButton_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/PlayPauseButton/PlayPauseButton_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class PlayPauseButton_Tests: XCTestCase { +@MainActor final class PlayPauseButton_Tests: XCTestCase { private var subject: PlayPauseButton! = .init() // MARK: - Lifecycle diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView+SwiftUI_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView+SwiftUI_Tests.swift index d5d0b637292..5afc50987ae 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView+SwiftUI_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView+SwiftUI_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class QuotedChatMessageView_SwiftUI_Tests: XCTestCase { +@MainActor final class QuotedChatMessageView_SwiftUI_Tests: XCTestCase { func test_injectedSwiftUIView() { struct CustomQuotedChatMessageView: QuotedChatMessageView.SwiftUIView { @ObservedObject var dataSource: QuotedChatMessageView.ObservedObject diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView_Tests.swift index 7ed89557034..98198345dac 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class QuotedChatMessageView_Tests: XCTestCase { +@MainActor final class QuotedChatMessageView_Tests: XCTestCase { private var view: QuotedChatMessageView! // MARK: - Lifecycle diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/RecordButton/RecordButton_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/RecordButton/RecordButton_Tests.swift index edc4bdaf1da..a084495a69e 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/RecordButton/RecordButton_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/RecordButton/RecordButton_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class RecordButton_Tests: XCTestCase { +@MainActor final class RecordButton_Tests: XCTestCase { private var subject: RecordButton! = .init() // MARK: - Lifecycle diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/SendButton/SendButton_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/SendButton/SendButton_Tests.swift index a9f091618cb..3a05789ce03 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/SendButton/SendButton_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/SendButton/SendButton_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class SendButton_Tests: XCTestCase { +@MainActor final class SendButton_Tests: XCTestCase { private lazy var container = UIView().withoutAutoresizingMaskConstraints override func setUp() { diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatCommandSuggestionView/ChatCommandSuggestionView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatCommandSuggestionView/ChatCommandSuggestionView_Tests.swift index 3a26edfd801..0823caab332 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatCommandSuggestionView/ChatCommandSuggestionView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatCommandSuggestionView/ChatCommandSuggestionView_Tests.swift @@ -7,7 +7,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatCommandSuggestionView_Tests: XCTestCase { +@MainActor final class ChatCommandSuggestionView_Tests: XCTestCase { /// Default width for the cell. private let defaultCellWidth: CGFloat = 250 diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatMentionSuggestionView/ChatMentionSuggestionView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatMentionSuggestionView/ChatMentionSuggestionView_Tests.swift index 3fb3cbbb515..04e92dc96dc 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatMentionSuggestionView/ChatMentionSuggestionView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatMentionSuggestionView/ChatMentionSuggestionView_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class ChatMentionSuggestionView_Tests: XCTestCase { +@MainActor final class ChatMentionSuggestionView_Tests: XCTestCase { /// Default reference width for the cell. Not related to any screen size. private static var defaultCellWidth: CGFloat = 300 diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatSuggestionsVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatSuggestionsVC_Tests.swift index c4bab9b36f4..42fb17e54bf 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatSuggestionsVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatSuggestionsVC_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatSuggestionsVC_Tests: XCTestCase { +@MainActor final class ChatSuggestionsVC_Tests: XCTestCase { // We need to provide a size to the suggestions view since here we are testing the view in isolation, // and so we can't attach it to a bottomAnchorView. The test to verify the height calculation dependent // on the rows should be done in the parent view controller tests. diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/TitleContainerView/TitleContainerView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/TitleContainerView/TitleContainerView_Tests.swift index af6ed79bc21..9d111d31e2c 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/TitleContainerView/TitleContainerView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/TitleContainerView/TitleContainerView_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class TitleContainerView_Tests: XCTestCase { +@MainActor final class TitleContainerView_Tests: XCTestCase { func test_defaultAppearance() { let view = TitleContainerView().withoutAutoresizingMaskConstraints view.addSizeConstraints() @@ -66,7 +66,7 @@ final class TitleContainerView_Tests: XCTestCase { } } -final class TitleContainerView_Swift_Tests: iOS13TestCase { +@MainActor final class TitleContainerView_Swift_Tests: iOS13TestCase { func test_swiftUIWrapper() { let view = TitleContainerView.asView((title: "Luke Skywalker", subtitle: "Last seen a long time ago...")) AssertSnapshot(view.frame(width: 320, height: 44)) diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift index c29daccd3a5..15d498d29ac 100644 --- a/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ComposerVC_Tests: XCTestCase { +@MainActor final class ComposerVC_Tests: XCTestCase { private var composerVC: ComposerVC! var mockedChatChannelController: ChatChannelController_Mock! diff --git a/Tests/StreamChatUITests/SnapshotTests/Gallery/Cells/VideoAttachmentGalleryCell_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/Gallery/Cells/VideoAttachmentGalleryCell_Tests.swift index d5c4ab92c82..80ed3cc1ea3 100644 --- a/Tests/StreamChatUITests/SnapshotTests/Gallery/Cells/VideoAttachmentGalleryCell_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/Gallery/Cells/VideoAttachmentGalleryCell_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatUI import XCTest -final class VideoAttachmentGalleryCell_Tests: XCTestCase { +@MainActor final class VideoAttachmentGalleryCell_Tests: XCTestCase { func test_whenContentIsSet_videoLoadingComponentIsInvoked() throws { // Create mock components let components: Components = .mock diff --git a/Tests/StreamChatUITests/SnapshotTests/Gallery/GalleryVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/Gallery/GalleryVC_Tests.swift index 9b1220b2c2e..904ae9c4a11 100644 --- a/Tests/StreamChatUITests/SnapshotTests/Gallery/GalleryVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/Gallery/GalleryVC_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class GalleryVC_Tests: XCTestCase { +@MainActor final class GalleryVC_Tests: XCTestCase { private var vc: GalleryVC! private var content: GalleryVC.Content! diff --git a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionControl_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionControl_Tests.swift index 2ffcde02864..9ecd8ef3287 100644 --- a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionControl_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionControl_Tests.swift @@ -8,7 +8,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class ChatMessageActionControl_Tests: XCTestCase { +@MainActor final class ChatMessageActionControl_Tests: XCTestCase { struct TestChatMessageActionItem: ChatMessageActionItem { let title: String let icon: UIImage diff --git a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionsVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionsVC_Tests.swift index 892a25ee412..1ec70f36396 100644 --- a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionsVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessageActionsVC_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -final class ChatMessageActionsVC_Tests: XCTestCase { +@MainActor final class ChatMessageActionsVC_Tests: XCTestCase { private var vc: ChatMessageActionsVC! private var chatMessageController: ChatMessageController_Mock! diff --git a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessagePopupVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessagePopupVC_Tests.swift index 55265afabb5..a0ac5f1d881 100644 --- a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessagePopupVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/ChatMessagePopupVC_Tests.swift @@ -10,7 +10,7 @@ import XCTest extension ChatMessagePopupVC: AppearanceProvider {} -final class ChatMessagePopupVC_Tests: XCTestCase { +@MainActor final class ChatMessagePopupVC_Tests: XCTestCase { private var vc: ChatMessagePopupVC! private var message: ChatMessage! private var reactionsController: ChatMessageReactionsPickerVC! diff --git a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/MessageActionsTransitionController_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/MessageActionsTransitionController_Tests.swift index ce8f409208d..aa5ac7f5d06 100644 --- a/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/MessageActionsTransitionController_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/MessageActionsPopup/MessageActionsTransitionController_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatUI import XCTest -final class MessageActionsTransitionController_Tests: XCTestCase { +@MainActor final class MessageActionsTransitionController_Tests: XCTestCase { private lazy var subject: ChatMessageActionsTransitionController! = .init(messageListVC: nil) override func tearDown() { diff --git a/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/Components/StreamAudioSessionFeedbackGenerator_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/Components/StreamAudioSessionFeedbackGenerator_Tests.swift index 5097d1824b3..b022445b34b 100644 --- a/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/Components/StreamAudioSessionFeedbackGenerator_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/Components/StreamAudioSessionFeedbackGenerator_Tests.swift @@ -9,7 +9,7 @@ import Foundation import UIKit import XCTest -final class StreamAudioSessionFeedbackGenerator_Tests: XCTestCase { +@MainActor final class StreamAudioSessionFeedbackGenerator_Tests: XCTestCase { private lazy var lightImpactFeedbackGenerator: SpyUIImpactFeedbackGenerator! = .init() private lazy var mediumImpactFeedbackGenerator: SpyUIImpactFeedbackGenerator! = .init() private lazy var heavyImpactFeedbackGenerator: SpyUIImpactFeedbackGenerator! = .init() @@ -142,7 +142,7 @@ final class StreamAudioSessionFeedbackGenerator_Tests: XCTestCase { ) } - func assertFeedbackGenerator( + @MainActor func assertFeedbackGenerator( _ action: @autoclosure () -> Void, expectedCalledFeedbackGenerator: @autoclosure () -> UIFeedbackGenerator, file: StaticString = #file, diff --git a/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/Views/AudioVisualizationView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/Views/AudioVisualizationView_Tests.swift index da9dd6e3ffa..eb9d86cec79 100644 --- a/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/Views/AudioVisualizationView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/Views/AudioVisualizationView_Tests.swift @@ -7,7 +7,7 @@ import StreamChat import StreamSwiftTestHelpers import XCTest -final class AudioVisualizationView_Tests: XCTestCase { +@MainActor final class AudioVisualizationView_Tests: XCTestCase { private var subject: AudioVisualizationView! = .init().withoutAutoresizingMaskConstraints override func tearDown() { diff --git a/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/VoiceRecordingVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/VoiceRecordingVC_Tests.swift index 05e483bf456..ff42616917d 100644 --- a/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/VoiceRecordingVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/VoiceRecording/VoiceRecordingVC_Tests.swift @@ -9,7 +9,7 @@ import AVFoundation import StreamSwiftTestHelpers import XCTest -final class VoiceRecordingVC_Tests: XCTestCase { +@MainActor final class VoiceRecordingVC_Tests: XCTestCase { private lazy var composerView: ComposerView! = .init() private lazy var assetPropertiesLoader: MockAssetPropertyLoader! = .init() private lazy var audioAnalyser: MockAudioAnalyser! = .init() diff --git a/Tests/StreamChatUITests/Utils/AppearanceProvider_Tests.swift b/Tests/StreamChatUITests/Utils/AppearanceProvider_Tests.swift index ffe479d0d3f..a2d19ed0ae1 100644 --- a/Tests/StreamChatUITests/Utils/AppearanceProvider_Tests.swift +++ b/Tests/StreamChatUITests/Utils/AppearanceProvider_Tests.swift @@ -7,7 +7,7 @@ import StreamChat @testable import StreamChatUI import XCTest -final class AppearanceProvider_Tests: XCTestCase { +@MainActor final class AppearanceProvider_Tests: XCTestCase { func test_appearance_passedDownToSubview() { let parentView = TestAppearanceView() let subView = TestAppearanceView() diff --git a/Tests/StreamChatUITests/Utils/ComponentsProvider_Tests.swift b/Tests/StreamChatUITests/Utils/ComponentsProvider_Tests.swift index e1de050d53e..6c600012d91 100644 --- a/Tests/StreamChatUITests/Utils/ComponentsProvider_Tests.swift +++ b/Tests/StreamChatUITests/Utils/ComponentsProvider_Tests.swift @@ -7,7 +7,7 @@ import StreamChat @testable import StreamChatUI import XCTest -final class ComponentsProvider_Tests: XCTestCase { +@MainActor final class ComponentsProvider_Tests: XCTestCase { func test_components_passedDownToSubview() { let parentView = TestViewWithExtraData() let subView = TestViewWithExtraData() diff --git a/Tests/StreamChatUITests/Utils/ImageLoading_Tests.swift b/Tests/StreamChatUITests/Utils/ImageLoading_Tests.swift index 88ac66a210e..577ad3498e2 100644 --- a/Tests/StreamChatUITests/Utils/ImageLoading_Tests.swift +++ b/Tests/StreamChatUITests/Utils/ImageLoading_Tests.swift @@ -6,7 +6,7 @@ @testable import StreamChatUI import XCTest -final class ImageLoading_Tests: XCTestCase { +@MainActor final class ImageLoading_Tests: XCTestCase { var sut: ImageLoading! var spy: ImageLoaderSpy! diff --git a/Tests/StreamChatUITests/Utils/NukeImageLoader_Tests.swift b/Tests/StreamChatUITests/Utils/NukeImageLoader_Tests.swift index 3a32675f6c2..fa6d5c840b5 100644 --- a/Tests/StreamChatUITests/Utils/NukeImageLoader_Tests.swift +++ b/Tests/StreamChatUITests/Utils/NukeImageLoader_Tests.swift @@ -96,7 +96,7 @@ extension NukeImageLoader_Tests { func loadImage(with request: ImageRequest, completion: @escaping (Result) -> Void) -> ImageTask { counter += 1 scheduledRequests.append((counter, request, completion)) - return ImageTask(taskId: Int64(counter), request: request, isDataTask: false) + return ImageTask(taskId: Int64(counter), request: request, isDataTask: false, pipeline: .shared, onEvent: nil) } } diff --git a/Tests/StreamChatUITests/Utils/TextViewMentionedUsersHandler_Tests.swift b/Tests/StreamChatUITests/Utils/TextViewMentionedUsersHandler_Tests.swift index 14f3cee9d28..6c94f16f45a 100644 --- a/Tests/StreamChatUITests/Utils/TextViewMentionedUsersHandler_Tests.swift +++ b/Tests/StreamChatUITests/Utils/TextViewMentionedUsersHandler_Tests.swift @@ -6,7 +6,7 @@ @testable import StreamChatUI import XCTest -final class TextViewMentionedUsersHandler_Tests: XCTestCase { +@MainActor final class TextViewMentionedUsersHandler_Tests: XCTestCase { func test_mentionedUserTapped_whenRangeIncludesMention() { let textView = UITextView() textView.text = "@Leia Hello!" diff --git a/Tests/StreamChatUITests/Utils/ViewPaginationHandling/InvertedScrollViewPaginationHandler_Tests.swift b/Tests/StreamChatUITests/Utils/ViewPaginationHandling/InvertedScrollViewPaginationHandler_Tests.swift index dd48330651e..c1bb1566833 100644 --- a/Tests/StreamChatUITests/Utils/ViewPaginationHandling/InvertedScrollViewPaginationHandler_Tests.swift +++ b/Tests/StreamChatUITests/Utils/ViewPaginationHandling/InvertedScrollViewPaginationHandler_Tests.swift @@ -6,7 +6,7 @@ @testable import StreamChatUI import XCTest -final class InvertedScrollViewPaginationHandler_Tests: XCTestCase { +@MainActor final class InvertedScrollViewPaginationHandler_Tests: XCTestCase { func test_topThreshold_shouldReturnBottomThresholdOfRegularPaginationHandler() { let scrollViewPaginationHandler = StatefulScrollViewPaginationHandler(scrollView: UIScrollView()) scrollViewPaginationHandler.bottomThreshold = 150 diff --git a/Tests/StreamChatUITests/Utils/ViewPaginationHandling/ScrollViewPaginationHandler_Tests.swift b/Tests/StreamChatUITests/Utils/ViewPaginationHandling/ScrollViewPaginationHandler_Tests.swift index fe24cfac15d..263cfad18cc 100644 --- a/Tests/StreamChatUITests/Utils/ViewPaginationHandling/ScrollViewPaginationHandler_Tests.swift +++ b/Tests/StreamChatUITests/Utils/ViewPaginationHandling/ScrollViewPaginationHandler_Tests.swift @@ -6,7 +6,7 @@ @testable import StreamChatUI import XCTest -final class ScrollViewPaginationHandler_Tests: XCTestCase { +@MainActor final class ScrollViewPaginationHandler_Tests: XCTestCase { func test_whenScrollViewContentOffsetReachesContentSizeHeight_onNewBottomPageIsCalled() { let exp = expectation(description: "on new bottom page closure is called") let scrollView = MockScrollView() diff --git a/Tests/StreamChatUITests/Utils/ViewPaginationHandling/StatefulScrollViewPaginationHandler_Tests.swift b/Tests/StreamChatUITests/Utils/ViewPaginationHandling/StatefulScrollViewPaginationHandler_Tests.swift index 958de148122..1ab9702f6b4 100644 --- a/Tests/StreamChatUITests/Utils/ViewPaginationHandling/StatefulScrollViewPaginationHandler_Tests.swift +++ b/Tests/StreamChatUITests/Utils/ViewPaginationHandling/StatefulScrollViewPaginationHandler_Tests.swift @@ -6,7 +6,7 @@ @testable import StreamChatUI import XCTest -final class StatefulStatefulScrollViewPaginationHandler_Tests: XCTestCase { +@MainActor final class StatefulStatefulScrollViewPaginationHandler_Tests: XCTestCase { func test_whenScrollViewContentOffsetReachesContentSizeHeight_onNewBottomPageIsCalled() { let exp = expectation(description: "on new bottom page closure is called") let scrollView = MockScrollView()