diff --git a/.github/workflows/smoke-checks.yml b/.github/workflows/smoke-checks.yml index fcd9b08d5..f16001ee7 100644 --- a/.github/workflows/smoke-checks.yml +++ b/.github/workflows/smoke-checks.yml @@ -66,7 +66,8 @@ jobs: build-xcode15: name: Build SDKs (Xcode 15) runs-on: macos-15 - if: ${{ github.event.inputs.record_snapshots != 'true' }} + #if: ${{ github.event.inputs.record_snapshots != 'true' }} + if: false env: XCODE_VERSION: "15.4" steps: diff --git a/.swiftformat b/.swiftformat index 069dc9c39..a7f8d7d74 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,6 +1,6 @@ # Stream rules --header "\nCopyright © {year} Stream.io Inc. All rights reserved.\n" ---swiftversion 5.9 +--swiftversion 6.0 --ifdef no-indent --disable redundantType diff --git a/DemoAppSwiftUI/AppConfiguration/AppConfiguration.swift b/DemoAppSwiftUI/AppConfiguration/AppConfiguration.swift index 19c6fc028..049dd9fed 100644 --- a/DemoAppSwiftUI/AppConfiguration/AppConfiguration.swift +++ b/DemoAppSwiftUI/AppConfiguration/AppConfiguration.swift @@ -7,7 +7,7 @@ import StreamChat import StreamChatSwiftUI final class AppConfiguration { - static let `default` = AppConfiguration() + @MainActor static let `default` = AppConfiguration() /// The translation language to set on connect. var translationLanguage: TranslationLanguage? diff --git a/DemoAppSwiftUI/AppConfiguration/AppConfigurationTranslationView.swift b/DemoAppSwiftUI/AppConfiguration/AppConfigurationTranslationView.swift index 779b83562..eb2e609c8 100644 --- a/DemoAppSwiftUI/AppConfiguration/AppConfigurationTranslationView.swift +++ b/DemoAppSwiftUI/AppConfiguration/AppConfigurationTranslationView.swift @@ -8,23 +8,17 @@ import SwiftUI struct AppConfigurationTranslationView: View { @Environment(\.dismiss) var dismiss - var selection: Binding = Binding { - AppConfiguration.default.translationLanguage - } set: { newValue in - AppConfiguration.default.translationLanguage = newValue - } - var body: some View { List { ForEach(TranslationLanguage.all, id: \.languageCode) { language in Button(action: { - selection.wrappedValue = language + AppConfiguration.default.translationLanguage = language dismiss() }) { HStack { Text(language.languageCode) Spacer() - if selection.wrappedValue == language { + if AppConfiguration.default.translationLanguage == language { Image(systemName: "checkmark") } } diff --git a/DemoAppSwiftUI/AppleMessageComposerView.swift b/DemoAppSwiftUI/AppleMessageComposerView.swift index 234a1e7f3..687112871 100644 --- a/DemoAppSwiftUI/AppleMessageComposerView.swift +++ b/DemoAppSwiftUI/AppleMessageComposerView.swift @@ -160,7 +160,7 @@ struct AppleMessageComposerView: View, KeyboardReadable { } ) .offset(y: -composerHeight) - .animation(nil) : nil, + .animation(.none, value: viewModel.showCommandsOverlay) : nil, alignment: .bottom ) .modifier(factory.makeComposerViewModifier()) @@ -214,7 +214,7 @@ struct BlurredBackground: View { } struct HeightPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat? = nil + static let defaultValue: CGFloat? = nil static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { value = value ?? nextValue() diff --git a/DemoAppSwiftUI/ChannelHeader/BlockedUsersViewModel.swift b/DemoAppSwiftUI/ChannelHeader/BlockedUsersViewModel.swift index ea31c8982..7ba14ffd7 100644 --- a/DemoAppSwiftUI/ChannelHeader/BlockedUsersViewModel.swift +++ b/DemoAppSwiftUI/ChannelHeader/BlockedUsersViewModel.swift @@ -6,7 +6,7 @@ import StreamChat import StreamChatSwiftUI import SwiftUI -class BlockedUsersViewModel: ObservableObject { +@MainActor class BlockedUsersViewModel: ObservableObject { @Injected(\.chatClient) var chatClient @@ -27,8 +27,10 @@ class BlockedUsersViewModel: ObservableObject { } else { let controller = chatClient.userController(userId: blockedUserId) controller.synchronize { [weak self] _ in - if let user = controller.user { - self?.blockedUsers.append(user) + Task { @MainActor in + if let user = controller.user { + self?.blockedUsers.append(user) + } } } } @@ -39,8 +41,10 @@ class BlockedUsersViewModel: ObservableObject { let unblockController = chatClient.userController(userId: user.id) unblockController.unblock { [weak self] error in if error == nil { - self?.blockedUsers.removeAll { blocked in - blocked.id == user.id + Task { @MainActor in + self?.blockedUsers.removeAll { blocked in + blocked.id == user.id + } } } } diff --git a/DemoAppSwiftUI/ChannelHeader/ChooseChannelQueryView.swift b/DemoAppSwiftUI/ChannelHeader/ChooseChannelQueryView.swift index 7e0e036d2..b95565d13 100644 --- a/DemoAppSwiftUI/ChannelHeader/ChooseChannelQueryView.swift +++ b/DemoAppSwiftUI/ChannelHeader/ChooseChannelQueryView.swift @@ -7,7 +7,9 @@ import StreamChatSwiftUI import SwiftUI struct ChooseChannelQueryView: View { - static let queryIdentifiers = ChannelListQueryIdentifier.allCases.sorted(using: KeyPathComparator(\.title)) + static var queryIdentifiers: [ChannelListQueryIdentifier] { + ChannelListQueryIdentifier.allCases.sorted(by: { $0.title < $1.title }) + } var body: some View { ForEach(Self.queryIdentifiers) { queryIdentifier in diff --git a/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift b/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift index a600027c0..ba027418a 100644 --- a/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift +++ b/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift @@ -6,7 +6,7 @@ import StreamChat import StreamChatSwiftUI import SwiftUI -class NewChatViewModel: ObservableObject, ChatUserSearchControllerDelegate { +@MainActor class NewChatViewModel: ObservableObject, ChatUserSearchControllerDelegate { @Injected(\.chatClient) var chatClient @@ -99,20 +99,24 @@ class NewChatViewModel: ObservableObject, ChatUserSearchControllerDelegate { if !loadingNextUsers { loadingNextUsers = true searchController.loadNextUsers(limit: 50) { [weak self] _ in - guard let self = self else { return } - self.chatUsers = self.searchController.userArray - self.loadingNextUsers = false + Task { @MainActor in + guard let self = self else { return } + self.chatUsers = self.searchController.userArray + self.loadingNextUsers = false + } } } } // MARK: - ChatUserSearchControllerDelegate - func controller( + nonisolated func controller( _ controller: ChatUserSearchController, didChangeUsers changes: [ListChange] ) { - chatUsers = controller.userArray + Task { @MainActor in + chatUsers = controller.userArray + } } // MARK: - private @@ -120,10 +124,12 @@ class NewChatViewModel: ObservableObject, ChatUserSearchControllerDelegate { private func searchUsers(with term: String?) { state = .loading searchController.search(term: term) { [weak self] error in - if error != nil { - self?.state = .error - } else { - self?.state = .loaded + Task { @MainActor in + if error != nil { + self?.state = .error + } else { + self?.state = .loaded + } } } } @@ -137,13 +143,15 @@ class NewChatViewModel: ObservableObject, ChatUserSearchControllerDelegate { extraData: [:] ) channelController?.synchronize { [weak self] error in - if error != nil { - self?.state = .error - self?.updatingSelectedUsers = false - } else { - withAnimation { - self?.state = .channel + Task { @MainActor in + if error != nil { + self?.state = .error self?.updatingSelectedUsers = false + } else { + withAnimation { + self?.state = .channel + self?.updatingSelectedUsers = false + } } } } diff --git a/DemoAppSwiftUI/CreateGroupView.swift b/DemoAppSwiftUI/CreateGroupView.swift index 132e4ce95..ec92076ae 100644 --- a/DemoAppSwiftUI/CreateGroupView.swift +++ b/DemoAppSwiftUI/CreateGroupView.swift @@ -176,7 +176,7 @@ struct SearchBar: View { } .padding(.trailing, 10) .transition(.move(edge: .trailing)) - .animation(.default) + .animation(.default, value: isEditing) } } } diff --git a/DemoAppSwiftUI/CreateGroupViewModel.swift b/DemoAppSwiftUI/CreateGroupViewModel.swift index c3884957e..1542659cb 100644 --- a/DemoAppSwiftUI/CreateGroupViewModel.swift +++ b/DemoAppSwiftUI/CreateGroupViewModel.swift @@ -6,7 +6,7 @@ import StreamChat import StreamChatSwiftUI import SwiftUI -class CreateGroupViewModel: ObservableObject, ChatUserSearchControllerDelegate { +@MainActor class CreateGroupViewModel: ObservableObject, ChatUserSearchControllerDelegate { @Injected(\.chatClient) var chatClient @@ -75,10 +75,12 @@ class CreateGroupViewModel: ObservableObject, ChatUserSearchControllerDelegate { members: Set(selectedUsers.map(\.id)) ) channelController.synchronize { [weak self] error in - if error != nil { - self?.errorShown = true - } else { - self?.showGroupConversation = true + Task { @MainActor in + if error != nil { + self?.errorShown = true + } else { + self?.showGroupConversation = true + } } } @@ -89,11 +91,13 @@ class CreateGroupViewModel: ObservableObject, ChatUserSearchControllerDelegate { // MARK: - ChatUserSearchControllerDelegate - func controller( + nonisolated func controller( _ controller: ChatUserSearchController, didChangeUsers changes: [ListChange] ) { - chatUsers = controller.userArray + Task { @MainActor in + chatUsers = controller.userArray + } } // MARK: - private @@ -101,10 +105,12 @@ class CreateGroupViewModel: ObservableObject, ChatUserSearchControllerDelegate { private func searchUsers(with term: String?) { state = .loading searchController.search(term: term) { [weak self] error in - if error != nil { - self?.state = .error - } else { - self?.state = .loaded + Task { @MainActor in + if error != nil { + self?.state = .error + } else { + self?.state = .loaded + } } } } diff --git a/DemoAppSwiftUI/DemoAppSwiftUIApp.swift b/DemoAppSwiftUI/DemoAppSwiftUIApp.swift index 5f5eccdfc..e5b698cf9 100644 --- a/DemoAppSwiftUI/DemoAppSwiftUIApp.swift +++ b/DemoAppSwiftUI/DemoAppSwiftUIApp.swift @@ -72,7 +72,7 @@ struct DemoAppSwiftUIApp: App { } } -class AppState: ObservableObject, CurrentChatUserControllerDelegate { +@MainActor class AppState: ObservableObject, CurrentChatUserControllerDelegate { @Injected(\.chatClient) var chatClient: ChatClient // Recreate the content view when channel query changes. @@ -123,13 +123,15 @@ class AppState: ObservableObject, CurrentChatUserControllerDelegate { contentIdentifier = identifier.rawValue } - func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) { - unreadCount = didChangeCurrentUserUnreadCount - let totalUnreadBadge = unreadCount.channels + unreadCount.threads - if #available(iOS 16.0, *) { - UNUserNotificationCenter.current().setBadgeCount(totalUnreadBadge) - } else { - UIApplication.shared.applicationIconBadgeNumber = totalUnreadBadge + nonisolated func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) { + Task { @MainActor in + unreadCount = didChangeCurrentUserUnreadCount + let totalUnreadBadge = unreadCount.channels + unreadCount.threads + if #available(iOS 16.0, *) { + UNUserNotificationCenter.current().setBadgeCount(totalUnreadBadge) + } else { + UIApplication.shared.applicationIconBadgeNumber = totalUnreadBadge + } } } } diff --git a/DemoAppSwiftUI/DemoUser.swift b/DemoAppSwiftUI/DemoUser.swift index 50352317d..47a1643e3 100644 --- a/DemoAppSwiftUI/DemoUser.swift +++ b/DemoAppSwiftUI/DemoUser.swift @@ -8,7 +8,7 @@ public let apiKeyString = "zcgvnykxsfm8" public let applicationGroupIdentifier = "group.io.getstream.iOS.ChatDemoAppSwiftUI" public let currentUserIdRegisteredForPush = "currentUserIdRegisteredForPush" -public struct UserCredentials: Codable { +public struct UserCredentials: Codable, Sendable { public let id: String public let name: String public let avatarURL: URL? diff --git a/DemoAppSwiftUI/LaunchAnimationState.swift b/DemoAppSwiftUI/LaunchAnimationState.swift index 4c584a301..48bfb81d3 100644 --- a/DemoAppSwiftUI/LaunchAnimationState.swift +++ b/DemoAppSwiftUI/LaunchAnimationState.swift @@ -4,7 +4,7 @@ import SwiftUI -class LaunchAnimationState: ObservableObject { +@MainActor class LaunchAnimationState: ObservableObject { @Published var showAnimation = true diff --git a/DemoAppSwiftUI/LoginView.swift b/DemoAppSwiftUI/LoginView.swift index 246d702a1..1442c0dfe 100644 --- a/DemoAppSwiftUI/LoginView.swift +++ b/DemoAppSwiftUI/LoginView.swift @@ -38,7 +38,6 @@ struct LoginView: View { DemoUserView(user: user) } .padding(.vertical, 4) - .animation(nil) } .listStyle(.plain) diff --git a/DemoAppSwiftUI/LoginViewModel.swift b/DemoAppSwiftUI/LoginViewModel.swift index efe5ab798..003990d81 100644 --- a/DemoAppSwiftUI/LoginViewModel.swift +++ b/DemoAppSwiftUI/LoginViewModel.swift @@ -6,7 +6,7 @@ import StreamChat import StreamChatSwiftUI import SwiftUI -class LoginViewModel: ObservableObject { +@MainActor class LoginViewModel: ObservableObject { @Published var demoUsers = UserCredentials.builtInUsers @Published var loading = false @@ -42,7 +42,7 @@ class LoginViewModel: ObservableObject { return } - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in withAnimation { self?.loading = false UnsecureRepository.shared.save(user: credentials) @@ -64,7 +64,7 @@ class LoginViewModel: ObservableObject { return } - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in withAnimation { self?.loading = false AppState.shared.userState = .loggedIn diff --git a/DemoAppSwiftUI/NotificationsHandler.swift b/DemoAppSwiftUI/NotificationsHandler.swift index 404395b15..5e2dd08bf 100644 --- a/DemoAppSwiftUI/NotificationsHandler.swift +++ b/DemoAppSwiftUI/NotificationsHandler.swift @@ -10,7 +10,7 @@ import UIKit /// Handles push notifications in the demo app. /// When a notification is received, the channel id is extracted from the notification object. /// The code below shows an example how to use it to navigate directly to the corresponding screen. -class NotificationsHandler: NSObject, ObservableObject, UNUserNotificationCenterDelegate { +@MainActor class NotificationsHandler: NSObject, ObservableObject, UNUserNotificationCenterDelegate { @Injected(\.chatClient) private var chatClient @@ -20,7 +20,7 @@ class NotificationsHandler: NSObject, ObservableObject, UNUserNotificationCenter override private init() {} - func userNotificationCenter( + nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void @@ -41,25 +41,28 @@ class NotificationsHandler: NSObject, ObservableObject, UNUserNotificationCenter return } - if AppState.shared.userState == .loggedIn { - notificationChannelId = cid.description - } else if let userId = UserDefaults(suiteName: applicationGroupIdentifier)?.string(forKey: currentUserIdRegisteredForPush), - let userCredentials = UserCredentials.builtInUsersByID(id: userId), - let token = try? Token(rawValue: userCredentials.token) { - loginAndNavigateToChannel( - userCredentials: userCredentials, - token: token, - cid: cid - ) + Task { @MainActor in + if AppState.shared.userState == .loggedIn { + notificationChannelId = cid.description + } else if + let userId = UserDefaults(suiteName: applicationGroupIdentifier)?.string(forKey: currentUserIdRegisteredForPush), + let userCredentials = UserCredentials.builtInUsersByID(id: userId), + let token = try? Token(rawValue: userCredentials.token) { + loginAndNavigateToChannel( + userCredentials: userCredentials, + token: token, + cid: cid + ) + } } } func setupRemoteNotifications() { UNUserNotificationCenter .current() - .requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + .requestAuthorization(options: [.alert, .sound, .badge]) { @Sendable granted, _ in if granted { - DispatchQueue.main.async { + Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() } } @@ -82,7 +85,7 @@ class NotificationsHandler: NSObject, ObservableObject, UNUserNotificationCenter return } - DispatchQueue.main.async { + Task { @MainActor in AppState.shared.userState = .loggedIn self?.notificationChannelId = cid.description } diff --git a/DemoAppSwiftUI/UserRepository.swift b/DemoAppSwiftUI/UserRepository.swift index 2369441ca..0cede2a45 100644 --- a/DemoAppSwiftUI/UserRepository.swift +++ b/DemoAppSwiftUI/UserRepository.swift @@ -34,7 +34,7 @@ final class UnsecureRepository: UserRepository { defaults.object(forKey: key.rawValue) as? T } - static let shared = UnsecureRepository() + @MainActor static let shared = UnsecureRepository() func save(user: UserCredentials) { let encoder = JSONEncoder() diff --git a/DemoAppSwiftUI/ViewFactoryExamples.swift b/DemoAppSwiftUI/ViewFactoryExamples.swift index 751e2b75d..e5e178e85 100644 --- a/DemoAppSwiftUI/ViewFactoryExamples.swift +++ b/DemoAppSwiftUI/ViewFactoryExamples.swift @@ -22,8 +22,8 @@ class DemoAppFactory: ViewFactory { func supportedMoreChannelActions( for channel: ChatChannel, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> [ChannelAction] { var actions = ChannelAction.defaultActions( for: channel, @@ -80,8 +80,8 @@ class DemoAppFactory: ViewFactory { private func archiveChannelAction( for channel: ChatChannel, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> ChannelAction { ChannelAction( title: channel.isArchived ? "Unarchive Channel" : "Archive Channel", @@ -91,18 +91,22 @@ class DemoAppFactory: ViewFactory { let channelController = self.chatClient.channelController(for: channel.cid) if channel.isArchived { channelController.unarchive { error in - if let error = error { - onError(error) - } else { - onDismiss() + Task { @MainActor in + if let error = error { + onError(error) + } else { + onDismiss() + } } } } else { channelController.archive { error in - if let error = error { - onError(error) - } else { - onDismiss() + Task { @MainActor in + if let error = error { + onError(error) + } else { + onDismiss() + } } } } @@ -114,8 +118,8 @@ class DemoAppFactory: ViewFactory { private func pinChannelAction( for channel: ChatChannel, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> ChannelAction { let pinChannel = ChannelAction( title: channel.isPinned ? "Unpin Channel" : "Pin Channel", @@ -125,18 +129,22 @@ class DemoAppFactory: ViewFactory { let channelController = self.chatClient.channelController(for: channel.cid) if channel.isPinned { channelController.unpin { error in - if let error = error { - onError(error) - } else { - onDismiss() + Task { @MainActor in + if let error = error { + onError(error) + } else { + onDismiss() + } } } } else { channelController.pin { error in - if let error = error { - onError(error) - } else { - onDismiss() + Task { @MainActor in + if let error = error { + onError(error) + } else { + onDismiss() + } } } } @@ -259,8 +267,8 @@ class CustomFactory: ViewFactory { // Example for an injected action. Uncomment to see it in action. func supportedMoreChannelActions( for channel: ChatChannel, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @Sendable() -> Void, + onError: @escaping @Sendable(Error) -> Void ) -> [ChannelAction] { var defaultActions = ChannelAction.defaultActions( for: channel, @@ -269,7 +277,7 @@ class CustomFactory: ViewFactory { onError: onError ) - let freeze = { + let freeze: @MainActor @Sendable() -> Void = { let controller = self.chatClient.channelController(for: channel.cid) controller.freezeChannel { error in if let error = error { @@ -300,8 +308,8 @@ class CustomFactory: ViewFactory { func makeMoreChannelActionsView( for channel: ChatChannel, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> some View { VStack { Text("This is our custom view") diff --git a/Package.swift b/Package.swift index 85a2bacc4..a0bbc7b12 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.0 import Foundation import PackageDescription diff --git a/Scripts/removePublicDeclarations.sh b/Scripts/removePublicDeclarations.sh index ac3fd2987..eba947a79 100755 --- a/Scripts/removePublicDeclarations.sh +++ b/Scripts/removePublicDeclarations.sh @@ -33,11 +33,6 @@ do replaceDeclaration 'signpost(log' 'signpost(nukeLog' $f replaceDeclaration ' Cache(' ' NukeCache(' $f replaceDeclaration ' Cache<' ' NukeCache<' $f - replaceDeclaration ' Image?' ' NukeImage?' $f - replaceDeclaration ' Image(' ' NukeImage(' $f - replaceDeclaration 'struct Image:' 'struct NukeImage:' $f - replaceDeclaration 'extension Image {' 'extension NukeImage {' $f - replaceDeclaration 'Content == Image' 'Content == NukeImage' $f replaceDeclaration ' VideoPlayerView' ' NukeVideoPlayerView' $f replaceDeclaration 'typealias Color' 'typealias NukeColor' $f replaceDeclaration 'extension Color' 'extension NukeColor' $f diff --git a/Sources/StreamChatSwiftUI/Appearance.swift b/Sources/StreamChatSwiftUI/Appearance.swift index d8a1c7ee6..83a8e1baa 100644 --- a/Sources/StreamChatSwiftUI/Appearance.swift +++ b/Sources/StreamChatSwiftUI/Appearance.swift @@ -21,7 +21,8 @@ public class Appearance { } /// Provider for custom localization which is dependent on App Bundle. - public static var localizationProvider: (_ key: String, _ table: String) -> String = { key, table in + nonisolated(unsafe) + public static var localizationProvider: @Sendable(_ key: String, _ table: String) -> String = { key, table in Bundle.streamChatUI.localizedString(forKey: key, value: nil, table: table) } } @@ -29,12 +30,12 @@ public class Appearance { // MARK: - Appearance + Default public extension Appearance { - static var `default`: Appearance = .init() + nonisolated(unsafe) static var `default`: Appearance = .init() } /// Provides the default value of the `Appearance` class. public struct AppearanceKey: EnvironmentKey { - public static let defaultValue: Appearance = Appearance() + public static var defaultValue: Appearance { Appearance() } } extension EnvironmentValues { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersViewModel.swift index d463cc011..938aeb9c1 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersViewModel.swift @@ -6,7 +6,7 @@ import StreamChat import SwiftUI /// View model for the `AddUsersView`. -class AddUsersViewModel: ObservableObject { +@MainActor class AddUsersViewModel: ObservableObject { @Injected(\.chatClient) private var chatClient @@ -46,9 +46,11 @@ class AddUsersViewModel: ObservableObject { if !loadingNextUsers { loadingNextUsers = true searchController.loadNextUsers { [weak self] _ in - guard let self = self else { return } - self.users = self.searchController.userArray - self.loadingNextUsers = false + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + self.users = self.searchController.userArray + self.loadingNextUsers = false + } } } } @@ -57,16 +59,20 @@ class AddUsersViewModel: ObservableObject { let filter: Filter = .notIn(.id, values: loadedUserIds) let query = UserListQuery(filter: filter) searchController.search(query: query) { [weak self] error in - guard let self = self, error == nil else { return } - self.users = self.searchController.userArray + MainActor.ensureIsolated { [weak self] in + guard let self = self, error == nil else { return } + self.users = self.searchController.userArray + } } } private func searchUsers(term: String) { searchController.search(term: searchText) { [weak self] error in - guard let self = self, error == nil else { return } - self.users = self.searchController.userArray.filter { user in - !self.loadedUserIds.contains(user.id) + MainActor.ensureIsolated { [weak self] in + guard let self = self, error == nil else { return } + self.users = self.searchController.userArray.filter { user in + !self.loadedUserIds.contains(user.id) + } } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoViewModel.swift index c0f016016..ab42fd1ca 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoViewModel.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI // View model for the `ChatChannelInfoView`. -public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDelegate { +@preconcurrency @MainActor public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDelegate { @Injected(\.chatClient) private var chatClient @@ -172,7 +172,7 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe loadAdditionalUsers() } - public func leaveConversationTapped(completion: @escaping () -> Void) { + public func leaveConversationTapped(completion: @escaping @MainActor() -> Void) { if !channel.isDirectMessageChannel { removeUserFromConversation(completion: completion) } else { @@ -195,20 +195,22 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe ) } - public func channelController( + nonisolated public func channelController( _ channelController: ChatChannelController, didUpdateChannel channel: EntityChange ) { - if let channel = channelController.channel { - self.channel = channel - if self.channel.lastActiveMembers.count > participants.count { - participants = channel.lastActiveMembers.map { member in - ParticipantInfo( - chatUser: member, - displayName: member.name ?? member.id, - onlineInfoText: onlineInfo(for: member), - isDeactivated: member.isDeactivated - ) + MainActor.ensureIsolated { + if let channel = channelController.channel { + self.channel = channel + if self.channel.lastActiveMembers.count > participants.count { + participants = channel.lastActiveMembers.map { member in + ParticipantInfo( + chatUser: member, + displayName: member.name ?? member.id, + onlineInfoText: onlineInfo(for: member), + isDeactivated: member.isDeactivated + ) + } } } } @@ -221,23 +223,27 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe // MARK: - private - private func removeUserFromConversation(completion: @escaping () -> Void) { + private func removeUserFromConversation(completion: @escaping @MainActor() -> Void) { guard let userId = chatClient.currentUserId else { return } channelController.removeMembers(userIds: [userId]) { [weak self] error in - if error != nil { - self?.errorShown = true - } else { - completion() + MainActor.ensureIsolated { [weak self] in + if error != nil { + self?.errorShown = true + } else { + completion() + } } } } - private func deleteChannel(completion: @escaping () -> Void) { + private func deleteChannel(completion: @escaping @MainActor() -> Void) { channelController.deleteChannel { [weak self] error in - if error != nil { - self?.errorShown = true - } else { - completion() + MainActor.ensureIsolated { [weak self] in + if error != nil { + self?.errorShown = true + } else { + completion() + } } } } @@ -249,19 +255,21 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe loadingUsers = true memberListController.loadNextMembers { [weak self] error in - guard let self = self else { return } - self.loadingUsers = false - if error == nil { - let newMembers = self.memberListController.members.map { member in - ParticipantInfo( - chatUser: member, - displayName: member.name ?? member.id, - onlineInfoText: self.onlineInfo(for: member), - isDeactivated: member.isDeactivated - ) - } - if newMembers.count > self.participants.count { - self.participants = newMembers + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + self.loadingUsers = false + if error == nil { + let newMembers = self.memberListController.members.map { member in + ParticipantInfo( + chatUser: member, + displayName: member.name ?? member.id, + onlineInfoText: self.onlineInfo(for: member), + isDeactivated: member.isDeactivated + ) + } + if newMembers.count > self.participants.count { + self.participants = newMembers + } } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/FileAttachmentsViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/FileAttachmentsViewModel.swift index cfc1b9e63..9297a2925 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/FileAttachmentsViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/FileAttachmentsViewModel.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// View model for the `FileAttachmentsView`. -class FileAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDelegate { +@MainActor class FileAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDelegate { @Published var loading = false @Published var attachmentsDataSource = [MonthlyFileAttachments]() @@ -68,17 +68,21 @@ class FileAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDel if !loadingNextMessages { loadingNextMessages = true messageSearchController.loadNextMessages { [weak self] _ in - guard let self = self else { return } - self.updateAttachments() - self.loadingNextMessages = false + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + self.updateAttachments() + self.loadingNextMessages = false + } } } } // MARK: - ChatMessageSearchControllerDelegate - func controller(_ controller: ChatMessageSearchController, didChangeMessages changes: [ListChange]) { - updateAttachments() + nonisolated func controller(_ controller: ChatMessageSearchController, didChangeMessages changes: [ListChange]) { + MainActor.ensureIsolated { + updateAttachments() + } } private func loadMessages() { @@ -89,9 +93,11 @@ class FileAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDel loading = true messageSearchController.search(query: query, completion: { [weak self] _ in - guard let self = self else { return } - self.updateAttachments() - self.loading = false + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + self.updateAttachments() + self.loading = false + } }) } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsViewModel.swift index 2ce591373..2c8c4a7e4 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsViewModel.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// View model for the `MediaAttachmentsView`. -class MediaAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDelegate { +@MainActor class MediaAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDelegate { @Published var mediaItems = [MediaItem]() @Published var loading = false @@ -48,17 +48,21 @@ class MediaAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDe if !loadingNextMessages { loadingNextMessages = true messageSearchController.loadNextMessages { [weak self] _ in - guard let self = self else { return } - self.updateAttachments() - self.loadingNextMessages = false + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + self.updateAttachments() + self.loadingNextMessages = false + } } } } // MARK: - ChatMessageSearchControllerDelegate - func controller(_ controller: ChatMessageSearchController, didChangeMessages changes: [ListChange]) { - updateAttachments() + nonisolated func controller(_ controller: ChatMessageSearchController, didChangeMessages changes: [ListChange]) { + MainActor.ensureIsolated { + updateAttachments() + } } private func loadMessages() { @@ -69,9 +73,11 @@ class MediaAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDe loading = true messageSearchController.search(query: query, completion: { [weak self] _ in - guard let self = self else { return } - self.updateAttachments() - self.loading = false + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + self.updateAttachments() + self.loading = false + } }) } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesViewModel.swift index 7fe257e80..68bfd9de4 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesViewModel.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// View model for the `PinnedMessagesView`. -public class PinnedMessagesViewModel: ObservableObject { +@preconcurrency @MainActor public class PinnedMessagesViewModel: ObservableObject { let channel: ChatChannel @@ -30,15 +30,17 @@ public class PinnedMessagesViewModel: ObservableObject { private func loadPinnedMessages() { channelController?.loadPinnedMessages(completion: { [weak self] result in - switch result { - case let .success(messages): - withAnimation { - self?.pinnedMessages = messages + MainActor.ensureIsolated { [weak self] in + switch result { + case let .success(messages): + withAnimation { + self?.pinnedMessages = messages + } + log.debug("Successfully loaded pinned messages") + case let .failure(error): + self?.pinnedMessages = self?.channel.pinnedMessages ?? [] + log.error("Error loading pinned messages \(error.localizedDescription)") } - log.debug("Successfully loaded pinned messages") - case let .failure(error): - self?.pinnedMessages = self?.channel.pinnedMessages ?? [] - log.error("Error loading pinned messages \(error.localizedDescription)") } }) } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift index 9d4af1754..c935dfd4e 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift @@ -5,7 +5,7 @@ import StreamChat /// Data source providing the chat messages. -protocol MessagesDataSource: AnyObject { +@MainActor protocol MessagesDataSource: AnyObject { /// Called when the messages are updated. /// @@ -31,7 +31,7 @@ protocol MessagesDataSource: AnyObject { } /// The data source for the channel. -protocol ChannelDataSource: AnyObject { +@MainActor protocol ChannelDataSource: AnyObject { /// Delegate implementing the `MessagesDataSource`. var delegate: MessagesDataSource? { get set } @@ -53,7 +53,7 @@ protocol ChannelDataSource: AnyObject { func loadPreviousMessages( before messageId: MessageId?, limit: Int, - completion: ((Error?) -> Void)? + completion: (@Sendable(Error?) -> Void)? ) /// Loads newer messages. @@ -62,7 +62,7 @@ protocol ChannelDataSource: AnyObject { /// - completion: called when the messages are loaded. func loadNextMessages( limit: Int, - completion: ((Error?) -> Void)? + completion: (@Sendable(Error?) -> Void)? ) /// Loads a page around the provided message id. @@ -71,12 +71,12 @@ protocol ChannelDataSource: AnyObject { /// - completion: called when the messages are loaded. func loadPageAroundMessageId( _ messageId: MessageId, - completion: ((Error?) -> Void)? + completion: (@Sendable(Error?) -> Void)? ) /// Loads the first page of the channel. /// - Parameter completion: called when the initial page is loaded. - func loadFirstPage(_ completion: ((_ error: Error?) -> Void)?) + func loadFirstPage(_ completion: (@Sendable(_ error: Error?) -> Void)?) } /// Implementation of `ChannelDataSource`. Loads the messages of the channel. @@ -102,32 +102,36 @@ class ChatChannelDataSource: ChannelDataSource, ChatChannelControllerDelegate { self.controller.delegate = self } - public func channelController( + nonisolated public func channelController( _ channelController: ChatChannelController, didUpdateMessages changes: [ListChange] ) { - delegate?.dataSource( - channelDataSource: self, - didUpdateMessages: channelController.messages, - changes: changes - ) + MainActor.ensureIsolated { + delegate?.dataSource( + channelDataSource: self, + didUpdateMessages: channelController.messages, + changes: changes + ) + } } - func channelController( + nonisolated func channelController( _ channelController: ChatChannelController, didUpdateChannel channel: EntityChange ) { - delegate?.dataSource( - channelDataSource: self, - didUpdateChannel: channel, - channelController: channelController - ) + MainActor.ensureIsolated { + delegate?.dataSource( + channelDataSource: self, + didUpdateChannel: channel, + channelController: channelController + ) + } } func loadPreviousMessages( before messageId: MessageId?, limit: Int, - completion: ((Error?) -> Void)? + completion: (@Sendable(Error?) -> Void)? ) { controller.loadPreviousMessages( before: messageId, @@ -136,18 +140,18 @@ class ChatChannelDataSource: ChannelDataSource, ChatChannelControllerDelegate { ) } - func loadNextMessages(limit: Int, completion: ((Error?) -> Void)?) { + func loadNextMessages(limit: Int, completion: (@Sendable(Error?) -> Void)?) { controller.loadNextMessages(limit: limit, completion: completion) } func loadPageAroundMessageId( _ messageId: MessageId, - completion: ((Error?) -> Void)? + completion: (@Sendable(Error?) -> Void)? ) { controller.loadPageAroundMessageId(messageId, completion: completion) } - func loadFirstPage(_ completion: ((_ error: Error?) -> Void)?) { + func loadFirstPage(_ completion: (@Sendable(_ error: Error?) -> Void)?) { controller.loadFirstPage(completion) } } @@ -184,41 +188,47 @@ class MessageThreadDataSource: ChannelDataSource, ChatMessageControllerDelegate self.messageController = messageController self.messageController.delegate = self self.messageController.loadPreviousReplies { [weak self] _ in - guard let self = self else { return } - self.delegate?.dataSource( - channelDataSource: self, - didUpdateMessages: self.messages, - changes: [] - ) + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + self.delegate?.dataSource( + channelDataSource: self, + didUpdateMessages: self.messages, + changes: [] + ) + } } } - func messageController( + nonisolated func messageController( _ controller: ChatMessageController, didChangeReplies changes: [ListChange] ) { - delegate?.dataSource( - channelDataSource: self, - didUpdateMessages: messages, - changes: changes - ) + MainActor.ensureIsolated { + delegate?.dataSource( + channelDataSource: self, + didUpdateMessages: messages, + changes: changes + ) + } } - func messageController( + nonisolated func messageController( _ controller: ChatMessageController, didChangeMessage change: EntityChange ) { - delegate?.dataSource( - channelDataSource: self, - didUpdateMessages: messages, - changes: [] - ) + MainActor.ensureIsolated { + delegate?.dataSource( + channelDataSource: self, + didUpdateMessages: messages, + changes: [] + ) + } } func loadPreviousMessages( before messageId: MessageId?, limit: Int, - completion: ((Error?) -> Void)? + completion: (@Sendable(Error?) -> Void)? ) { messageController.loadPreviousReplies( before: messageId, @@ -227,18 +237,18 @@ class MessageThreadDataSource: ChannelDataSource, ChatMessageControllerDelegate ) } - func loadNextMessages(limit: Int, completion: ((Error?) -> Void)?) { + func loadNextMessages(limit: Int, completion: (@Sendable(Error?) -> Void)?) { messageController.loadNextReplies(limit: limit, completion: completion) } func loadPageAroundMessageId( _ messageId: MessageId, - completion: ((Error?) -> Void)? + completion: (@Sendable(Error?) -> Void)? ) { messageController.loadPageAroundReplyId(messageId, completion: completion) } - func loadFirstPage(_ completion: ((_ error: Error?) -> Void)?) { + func loadFirstPage(_ completion: (@Sendable(_ error: Error?) -> Void)?) { messageController.loadFirstPage(completion) } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift index b6e5c90bc..d8618d721 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// View model for the `ChatChannelView`. -open class ChatChannelViewModel: ObservableObject, MessagesDataSource { +@preconcurrency @MainActor open class ChatChannelViewModel: ObservableObject, MessagesDataSource { @Injected(\.chatClient) private var chatClient @Injected(\.utils) private var utils @@ -314,18 +314,20 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { return false } loadingMessagesAround = true - channelDataSource.loadPageAroundMessageId(baseId) { [weak self] error in + channelDataSource.loadPageAroundMessageId(baseId) { [weak self, channelController] error in if error != nil { log.error("Error loading messages around message \(messageId)") return } var toJumpId = messageId - if toJumpId == baseId, let message = self?.channelController.dataStore.message(id: toJumpId) { + if toJumpId == baseId, let message = channelController.dataStore.message(id: toJumpId) { toJumpId = message.messageId } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self?.scrolledId = toJumpId - self?.loadingMessagesAround = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, toJumpId] in + MainActor.ensureIsolated { [weak self] in + self?.scrolledId = toJumpId + self?.loadingMessagesAround = false + } } } return false @@ -564,7 +566,9 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in - self?.currentDate = nil + MainActor.ensureIsolated { [weak self] in + self?.currentDate = nil + } } ) } @@ -787,13 +791,15 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } deinit { - messageCachingUtils.clearCache() - if messageController == nil { - utils.channelControllerFactory.clearCurrentController() - cleanupAudioPlayer() - ImageCache.shared.trim(toCost: utils.messageListConfig.cacheSizeOnChatDismiss) - if !channelDataSource.hasLoadedAllNextMessages { - channelDataSource.loadFirstPage { _ in } + MainActor.ensureIsolated { + messageCachingUtils.clearCache() + if messageController == nil { + utils.channelControllerFactory.clearCurrentController() + cleanupAudioPlayer() + ImageCache.shared.trim(toCost: utils.messageListConfig.cacheSizeOnChatDismiss) + if !channelDataSource.hasLoadedAllNextMessages { + channelDataSource.loadFirstPage { _ in } + } } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift index 3fa0b7fef..6481b462e 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift @@ -52,7 +52,7 @@ public struct ComposerConfig { self.isVoiceRecordingEnabled = isVoiceRecordingEnabled } - public static var defaultAttachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload] = { _ in + nonisolated(unsafe) public static var defaultAttachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload] = { _ in /// This now returns empty array by default since attachmentPayloadConverter has been deprecated. [] } @@ -64,7 +64,7 @@ public enum GallerySupportedTypes { case videos } -public struct PaddingsConfig { +public struct PaddingsConfig: Sendable { public let top: CGFloat public let bottom: CGFloat public let leading: CGFloat diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift index c8b50be37..7ef3bee11 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift @@ -16,7 +16,7 @@ public enum AttachmentPickerState { } /// Struct representing an asset added to the composer. -public struct AddedAsset: Identifiable, Equatable { +public struct AddedAsset: Identifiable, Equatable, Sendable { public static func == (lhs: AddedAsset, rhs: AddedAsset) -> Bool { lhs.id == rhs.id @@ -107,12 +107,12 @@ extension AnyChatMessageAttachment { } /// Type of asset added to the composer. -public enum AssetType { +public enum AssetType: Sendable { case image case video } -public struct CustomAttachment: Identifiable, Equatable { +public struct CustomAttachment: Identifiable, Equatable, Sendable { public static func == (lhs: CustomAttachment, rhs: CustomAttachment) -> Bool { lhs.id == rhs.id @@ -128,7 +128,7 @@ public struct CustomAttachment: Identifiable, Equatable { } /// Represents an added voice recording. -public struct AddedVoiceRecording: Identifiable, Equatable { +public struct AddedVoiceRecording: Identifiable, Equatable, Sendable { public var id: String { url.absoluteString } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift index 1e921a6cb..4b3b7075f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift @@ -102,11 +102,13 @@ public struct MessageComposerView: View, KeyboardReadable quotedMessage: quotedMessage, editedMessage: editedMessage ) { - // Calling onMessageSent() before erasing the edited and quoted message - // so that onMessageSent can use them for state handling. - onMessageSent() - quotedMessage = nil - editedMessage = nil + MainActor.ensureIsolated { + // Calling onMessageSent() before erasing the edited and quoted message + // so that onMessageSent can use them for state handling. + onMessageSent() + quotedMessage = nil + editedMessage = nil + } } } .environmentObject(viewModel) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel+Recording.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel+Recording.swift index 977d1953a..235ea0742 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel+Recording.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel+Recording.swift @@ -5,7 +5,7 @@ import StreamChat import SwiftUI -public struct AudioRecordingInfo: Equatable { +public struct AudioRecordingInfo: Equatable, Sendable { /// The waveform of the recording. public var waveform: [Float] /// The duration of the recording. @@ -22,58 +22,66 @@ extension AudioRecordingInfo { } extension MessageComposerViewModel: AudioRecordingDelegate { - public func audioRecorder( + nonisolated public func audioRecorder( _ audioRecorder: AudioRecording, didUpdateContext: AudioRecordingContext ) { - audioRecordingInfo.update( - with: didUpdateContext.averagePower, - duration: didUpdateContext.duration - ) + MainActor.ensureIsolated { + audioRecordingInfo.update( + with: didUpdateContext.averagePower, + duration: didUpdateContext.duration + ) + } } - public func audioRecorder( + nonisolated public func audioRecorder( _ audioRecorder: AudioRecording, didFinishRecordingAtURL location: URL ) { - if audioRecordingInfo == .initial { return } - audioAnalysisFactory?.waveformVisualisation( - fromAudioURL: location, - for: waveformTargetSamples, - completionHandler: { [weak self] result in - guard let self else { return } - switch result { - case let .success(waveform): - DispatchQueue.main.async { - let recording = AddedVoiceRecording( - url: location, - duration: self.audioRecordingInfo.duration, - waveform: waveform - ) - if self.recordingState == .stopped { - self.pendingAudioRecording = recording - self.audioRecordingInfo.waveform = waveform - } else { - self.addedVoiceRecordings.append(recording) + MainActor.ensureIsolated { + if audioRecordingInfo == .initial { return } + audioAnalysisFactory?.waveformVisualisation( + fromAudioURL: location, + for: waveformTargetSamples, + completionHandler: { [weak self] result in + MainActor.ensureIsolated { [weak self] in + guard let self else { return } + switch result { + case let .success(waveform): + DispatchQueue.main.async { + let recording = AddedVoiceRecording( + url: location, + duration: self.audioRecordingInfo.duration, + waveform: waveform + ) + if self.recordingState == .stopped { + self.pendingAudioRecording = recording + self.audioRecordingInfo.waveform = waveform + } else { + self.addedVoiceRecordings.append(recording) + self.recordingState = .initial + self.audioRecordingInfo = .initial + } + } + case let .failure(error): + log.error(error) self.recordingState = .initial - self.audioRecordingInfo = .initial } } - case let .failure(error): - log.error(error) - self.recordingState = .initial } - } - ) + ) + } } - public func audioRecorder( + nonisolated public func audioRecorder( _ audioRecorder: AudioRecording, didFailWithError error: Error ) { - log.error(error) - recordingState = .initial - audioRecordingInfo = .initial + MainActor.ensureIsolated { + log.error(error) + recordingState = .initial + audioRecordingInfo = .initial + } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift index f2b5e67f8..6a1be238f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift @@ -8,7 +8,7 @@ import StreamChat import SwiftUI /// View model for the `MessageComposerView`. -open class MessageComposerViewModel: ObservableObject { +@preconcurrency @MainActor open class MessageComposerViewModel: ObservableObject { @Injected(\.chatClient) private var chatClient @Injected(\.utils) internal var utils @@ -352,7 +352,7 @@ open class MessageComposerViewModel: ObservableObject { skipPush: Bool = false, skipEnrichUrl: Bool = false, extraData: [String: RawJSON] = [:], - completion: @escaping () -> Void + completion: @escaping @MainActor() -> Void ) { defer { checkChannelCooldown() @@ -362,8 +362,10 @@ open class MessageComposerViewModel: ObservableObject { commandsHandler.executeOnMessageSent( composerCommand: composerCommand ) { [weak self] _ in - self?.clearInputData() - completion() + MainActor.ensureIsolated { + self?.clearInputData() + completion() + } } if composerCommand.replacesMessageSent { @@ -396,12 +398,14 @@ open class MessageComposerViewModel: ObservableObject { skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, extraData: extraData - ) { [weak self] in - switch $0 { - case .success: - completion() - case .failure: - self?.errorShown = true + ) { [weak self] result in + MainActor.ensureIsolated { [weak self] in + switch result { + case .success: + completion() + case .failure: + self?.errorShown = true + } } } } else { @@ -414,12 +418,14 @@ open class MessageComposerViewModel: ObservableObject { skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, extraData: extraData - ) { [weak self] in - switch $0 { - case .success: - completion() - case .failure: - self?.errorShown = true + ) { [weak self] result in + MainActor.ensureIsolated { [weak self] in + switch result { + case .success: + completion() + case .failure: + self?.errorShown = true + } } } } @@ -599,14 +605,16 @@ open class MessageComposerViewModel: ObservableObject { } public func askForPhotosPermission() { - PHPhotoLibrary.requestAuthorization { [weak self] (status) in + PHPhotoLibrary.requestAuthorization { @Sendable [weak self] (status) in guard let self else { return } switch status { case .authorized, .limited: log.debug("Access to photos granted.") - self.fetchAssets() + Task { @MainActor [weak self] in + self?.fetchAssets() + } case .denied, .restricted, .notDetermined: - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in self?.imageAssets = PHFetchResult() } log.debug("Access to photos is denied or not determined, showing the no permissions screen.") @@ -650,7 +658,9 @@ open class MessageComposerViewModel: ObservableObject { selectedRangeLocation = message.text.count attachmentsConverter.attachmentsToAssets(message.allAttachments) { [weak self] assets in - self?.updateComposerAssets(assets) + MainActor.ensureIsolated { + self?.updateComposerAssets(assets) + } } } @@ -710,7 +720,7 @@ open class MessageComposerViewModel: ObservableObject { private func edit( message: ChatMessage, attachments: [AnyAttachmentPayload]?, - completion: @escaping () -> Void + completion: @escaping @MainActor() -> Void ) { guard let channelId = channelController.channel?.cid else { return @@ -730,10 +740,12 @@ open class MessageComposerViewModel: ObservableObject { text: adjustedText, attachments: newAttachments ) { [weak self] error in - if error != nil { - self?.errorShown = true - } else { - completion() + MainActor.ensureIsolated { [weak self] in + if error != nil { + self?.errorShown = true + } else { + completion() + } } } @@ -813,10 +825,12 @@ open class MessageComposerViewModel: ObservableObject { withTimeInterval: 1, repeats: true, block: { [weak self] _ in - self?.cooldownDuration -= 1 - if self?.cooldownDuration == 0 { - self?.timer?.invalidate() - self?.timer = nil + MainActor.ensureIsolated { [weak self] in + self?.cooldownDuration -= 1 + if self?.cooldownDuration == 0 { + self?.timer?.invalidate() + self?.timer = nil + } } } ) @@ -894,27 +908,29 @@ open class MessageComposerViewModel: ObservableObject { } extension MessageComposerViewModel: EventsControllerDelegate { - public func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { - if let event = event as? DraftUpdatedEvent { - let isFromSameThread = messageController?.messageId == event.draftMessage.threadId - let isFromSameChannel = channelController.cid == event.cid && messageController == nil - if isFromSameThread || isFromSameChannel { - fillDraftMessage() + nonisolated public func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { + MainActor.ensureIsolated { + if let event = event as? DraftUpdatedEvent { + let isFromSameThread = messageController?.messageId == event.draftMessage.threadId + let isFromSameChannel = channelController.cid == event.cid && messageController == nil + if isFromSameThread || isFromSameChannel { + fillDraftMessage() + } } - } - - if let event = event as? DraftDeletedEvent { - let isFromSameThread = messageController?.messageId == event.threadId - let isFromSameChannel = channelController.cid == event.cid && messageController == nil - if isFromSameThread || isFromSameChannel { - clearInputData() + + if let event = event as? DraftDeletedEvent { + let isFromSameThread = messageController?.messageId == event.threadId + let isFromSameChannel = channelController.cid == event.cid && messageController == nil + if isFromSameThread || isFromSameChannel { + clearInputData() + } } } } } // The assets added to the composer. -struct ComposerAssets { +struct ComposerAssets: Sendable { // Image and Video Assets. var mediaAssets: [AddedAsset] = [] // File Assets. @@ -927,7 +943,7 @@ struct ComposerAssets { // A asset containing file information. // If it has a payload, it means that the file is already uploaded to the server. -struct FileAddedAsset { +struct FileAddedAsset: Sendable { var url: URL var payload: FileAttachmentPayload? } @@ -974,10 +990,10 @@ class MessageAttachmentsConverter { /// This operation is asynchronous to make sure loading expensive assets are not done in the main thread. func attachmentsToAssets( _ attachments: [AnyChatMessageAttachment], - completion: @escaping (ComposerAssets) -> Void + completion: @escaping @Sendable(ComposerAssets) -> Void ) { queue.async { - let addedAssets = self.attachmentsToAssets(attachments) + let addedAssets = Self.attachmentsToAssets(attachments) DispatchQueue.main.async { completion(addedAssets) } @@ -988,7 +1004,7 @@ class MessageAttachmentsConverter { /// /// This operation is synchronous and should only be used if all attachments are already loaded. /// Like for example, for draft messages. - func attachmentsToAssets( + static func attachmentsToAssets( _ attachments: [AnyChatMessageAttachment] ) -> ComposerAssets { var addedAssets = ComposerAssets() diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAssetsUtils.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAssetsUtils.swift index 388b29aa6..0084d383e 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAssetsUtils.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAssetsUtils.swift @@ -34,7 +34,7 @@ public class PhotoAssetLoader: NSObject, ObservableObject { } } - func compressAsset(at url: URL, type: AssetType, completion: @escaping (URL?) -> Void) { + func compressAsset(at url: URL, type: AssetType, completion: @escaping @Sendable(URL?) -> Void) { if type == .video { let compressedURL = NSURL.fileURL(withPath: NSTemporaryDirectory() + UUID().uuidString + ".mp4") compressVideo(inputURL: url, outputURL: compressedURL) { exportSession in @@ -66,7 +66,7 @@ public class PhotoAssetLoader: NSObject, ObservableObject { private func compressVideo( inputURL: URL, outputURL: URL, - handler: @escaping (_ exportSession: AVAssetExportSession?) -> Void + handler: @escaping @Sendable(_ exportSession: AVAssetExportSession?) -> Void ) { let urlAsset = AVURLAsset(url: inputURL, options: nil) @@ -80,8 +80,9 @@ public class PhotoAssetLoader: NSObject, ObservableObject { exportSession.outputURL = outputURL exportSession.outputFileType = .mp4 + nonisolated(unsafe) let unsafeSession = exportSession exportSession.exportAsynchronously { - handler(exportSession) + handler(unsafeSession) } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAttachmentPickerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAttachmentPickerView.swift index 2e573dd7f..d134239a6 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAttachmentPickerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAttachmentPickerView.swift @@ -179,8 +179,10 @@ public struct PhotoAttachmentCell: View { if let assetURL = assetURL, assetLoader.assetExceedsAllowedSize(url: assetURL) { compressing = true assetLoader.compressAsset(at: assetURL, type: assetType) { url in - self.assetURL = url - self.compressing = false + MainActor.ensureIsolated { + self.assetURL = url + self.compressing = false + } } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/CommandsConfig.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/CommandsConfig.swift index 8b344a168..c058e18ff 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/CommandsConfig.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/CommandsConfig.swift @@ -17,7 +17,7 @@ public protocol CommandsConfig { /// Creates the main commands handler. /// - Parameter channelController: the controller of the channel. /// - Returns: `CommandsHandler`. - func makeCommandsHandler( + @preconcurrency @MainActor func makeCommandsHandler( with channelController: ChatChannelController ) -> CommandsHandler } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/CommandsHandler.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/CommandsHandler.swift index 44f9e433f..a42820856 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/CommandsHandler.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/CommandsHandler.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// Defines methods for handling commands. -public protocol CommandHandler { +@preconcurrency @MainActor public protocol CommandHandler { /// Identifier of the command. var id: String { get } @@ -64,7 +64,7 @@ public protocol CommandHandler { /// - completion: called when the command is executed. func executeOnMessageSent( composerCommand: ComposerCommand, - completion: @escaping (Error?) -> Void + completion: @escaping @Sendable(Error?) -> Void ) } @@ -77,7 +77,7 @@ extension CommandHandler { public func executeOnMessageSent( composerCommand: ComposerCommand, - completion: @escaping (Error?) -> Void + completion: @escaping @Sendable(Error?) -> Void ) { // optional method. } @@ -211,7 +211,7 @@ public class CommandsHandler: CommandHandler { public func executeOnMessageSent( composerCommand: ComposerCommand, - completion: @escaping (Error?) -> Void + completion: @escaping @Sendable(Error?) -> Void ) { if let handler = commandHandler(for: composerCommand) { handler.executeOnMessageSent( diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/InstantCommandsHandler.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/InstantCommandsHandler.swift index 319d1bc08..9e91356f6 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/InstantCommandsHandler.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/InstantCommandsHandler.swift @@ -100,7 +100,7 @@ public class InstantCommandsHandler: CommandHandler { public func executeOnMessageSent( composerCommand: ComposerCommand, - completion: @escaping (Error?) -> Void + completion: @escaping @Sendable(Error?) -> Void ) { if let handler = commandHandler(for: composerCommand) { handler.executeOnMessageSent( diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/MuteCommandHandler.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/MuteCommandHandler.swift index 478e8c69e..ddd020c5f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/MuteCommandHandler.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/MuteCommandHandler.swift @@ -33,13 +33,15 @@ public class MuteCommandHandler: TwoStepMentionCommand { override public func executeOnMessageSent( composerCommand: ComposerCommand, - completion: @escaping (Error?) -> Void + completion: @escaping @Sendable(Error?) -> Void ) { if let mutedUser = selectedUser { chatClient .userController(userId: mutedUser.id) .mute { [weak self] error in - self?.selectedUser = nil + MainActor.ensureIsolated { + self?.selectedUser = nil + } completion(error) } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/TwoStepMentionCommand.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/TwoStepMentionCommand.swift index b46753046..2a732b607 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/TwoStepMentionCommand.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/TwoStepMentionCommand.swift @@ -132,7 +132,7 @@ open class TwoStepMentionCommand: CommandHandler { open func executeOnMessageSent( composerCommand: ComposerCommand, - completion: @escaping (Error?) -> Void + completion: @escaping @Sendable(Error?) -> Void ) { // Implement in subclasses. } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/UnmuteCommandHandler.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/UnmuteCommandHandler.swift index 22920a71a..4a3443811 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/UnmuteCommandHandler.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/UnmuteCommandHandler.swift @@ -33,14 +33,16 @@ public class UnmuteCommandHandler: TwoStepMentionCommand { override public func executeOnMessageSent( composerCommand: ComposerCommand, - completion: @escaping (Error?) -> Void + completion: @escaping @Sendable(Error?) -> Void ) { if let mutedUser = selectedUser { chatClient .userController(userId: mutedUser.id) .unmute { [weak self] error in - self?.selectedUser = nil - completion(error) + MainActor.ensureIsolated { [weak self] in + self?.selectedUser = nil + completion(error) + } } return diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/Mentions/MentionsCommandHandler.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/Mentions/MentionsCommandHandler.swift index 4620e608a..18759bbca 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/Mentions/MentionsCommandHandler.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/Mentions/MentionsCommandHandler.swift @@ -152,15 +152,16 @@ public struct MentionsCommandHandler: CommandHandler { private func searchAllUsers(for typingMention: String) -> Future { Future { promise in + nonisolated(unsafe) let unsafePromise = promise let query = queryForMentionSuggestionsSearch(typingMention: typingMention) userSearchController.search(query: query) { error in if let error = error { - promise(.failure(error)) + unsafePromise(.failure(error)) return } let users = userSearchController.userArray let suggestionInfo = SuggestionInfo(key: id, value: users) - promise(.success(suggestionInfo)) + unsafePromise(.success(suggestionInfo)) } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/TypingSuggester.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/TypingSuggester.swift index 6a713e41c..e7ec30703 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/TypingSuggester.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/TypingSuggester.swift @@ -33,7 +33,7 @@ public struct TypingSuggestionOptions { } /// A structure that contains the information of the typing suggestion. -public struct TypingSuggestion { +public struct TypingSuggestion: Sendable { /// A String representing the currently typing text. public let text: String /// A NSRange that stores the location of the typing suggestion in relation with the whole input. diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/VoiceRecording/AudioSessionFeedbackGenerator.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/VoiceRecording/AudioSessionFeedbackGenerator.swift index aae6daca7..5b4c5752f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/VoiceRecording/AudioSessionFeedbackGenerator.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/VoiceRecording/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/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/WaveformView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/WaveformView.swift index 7b1b56300..59877c2be 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/WaveformView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/WaveformView.swift @@ -15,7 +15,7 @@ open class WaveformView: UIView { open var onSliderChanged: ((TimeInterval) -> Void)? open var onSliderTapped: (() -> Void)? - 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/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift index 370ed2052..1bb07c4cf 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift @@ -103,7 +103,12 @@ struct LazyGiphyView: View { var body: some View { LazyImage(imageURL: source) { state in if let imageContainer = state.imageContainer { - NukeImage(imageContainer) + if imageContainer.type == .gif { + AnimatedGifView(imageContainer: imageContainer) + .frame(width: width) + } else { + state.image + } } else if state.error != nil { Color(.secondarySystemBackground) } else { @@ -119,3 +124,18 @@ struct LazyGiphyView: View { .aspectRatio(contentMode: .fit) } } + +private struct AnimatedGifView: UIViewRepresentable { + let imageContainer: ImageContainer + + func makeUIView(context: Context) -> UIImageView { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + if let gifData = imageContainer.data, let image = try? UIImage(gifData: gifData) { + imageView.setGifImage(image) + } + return imageView + } + + func updateUIView(_ uiView: UIImageView, context: Context) {} +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift index eacde8615..760e2f7a0 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift @@ -400,12 +400,14 @@ struct LazyLoadingImage: View { resize: resize, preferredSize: CGSize(width: width, height: height) ) { result in - switch result { - case let .success(image): - self.image = image - onImageLoaded(image) - case let .failure(error): - self.error = error + MainActor.ensureIsolated { + switch result { + case let .success(image): + self.image = image + onImageLoaded(image) + case let .failure(error): + self.error = error + } } } } @@ -441,7 +443,7 @@ public struct MediaAttachment { func generateThumbnail( resize: Bool, preferredSize: CGSize, - completion: @escaping (Result) -> Void + completion: @escaping @Sendable(Result) -> Void ) { if type == .image { utils.imageLoader.loadImage( diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift index 1c7b5158d..9fc663cce 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift @@ -108,12 +108,18 @@ public struct LinkAttachmentView: View { VStack(alignment: .leading, spacing: padding) { if !imageHidden { ZStack { - LazyImage(imageURL: linkAttachment.previewURL ?? linkAttachment.originalURL) - .onDisappear(.cancel) - .processors([ImageProcessors.Resize(width: width)]) - .priority(.high) - .frame(width: width - 2 * padding, height: (width - 2 * padding) / 2) - .cornerRadius(14) + LazyImage(imageURL: linkAttachment.previewURL ?? linkAttachment.originalURL) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } + } + .onDisappear(.cancel) + .processors([ImageProcessors.Resize(width: width)]) + .priority(.high) + .frame(width: width - 2 * padding, height: (width - 2 * padding) / 2) + .cornerRadius(14) if !authorHidden { BottomLeftView { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageAvatarView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageAvatarView.swift index 0c996ec4f..946f344eb 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageAvatarView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageAvatarView.swift @@ -36,23 +36,29 @@ public struct MessageAvatarView: View { preferredSize: size ) - LazyImage(imageURL: adjustedURL) - .onDisappear(.cancel) - .priority(.normal) - .clipShape(Circle()) - .frame( - width: size.width, - height: size.height - ) - .overlay( - showOnlineIndicator ? - TopRightView { - OnlineIndicatorView(indicatorSize: size.width * 0.3) - } - .offset(x: 3, y: -1) - : nil - ) - .accessibilityIdentifier("MessageAvatarView") + LazyImage(imageURL: adjustedURL) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } + } + .onDisappear(.cancel) + .priority(.normal) + .clipShape(Circle()) + .frame( + width: size.width, + height: size.height + ) + .overlay( + showOnlineIndicator ? + TopRightView { + OnlineIndicatorView(indicatorSize: size.width * 0.3) + } + .offset(x: 3, y: -1) + : nil + ) + .accessibilityIdentifier("MessageAvatarView") } else { Image(uiImage: images.userAvatarPlaceholder2) .resizable() @@ -66,5 +72,5 @@ public struct MessageAvatarView: View { } extension CGSize { - public static var messageAvatarSize = CGSize(width: 36, height: 36) + @preconcurrency @MainActor public static var messageAvatarSize = CGSize(width: 36, height: 36) } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index e30209027..abb3db196 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -565,7 +565,7 @@ private class MessageRenderingUtil { private var previousTopMessage: ChatMessage? - static let shared = MessageRenderingUtil() + @MainActor static let shared = MessageRenderingUtil() var hasPreviousMessageSet: Bool { previousTopMessage != nil diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentViewModel.swift index 545700142..9ac84c881 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentViewModel.swift @@ -6,7 +6,7 @@ import StreamChat import SwiftUI /// View model for the `PollAttachmentView`. -public class PollAttachmentViewModel: ObservableObject, PollControllerDelegate { +@preconcurrency @MainActor public class PollAttachmentViewModel: ObservableObject, PollControllerDelegate { static let numberOfVisibleOptionsShown = 10 private var isCastingVote = false @@ -120,8 +120,10 @@ public class PollAttachmentViewModel: ObservableObject, PollControllerDelegate { self.pollController = pollController pollController.delegate = self pollController.synchronize { [weak self] _ in - guard let self else { return } - self.currentUserVotes = Array(self.pollController.ownVotes) + MainActor.ensureIsolated { [weak self] in + guard let self else { return } + self.currentUserVotes = Array(self.pollController.ownVotes) + } } } @@ -141,8 +143,10 @@ public class PollAttachmentViewModel: ObservableObject, PollControllerDelegate { log.debug("Vote already added") } } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self?.isCastingVote = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + MainActor.ensureIsolated { [weak self] in + self?.isCastingVote = false + } } } } @@ -174,9 +178,11 @@ public class PollAttachmentViewModel: ObservableObject, PollControllerDelegate { pollController.removePollVote( voteId: vote.id ) { [weak self] error in - self?.isCastingVote = false - if let error { - log.error("Error removing a vote \(error.localizedDescription)") + MainActor.ensureIsolated { [weak self] in + self?.isCastingVote = false + if let error { + log.error("Error removing a vote \(error.localizedDescription)") + } } } } @@ -188,10 +194,12 @@ public class PollAttachmentViewModel: ObservableObject, PollControllerDelegate { guard !isClosingPoll else { return } isClosingPoll = true pollController.closePoll { [weak self] error in - self?.isClosingPoll = false - if let error { - log.error("Error closing the poll \(error.localizedDescription)") - NotificationCenter.default.post(name: .showChannelAlertBannerNotification, object: nil) + MainActor.ensureIsolated { [weak self] in + self?.isClosingPoll = false + if let error { + log.error("Error closing the poll \(error.localizedDescription)") + NotificationCenter.default.post(name: .showChannelAlertBannerNotification, object: nil) + } } } } @@ -224,15 +232,19 @@ public class PollAttachmentViewModel: ObservableObject, PollControllerDelegate { // MARK: - PollControllerDelegate - public func pollController(_ pollController: PollController, didUpdatePoll poll: EntityChange) { - self.poll = poll.item + nonisolated public func pollController(_ pollController: PollController, didUpdatePoll poll: EntityChange) { + MainActor.ensureIsolated { + self.poll = poll.item + } } - public func pollController( + nonisolated public func pollController( _ pollController: PollController, didUpdateCurrentUserVotes votes: [ListChange] ) { - currentUserVotes = Array(pollController.ownVotes) + MainActor.ensureIsolated { + currentUserVotes = Array(pollController.ownVotes) + } } // MARK: - private diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsViewModel.swift index feb442238..439659526 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsViewModel.swift @@ -6,7 +6,7 @@ import Combine import StreamChat import SwiftUI -class PollCommentsViewModel: ObservableObject, PollVoteListControllerDelegate { +@MainActor class PollCommentsViewModel: ObservableObject, PollVoteListControllerDelegate { @Injected(\.chatClient) var chatClient @@ -53,11 +53,13 @@ class PollCommentsViewModel: ObservableObject, PollVoteListControllerDelegate { func refresh() { loadingComments = true commentsController.synchronize { [weak self] error in - guard let self else { return } - self.loadingComments = false - self.comments = Array(self.commentsController.votes) - if error != nil { - self.errorShown = true + MainActor.ensureIsolated { [weak self] in + guard let self else { return } + self.loadingComments = false + self.comments = Array(self.commentsController.votes) + if error != nil { + self.errorShown = true + } } } } @@ -72,9 +74,11 @@ class PollCommentsViewModel: ObservableObject, PollVoteListControllerDelegate { func add(comment: String) { pollController.castPollVote(answerText: comment, optionId: nil) { [weak self] error in - if let error { - log.error("Error casting a vote \(error.localizedDescription)") - self?.errorShown = true + MainActor.ensureIsolated { [weak self] in + if let error { + log.error("Error casting a vote \(error.localizedDescription)") + self?.errorShown = true + } } } newCommentText = "" @@ -88,16 +92,18 @@ class PollCommentsViewModel: ObservableObject, PollVoteListControllerDelegate { loadComments() } - func controller( + nonisolated func controller( _ controller: PollVoteListController, didChangeVotes changes: [ListChange] ) { - if animateChanges { - withAnimation { - self.comments = Array(self.commentsController.votes) + MainActor.ensureIsolated { + if animateChanges { + withAnimation { + self.comments = Array(self.commentsController.votes) + } + } else { + comments = Array(commentsController.votes) } - } else { - comments = Array(commentsController.votes) } } @@ -105,9 +111,11 @@ class PollCommentsViewModel: ObservableObject, PollVoteListControllerDelegate { guard !loadingComments, !commentsController.hasLoadedAllVotes else { return } loadingComments = true commentsController.loadMoreVotes { [weak self] error in - self?.loadingComments = false - if error != nil { - self?.errorShown = true + MainActor.ensureIsolated { [weak self] in + self?.loadingComments = false + if error != nil { + self?.errorShown = true + } } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollOptionAllVotesViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollOptionAllVotesViewModel.swift index 810b26c65..d77770d5a 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollOptionAllVotesViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollOptionAllVotesViewModel.swift @@ -6,7 +6,7 @@ import Combine import StreamChat import SwiftUI -class PollOptionAllVotesViewModel: ObservableObject, PollVoteListControllerDelegate { +@MainActor class PollOptionAllVotesViewModel: ObservableObject, PollVoteListControllerDelegate { let poll: Poll let option: PollOption @@ -40,13 +40,15 @@ class PollOptionAllVotesViewModel: ObservableObject, PollVoteListControllerDeleg func refresh() { controller.synchronize { [weak self] error in - guard let self else { return } - self.pollVotes = Array(self.controller.votes) - if self.pollVotes.isEmpty { - self.loadVotes() - } - if error != nil { - self.errorShown = true + MainActor.ensureIsolated { [weak self] in + guard let self else { return } + self.pollVotes = Array(self.controller.votes) + if self.pollVotes.isEmpty { + self.loadVotes() + } + if error != nil { + self.errorShown = true + } } } } @@ -59,16 +61,18 @@ class PollOptionAllVotesViewModel: ObservableObject, PollVoteListControllerDeleg loadVotes() } - func controller( + nonisolated func controller( _ controller: PollVoteListController, didChangeVotes changes: [ListChange] ) { - if animateChanges { - withAnimation { - self.pollVotes = Array(self.controller.votes) + MainActor.ensureIsolated { + if animateChanges { + withAnimation { + self.pollVotes = Array(self.controller.votes) + } + } else { + pollVotes = Array(controller.votes) } - } else { - pollVotes = Array(controller.votes) } } @@ -76,10 +80,12 @@ class PollOptionAllVotesViewModel: ObservableObject, PollVoteListControllerDeleg loadingVotes = true controller.loadMoreVotes { [weak self] error in - guard let self else { return } - self.loadingVotes = false - if error != nil { - self.errorShown = true + MainActor.ensureIsolated { [weak self] in + guard let self else { return } + self.loadingVotes = false + if error != nil { + self.errorShown = true + } } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollsConfig.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollsConfig.swift index 738b04369..0c13e3d9d 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollsConfig.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollsConfig.swift @@ -5,7 +5,7 @@ import Foundation /// Config for various poll settings. -public struct PollsConfig { +public struct PollsConfig: Sendable { /// Configuration for allowing multiple answers in a poll. public var multipleAnswers: 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/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift index 74d8f7907..fb4a605ec 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift @@ -120,7 +120,13 @@ public struct QuotedMessageView: View { LazyImage( imageURL: quotedMessage.linkAttachments[0].previewURL ?? quotedMessage.linkAttachments[0] .originalURL - ) + ) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } + } .onDisappear(.cancel) .processors([ImageProcessors.Resize(width: attachmentWidth)]) .priority(.high) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ReactionsIconProvider.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ReactionsIconProvider.swift index f35acc908..25a5f8255 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ReactionsIconProvider.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ReactionsIconProvider.swift @@ -6,8 +6,7 @@ import StreamChat import SwiftUI class ReactionsIconProvider { - static var colors: ColorPalette = InjectedValues[\.colors] - static var images: Images = InjectedValues[\.images] + static var images: Images { InjectedValues[\.images] } static func icon(for reaction: MessageReactionType, useLargeIcons: Bool) -> UIImage? { if useLargeIcons { @@ -19,7 +18,9 @@ class ReactionsIconProvider { static func color(for reaction: MessageReactionType, userReactionIDs: Set) -> Color? { let containsUserReaction = userReactionIDs.contains(reaction) - let color = containsUserReaction ? colors.reactionCurrentUserColor : colors.reactionOtherUserColor + let color = containsUserReaction ? + InjectedValues[\.colors].reactionCurrentUserColor : + InjectedValues[\.colors].reactionOtherUserColor if let color = color { return Color(color) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift index 534240fd8..50655158a 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift @@ -206,11 +206,13 @@ struct VideoAttachmentContentView: View { } .onAppear { videoPreviewLoader.loadPreviewForVideo(at: attachment.videoURL) { result in - switch result { - case let .success(image): - self.previewImage = image - case let .failure(error): - self.error = error + MainActor.ensureIsolated { + switch result { + case let .success(image): + self.previewImage = image + case let .failure(error): + self.error = error + } } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Polls/CreatePollViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/Polls/CreatePollViewModel.swift index 102dd6dfe..7c4408bfd 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Polls/CreatePollViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Polls/CreatePollViewModel.swift @@ -7,7 +7,7 @@ import Foundation import StreamChat import SwiftUI -class CreatePollViewModel: ObservableObject { +@MainActor class CreatePollViewModel: ObservableObject { @Injected(\.utils) var utils @@ -102,7 +102,7 @@ class CreatePollViewModel: ObservableObject { .store(in: &cancellables) } - func createPoll(completion: @escaping () -> Void) { + func createPoll(completion: @escaping @MainActor() -> Void) { let pollOptions = options .map(\.trimmed) .filter { !$0.isEmpty } @@ -117,13 +117,15 @@ class CreatePollViewModel: ObservableObject { votingVisibility: anonymousPoll ? .anonymous : .public, options: pollOptions ) { [weak self] result in - switch result { - case let .success(messageId): - log.debug("Created poll in message with id \(messageId)") - completion() - case let .failure(error): - log.error("Error creating a poll: \(error.localizedDescription)") - self?.errorShown = true + MainActor.ensureIsolated { [weak self] in + switch result { + case let .success(messageId): + log.debug("Created poll in message with id \(messageId)") + completion() + case let .failure(error): + log.error("Error creating a poll: \(error.localizedDescription)") + self?.errorShown = true + } } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift index 8a0e9912d..68a41ab81 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift @@ -20,8 +20,8 @@ public extension MessageAction { for message: ChatMessage, channel: ChatChannel, chatClient: ChatClient, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> [MessageAction] { var messageActions = [MessageAction]() @@ -237,7 +237,7 @@ public extension MessageAction { /// The action to copy the message text. static func copyMessageAction( for message: ChatMessage, - onFinish: @escaping (MessageActionInfo) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void ) -> MessageAction { let copyAction = MessageAction( id: MessageActionId.copy, @@ -263,7 +263,7 @@ public extension MessageAction { static func editMessageAction( for message: ChatMessage, channel: ChatChannel, - onFinish: @escaping (MessageActionInfo) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void ) -> MessageAction { let editAction = MessageAction( id: MessageActionId.edit, @@ -289,25 +289,27 @@ public extension MessageAction { for message: ChatMessage, channel: ChatChannel, chatClient: ChatClient, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageAction { let messageController = chatClient.messageController( cid: channel.cid, messageId: message.id ) - let pinMessage = { + let pinMessage: @MainActor() -> Void = { messageController.pin(MessagePinning.noExpiration) { error in - if let error = error { - onError(error) - } else { - onFinish( - MessageActionInfo( - message: message, - identifier: "pin" + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: "pin" + ) ) - ) + } } } } @@ -329,25 +331,27 @@ public extension MessageAction { for message: ChatMessage, channel: ChatChannel, chatClient: ChatClient, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageAction { let messageController = chatClient.messageController( cid: channel.cid, messageId: message.id ) - let pinMessage = { + let pinMessage: @MainActor() -> Void = { messageController.unpin { error in - if let error = error { - onError(error) - } else { - onFinish( - MessageActionInfo( - message: message, - identifier: "unpin" + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: "unpin" + ) ) - ) + } } } } @@ -368,7 +372,7 @@ public extension MessageAction { static func replyAction( for message: ChatMessage, channel: ChatChannel, - onFinish: @escaping (MessageActionInfo) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void ) -> MessageAction { let replyAction = MessageAction( id: MessageActionId.reply, @@ -418,25 +422,27 @@ public extension MessageAction { for message: ChatMessage, channel: ChatChannel, chatClient: ChatClient, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageAction { let messageController = chatClient.messageController( cid: channel.cid, messageId: message.id ) - let deleteAction = { + let deleteAction: @MainActor() -> Void = { messageController.deleteMessage { error in - if let error = error { - onError(error) - } else { - onFinish( - MessageActionInfo( - message: message, - identifier: "delete" + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: "delete" + ) ) - ) + } } } } @@ -464,25 +470,27 @@ public extension MessageAction { for message: ChatMessage, channel: ChatChannel, chatClient: ChatClient, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageAction { let messageController = chatClient.messageController( cid: channel.cid, messageId: message.id ) - let flagAction = { + let flagAction: @MainActor() -> Void = { messageController.flag { error in - if let error = error { - onError(error) - } else { - onFinish( - MessageActionInfo( - message: message, - identifier: "flag" + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: "flag" + ) ) - ) + } } } } @@ -510,23 +518,25 @@ public extension MessageAction { for message: ChatMessage, channel: ChatChannel, chatClient: ChatClient, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageAction { let channelController = InjectedValues[\.utils] .channelControllerFactory .makeChannelController(for: channel.cid) - let action = { + let action: @MainActor() -> Void = { channelController.markUnread(from: message.id) { result in - if case let .failure(error) = result { - onError(error) - } else { - onFinish( - MessageActionInfo( - message: message, - identifier: MessageActionId.markUnread + MainActor.ensureIsolated { + if case let .failure(error) = result { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: MessageActionId.markUnread + ) ) - ) + } } } } @@ -546,20 +556,22 @@ public extension MessageAction { static func markThreadAsUnreadAction( messageController: ChatMessageController, message: ChatMessage, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageAction { - let action = { + let action: @MainActor() -> Void = { messageController.markThreadUnread() { error in - if let error { - onError(error) - } else { - onFinish( - MessageActionInfo( - message: message, - identifier: MessageActionId.markUnread + MainActor.ensureIsolated { + if let error { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: MessageActionId.markUnread + ) ) - ) + } } } } @@ -581,21 +593,23 @@ public extension MessageAction { channel: ChatChannel, chatClient: ChatClient, userToMute: ChatUser, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageAction { let muteController = chatClient.userController(userId: userToMute.id) - let muteAction = { + let muteAction: @MainActor() -> Void = { muteController.mute { error in - if let error = error { - onError(error) - } else { - onFinish( - MessageActionInfo( - message: message, - identifier: "mute" + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: "mute" + ) ) - ) + } } } } @@ -618,21 +632,23 @@ public extension MessageAction { channel: ChatChannel, chatClient: ChatClient, userToBlock: ChatUser, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageAction { let blockController = chatClient.userController(userId: userToBlock.id) - let blockAction = { + let blockAction: @MainActor() -> Void = { blockController.block { error in - if let error = error { - onError(error) - } else { - onFinish( - MessageActionInfo( - message: message, - identifier: "block" + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: "block" + ) ) - ) + } } } } @@ -659,21 +675,23 @@ public extension MessageAction { channel: ChatChannel, chatClient: ChatClient, userToUnmute: ChatUser, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageAction { let unmuteController = chatClient.userController(userId: userToUnmute.id) - let unmuteAction = { + let unmuteAction: @MainActor() -> Void = { unmuteController.unmute { error in - if let error = error { - onError(error) - } else { - onFinish( - MessageActionInfo( - message: message, - identifier: "unmute" + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: "unmute" + ) ) - ) + } } } } @@ -696,21 +714,23 @@ public extension MessageAction { channel: ChatChannel, chatClient: ChatClient, userToUnblock: ChatUser, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageAction { let blockController = chatClient.userController(userId: userToUnblock.id) - let unblockAction = { + let unblockAction: @MainActor() -> Void = { blockController.unblock { error in - if let error = error { - onError(error) - } else { - onFinish( - MessageActionInfo( - message: message, - identifier: "unblock" + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: "unblock" + ) ) - ) + } } } } @@ -736,25 +756,27 @@ public extension MessageAction { for message: ChatMessage, channel: ChatChannel, chatClient: ChatClient, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageAction { let messageController = chatClient.messageController( cid: channel.cid, messageId: message.id ) - let resendAction = { + let resendAction: @MainActor() -> Void = { messageController.resendMessage { error in - if let error = error { - onError(error) - } else { - onFinish( - MessageActionInfo( - message: message, - identifier: "resend" + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: "resend" + ) ) - ) + } } } } @@ -776,8 +798,8 @@ public extension MessageAction { for message: ChatMessage, channel: ChatChannel, chatClient: ChatClient, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> [MessageAction] { var messageActions = [MessageAction]() @@ -808,8 +830,8 @@ public extension MessageAction { for message: ChatMessage, channel: ChatChannel, chatClient: ChatClient, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> [MessageAction] { var messageActions = [MessageAction]() diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift index 77f5303df..471aba4ee 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift @@ -12,7 +12,7 @@ public protocol MessageActionsResolving { /// - Parameters: /// - info: the message action info. /// - viewModel: used to modify a state after action execution. - func resolveMessageAction( + @preconcurrency @MainActor func resolveMessageAction( info: MessageActionInfo, viewModel: ChatChannelViewModel ) @@ -42,7 +42,7 @@ public class MessageActionsResolver: MessageActionsResolving { } else if info.identifier == MessageActionId.markUnread { viewModel.firstUnreadMessageId = info.message.messageId } - + viewModel.reactionsShown = false } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsViewModel.swift index 40506565c..b1ae4d342 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsViewModel.swift @@ -6,7 +6,7 @@ import StreamChat import SwiftUI /// View model for the `MessageActionsView`. -open class MessageActionsViewModel: ObservableObject { +@preconcurrency @MainActor open class MessageActionsViewModel: ObservableObject { @Published public var messageActions: [MessageAction] @Published public var alertShown = false @Published public var alertAction: MessageAction? { @@ -26,7 +26,7 @@ public struct MessageAction: Identifiable, Equatable { public let title: String public let iconName: String - public let action: () -> Void + public let action: @MainActor() -> Void public let confirmationPopup: ConfirmationPopup? public let isDestructive: Bool public var navigationDestination: AnyView? @@ -35,7 +35,7 @@ public struct MessageAction: Identifiable, Equatable { id: String = UUID().uuidString, title: String, iconName: String, - action: @escaping () -> Void, + action: @escaping @MainActor() -> Void, confirmationPopup: ConfirmationPopup?, isDestructive: Bool ) { @@ -53,7 +53,7 @@ public struct MessageAction: Identifiable, Equatable { } /// Provides information about a performed `MessageAction`. -public struct MessageActionInfo { +public struct MessageActionInfo: Sendable { public let message: ChatMessage public let identifier: String diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift index d5658b8bd..3f807ec5b 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift @@ -60,7 +60,7 @@ struct ReactionsOverlayContainer: View { public extension ChatMessage { - func reactionOffsetX( + @preconcurrency @MainActor func reactionOffsetX( for contentRect: CGRect, availableWidth: CGFloat = UIScreen.main.bounds.width, reactionsSize: CGFloat diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayViewModel.swift index 3aa4c5872..883ce5119 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayViewModel.swift @@ -6,7 +6,7 @@ import Combine import StreamChat import SwiftUI -open class ReactionsOverlayViewModel: ObservableObject, ChatMessageControllerDelegate { +@preconcurrency @MainActor open class ReactionsOverlayViewModel: ObservableObject, ChatMessageControllerDelegate { @Injected(\.chatClient) private var chatClient @Injected(\.utils) private var utils @@ -42,13 +42,15 @@ open class ReactionsOverlayViewModel: ObservableObject, ChatMessageControllerDel // MARK: - ChatMessageControllerDelegate - public func messageController( + nonisolated public func messageController( _ controller: ChatMessageController, didChangeMessage change: EntityChange ) { - if let message = controller.message { - withAnimation { - self.message = message + MainActor.ensureIsolated { + if let message = controller.message { + withAnimation { + self.message = message + } } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsUsersViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsUsersViewModel.swift index ae9674e9b..95321c2fa 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsUsersViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsUsersViewModel.swift @@ -5,7 +5,7 @@ import StreamChat import SwiftUI -class ReactionsUsersViewModel: ObservableObject, ChatMessageControllerDelegate { +@MainActor class ReactionsUsersViewModel: ObservableObject, ChatMessageControllerDelegate { @Published var reactions: [ChatMessageReaction] = [] var totalReactionsCount: Int { @@ -42,11 +42,15 @@ class ReactionsUsersViewModel: ObservableObject, ChatMessageControllerDelegate { isLoading = true messageController.loadNextReactions { [weak self] _ in - self?.isLoading = false + MainActor.ensureIsolated { [weak self] in + self?.isLoading = false + } } } - func messageController(_ controller: ChatMessageController, didChangeReactions reactions: [ChatMessageReaction]) { - self.reactions = reactions + nonisolated func messageController(_ controller: ChatMessageController, didChangeReactions reactions: [ChatMessageReaction]) { + MainActor.ensureIsolated { + self.reactions = reactions + } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Utils/ChatChannelHelpers.swift b/Sources/StreamChatSwiftUI/ChatChannel/Utils/ChatChannelHelpers.swift index 7d3ad83d5..6287ff591 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Utils/ChatChannelHelpers.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Utils/ChatChannelHelpers.swift @@ -19,7 +19,7 @@ extension View { } struct ScrollViewOffsetPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat? = nil + static let defaultValue: CGFloat? = nil static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { value = value ?? nextValue() @@ -27,7 +27,7 @@ struct ScrollViewOffsetPreferenceKey: PreferenceKey { } struct WidthPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat? = nil + static let defaultValue: CGFloat? = nil static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { value = nextValue() ?? value @@ -35,7 +35,7 @@ struct WidthPreferenceKey: PreferenceKey { } struct HeightPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat? = nil + static let defaultValue: CGFloat? = nil static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { value = value ?? nextValue() @@ -81,7 +81,7 @@ public struct BottomLeftView: View { } /// Returns the top most view controller. -func topVC() -> UIViewController? { +@MainActor func topVC() -> UIViewController? { // TODO: Refactor ReactionsOverlayView to use a background blur, instead of a snapshot. /// Since the current approach is too error-prone and dependent of the app's hierarchy, diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChannelAvatarsMerger.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChannelAvatarsMerger.swift index 27101bb7e..6f5008540 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChannelAvatarsMerger.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChannelAvatarsMerger.swift @@ -4,7 +4,7 @@ import UIKit -public protocol ChannelAvatarsMerging { +public protocol ChannelAvatarsMerging: Sendable { /// Creates a merged avatar from the provided user images. /// - Parameter avatars: the avatars to be merged. /// - Returns: optional image, if the creation succeeded. @@ -12,23 +12,23 @@ public protocol ChannelAvatarsMerging { } /// Default implementation of `ChannelAvatarsMerging`. -public class ChannelAvatarsMerger: ChannelAvatarsMerging { +public final class ChannelAvatarsMerger: ChannelAvatarsMerging { public init() { // Public init. } - @Injected(\.utils) private var utils - @Injected(\.images) private var images + private var images: Images { InjectedValues[\.images] } + private var utils: Utils { InjectedValues[\.utils] } /// Context provided utils. - private lazy var imageProcessor = utils.imageProcessor - private lazy var imageMerger = utils.imageMerger + private var imageProcessor: ImageProcessor { utils.imageProcessor } + private var imageMerger: ImageMerging { utils.imageMerger } /// Placeholder images. - private lazy var placeholder1 = images.userAvatarPlaceholder1 - private lazy var placeholder2 = images.userAvatarPlaceholder2 - private lazy var placeholder3 = images.userAvatarPlaceholder3 - private lazy var placeholder4 = images.userAvatarPlaceholder4 + private var placeholder1: UIImage { images.userAvatarPlaceholder1 } + private var placeholder2: UIImage { images.userAvatarPlaceholder2 } + private var placeholder3: UIImage { images.userAvatarPlaceholder3 } + private var placeholder4: UIImage { images.userAvatarPlaceholder4 } /// Creates a merged avatar from the given images /// - Parameter avatars: The individual avatars diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChannelHeaderLoader.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChannelHeaderLoader.swift index ad370843e..a07687a40 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChannelHeaderLoader.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChannelHeaderLoader.swift @@ -7,7 +7,7 @@ import Foundation import StreamChat import UIKit -open class ChannelHeaderLoader: ObservableObject { +@preconcurrency @MainActor open class ChannelHeaderLoader: ObservableObject { @Injected(\.images) private var images @Injected(\.utils) private var utils @Injected(\.chatClient) private var chatClient @@ -36,7 +36,7 @@ open class ChannelHeaderLoader: ObservableObject { private var loadedImages = [ChannelId: UIImage]() private let didLoadImage = PassthroughSubject() - public init() { + nonisolated public init() { // Public init. } @@ -119,8 +119,8 @@ open class ChannelHeaderLoader: ObservableObject { imageCDN: imageCDN ) { [weak self] images in guard let self = self else { return } - DispatchQueue.global(qos: .userInteractive).async { - let image = self.channelAvatarsMerger.createMergedAvatar(from: images) + DispatchQueue.global(qos: .userInteractive).async { [channelAvatarsMerger] in + let image = channelAvatarsMerger.createMergedAvatar(from: images) DispatchQueue.main.async { if let image = image { self.didFinishedLoading(for: cid, image: image) diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelHelperViews.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelHelperViews.swift index c4e0a5da8..c0c303c2b 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelHelperViews.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelHelperViews.swift @@ -89,7 +89,7 @@ struct EmptyViewModifier: ViewModifier { extension CGSize { /// Default size of the avatar used in the channel list. - public static var defaultAvatarSize: CGSize = CGSize(width: 48, height: 48) + nonisolated(unsafe) public static var defaultAvatarSize: CGSize = CGSize(width: 48, height: 48) } /// Provides access to the the app's tab bar (if present). @@ -127,9 +127,9 @@ struct TabBarAccessor: UIViewControllerRepresentable { } var isIphone: Bool { - UIDevice.current.userInterfaceIdiom == .phone + UITraitCollection.current.userInterfaceIdiom == .phone } var isIPad: Bool { - UIDevice.current.userInterfaceIdiom == .pad + UITraitCollection.current.userInterfaceIdiom == .pad } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift index e36f5de9f..48369e9bc 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift @@ -268,7 +268,7 @@ public struct UnreadIndicatorView: View { } } -public struct InjectedChannelInfo { +public struct InjectedChannelInfo: Sendable { public var subtitle: String? public var unreadCount: Int public var timestamp: String? diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift index f2017faa5..e5f7d3915 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift @@ -9,7 +9,10 @@ import SwiftUI import UIKit /// View model for the `ChatChannelListView`. -open class ChatChannelListViewModel: ObservableObject, ChatChannelListControllerDelegate, ChatMessageSearchControllerDelegate { +@preconcurrency @MainActor open class ChatChannelListViewModel: + ObservableObject, + ChatChannelListControllerDelegate, + ChatMessageSearchControllerDelegate { /// Context provided dependencies. @Injected(\.chatClient) private var chatClient: ChatClient @@ -176,8 +179,10 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController if !loadingNextChannels { loadingNextChannels = true controller?.loadNextChannels(limit: 30) { [weak self] _ in - guard let self = self else { return } - self.loadingNextChannels = false + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + self.loadingNextChannels = false + } } } } @@ -216,8 +221,9 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController controller.deleteChannel { [weak self] error in if error != nil { - // handle error - self?.setChannelAlertType(.error) + MainActor.ensureIsolated { [weak self] in + self?.setChannelAlertType(.error) + } } } } @@ -232,11 +238,13 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController // MARK: - ChatChannelListControllerDelegate - public func controller( + nonisolated public func controller( _ controller: ChatChannelListController, didChangeChannels changes: [ListChange] ) { - handleChannelListChanges(controller) + MainActor.ensureIsolated { + handleChannelListChanges(controller) + } } open func controller( @@ -268,8 +276,13 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController // MARK: - ChatMessageSearchControllerDelegate - public func controller(_ controller: ChatMessageSearchController, didChangeMessages changes: [ListChange]) { - updateMessageSearchResults() + nonisolated public func controller( + _ controller: ChatMessageSearchController, + didChangeMessages changes: [ListChange] + ) { + MainActor.ensureIsolated { + updateMessageSearchResults() + } } // MARK: - private @@ -328,15 +341,17 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController loading = channels.isEmpty controller?.synchronize { [weak self] error in - guard let self = self else { return } - self.loading = false - if error != nil { - // handle error - self.setChannelAlertType(.error) - } else { - // access channels - self.updateChannels() - self.checkForDeeplinks() + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + self.loading = false + if error != nil { + // handle error + self.setChannelAlertType(.error) + } else { + // access channels + self.updateChannels() + self.checkForDeeplinks() + } } } } @@ -375,8 +390,10 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController if !loadingNextChannels { loadingNextChannels = true messageSearchController.loadNextMessages { [weak self] _ in - guard let self = self else { return } - self.loadingNextChannels = false + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + self.loadingNextChannels = false + } } } } @@ -393,9 +410,11 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController if !loadingNextChannels { loadingNextChannels = true channelListSearchController.loadNextChannels { [weak self] _ in - guard let self = self else { return } - self.loadingNextChannels = false - self.updateChannelSearchResults() + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + self.loadingNextChannels = false + self.updateChannelSearchResults() + } } } } @@ -405,9 +424,11 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController messageSearchController = chatClient.messageSearchController() loadingSearchResults = true messageSearchController?.search(text: searchText) { [weak self] _ in - self?.loadingSearchResults = false - self?.messageSearchController?.delegate = self - self?.updateMessageSearchResults() + MainActor.ensureIsolated { [weak self] in + self?.loadingSearchResults = false + self?.messageSearchController?.delegate = self + self?.updateMessageSearchResults() + } } } @@ -425,8 +446,10 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController channelListSearchController = chatClient.channelListController(query: query) loadingSearchResults = true channelListSearchController?.synchronize { [weak self] _ in - self?.loadingSearchResults = false - self?.updateChannelSearchResults() + MainActor.ensureIsolated { [weak self] in + self?.loadingSearchResults = false + self?.updateChannelSearchResults() + } } } @@ -435,10 +458,10 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController return } - queue.async { [weak self] in + queue.async { [weak self, chatClient] in let results: [ChannelSelectionInfo] = messageSearchController.messages.compactMap { message in guard let channelId = message.cid else { return nil } - guard let channel = self?.chatClient.channelController(for: channelId).channel else { + guard let channel = chatClient.channelController(for: channelId).channel else { return nil } return ChannelSelectionInfo( @@ -447,7 +470,7 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController searchType: .messages ) } - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in self?.searchResults = results } } @@ -480,11 +503,13 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController private func observeClientIdChange() { timer?.invalidate() timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { [weak self] _ in - guard let self = self else { return } - if self.chatClient.currentUserId != nil { - self.stopTimer() - self.makeDefaultChannelListController() - self.setupChannelListController() + MainActor.ensureIsolated { [weak self] in + guard let self = self else { return } + if self.chatClient.currentUserId != nil { + self.stopTimer() + self.makeDefaultChannelListController() + self.setupChannelListController() + } } }) } @@ -597,13 +622,13 @@ public enum ChannelPopupType { } /// The type of data the channel list should perform a search. -public struct ChannelListSearchType: Equatable { +public struct ChannelListSearchType: Equatable, Sendable { let type: String private init(type: String) { self.type = type } - public static var channels = Self(type: "channels") - public static var messages = Self(type: "messages") + public static let channels = Self(type: "channels") + public static let messages = Self(type: "messages") } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift index 38289155d..510540183 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift @@ -72,7 +72,7 @@ public struct ChatChannelNavigatableListItem Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> [ChannelAction] { var actions = [ChannelAction]() @@ -80,19 +80,21 @@ extension ChannelAction { return actions } - private static func muteAction( + @MainActor private static func muteAction( for channel: ChatChannel, chatClient: ChatClient, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> ChannelAction { - let muteAction = { + let muteAction: @MainActor() -> Void = { let controller = chatClient.channelController(for: channel.cid) controller.muteChannel { error in - if let error = error { - onError(error) - } else { - onDismiss() + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onDismiss() + } } } } @@ -111,19 +113,21 @@ extension ChannelAction { return muteUser } - private static func unmuteAction( + @MainActor private static func unmuteAction( for channel: ChatChannel, chatClient: ChatClient, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> ChannelAction { - let unMuteAction = { + let unMuteAction: @MainActor() -> Void = { let controller = chatClient.channelController(for: channel.cid) controller.unmuteChannel { error in - if let error = error { - onError(error) - } else { - onDismiss() + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onDismiss() + } } } } @@ -143,19 +147,21 @@ extension ChannelAction { return unmuteUser } - private static func deleteAction( + @MainActor private static func deleteAction( for channel: ChatChannel, chatClient: ChatClient, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> ChannelAction { - let deleteConversationAction = { + let deleteConversationAction: @MainActor() -> Void = { let controller = chatClient.channelController(for: channel.cid) controller.deleteChannel { error in - if let error = error { - onError(error) - } else { - onDismiss() + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onDismiss() + } } } } @@ -175,20 +181,22 @@ extension ChannelAction { return deleteConversation } - private static func leaveGroup( + @MainActor private static func leaveGroup( for channel: ChatChannel, chatClient: ChatClient, userId: String, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> ChannelAction { - let leaveAction = { + let leaveAction: @MainActor() -> Void = { let controller = chatClient.channelController(for: channel.cid) controller.removeMembers(userIds: [userId]) { error in - if let error = error { - onError(error) - } else { - onDismiss() + MainActor.ensureIsolated { + if let error = error { + onError(error) + } else { + onDismiss() + } } } } @@ -208,7 +216,7 @@ extension ChannelAction { return leaveConversation } - private static func viewInfo(for channel: ChatChannel) -> ChannelAction { + @MainActor private static func viewInfo(for channel: ChatChannel) -> ChannelAction { var viewInfo = ChannelAction( title: L10n.Alert.Actions.viewInfoTitle, iconName: "person.fill", @@ -222,7 +230,7 @@ extension ChannelAction { return viewInfo } - private static func naming(for channel: ChatChannel) -> String { + @MainActor private static func naming(for channel: ChatChannel) -> String { channel.isDirectMessageChannel ? L10n.Channel.Name.directMessage : L10n.Channel.Name.group } } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/MoreChannelActionsViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannelList/MoreChannelActionsViewModel.swift index b24030e8e..94600434f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/MoreChannelActionsViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/MoreChannelActionsViewModel.swift @@ -8,7 +8,7 @@ import SwiftUI import UIKit /// View model for the more channel actions. -open class MoreChannelActionsViewModel: ObservableObject { +@preconcurrency @MainActor open class MoreChannelActionsViewModel: ObservableObject { /// Context provided values. @Injected(\.utils) private var utils @Injected(\.chatClient) private var chatClient @@ -93,22 +93,22 @@ open class MoreChannelActionsViewModel: ObservableObject { } /// Model describing a channel action. -public struct ChannelAction: Identifiable { +public struct ChannelAction: Identifiable, Sendable { public var id: String { "\(title)-\(iconName)" } public let title: String public let iconName: String - public let action: () -> Void + public let action: @MainActor() -> Void public let confirmationPopup: ConfirmationPopup? public let isDestructive: Bool - public var navigationDestination: AnyView? + nonisolated(unsafe) public var navigationDestination: AnyView? public init( title: String, iconName: String, - action: @escaping () -> Void, + action: @escaping @MainActor() -> Void, confirmationPopup: ConfirmationPopup?, isDestructive: Bool ) { @@ -121,7 +121,7 @@ public struct ChannelAction: Identifiable { } /// Model describing confirmation popup data. -public struct ConfirmationPopup { +public struct ConfirmationPopup: Sendable { public init(title: String, message: String?, buttonTitle: String) { self.title = title self.message = message diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListViewModel.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListViewModel.swift index 23c0a5a25..a2ac4a1f7 100644 --- a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListViewModel.swift @@ -7,7 +7,10 @@ import Foundation import StreamChat /// The ViewModel for the `ChatThreadListView`. -open class ChatThreadListViewModel: ObservableObject, ChatThreadListControllerDelegate, EventsControllerDelegate { +@preconcurrency @MainActor open class ChatThreadListViewModel: + ObservableObject, + ChatThreadListControllerDelegate, + EventsControllerDelegate { /// Context provided dependencies. @Injected(\.chatClient) private var chatClient: ChatClient @@ -119,15 +122,17 @@ open class ChatThreadListViewModel: ObservableObject, ChatThreadListControllerDe isReloading = !isEmpty preselectThreadIfNeeded() threadListController.synchronize { [weak self] error in - self?.isLoading = false - self?.isReloading = false - self?.hasLoadedThreads = error == nil - self?.failedToLoadThreads = error != nil - self?.isEmpty = self?.threadListController.threads.isEmpty == true - self?.preselectThreadIfNeeded() - self?.hasLoadedAllThreads = self?.threadListController.hasLoadedAllThreads ?? false - if error == nil { - self?.newAvailableThreadIds = [] + MainActor.ensureIsolated { [weak self] in + self?.isLoading = false + self?.isReloading = false + self?.hasLoadedThreads = error == nil + self?.failedToLoadThreads = error != nil + self?.isEmpty = self?.threadListController.threads.isEmpty == true + self?.preselectThreadIfNeeded() + self?.hasLoadedAllThreads = self?.threadListController.hasLoadedAllThreads ?? false + if error == nil { + self?.newAvailableThreadIds = [] + } } } } @@ -149,30 +154,36 @@ open class ChatThreadListViewModel: ObservableObject, ChatThreadListControllerDe isLoadingMoreThreads = true threadListController.loadMoreThreads { [weak self] result in - self?.isLoadingMoreThreads = false - self?.hasLoadedAllThreads = self?.threadListController.hasLoadedAllThreads ?? false - let threads = try? result.get() - self?.failedToLoadMoreThreads = threads == nil + MainActor.ensureIsolated { [weak self] in + self?.isLoadingMoreThreads = false + self?.hasLoadedAllThreads = self?.threadListController.hasLoadedAllThreads ?? false + let threads = try? result.get() + self?.failedToLoadMoreThreads = threads == nil + } } } - public func controller( + nonisolated public func controller( _ controller: ChatThreadListController, didChangeThreads changes: [ListChange] ) { - threads = controller.threads + MainActor.ensureIsolated { + threads = controller.threads + } } - public 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) + nonisolated public func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { + MainActor.ensureIsolated { + 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) + } + default: + break } - default: - break } } diff --git a/Sources/StreamChatSwiftUI/CommonViews/AlertBannerViewModifier.swift b/Sources/StreamChatSwiftUI/CommonViews/AlertBannerViewModifier.swift index ec54dad6c..dc7f98c05 100644 --- a/Sources/StreamChatSwiftUI/CommonViews/AlertBannerViewModifier.swift +++ b/Sources/StreamChatSwiftUI/CommonViews/AlertBannerViewModifier.swift @@ -61,7 +61,9 @@ private struct AlertBannerViewModifier: ViewModifier { guard newValue else { return } timer?.invalidate() timer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { _ in - isPresented = false + MainActor.ensureIsolated { + isPresented = false + } } } } diff --git a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift index 3e14a3829..df17a54e8 100644 --- a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift +++ b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift @@ -30,8 +30,8 @@ extension ViewFactory { public func supportedMoreChannelActions( for channel: ChatChannel, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> [ChannelAction] { ChannelAction.defaultActions( for: channel, @@ -44,8 +44,8 @@ extension ViewFactory { public func makeMoreChannelActionsView( for channel: ChatChannel, swipedChannelId: Binding, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> some View { MoreChannelActionsView( channel: channel, @@ -833,8 +833,8 @@ extension ViewFactory { public func supportedMessageActions( for message: ChatMessage, channel: ChatChannel, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> [MessageAction] { MessageAction.defaultActions( factory: self, @@ -849,8 +849,8 @@ extension ViewFactory { public func makeMessageActionsView( for message: ChatMessage, channel: ChatChannel, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> some View { let messageActions = supportedMessageActions( for: message, diff --git a/Sources/StreamChatSwiftUI/DependencyInjection.swift b/Sources/StreamChatSwiftUI/DependencyInjection.swift index 6391ba7d5..5ff99b927 100644 --- a/Sources/StreamChatSwiftUI/DependencyInjection.swift +++ b/Sources/StreamChatSwiftUI/DependencyInjection.swift @@ -16,7 +16,7 @@ public protocol InjectionKey { /// Provides access to injected dependencies. public struct InjectedValues { /// This is only used as an accessor to the computed properties within extensions of `InjectedValues`. - private static var current = InjectedValues() + nonisolated(unsafe) private static var current = InjectedValues() /// A static subscript for updating the `currentValue` of `InjectionKey` instances. public static subscript(key: K.Type) -> K.Value where K: InjectionKey { diff --git a/Sources/StreamChatSwiftUI/StreamChat.swift b/Sources/StreamChatSwiftUI/StreamChat.swift index 75f03f944..c62a4701f 100644 --- a/Sources/StreamChatSwiftUI/StreamChat.swift +++ b/Sources/StreamChatSwiftUI/StreamChat.swift @@ -26,7 +26,8 @@ public class StreamChat { /// Returns the current value for the `StreamChat` instance. private struct StreamChatProviderKey: InjectionKey { - static var currentValue: StreamChat? + // Note: This is nonisolated because of the protocol requirement, but it is guarded by MainActor in StreamChat's init. + static nonisolated(unsafe) var currentValue: StreamChat? } extension InjectedValues { diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCache.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCache.swift index f63441f98..bf373926f 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCache.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCache.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -11,7 +11,7 @@ import Foundation /// 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: /// /// ```swift @@ -45,17 +45,16 @@ final class DataCache: DataCaching, @unchecked Sendable { /// 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 @@ -66,6 +65,10 @@ final class DataCache: DataCaching, @unchecked Sendable { 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) @@ -84,6 +87,7 @@ final class DataCache: DataCaching, @unchecked Sendable { /// - 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) } @@ -97,28 +101,30 @@ final class DataCache: DataCaching, @unchecked Sendable { 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() + } } } @@ -137,7 +143,7 @@ final class DataCache: DataCaching, @unchecked Sendable { 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. @@ -177,7 +183,7 @@ final class DataCache: DataCaching, @unchecked Sendable { /// 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) { @@ -232,9 +238,7 @@ final class DataCache: DataCaching, @unchecked Sendable { /// Returns `url` for the given cache key. func url(for key: String) -> URL? { - guard let filename = self.filename(for: key) else { - return nil - } + guard let filename = self.filename(for: key) else { return nil } return self.path.appendingPathComponent(filename, isDirectory: false) } @@ -250,9 +254,9 @@ final class DataCache: DataCaching, @unchecked Sendable { /// operations for the given key are finished. 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) } } } @@ -318,26 +322,35 @@ final class DataCache: DataCaching, @unchecked Sendable { 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() { @@ -391,6 +404,26 @@ final class DataCache: DataCaching, @unchecked Sendable { } } + // 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. @@ -478,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/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCaching.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCaching.swift index 4f8dcc9ef..39e58c7b5 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCaching.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCaching.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCache.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCache.swift index f67818268..dc6af2da7 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCache.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCache.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation #if !os(macOS) @@ -57,31 +57,21 @@ final class ImageCache: ImageCaching { /// Shared `Cache` instance. static let shared = ImageCache() - deinit { - #if TRACK_ALLOCATIONS - Allocations.decrement("ImageCache") - #endif - } - /// 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) - - #if TRACK_ALLOCATIONS - Allocations.increment("ImageCache") - #endif } - /// Returns a recommended cost limit which is computed based on the amount - /// of the physical memory available on the device. + /// 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 = physicalMemory / UInt64(1 / ratio) - return limit > UInt64(Int.max) ? Int.max : Int(limit) + let limit = min(536_870_912, physicalMemory / UInt64(1 / ratio)) + return Int(limit) } subscript(key: ImageCacheKey) -> ImageContainer? { @@ -97,7 +87,7 @@ final class ImageCache: ImageCaching { /// Removes all cached images. func removeAll() { - impl.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. diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCaching.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCaching.swift index 74152bd83..9ea9c4293 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCaching.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCaching.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -24,7 +24,7 @@ struct ImageCacheKey: Hashable, Sendable { // This is faster than using AnyHashable (and it shows in performance tests). enum Inner: Hashable, Sendable { case custom(String) - case `default`(CacheKey) + case `default`(MemoryCacheKey) } init(key: String) { @@ -32,6 +32,6 @@ struct ImageCacheKey: Hashable, Sendable { } init(request: ImageRequest) { - self.key = .default(request.makeImageCacheKey()) + self.key = .default(MemoryCacheKey(request)) } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/NukeCache.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/NukeCache.swift index e0b45ccd5..8332af026 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/NukeCache.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/NukeCache.swift @@ -1,10 +1,10 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation -#if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) import UIKit.UIApplication #endif @@ -20,8 +20,8 @@ final class NukeCache: @unchecked Sendable { } var conf: Configuration { - get { lock.sync { _conf } } - set { lock.sync { _conf = newValue } } + get { withLock { _conf } } + set { withLock { _conf = newValue } } } private var _conf: Configuration { @@ -29,48 +29,48 @@ final class NukeCache: @unchecked Sendable { } var totalCost: Int { - lock.sync { _totalCost } + withLock { _totalCost } } var totalCount: Int { - lock.sync { map.count } + withLock { map.count } } private var _totalCost = 0 private var map = [Key: LinkedList.Node]() private let list = LinkedList() - private let lock = NSLock() + 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?.removeAll() + self?.removeAllCachedValues() } self.memoryPressure.resume() -#if os(iOS) || os(tvOS) - self.registerForEnterBackground() -#endif - -#if TRACK_ALLOCATIONS - Allocations.increment("Cache") +#if os(iOS) || os(tvOS) || os(visionOS) + Task { + await registerForEnterBackground() + } #endif } deinit { - memoryPressure.cancel() + lock.deinitialize(count: 1) + lock.deallocate() -#if TRACK_ALLOCATIONS - Allocations.decrement("Cache") -#endif + memoryPressure.cancel() } -#if os(iOS) || os(tvOS) - private func registerForEnterBackground() { +#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() } @@ -78,8 +78,8 @@ final class NukeCache: @unchecked Sendable { #endif func value(forKey key: Key) -> Value? { - lock.lock() - defer { lock.unlock() } + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } guard let node = map[key] else { return nil @@ -98,13 +98,13 @@ final class NukeCache: @unchecked Sendable { } func set(_ value: Value, forKey key: Key, cost: Int = 0, ttl: TimeInterval? = nil) { - lock.lock() - defer { lock.unlock() } + 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 > 2147483647 || cost < Int(sanitizedEntryLimit * Double(_conf.costLimit)) else { + guard _conf.costLimit > 2_147_483_647 || cost < Int(sanitizedEntryLimit * Double(_conf.costLimit)) else { return } @@ -117,8 +117,8 @@ final class NukeCache: @unchecked Sendable { @discardableResult func removeValue(forKey key: Key) -> Value? { - lock.lock() - defer { lock.unlock() } + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } guard let node = map[key] else { return nil @@ -129,7 +129,10 @@ final class NukeCache: @unchecked Sendable { private func _add(_ element: Entry) { if let existingNode = map[element.key] { - _remove(node: existingNode) + // 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 @@ -141,12 +144,12 @@ final class NukeCache: @unchecked Sendable { _totalCost -= node.value.cost } - func removeAll() { - lock.lock() - defer { lock.unlock() } + func removeAllCachedValues() { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } map.removeAll() - list.removeAll() + list.removeAllElements() _totalCost = 0 } @@ -155,8 +158,8 @@ final class NukeCache: @unchecked Sendable { // 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.lock() - defer { lock.unlock() } + 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)) @@ -168,7 +171,7 @@ final class NukeCache: @unchecked Sendable { } func trim(toCost limit: Int) { - lock.sync { _trim(toCost: limit) } + withLock { _trim(toCost: limit) } } private func _trim(toCost limit: Int) { @@ -176,7 +179,7 @@ final class NukeCache: @unchecked Sendable { } func trim(toCount limit: Int) { - lock.sync { _trim(toCount: limit) } + withLock { _trim(toCount: limit) } } private func _trim(toCount limit: Int) { @@ -189,13 +192,19 @@ final class NukeCache: @unchecked Sendable { } } + 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 = expiration else { + guard let expiration else { return false } return expiration.timeIntervalSinceNow < 0 diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/AssetType.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/AssetType.swift index 79800ae26..06789ffef 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/AssetType.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/AssetType.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -36,10 +36,6 @@ struct NukeAssetType: ExpressibleByStringLiteral, Hashable, Sendable { static let m4v: NukeAssetType = "public.m4v" static let mov: NukeAssetType = "public.mov" - - var isVideo: Bool { - self == .mp4 || self == .m4v || self == .mov - } } extension NukeAssetType { @@ -57,7 +53,7 @@ extension NukeAssetType { return false } return zip(numbers.indices, numbers).allSatisfy { index, number in - guard let number = number else { return true } + guard let number else { return true } guard (index + offset) < data.count else { return false } return data[index + offset] == number } @@ -79,8 +75,11 @@ extension NukeAssetType { // 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 } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoderRegistry.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoderRegistry.swift index 8eddb0edc..ca23b5adc 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoderRegistry.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoderRegistry.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -15,9 +15,6 @@ final class ImageDecoderRegistry: @unchecked Sendable { /// Initializes a custom registry. init() { register(ImageDecoders.Default.init) - #if !os(watchOS) - register(ImageDecoders.Video.init) - #endif } /// Returns a decoder that matches the given context. @@ -65,7 +62,7 @@ struct ImageDecodingContext: @unchecked Sendable { var urlResponse: URLResponse? var cacheType: ImageResponse.CacheType? - init(request: ImageRequest, data: Data, isCompleted: Bool, urlResponse: URLResponse?, 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 diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Default.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Default.swift index cf19bebd8..9f5cf1b36 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Default.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Default.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). #if !os(macOS) import UIKit @@ -27,7 +27,7 @@ extension ImageDecoders { private var scanner = ProgressiveJPEGScanner() private var isPreviewForGIFGenerated = false - private var scale: CGFloat? + private var scale: CGFloat = 1.0 private var thumbnail: ImageRequest.ThumbnailOptions? private let lock = NSLock() @@ -38,8 +38,8 @@ extension ImageDecoders { /// Returns `nil` if progressive decoding is not allowed for the given /// content. init?(context: ImageDecodingContext) { - self.scale = context.request.scale.map { CGFloat($0) } - self.thumbnail = context.request.thubmnail + 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 @@ -51,8 +51,10 @@ extension ImageDecoders { defer { lock.unlock() } func makeImage() -> PlatformImage? { - if let thumbnail = self.thumbnail { - return makeThumbnail(data: data, options: thumbnail) + if let thumbnail { + return makeThumbnail(data: data, + options: thumbnail, + scale: scale) } return ImageDecoders.Default._decode(data, scale: scale) } @@ -162,12 +164,12 @@ private struct ProgressiveJPEGScanner: Sendable { } extension ImageDecoders.Default { - private static func _decode(_ data: Data, scale: CGFloat?) -> PlatformImage? { - #if os(macOS) + private 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 +#else + return UIImage(data: data, scale: scale) +#endif } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Empty.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Empty.swift index b35e3b592..e72d641d5 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Empty.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Empty.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoding.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoding.swift index da68348ab..70c588dfb 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoding.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoding.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -54,11 +54,11 @@ extension ImageDecoding { throw ImageDecodingError.unknown } } - #if !os(macOS) +#if !os(macOS) if container.userInfo[.isThumbnailKey] == nil { ImageDecompression.setDecompressionNeeded(true, for: container.image) } - #endif +#endif return ImageResponse(container: container, request: context.request, urlResponse: context.urlResponse, cacheType: context.cacheType) } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+Default.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+Default.swift index 520fe982f..9d0442af1 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+Default.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+Default.swift @@ -1,9 +1,15 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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. diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+ImageIO.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+ImageIO.swift index 0e9449261..d5715ace7 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+ImageIO.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+ImageIO.swift @@ -1,11 +1,17 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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. /// @@ -24,38 +30,38 @@ extension ImageEncoders { self.compressionRatio = compressionRatio } - private static let lock = NSLock() - private static var availability = [NukeAssetType: Bool]() + private static let availability = Atomic<[NukeAssetType: 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: NukeAssetType) -> Bool { - lock.lock() - defer { lock.unlock() } - if let isAvailable = availability[type] { + if let isAvailable = availability.value[type] { return isAvailable } let isAvailable = CGImageDestinationCreateWithData( NSMutableData() as CFMutableData, type.rawValue as CFString, 1, nil ) != nil - availability[type] = isAvailable + availability.withLock { $0[type] = isAvailable } return isAvailable } func encode(_ image: PlatformImage) -> Data? { - let data = NSMutableData() - let options: NSDictionary = [ + 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 ] - guard let source = image.cgImage, - let destination = CGImageDestinationCreateWithData( - data as CFMutableData, type.rawValue as CFString, 1, nil - ) else { - return nil +#if canImport(UIKit) + options[kCGImagePropertyOrientation] = CGImagePropertyOrientation(image.imageOrientation).rawValue +#endif + CGImageDestinationAddImage(destination, source, options as CFDictionary) + guard CGImageDestinationFinalize(destination) else { + return nil } - CGImageDestinationAddImage(destination, source, options) - CGImageDestinationFinalize(destination) return data as Data } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders.swift index c65837ae5..2d1bc5e61 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoding.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoding.swift index 5c11da6e4..b2c48759d 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoding.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoding.swift @@ -1,11 +1,13 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -#if !os(macOS) +#if canImport(UIKit) import UIKit -#else -import Cocoa +#endif + +#if canImport(AppKit) +import AppKit #endif import ImageIO @@ -23,7 +25,10 @@ protocol ImageEncoding: Sendable { extension ImageEncoding { func encode(_ container: ImageContainer, context: ImageEncodingContext) -> Data? { - self.encode(container.image) + if container.type == .gif { + return container.data + } + return self.encode(container.image) } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageContainer.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageContainer.swift index dabf7632a..4c11f3de6 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageContainer.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageContainer.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). #if !os(watchOS) import AVKit @@ -20,19 +20,31 @@ typealias PlatformImage = NSImage /// An image container with an image and associated metadata. struct ImageContainer: @unchecked Sendable { - #if os(macOS) +#if os(macOS) /// A fetched image. - var image: NSImage - #else + var image: NSImage { + get { ref.image } + set { mutate { $0.image = newValue } } + } +#else /// A fetched image. - var image: UIImage - #endif + var image: UIImage { + get { ref.image } + set { mutate { $0.image = newValue } } + } +#endif /// An image type. - var type: NukeAssetType? + var type: NukeAssetType? { + 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 + 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. @@ -42,29 +54,22 @@ struct ImageContainer: @unchecked Sendable { /// /// - note: The `data`, along with the image container itself gets stored /// in the memory cache. - var data: Data? - - #if !os(watchOS) - /// Represents in-memory video asset. - var asset: AVAsset? - #endif + var data: Data? { + get { ref.data } + set { mutate { $0.data = newValue } } + } /// An metadata provided by the user. - var userInfo: [UserInfoKey: Any] + 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: NukeAssetType? = 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 - - #if !os(watchOS) - if type?.isVideo == true { - self.asset = data.flatMap { AVDataAsset(data: $0, type: type) } - } - #endif + self.ref = Container(image: image, type: type, isPreview: isPreview, data: data, userInfo: userInfo) } func map(_ closure: (PlatformImage) throws -> PlatformImage) rethrows -> ImageContainer { @@ -91,4 +96,37 @@ struct ImageContainer: @unchecked Sendable { /// 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: NukeAssetType? + var isPreview: Bool + var data: Data? + var userInfo: [UserInfoKey: Any] + + init(image: PlatformImage, type: NukeAssetType?, 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/StreamChatSwiftUI/StreamNuke/Nuke/ImageRequest.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageRequest.swift index a7e79e76b..73c52c61a 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageRequest.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageRequest.swift @@ -1,9 +1,18 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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. @@ -17,10 +26,9 @@ import Combine /// priority: .high, /// options: [.reloadIgnoringCachedData] /// ) -/// let response = try await pipeline.image(for: request) +/// let image = try await pipeline.image(for: request) /// ``` -struct ImageRequest: CustomStringConvertible, Sendable { - +struct ImageRequest: CustomStringConvertible, Sendable, ExpressibleByStringLiteral { // MARK: Options /// The relative priority of the request. The priority affects the order in @@ -78,13 +86,7 @@ struct ImageRequest: CustomStringConvertible, Sendable { /// 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? { - 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 - } - } + var imageId: String? { ref.originalImageId } /// Returns a debug request description. var description: String { @@ -93,6 +95,11 @@ struct ImageRequest: CustomStringConvertible, Sendable { // 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: @@ -105,7 +112,7 @@ struct ImageRequest: CustomStringConvertible, Sendable { /// ```swift /// let request = ImageRequest( /// url: URL(string: "http://..."), - /// processors: [ImageProcessors.Resize(size: imageView.bounds.size)], + /// processors: [.resize(size: imageView.bounds.size)], /// priority: .high /// ) /// ``` @@ -137,7 +144,7 @@ struct ImageRequest: CustomStringConvertible, Sendable { /// ```swift /// let request = ImageRequest( /// url: URLRequest(url: URL(string: "http://...")), - /// processors: [ImageProcessors.Resize(size: imageView.bounds.size)], + /// processors: [.resize(size: imageView.bounds.size)], /// priority: .high /// ) /// ``` @@ -272,7 +279,7 @@ struct ImageRequest: CustomStringConvertible, Sendable { /// Returns a raw value. let rawValue: UInt16 - /// Initialializes options with a given raw values. + /// Initializes options with a given raw values. init(rawValue: UInt16) { self.rawValue = rawValue } @@ -312,7 +319,7 @@ struct ImageRequest: CustomStringConvertible, Sendable { /// Perform data loading immediately, ignoring ``ImagePipeline/Configuration-swift.struct/dataLoadingQueue``. It /// can be used to elevate priority of certain tasks. /// - /// - importajt: If there is an outstanding task for loading the same + /// - 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) } @@ -350,8 +357,7 @@ struct ImageRequest: CustomStringConvertible, Sendable { /// ``` 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. + /// 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 @@ -360,28 +366,37 @@ struct ImageRequest: CustomStringConvertible, Sendable { /// (``ImageProcessors/Resize``). /// /// - note: You must be using the default image decoder to make it work. - static let thumbnailKey: ImageRequest.UserInfoKey = "github.com/kean/nuke/thumbmnailKey" + 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 { - /// The maximum width and height in pixels of a thumbnail. If this key - /// is not specified, the width and height of a thumbnail is not limited - /// and thumbnails may be as big as the image itself. - var maxPixelSize: Float + 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 - /// ``maxPixelSize``. + /// 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 - /// ``maxPixelSize``. + /// 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 @@ -392,20 +407,32 @@ struct ImageRequest: CustomStringConvertible, Sendable { /// creation time. var shouldCacheImmediately = true - init(maxPixelSize: Float, - createThumbnailFromImageIfAbsent: Bool = true, - createThumbnailFromImageAlways: Bool = true, - createThumbnailWithTransform: Bool = true, - shouldCacheImmediately: Bool = true) { - self.maxPixelSize = maxPixelSize - self.createThumbnailFromImageIfAbsent = createThumbnailFromImageIfAbsent - self.createThumbnailFromImageAlways = createThumbnailFromImageAlways - self.createThumbnailWithTransform = createThumbnailWithTransform - self.shouldCacheImmediately = shouldCacheImmediately + /// 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? { + StreamChatSwiftUI.makeThumbnail(data: data, options: self) } var identifier: String { - "com.github/kean/nuke/thumbnail?mxs=\(maxPixelSize),options=\(createThumbnailFromImageIfAbsent)\(createThumbnailFromImageAlways)\(createThumbnailWithTransform)\(shouldCacheImmediately)" + "com.github/kean/nuke/thumbnail?\(targetSize.parameters),options=\(createThumbnailFromImageIfAbsent)\(createThumbnailFromImageAlways)\(createThumbnailWithTransform)\(shouldCacheImmediately)" } } @@ -435,7 +462,7 @@ struct ImageRequest: CustomStringConvertible, Sendable { return imageId ?? "" } - var thubmnail: ThumbnailOptions? { + var thumbnail: ThumbnailOptions? { ref.userInfo?[.thumbnailKey] as? ThumbnailOptions } @@ -461,28 +488,20 @@ extension ImageRequest { 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. - deinit { - #if TRACK_ALLOCATIONS - Allocations.decrement("ImageRequest.Container") - #endif - } - /// 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 - - #if TRACK_ALLOCATIONS - Allocations.increment("ImageRequest.Container") - #endif } /// Creates a copy. @@ -491,11 +510,8 @@ extension ImageRequest { self.processors = ref.processors self.priority = ref.priority self.options = ref.options + self.originalImageId = ref.originalImageId self.userInfo = ref.userInfo - - #if TRACK_ALLOCATIONS - Allocations.increment("ImageRequest.Container") - #endif } } @@ -512,5 +528,13 @@ extension ImageRequest { 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/StreamChatSwiftUI/StreamNuke/Nuke/ImageResponse.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageResponse.swift index e1adbbd47..fb22a93c4 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageResponse.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageResponse.swift @@ -1,13 +1,15 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation -#if !os(macOS) -import UIKit.UIImage -#else -import AppKit.NSImage +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit #endif /// An image response that contains a fetched image and some metadata. @@ -15,13 +17,13 @@ struct ImageResponse: @unchecked Sendable { /// An image container with an image and associated metadata. var container: ImageContainer - #if os(macOS) +#if os(macOS) /// A convenience computed property that returns an image from the container. var image: NSImage { container.image } - #else +#else /// A convenience computed property that returns an image from the container. var image: UIImage { container.image } - #endif +#endif /// Returns `true` if the image in the container is a preview of the image. var isPreview: Bool { container.isPreview } @@ -52,10 +54,4 @@ struct ImageResponse: @unchecked Sendable { /// Disk cache (see ``DataCaching``) case disk } - - func map(_ transform: (ImageContainer) throws -> ImageContainer) rethrows -> ImageResponse { - var response = self - response.container = try transform(response.container) - return response - } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageTask.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageTask.swift index a82872fb9..edd401b10 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageTask.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageTask.swift @@ -1,8 +1,17 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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``. /// @@ -14,33 +23,21 @@ final class ImageTask: Hashable, CustomStringConvertible, @unchecked Sendable { /// Unique only within that pipeline. let taskId: Int64 - /// The original request. + /// The original request that the task was created with. let request: ImageRequest - /// Updates the priority of the task, even if it is already running. + /// The priority of the task. The priority can be updated dynamically even + /// for a task that is already running. var priority: ImageRequest.Priority { - get { sync { _priority } } - set { - let didChange: Bool = sync { - guard _priority != newValue else { return false } - _priority = newValue - return _state == .running - } - guard didChange else { return } - pipeline?.imageTaskUpdatePriorityCalled(self, priority: newValue) - } + get { withLock { $0.priority } } + set { setPriority(newValue) } } - private var _priority: ImageRequest.Priority /// Returns the current download progress. Returns zeros before the download /// is started and the expected size of the resource is known. - /// - /// - important: Must be accessed only from the callback queue (main by default). - var progress: Progress { - get { sync { _progress } } - set { sync { _progress = newValue } } + var currentProgress: Progress { + withLock { $0.progress } } - private var _progress = Progress(completed: 0, total: 0) /// The download progress. struct Progress: Hashable, Sendable { @@ -57,14 +54,14 @@ final class ImageTask: Hashable, CustomStringConvertible, @unchecked Sendable { /// Initializes progress with the given status. init(completed: Int64, total: Int64) { - self.completed = completed - self.total = total + (self.completed, self.total) = (completed, total) } } /// The current state of the task. - var state: State { sync { _state } } - private var _state: State = .running + var state: State { + withLock { $0.state } + } /// The state of the image task. enum State { @@ -76,35 +73,94 @@ final class ImageTask: Hashable, CustomStringConvertible, @unchecked Sendable { case completed } - var onCancel: (() -> Void)? + // 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 + } + } - weak var pipeline: ImagePipeline? - weak var delegate: ImageTaskDelegate? - var callbackQueue: DispatchQueue? - var isDataTask = false + // 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() - - #if TRACK_ALLOCATIONS - Allocations.decrement("ImageTask") - #endif } - init(taskId: Int64, request: ImageRequest) { + init(taskId: Int64, request: ImageRequest, isDataTask: Bool, pipeline: ImagePipeline, onEvent: ((Event, ImageTask) -> Void)?) { self.taskId = taskId self.request = request - self._priority = request.priority + 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()) - - #if TRACK_ALLOCATIONS - Allocations.increment("ImageTask") - #endif } /// Marks task as being cancelled. @@ -112,29 +168,96 @@ final class ImageTask: Hashable, CustomStringConvertible, @unchecked Sendable { /// The pipeline will immediately cancel any work associated with a task /// unless there is an equivalent outstanding task running. func cancel() { - os_unfair_lock_lock(lock) - guard _state == .running else { - return os_unfair_lock_unlock(lock) + let didChange: Bool = withLock { + guard $0.state == .running else { return false } + $0.state = .cancelled + return true } - _state = .cancelled - os_unfair_lock_unlock(lock) - + guard didChange else { return } // Make sure it gets called once (expensive) pipeline?.imageTaskCancelCalled(self) } - func didComplete() { - os_unfair_lock_lock(lock) - guard _state == .running else { - return os_unfair_lock_unlock(lock) + private func setPriority(_ newValue: ImageRequest.Priority) { + let didChange: Bool = withLock { + guard $0.priority != newValue else { return false } + $0.priority = newValue + return $0.state == .running } - _state = .completed - os_unfair_lock_unlock(lock) + guard didChange else { return } + pipeline?.imageTaskUpdatePriorityCalled(self, priority: newValue) } - private func sync(_ closure: () -> T) -> T { - os_unfair_lock_lock(lock) - defer { os_unfair_lock_unlock(lock) } - return closure() + // 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 @@ -150,47 +273,65 @@ final class ImageTask: Hashable, CustomStringConvertible, @unchecked Sendable { // MARK: CustomStringConvertible var description: String { - "ImageTask(id: \(taskId), priority: \(_priority), progress: \(progress.completed) / \(progress.total), state: \(state))" + "ImageTask(id: \(taskId), priority: \(priority), progress: \(currentProgress.completed) / \(currentProgress.total), state: \(state))" } } -/// A protocol that defines methods that image pipeline instances call on their -/// delegates to handle task-level events. -protocol ImageTaskDelegate: AnyObject { - /// Gets called when the task is created. Unlike other methods, it is called - /// immediately on the caller's queue. - func imageTaskCreated(_ task: ImageTask) - - /// Gets called when the task is started. The caller can save the instance - /// of the class to update the task later. - func imageTaskDidStart(_ task: ImageTask) +@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() + } + } + } + } - /// Gets called when the progress is updated. - func imageTask(_ task: ImageTask, didUpdateProgress progress: ImageTask.Progress) + // Synchronized on `pipeline.queue` + private func _makeEventsSubject() -> PassthroughSubject? { + guard _state == .running else { + return nil + } + if _events == nil { + _events = PassthroughSubject() + } + return _events! + } - /// Gets called when a new progressive image is produced. - func imageTask(_ task: ImageTask, didReceivePreview response: ImageResponse) + private func withLock(_ closure: (inout PublicState) -> T) -> T { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return closure(&publicState) + } - /// Gets called when the task is cancelled. + /// Contains the state synchronized using the internal lock. /// - /// - important: This doesn't get called immediately. - func imageTaskDidCancel(_ task: ImageTask) - - /// If you cancel the task from the same queue as the callback queue, this - /// callback is guaranteed not to be called. - func imageTask(_ task: ImageTask, didCompleteWithResult result: Result) -} - -extension ImageTaskDelegate { - func imageTaskCreated(_ task: ImageTask) {} - - func imageTaskDidStart(_ task: ImageTask) {} - - func imageTask(_ task: ImageTask, didUpdateProgress progress: ImageTask.Progress) {} - - func imageTask(_ task: ImageTask, didReceivePreview response: ImageResponse) {} - - func imageTaskDidCancel(_ task: ImageTask) {} - - func imageTask(_ task: ImageTask, didCompleteWithResult result: Result) {} + /// - 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/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Allocations.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Allocations.swift deleted file mode 100644 index 264bf9d25..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Allocations.swift +++ /dev/null @@ -1,81 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2022 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/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Atomic.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Atomic.swift new file mode 100644 index 000000000..43055ce35 --- /dev/null +++ b/Sources/StreamChatSwiftUI/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 Atomic: @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/StreamChatSwiftUI/StreamNuke/Nuke/Internal/DataPublisher.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/DataPublisher.swift index 4342c568b..fc3afca54 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/DataPublisher.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/DataPublisher.swift @@ -1,9 +1,9 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation -import Combine +@preconcurrency import Combine final class DataPublisher { let id: String @@ -34,20 +34,27 @@ final class DataPublisher { } private func publisher(from closure: @Sendable @escaping () async throws -> Data) -> AnyPublisher { - let subject = PassthroughSubject() - Task { - do { - let data = try await closure() - subject.send(data) - subject.send(completion: .finished) - } catch { - subject.send(completion: .failure(error)) + 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)) + } + } } - } - return subject.eraseToAnyPublisher() + }.eraseToAnyPublisher() } enum PublisherCompletion { case finished case failure(Error) } + +/// - warning: Avoid using it! +struct UncheckedSendableBox: @unchecked Sendable { + let value: Value +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Deprecated.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Deprecated.swift deleted file mode 100644 index f103a2c9a..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Deprecated.swift +++ /dev/null @@ -1,158 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). - -import Foundation - -// Deprecated in Nuke 11.0 -@available(*, deprecated, message: "Please use ImageDecodingRegistry directly.") -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) -} - -// Deprecated in Nuke 11.0 -@available(*, deprecated, message: "Please use ImageDecodingRegistry directly.") -extension ImageDecoderRegistering { - /// The default implementation which simply returns `nil` (no progressive - /// decoding available). - init?(partiallyDownloadedData data: Data, context: ImageDecodingContext) { - return nil - } -} - -extension ImageDecoderRegistry { - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please use register method that accepts a closure.") - 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) - } - } - } -} - -extension ImageProcessingContext { - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please use `isCompleted` instead.") - var isFinal: Bool { - isCompleted - } -} - -extension ImageContainer { - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please create a copy of and modify it instead or define a similar helper method yourself.") - 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) - } -} - -extension ImageTask { - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please use progress.completed instead.") - var completedUnitCount: Int64 { progress.completed } - - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please use progress.total instead.") - var totalUnitCount: Int64 { progress.total } -} - -extension DataCache { - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please use String directly instead.") - typealias Key = String -} - -extension ImageCaching { - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please use ImagePipeline.Cache that goes through ImagePipelineDelegate instead.") - subscript(request: any ImageRequestConvertible) -> ImageContainer? { - get { self[ImageCacheKey(request: request.asImageRequest())] } - set { self[ImageCacheKey(request: request.asImageRequest())] = newValue } - } -} - -extension ImagePipeline.Configuration { - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please use `ImagePipeline.DataCachePolicy`") - typealias DataCachePolicy = ImagePipeline.DataCachePolicy -} - -// MARK: - ImageRequestConvertible - -/// Represents a type that can be converted to an ``ImageRequest``. -/// -/// - warning: Soft-deprecated in Nuke 11.0. -protocol ImageRequestConvertible { - /// Returns a request. - 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)) } -} - -// Deprecated in Nuke 11.1 -@available(*, deprecated, message: "Please use `DataLoader/delegate` instead") -protocol DataLoaderObserving { - func dataLoader(_ loader: DataLoader, urlSession: URLSession, dataTask: URLSessionDataTask, didReceiveEvent event: DataTaskEvent) - - /// Sent when complete statistics information has been collected for the task. - func dataLoader(_ loader: DataLoader, urlSession: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) -} - -@available(*, deprecated, message: "Please use `DataLoader/delegate` instead") -extension DataLoaderObserving { - func dataLoader(_ loader: DataLoader, urlSession: URLSession, dataTask: URLSessionDataTask, didReceiveEvent event: DataTaskEvent) { - // Do nothing - } - - func dataLoader(_ loader: DataLoader, urlSession: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { - // Do nothing - } -} - -/// Deprecated in Nuke 11.1 -enum DataTaskEvent { - case resumed - case receivedResponse(response: URLResponse) - case receivedData(data: Data) - case completed(error: Error?) -} - -// Deprecated in Nuke 11.1 -protocol _DataLoaderObserving: AnyObject { - func dataTask(_ dataTask: URLSessionDataTask, didReceiveEvent event: DataTaskEvent) - func task(_ task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) -} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Extensions.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Extensions.swift index 960c1ca5f..03b46dc5f 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Extensions.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Extensions.swift @@ -1,9 +1,9 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 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" } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Graphics.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Graphics.swift index 6133dff9a..a30b7e3de 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Graphics.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Graphics.swift @@ -1,22 +1,22 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation -#if os(iOS) || os(tvOS) || os(watchOS) -import UIKit -#endif - #if os(watchOS) import ImageIO import CoreGraphics import WatchKit.WKInterfaceDevice #endif -#if os(macOS) -import Cocoa -#endif +#if canImport(UIKit) + import UIKit + #endif + + #if canImport(AppKit) + import AppKit + #endif extension PlatformImage { var processed: ImageProcessingExtensions { @@ -28,14 +28,14 @@ struct ImageProcessingExtensions { let image: PlatformImage func byResizing(to targetSize: CGSize, - contentMode: ImageProcessors.Resize.ContentMode, + contentMode: ImageProcessingOptions.ContentMode, upscale: Bool) -> PlatformImage? { guard let cgImage = image.cgImage else { return nil } - #if os(iOS) || os(tvOS) || os(watchOS) +#if canImport(UIKit) let targetSize = targetSize.rotatedForOrientation(image.imageOrientation) - #endif +#endif let scale = cgImage.size.getScale(targetSize: targetSize, contentMode: contentMode) guard scale < 1 || upscale else { return image // The image doesn't require scaling @@ -50,9 +50,9 @@ struct ImageProcessingExtensions { guard let cgImage = image.cgImage else { return nil } - #if os(iOS) || os(tvOS) || os(watchOS) +#if canImport(UIKit) let targetSize = targetSize.rotatedForOrientation(image.imageOrientation) - #endif +#endif let scale = cgImage.size.getScale(targetSize: targetSize, contentMode: .aspectFill) let scaledSize = cgImage.size.scaled(by: scale) let drawRect = scaledSize.centeredInRectWithSize(targetSize) @@ -82,8 +82,8 @@ struct ImageProcessingExtensions { 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) + 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 @@ -107,7 +107,7 @@ struct ImageProcessingExtensions { ctx.clip() ctx.draw(cgImage, in: CGRect(origin: CGPoint.zero, size: cgImage.size)) - if let border = border { + if let border { ctx.setStrokeColor(border.color.cgColor) ctx.addPath(path) ctx.setLineWidth(border.width) @@ -131,7 +131,7 @@ extension PlatformImage { /// /// - 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 { + guard let cgImage else { return nil } guard let ctx = CGContext.make(cgImage, size: canvasSize) else { @@ -146,12 +146,12 @@ extension PlatformImage { /// Decompresses the input image by drawing in the the `CGContext`. func decompressed(isUsingPrepareForDisplay: Bool) -> PlatformImage? { -#if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) if isUsingPrepareForDisplay, #available(iOS 15.0, tvOS 15.0, *) { return preparingForDisplay() } #endif - guard let cgImage = cgImage else { + guard let cgImage else { return nil } return draw(inCanvasWithSize: cgImage.size, drawRect: CGRect(origin: .zero, size: cgImage.size)) @@ -160,35 +160,39 @@ extension PlatformImage { 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 - ) { + 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( + 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), + width: Int(size.width), + height: Int(size.height), bitsPerComponent: 8, bytesPerRow: 0, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: alphaInfo.rawValue + 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 { @@ -201,7 +205,7 @@ extension CGFloat { } extension CGSize { - func getScale(targetSize: CGSize, contentMode: ImageProcessors.Resize.ContentMode) -> CGFloat { + func getScale(targetSize: CGSize, contentMode: ImageProcessingOptions.ContentMode) -> CGFloat { let scaleHor = targetSize.width / width let scaleVert = targetSize.height / height @@ -224,8 +228,48 @@ extension CGSize { } } -#if os(iOS) || os(tvOS) || os(watchOS) +#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: @@ -287,15 +331,14 @@ extension CGSize { } } -@MainActor enum Screen { #if os(iOS) || os(tvOS) /// Returns the current screen scale. - static let scale: CGFloat = UIScreen.main.scale + static let scale: CGFloat = UITraitCollection.current.displayScale #elseif os(watchOS) /// Returns the current screen scale. static let scale: CGFloat = WKInterfaceDevice.current().screenScale -#elseif os(macOS) +#else /// Always returns 1. static let scale: CGFloat = 1 #endif @@ -321,18 +364,59 @@ extension NukeColor { } /// Creates an image thumbnail. Uses significantly less memory than other options. -func makeThumbnail(data: Data, options: ImageRequest.ThumbnailOptions) -> PlatformImage? { +/// - 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: options.maxPixelSize] as CFDictionary - guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else { + 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/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImagePublisher.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImagePublisher.swift index c7df3e954..985fbc992 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImagePublisher.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImagePublisher.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2020-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). import Foundation import Combine @@ -47,7 +47,7 @@ private final class ImageSubscription: Subscription where S: Subscriber, S: S func request(_ demand: Subscribers.Demand) { guard demand > 0 else { return } - guard let subscriber = subscriber else { return } + guard let subscriber else { return } if let image = pipeline.cache[request] { _ = subscriber.receive(ImageResponse(container: image, request: request, cacheType: .memory)) @@ -60,9 +60,8 @@ private final class ImageSubscription: Subscription where S: Subscriber, S: S 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/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImageRequestKeys.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImageRequestKeys.swift index 2f4a03c45..90046776b 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImageRequestKeys.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImageRequestKeys.swift @@ -1,92 +1,76 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 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)\(thubmnail?.identifier ?? "")\(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 decoded image. - func makeDecodedImageLoadKey() -> DecodedImageLoadKey { - DecodedImageLoadKey(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 { +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.thumbnail = request.thubmnail + 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: CacheKey, rhs: CacheKey) -> Bool { - lhs.imageId == rhs.imageId && lhs.thumbnail == rhs.thumbnail && lhs.processors == rhs.processors + 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. -struct ImageLoadKey: Hashable { - let cacheKey: CacheKey - let options: ImageRequest.Options - let thumbnail: ImageRequest.ThumbnailOptions? - let loadKey: DataLoadKey +final class TaskLoadImageKey: Hashable, Sendable { + private let loadKey: TaskFetchOriginalImageKey + private let options: ImageRequest.Options + private let processors: [any ImageProcessing] init(_ request: ImageRequest) { - self.cacheKey = CacheKey(request) + self.loadKey = TaskFetchOriginalImageKey(request) self.options = request.options - self.thumbnail = request.thubmnail - self.loadKey = DataLoadKey(request) + 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 decoded image. -struct DecodedImageLoadKey: Hashable { - let dataLoadKey: DataLoadKey - let thumbnail: ImageRequest.ThumbnailOptions? +/// 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 = DataLoadKey(request) - self.thumbnail = request.thubmnail + self.dataLoadKey = TaskFetchOriginalDataKey(request) + self.scale = request.scale ?? 1 + self.thumbnail = request.thumbnail } } -/// Uniquely identifies a task of retrieving the original image dataa. -struct DataLoadKey: Hashable { +/// 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 @@ -103,13 +87,3 @@ struct DataLoadKey: Hashable { } } } - -struct ImageProcessingKey: Equatable, Hashable { - let imageId: ObjectIdentifier - let processorId: AnyHashable - - init(image: ImageResponse, processor: any ImageProcessing) { - self.imageId = ObjectIdentifier(image.image) - self.processorId = processor.hashableIdentifier - } -} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/LinkedList.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/LinkedList.swift index afd492d05..2f33ab63d 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/LinkedList.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/LinkedList.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 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/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Log.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Log.swift index 39d86899f..a431b7dd3 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Log.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Log.swift @@ -1,46 +1,30 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation import os -func signpost(_ object: AnyObject, _ name: StaticString, _ type: OSSignpostType) { - guard ImagePipeline.Configuration.isSignpostLoggingEnabled else { return } - - let signpostId = OSSignpostID(log: nukeLog, object: object) - os_signpost(type, log: nukeLog, name: name, signpostID: signpostId) -} - func signpost(_ object: AnyObject, _ name: StaticString, _ type: OSSignpostType, _ message: @autoclosure () -> String) { guard ImagePipeline.Configuration.isSignpostLoggingEnabled else { return } + let nukeLog = nukeLog.value let signpostId = OSSignpostID(log: nukeLog, object: object) os_signpost(type, log: nukeLog, name: name, signpostID: signpostId, "%{public}s", message()) } func signpost(_ name: StaticString, _ work: () throws -> T) rethrows -> T { - try signpost(name, "", work) -} - -func signpost(_ name: StaticString, _ message: @autoclosure () -> String, _ work: () throws -> T) rethrows -> T { guard ImagePipeline.Configuration.isSignpostLoggingEnabled else { return try work() } + let nukeLog = nukeLog.value let signpostId = OSSignpostID(log: nukeLog) - let message = message() - if !message.isEmpty { - os_signpost(.begin, log: nukeLog, name: name, signpostID: signpostId, "%{public}s", message) - } else { - os_signpost(.begin, log: nukeLog, name: name, signpostID: signpostId) - } + os_signpost(.begin, log: nukeLog, name: name, signpostID: signpostId) let result = try work() os_signpost(.end, log: nukeLog, name: name, signpostID: signpostId) return result } -private let nukeLog = OSLog(subsystem: "com.github.kean.Nuke.ImagePipeline", category: "Image Loading") - -private let byteFormatter = ByteCountFormatter() +private let nukeLog = Atomic(value: OSLog(subsystem: "com.github.kean.Nuke.ImagePipeline", category: "Image Loading")) enum Formatter { static func bytes(_ count: Int) -> String { @@ -48,6 +32,6 @@ enum Formatter { } static func bytes(_ count: Int64) -> String { - byteFormatter.string(fromByteCount: count) + ByteCountFormatter().string(fromByteCount: count) } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Operation.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Operation.swift index 13b50bcea..a112d1436 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Operation.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Operation.swift @@ -1,10 +1,10 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation -final class Operation: Foundation.Operation { +final class Operation: Foundation.Operation, @unchecked Sendable { override var isExecuting: Bool { get { os_unfair_lock_lock(lock) @@ -46,12 +46,8 @@ final class Operation: Foundation.Operation { private let lock: os_unfair_lock_t deinit { - self.lock.deinitialize(count: 1) - self.lock.deallocate() - - #if TRACK_ALLOCATIONS - Allocations.decrement("Operation") - #endif + lock.deinitialize(count: 1) + lock.deallocate() } init(starter: @escaping Starter) { @@ -59,10 +55,6 @@ final class Operation: Foundation.Operation { self.lock = .allocate(capacity: 1) self.lock.initialize(to: os_unfair_lock()) - - #if TRACK_ALLOCATIONS - Allocations.increment("Operation") - #endif } override func start() { diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/RateLimiter.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/RateLimiter.swift index 1d6caee3d..d85c34a41 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/RateLimiter.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/RateLimiter.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -33,16 +33,6 @@ final class RateLimiter: @unchecked Sendable { 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` diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ResumableData.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ResumableData.swift index 7752e7fc0..85f7f9a39 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ResumableData.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ResumableData.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -94,11 +94,11 @@ final class ResumableDataStorage: @unchecked Sendable { } } - func removeAll() { + func removeAllResponses() { lock.lock() defer { lock.unlock() } - cache?.removeAll() + cache?.removeAllCachedValues() } // MARK: Storage diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoader.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoader.swift index c31a4df9c..810e760ec 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoader.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoader.swift @@ -1,23 +1,21 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation /// Provides basic networking using `URLSession`. -final class DataLoader: DataLoading, _DataLoaderObserving, @unchecked Sendable { +final class DataLoader: DataLoading, @unchecked Sendable { let session: URLSession - private let impl = _DataLoader() - - @available(*, deprecated, message: "Please use `DataLoader/delegate` instead") - var observer: (any DataLoaderObserving)? + 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, but can't affect them. + /// 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. @@ -33,10 +31,6 @@ final class DataLoader: DataLoading, _DataLoaderObserving, @unchecked Sendable { deinit { session.invalidateAndCancel() - - #if TRACK_ALLOCATIONS - Allocations.decrement("DataLoader") - #endif } /// Initializes ``DataLoader`` with the given configuration. @@ -47,17 +41,12 @@ final class DataLoader: DataLoading, _DataLoaderObserving, @unchecked Sendable { /// - validate: Validates the response. By default, check if the status /// code is in the acceptable range (`200..<300`). init(configuration: URLSessionConfiguration = DataLoader.defaultConfiguration, - validate: @escaping (URLResponse) -> Swift.Error? = DataLoader.validate) { + 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" - self.impl.validate = validate - self.impl.observer = self - - #if TRACK_ALLOCATIONS - Allocations.increment("DataLoader") - #endif } /// Returns a default configuration which has a `sharedUrlCache` set @@ -70,16 +59,16 @@ final class DataLoader: DataLoading, _DataLoaderObserving, @unchecked Sendable { /// 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? { + @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) +#if !os(macOS) && !targetEnvironment(macCatalyst) private static let cachePath = "com.github.kean.Nuke.Cache" - #else +#else private static let cachePath: String = { let cachePaths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true) if let cachePath = cachePaths.first, let identifier = Bundle.main.bundleIdentifier { @@ -88,17 +77,17 @@ final class DataLoader: DataLoading, _DataLoaderObserving, @unchecked Sendable { return "" }() - #endif +#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) +#if targetEnvironment(macCatalyst) return URLCache(memoryCapacity: 0, diskCapacity: diskCapacity, directory: URL(fileURLWithPath: cachePath)) - #else +#else return URLCache(memoryCapacity: 0, diskCapacity: diskCapacity, diskPath: cachePath) - #endif +#endif }() func loadData(with request: URLRequest, @@ -123,28 +112,19 @@ final class DataLoader: DataLoading, _DataLoaderObserving, @unchecked Sendable { } } } - - // MARK: _DataLoaderObserving - - @available(*, deprecated, message: "Please use `DataLoader/delegate` instead") - func dataTask(_ dataTask: URLSessionDataTask, didReceiveEvent event: DataTaskEvent) { - observer?.dataLoader(self, urlSession: session, dataTask: dataTask, didReceiveEvent: event) - } - - @available(*, deprecated, message: "Please use `DataLoader/delegate` instead") - func task(_ task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { - observer?.dataLoader(self, urlSession: session, task: task, didFinishCollecting: metrics) - } } // 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 final class _DataLoader: NSObject, URLSessionDataDelegate, @unchecked Sendable { + let validate: @Sendable (URLResponse) -> Swift.Error? private var handlers = [URLSessionTask: _Handler]() var delegate: URLSessionDelegate? - weak var observer: (any _DataLoaderObserving)? + + init(validate: @Sendable @escaping (URLResponse) -> Swift.Error?) { + self.validate = validate + } /// Loads data with the given request. func loadData(with task: URLSessionDataTask, @@ -155,9 +135,7 @@ private final class _DataLoader: NSObject, URLSessionDataDelegate { session.delegateQueue.addOperation { // `URLSession` is configured to use this same queue self.handlers[task] = handler } - task.taskDescription = "Nuke Load Data" task.resume() - send(task, .resumed) return AnonymousCancellable { task.cancel() } } @@ -178,7 +156,6 @@ private final class _DataLoader: NSObject, URLSessionDataDelegate { didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { (delegate as? URLSessionDataDelegate)?.urlSession?(session, dataTask: dataTask, didReceive: response, completionHandler: { _ in }) - send(dataTask, .receivedResponse(response: response)) guard let handler = handlers[dataTask] else { completionHandler(.cancel) @@ -194,12 +171,7 @@ private final class _DataLoader: NSObject, URLSessionDataDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { (delegate as? URLSessionTaskDelegate)?.urlSession?(session, task: task, didCompleteWithError: error) - assert(task is URLSessionDataTask) - if let dataTask = task as? URLSessionDataTask { - send(dataTask, .completed(error: error)) - } - guard let handler = handlers[task] else { return } @@ -209,14 +181,30 @@ private final class _DataLoader: NSObject, URLSessionDataDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { (delegate as? URLSessionTaskDelegate)?.urlSession?(session, task: task, didFinishCollecting: metrics) - observer?.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) - send(dataTask, .receivedData(data: data)) guard let handler = handlers[dataTask], let response = dataTask.response else { return @@ -225,13 +213,14 @@ private final class _DataLoader: NSObject, URLSessionDataDelegate { handler.didReceiveData(data, response) } - // MARK: Internal - - private func send(_ dataTask: URLSessionDataTask, _ event: DataTaskEvent) { - observer?.dataTask(dataTask, didReceiveEvent: event) + 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) } - private final class _Handler { + // MARK: Internal + + private final class _Handler: @unchecked Sendable { let didReceiveData: (Data, URLResponse) -> Void let completion: (Error?) -> Void diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoading.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoading.swift index 06e5f5460..be1ab748b 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoading.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoading.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineCache.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Cache.swift similarity index 97% rename from Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineCache.swift rename to Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Cache.swift index 327f09ad6..d8f958008 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineCache.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Cache.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -200,7 +200,7 @@ extension ImagePipeline.Cache { if let customKey = pipeline.delegate.cacheKey(for: request, pipeline: pipeline) { return customKey } - return request.makeDataCacheKey() // Use the default key + return "\(request.preferredImageId)\(request.thumbnail?.identifier ?? "")\(ImageProcessors.Composition(request.processors).identifier)" } // MARK: Misc @@ -222,7 +222,7 @@ extension ImagePipeline.Cache { // MARK: Private private func decodeImageData(_ data: Data, for request: ImageRequest) -> ImageContainer? { - let context = ImageDecodingContext(request: request, data: data, isCompleted: true, urlResponse: nil, cacheType: .disk) + let context = ImageDecodingContext(request: request, data: data, cacheType: .disk) guard let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) else { return nil } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineConfiguration.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Configuration.swift similarity index 80% rename from Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineConfiguration.swift rename to Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Configuration.swift index eecb43ae2..fac24039a 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineConfiguration.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Configuration.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -77,11 +77,11 @@ extension ImagePipeline { /// ```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) + /// .resize(size: CGSize(width: 44, height: 44)), + /// .gaussianBlur(radius: 8) /// ])) /// pipeline.loadImage(with: ImageRequest(url: url, processors: [ - /// ImageProcessors.Resize(size: CGSize(width: 44, height: 44)) + /// .resize(size: CGSize(width: 44, height: 44)) /// ])) /// ``` /// @@ -114,9 +114,19 @@ extension ImagePipeline { /// `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. - var callbackQueue = DispatchQueue.main + @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) @@ -125,7 +135,12 @@ extension ImagePipeline { /// 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 + static var isSignpostLoggingEnabled: Bool { + get { _isSignpostLoggingEnabled.value } + set { _isSignpostLoggingEnabled.value = newValue } + } + + private static let _isSignpostLoggingEnabled = Atomic(value: false) private var isCustomImageCacheProvided = false @@ -136,7 +151,8 @@ extension ImagePipeline { /// 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. + // 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. @@ -201,7 +217,7 @@ extension ImagePipeline { config.dataLoader = dataLoader let dataCache = try? DataCache(name: name) - if let sizeLimit = sizeLimit { + if let sizeLimit { dataCache?.sizeLimit = sizeLimit } config.dataCache = dataCache @@ -212,34 +228,39 @@ extension ImagePipeline { /// Determines what images are stored in the disk cache. enum DataCachePolicy: Sendable { - /// 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). + /// 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 ``ImagePipeline/loadData(with:completion:)`` method - /// will not store the images in the disk cache for requests with + /// - 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 - /// For all requests, only store the original image data, unless - /// the resource is local (file:// or data:// scheme is used). + /// Store only original image data. + /// + /// - note: If the resource is local (file:// or data:// URL scheme), + /// data isn't stored. case storeOriginalData - /// For all requests, encode and store decoded images after all - /// processors are applied. + /// 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 ``ImagePipeline/loadData(with:completion:)`` method - /// will not store the images in the disk cache – this method only + /// - 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 - /// For requests with processors, encode and store processed images. - /// For all requests, store original image data. + /// 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/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineDelegate.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Delegate.swift similarity index 69% rename from Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineDelegate.swift rename to Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Delegate.swift index 89093eb85..d5650a33d 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineDelegate.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Delegate.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -8,7 +8,7 @@ import Foundation /// /// - important: The delegate methods are performed on the pipeline queue in the /// background. -protocol ImagePipelineDelegate: ImageTaskDelegate, Sendable { +protocol ImagePipelineDelegate: AnyObject, Sendable { // MARK: Configuration /// Returns data loader for the given request. @@ -59,6 +59,30 @@ protocol ImagePipelineDelegate: ImageTaskDelegate, Sendable { 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 { @@ -99,6 +123,20 @@ extension ImagePipelineDelegate { 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/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineError.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Error.swift similarity index 97% rename from Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineError.swift rename to Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Error.swift index b03117032..326333e1d 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineError.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline+Error.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline.swift index b50f277f6..b619c4e85 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline.swift @@ -1,30 +1,42 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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(configuration: .withURLCache) + static var shared: ImagePipeline { + get { _shared.value } + set { _shared.value = newValue } + } + + private static let _shared = Atomic(value: ImagePipeline(configuration: .withURLCache)) /// The pipeline configuration. let configuration: Configuration /// Provides access to the underlying caching subsystems. - var cache: ImagePipeline.Cache { ImagePipeline.Cache(pipeline: self) } + 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 tasksFetchDecodedImage: TaskPool - private let tasksFetchOriginalImageData: TaskPool - private let tasksProcessImage: TaskPool + 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) @@ -41,15 +53,13 @@ final class ImagePipeline: @unchecked Sendable { let rateLimiter: RateLimiter? let id = UUID() + var onTaskStarted: ((ImageTask) -> Void)? // Debug purposes deinit { lock.deinitialize(count: 1) lock.deallocate() ResumableDataStorage.shared.unregister(self) - #if TRACK_ALLOCATIONS - Allocations.decrement("ImagePipeline") - #endif } /// Initializes the instance with the given configuration. @@ -66,18 +76,13 @@ final class ImagePipeline: @unchecked Sendable { let isCoalescingEnabled = configuration.isTaskCoalescingEnabled self.tasksLoadData = TaskPool(isCoalescingEnabled) self.tasksLoadImage = TaskPool(isCoalescingEnabled) - self.tasksFetchDecodedImage = TaskPool(isCoalescingEnabled) - self.tasksFetchOriginalImageData = TaskPool(isCoalescingEnabled) - self.tasksProcessImage = 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) - - #if TRACK_ALLOCATIONS - Allocations.increment("ImagePipeline") - #endif } /// A convenience way to initialize the pipeline with a closure. @@ -106,89 +111,45 @@ final class ImagePipeline: @unchecked Sendable { queue.async { guard !self.isInvalidated else { return } self.isInvalidated = true - self.tasks.keys.forEach { self.cancel($0) } + self.tasks.keys.forEach(self.cancelImageTask) } } // MARK: - Loading Images (Async/Await) - /// Returns an image for the given URL. + /// Creates a task with the given URL. /// - /// - parameters: - /// - request: An image request. - /// - delegate: A delegate for monitoring the request progress. The delegate - /// is captured as a weak reference and is called on the main queue. You - /// can change the callback queue using ``Configuration-swift.struct/callbackQueue``. - func image(for url: URL, delegate: (any ImageTaskDelegate)? = nil) async throws -> ImageResponse { - try await image(for: ImageRequest(url: url), delegate: delegate) + /// The task starts executing the moment it is created. + func imageTask(with url: URL) -> ImageTask { + imageTask(with: ImageRequest(url: url)) } - /// Returns an image for the given request. + /// Creates a task with the given request. /// - /// - parameters: - /// - request: An image request. - /// - delegate: A delegate for monitoring the request progress. The delegate - /// is captured as a weak reference and is called on the main queue. You - /// can change the callback queue using ``Configuration-swift.struct/callbackQueue``. - func image(for request: ImageRequest, delegate: (any ImageTaskDelegate)? = nil) async throws -> ImageResponse { - let task = makeImageTask(request: request, queue: nil) - task.delegate = delegate - - self.delegate.imageTaskCreated(task) - task.delegate?.imageTaskCreated(task) - - return try await withTaskCancellationHandler( - operation: { - try await withUnsafeThrowingContinuation { continuation in - task.onCancel = { - continuation.resume(throwing: CancellationError()) - } - self.queue.async { - self.startImageTask(task, progress: nil) { result in - continuation.resume(with: result) - } - } - } - }, - onCancel: { - task.cancel() - } - ) + /// The task starts executing the moment it is created. + func imageTask(with request: ImageRequest) -> ImageTask { + makeStartedImageTask(with: request) } - // MARK: - Loading Data (Async/Await) + /// Returns an image for the given URL. + func image(for url: URL) async throws -> PlatformImage { + try await image(for: ImageRequest(url: url)) + } - /// Returns image data for the given URL. - /// - /// - parameter request: An image request. - @discardableResult - func data(for url: URL) async throws -> (Data, URLResponse?) { - try await data(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. - @discardableResult func data(for request: ImageRequest) async throws -> (Data, URLResponse?) { - let task = makeImageTask(request: request, queue: nil, isDataTask: true) - return try await withTaskCancellationHandler( - operation: { - try await withUnsafeThrowingContinuation { continuation in - task.onCancel = { - continuation.resume(throwing: CancellationError()) - } - self.queue.async { - self.startDataTask(task, progress: nil) { result in - continuation.resume(with: result.map { $0 }) - } - } - } - }, - onCancel: { - task.cancel() - } - ) + let task = makeStartedImageTask(with: request, isDataTask: true) + let response = try await task.response + return (response.container.data ?? Data(), response.urlResponse) } // MARK: - Loading Images (Closures) @@ -200,137 +161,110 @@ final class ImagePipeline: @unchecked Sendable { /// - completion: A closure to be called on the main thread when the request /// is finished. @discardableResult func loadImage( - with request: any ImageRequestConvertible, + 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, queue: nil, progress: nil, completion: completion) + _loadImage(with: request, progress: nil, completion: completion) } /// Loads an image for the given request. /// /// - 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 loadImage( - with request: any ImageRequestConvertible, + with request: ImageRequest, queue: DispatchQueue? = nil, progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)?, completion: @escaping (_ result: Result) -> Void ) -> ImageTask { - loadImage(with: request, isConfined: false, queue: queue, progress: { + _loadImage(with: request, queue: queue, progress: { progress?($0, $1.completed, $1.total) }, completion: completion) } - func loadImage( - with request: any ImageRequestConvertible, - isConfined: Bool, - queue callbackQueue: DispatchQueue?, + func _loadImage( + with request: ImageRequest, + isDataTask: Bool = false, + queue callbackQueue: DispatchQueue? = nil, progress: ((ImageResponse?, ImageTask.Progress) -> Void)?, completion: @escaping (Result) -> Void ) -> ImageTask { - let task = makeImageTask(request: request.asImageRequest(), queue: callbackQueue) - delegate.imageTaskCreated(task) - func start() { - startImageTask(task, progress: progress, completion: completion) - } - if isConfined { - start() - } else { - self.queue.async { start() } + 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) + } + } } - return task } - private func startImageTask( - _ task: ImageTask, - progress progressHandler: ((ImageResponse?, ImageTask.Progress) -> Void)?, - completion: @escaping (Result) -> Void - ) { - guard !isInvalidated else { - dispatchCallback(to: task.callbackQueue) { - let error = Error.pipelineInvalidated - self.delegate.imageTask(task, didCompleteWithResult: .failure(error)) - task.delegate?.imageTask(task, didCompleteWithResult: .failure(error)) - - completion(.failure(error)) + // 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() } - return - } - - self.delegate.imageTaskDidStart(task) - task.delegate?.imageTaskDidStart(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 } - - if event.isCompleted { - task.didComplete() - self.tasks[task] = nil - } - - self.dispatchCallback(to: task.callbackQueue) { - guard task.state != .cancelled else { return } - - switch event { - case let .value(response, isCompleted): - if isCompleted { - self.delegate.imageTask(task, didCompleteWithResult: .success(response)) - task.delegate?.imageTask(task, didCompleteWithResult: .success(response)) - - completion(.success(response)) - } else { - self.delegate.imageTask(task, didReceivePreview: response) - task.delegate?.imageTask(task, didReceivePreview: response) - - progressHandler?(response, task.progress) - } - case let .progress(progress): - self.delegate.imageTask(task, didUpdateProgress: progress) - task.delegate?.imageTask(task, didUpdateProgress: progress) - - task.progress = progress - progressHandler?(nil, progress) - case let .error(error): - self.delegate.imageTask(task, didCompleteWithResult: .failure(error)) - task.delegate?.imageTask(task, didCompleteWithResult: .failure(error)) - - completion(.failure(error)) - } - } } } - private func makeImageTask(request: ImageRequest, queue: DispatchQueue?, isDataTask: Bool = false) -> ImageTask { - let task = ImageTask(taskId: nextTaskId, request: request) - task.pipeline = self - task.callbackQueue = queue - task.isDataTask = isDataTask - return task - } - // 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: any ImageRequestConvertible, + @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 { - loadData(with: request, queue: nil, progress: nil, completion: completion) + _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:)`` for the request at any point after calling - /// ``loadData(with:completion:)``, the pipeline will use the same operation to load the data, + /// 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: @@ -340,74 +274,20 @@ final class ImagePipeline: @unchecked Sendable { /// - 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: any ImageRequestConvertible, + with request: ImageRequest, queue: DispatchQueue? = nil, - progress: ((_ completed: Int64, _ total: Int64) -> Void)?, - completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void - ) -> ImageTask { - loadData(with: request, isConfined: false, queue: queue, progress: progress, completion: completion) - } - - func loadData( - with request: any ImageRequestConvertible, - isConfined: Bool, - queue: DispatchQueue?, - progress: ((_ completed: Int64, _ total: Int64) -> Void)?, - completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void - ) -> ImageTask { - let task = makeImageTask(request: request.asImageRequest(), queue: queue, isDataTask: true) - func start() { - startDataTask(task, progress: progress, completion: completion) - } - if isConfined { - start() - } else { - self.queue.async { start() } - } - return task - } - - private func startDataTask( - _ task: ImageTask, progress progressHandler: ((_ completed: Int64, _ total: Int64) -> Void)?, completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void - ) { - guard !isInvalidated else { - dispatchCallback(to: task.callbackQueue) { - let error = Error.pipelineInvalidated - self.delegate.imageTask(task, didCompleteWithResult: .failure(error)) - task.delegate?.imageTask(task, didCompleteWithResult: .failure(error)) - - completion(.failure(error)) + ) -> 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) } - return + completion(result) } - - 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 { - task.didComplete() - self.tasks[task] = nil - } - - self.dispatchCallback(to: task.callbackQueue) { - guard task.state != .cancelled else { return } - - switch event { - case let .value(response, isCompleted): - if isCompleted { - completion(.success(response)) - } - case let .progress(progress): - task.progress = progress - progressHandler?(progress.completed, progress.total) - case let .error(error): - completion(.failure(error)) - } - } - } } // MARK: - Loading Images (Combine) @@ -422,24 +302,53 @@ final class ImagePipeline: @unchecked Sendable { ImagePublisher(request: request, pipeline: self).eraseToAnyPublisher() } - // MARK: - Image Task Events + // MARK: - ImageTask (Internal) - func imageTaskCancelCalled(_ task: ImageTask) { - queue.async { - self.cancel(task) + 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 } - private func cancel(_ task: ImageTask) { - guard let subscription = tasks.removeValue(forKey: task) else { return } - dispatchCallback(to: task.callbackQueue) { - if !task.isDataTask { - self.delegate.imageTaskDidCancel(task) - task.delegate?.imageTaskDidCancel(task) - } - task.onCancel?() // Order is important + // 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) } - subscription.unsubscribe() + 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) { @@ -448,11 +357,25 @@ final class ImagePipeline: @unchecked Sendable { } } - private func dispatchCallback(to callbackQueue: DispatchQueue?, _ closure: @escaping () -> Void) { - if callbackQueue === self.queue { - closure() - } else { - (callbackQueue ?? self.configuration.callbackQueue).async(execute: closure) + 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) + } } } @@ -463,12 +386,11 @@ final class ImagePipeline: @unchecked Sendable { // // `loadImage()` call is represented by TaskLoadImage: // - // TaskLoadImage -> TaskFetchDecodedImage -> TaskFetchOriginalImageData - // -> TaskProcessImage + // TaskLoadImage -> TaskFetchOriginalImage -> TaskFetchOriginalData // // `loadData()` call is represented by TaskLoadData: // - // TaskLoadData -> TaskFetchOriginalImageData + // TaskLoadData -> TaskFetchOriginalData // // // Each task represents a resource or a piece of work required to produce the @@ -478,34 +400,40 @@ final class ImagePipeline: @unchecked Sendable { // is created. The work is split between tasks to minimize any duplicated work. func makeTaskLoadImage(for request: ImageRequest) -> AsyncTask.Publisher { - tasksLoadImage.publisherForKey(request.makeImageLoadKey()) { + tasksLoadImage.publisherForKey(TaskLoadImageKey(request)) { TaskLoadImage(self, request) } } - func makeTaskLoadData(for request: ImageRequest) -> AsyncTask<(Data, URLResponse?), Error>.Publisher { - tasksLoadData.publisherForKey(request.makeImageLoadKey()) { + func makeTaskLoadData(for request: ImageRequest) -> AsyncTask.Publisher { + tasksLoadData.publisherForKey(TaskLoadImageKey(request)) { TaskLoadData(self, request) } } - func makeTaskProcessImage(key: ImageProcessingKey, process: @escaping () throws -> ImageResponse) -> AsyncTask.Publisher { - tasksProcessImage.publisherForKey(key) { - OperationTask(self, configuration.imageProcessingQueue, process) + func makeTaskFetchOriginalImage(for request: ImageRequest) -> AsyncTask.Publisher { + tasksFetchOriginalImage.publisherForKey(TaskFetchOriginalImageKey(request)) { + TaskFetchOriginalImage(self, request) } } - func makeTaskFetchDecodedImage(for request: ImageRequest) -> AsyncTask.Publisher { - tasksFetchDecodedImage.publisherForKey(request.makeDecodedImageLoadKey()) { - TaskFetchDecodedImage(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) } } - func makeTaskFetchOriginalImageData(for request: ImageRequest) -> AsyncTask<(Data, URLResponse?), Error>.Publisher { - tasksFetchOriginalImageData.publisherForKey(request.makeDataLoadKey()) { - request.publisher == nil ? - TaskFetchOriginalImageData(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/StreamChatSwiftUI/StreamNuke/Nuke/Prefetching/ImagePrefetcher.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Prefetching/ImagePrefetcher.swift index 795075d73..779fd4eee 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Prefetching/ImagePrefetcher.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Prefetching/ImagePrefetcher.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -12,12 +12,6 @@ import Foundation /// All ``ImagePrefetcher`` methods are thread-safe and are optimized to be used /// even from the main thread during scrolling. final class ImagePrefetcher: @unchecked Sendable { - private let pipeline: ImagePipeline - private var tasks = [ImageLoadKey: Task]() - private let destination: Destination - let queue = OperationQueue() // internal for testing - var didComplete: (() -> Void)? // called when # of in-flight tasks decrements to 0 - /// Pauses the prefetching. /// /// - note: When you pause, the prefetcher will finish outstanding tasks @@ -36,7 +30,6 @@ final class ImagePrefetcher: @unchecked Sendable { pipeline.queue.async { self.didUpdatePriority(to: newValue) } } } - private var _priority: ImageRequest.Priority = .low /// Prefetching destination. enum Destination: Sendable { @@ -52,6 +45,19 @@ final class ImagePrefetcher: @unchecked Sendable { case diskCache } + /// 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: @@ -65,23 +71,17 @@ final class ImagePrefetcher: @unchecked Sendable { 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. @@ -103,46 +103,42 @@ final class ImagePrefetcher: @unchecked Sendable { /// See also ``startPrefetching(with:)-1jef2`` that works with `URL`. func startPrefetching(with requests: [ImageRequest]) { pipeline.queue.async { - for request in requests { - var request = request - if self._priority != request.priority { - 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 } @@ -150,9 +146,14 @@ final class ImagePrefetcher: @unchecked Sendable { 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 @@ -180,7 +181,7 @@ final class ImagePrefetcher: @unchecked Sendable { } private func _stopPrefetching(with request: ImageRequest) { - if let task = tasks.removeValue(forKey: request.makeImageLoadKey()) { + if let task = tasks.removeValue(forKey: TaskLoadImageKey(request)) { task.cancel() } } @@ -202,13 +203,13 @@ final class ImagePrefetcher: @unchecked Sendable { } private final class Task: @unchecked Sendable { - let key: ImageLoadKey + 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/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageDecompression.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageDecompression.swift index d9db20145..2dfade350 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageDecompression.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageDecompression.swift @@ -1,10 +1,13 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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 @@ -12,17 +15,18 @@ enum ImageDecompression { // MARK: Managing Decompression State - static var isDecompressionNeededAK = "ImageDecompressor.isDecompressionNeeded.AssociatedKey" +#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) { - withUnsafePointer(to: &isDecompressionNeededAK) { keyPointer in - objc_setAssociatedObject(image, keyPointer, isDecompressionNeeded, .OBJC_ASSOCIATION_RETAIN) - } + objc_setAssociatedObject(image, isDecompressionNeededAK, isDecompressionNeeded, .OBJC_ASSOCIATION_RETAIN) } static func isDecompressionNeeded(for image: PlatformImage) -> Bool? { - return withUnsafePointer(to: &isDecompressionNeededAK) { keyPointer in - objc_getAssociatedObject(image, keyPointer) as? Bool - } + objc_getAssociatedObject(image, isDecompressionNeededAK) as? Bool } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessing.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessing.swift index ceb4b18a5..0b5f065ae 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessing.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessing.swift @@ -1,9 +1,15 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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: diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessingOptions.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessingOptions.swift index b71b6151d..67a817eee 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessingOptions.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessingOptions.swift @@ -1,15 +1,15 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation -#if os(iOS) || os(tvOS) || os(watchOS) +#if canImport(UIKit) import UIKit #endif -#if os(macOS) -import Cocoa +#if canImport(AppKit) +import AppKit #endif /// A namespace with shared image processing options. @@ -37,7 +37,7 @@ enum ImageProcessingOptions: Sendable { struct Border: Hashable, CustomStringConvertible, @unchecked Sendable { let width: CGFloat - #if os(iOS) || os(tvOS) || os(watchOS) +#if canImport(UIKit) let color: UIColor /// - parameters: @@ -48,7 +48,7 @@ enum ImageProcessingOptions: Sendable { self.color = color self.width = width.converted(to: unit) } - #else +#else let color: NSColor /// - parameters: @@ -59,10 +59,28 @@ enum ImageProcessingOptions: Sendable { self.color = color self.width = width.converted(to: unit) } - #endif +#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/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Anonymous.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Anonymous.swift index 79f130ceb..3c40d71d2 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Anonymous.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Anonymous.swift @@ -1,9 +1,15 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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 { diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Circle.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Circle.swift index 738d0b22a..1d185e988 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Circle.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Circle.swift @@ -1,9 +1,15 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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, diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Composition.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Composition.swift index b2367638e..a4744a076 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Composition.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Composition.swift @@ -1,9 +1,15 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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 { diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+CoreImage.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+CoreImage.swift index d9fc8ac4c..fc056b897 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+CoreImage.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+CoreImage.swift @@ -1,12 +1,18 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -#if os(iOS) || os(tvOS) || os(macOS) +#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. @@ -21,40 +27,63 @@ extension ImageProcessors { /// - [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 name: String - let parameters: [String: Any] + 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.name = name - self.parameters = parameters + self.filter = .named(name, parameters: parameters) self.identifier = identifier } + /// Initializes the processor with a name of the `CIFilter`. init(name: String) { - self.name = name - self.parameters = [:] + 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(_:)) + try container.map(_process) } private func _process(_ image: PlatformImage) throws -> PlatformImage { - try CoreImageFilter.applyFilter(named: name, parameters: parameters, to: image) + 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(options: [.priorityRequestLow: true]) + static var context: CIContext { + get { _context.value } + set { _context.value = newValue } + } + + private static let _context = Atomic(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 { @@ -85,10 +114,15 @@ extension ImageProcessors { } var description: String { - "CoreImageFilter(name: \(name), parameters: \(parameters))" + 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 { + enum Error: Swift.Error, CustomStringConvertible, @unchecked Sendable { case failedToCreateFilter(name: String, parameters: [String: Any]) case inputImageIsEmpty(inputImage: PlatformImage) case failedToApplyFilter(filter: CIFilter) diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+GaussianBlur.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+GaussianBlur.swift index 6cc827c26..1921c7889 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+GaussianBlur.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+GaussianBlur.swift @@ -1,12 +1,18 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -#if os(iOS) || os(tvOS) || os(macOS) +#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 { diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Resize.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Resize.swift index 9732578c3..7431c5f3a 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Resize.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Resize.swift @@ -1,35 +1,27 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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: Size - private let contentMode: ContentMode + private let size: ImageTargetSize + private let contentMode: ImageProcessingOptions.ContentMode private let crop: Bool private let upscale: Bool - /// 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" - } - } - } + // Deprecated in Nuke 12.0 + @available(*, deprecated, message: "Renamed to `ImageProcessingOptions.ContentMode") + typealias ContentMode = ImageProcessingOptions.ContentMode /// Initializes the processor with the given size. /// @@ -40,8 +32,8 @@ extension ImageProcessors { /// - 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: ContentMode = .aspectFill, crop: Bool = false, upscale: Bool = false) { - self.size = Size(size: size, unit: unit) + 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 @@ -85,7 +77,7 @@ extension ImageProcessors { } // Adds Hashable without making changes to CGSize API -private struct Size: Hashable { +struct ImageTargetSize: Hashable { let cgSize: CGSize /// Creates the size in pixels by scaling to the input size to the screen scale diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+RoundedCorners.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+RoundedCorners.swift index d60ba88e9..d43ec4a1e 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+RoundedCorners.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+RoundedCorners.swift @@ -1,10 +1,16 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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. /// diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors.swift index 68bd86ef8..8223ea49c 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors.swift @@ -1,15 +1,15 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation -#if os(iOS) || os(tvOS) || os(watchOS) +#if canImport(UIKit) import UIKit #endif -#if os(macOS) -import Cocoa +#if canImport(AppKit) +import AppKit #endif /// A namespace for all processors that implement ``ImageProcessing`` protocol. @@ -20,12 +20,12 @@ extension ImageProcessing where Self == ImageProcessors.Resize { /// /// - parameters /// - size: The target size. - /// - unit: Unit of 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: ImageProcessors.Resize.ContentMode = .aspectFill, crop: Bool = false, upscale: Bool = false) -> ImageProcessors.Resize { + 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) } @@ -33,7 +33,7 @@ extension ImageProcessing where Self == ImageProcessors.Resize { /// /// - parameters: /// - width: The target width. - /// - unit: Unit of the target size. + /// - 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) @@ -43,7 +43,7 @@ extension ImageProcessing where Self == ImageProcessors.Resize { /// /// - parameters: /// - height: The target height. - /// - unit: Unit of the target size. + /// - 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) @@ -86,7 +86,7 @@ extension ImageProcessing where Self == ImageProcessors.Anonymous { } } -#if os(iOS) || os(tvOS) || os(macOS) +#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) extension ImageProcessing where Self == ImageProcessors.CoreImageFilter { /// Applies Core Image filter – `CIFilter` – to the image. @@ -101,6 +101,10 @@ extension ImageProcessing where Self == ImageProcessors.CoreImageFilter { 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 { diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/AsyncPipelineTask.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/AsyncPipelineTask.swift new file mode 100644 index 000000000..2865e3bae --- /dev/null +++ b/Sources/StreamChatSwiftUI/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/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/AsyncTask.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/AsyncTask.swift index f4c555ea1..e381f51fe 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/AsyncTask.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/AsyncTask.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -50,7 +50,6 @@ class AsyncTask: AsyncTaskSubscriptionDelegate guard oldValue != priority else { return } operation?.queuePriority = priority.queuePriority dependency?.setPriority(priority) - dependency2?.setPriority(priority) } } @@ -64,14 +63,6 @@ class AsyncTask: AsyncTaskSubscriptionDelegate } } - // 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 AsyncTask: AsyncTaskSubscriptionDelegate /// Publishes the results of the task. var publisher: Publisher { Publisher(task: self) } - #if TRACK_ALLOCATIONS - deinit { - Allocations.decrement("AsyncTask") - } - - init() { - Allocations.increment("AsyncTask") - } - #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 AsyncTask: AsyncTaskSubscriptionDelegate // The task may have been completed synchronously by `starter`. guard !isDisposed else { return nil } - return subscription } @@ -184,7 +164,7 @@ class AsyncTask: AsyncTaskSubscriptionDelegate } inlineSubscription?.closure(event) - if let subscriptions = subscriptions { + if let subscriptions { for subscription in subscriptions.values { subscription.closure(event) } @@ -204,7 +184,6 @@ class AsyncTask: AsyncTaskSubscriptionDelegate if reason == .cancelled { operation?.cancel() dependency?.unsubscribe() - dependency2?.unsubscribe() onCancelled?() } onDisposed?() @@ -213,7 +192,7 @@ class AsyncTask: AsyncTaskSubscriptionDelegate // 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 AsyncTask: AsyncTaskSubscriptionDelegate 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 @@ -244,7 +223,7 @@ extension 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) } @@ -253,7 +232,7 @@ extension AsyncTask { /// - notes: Returns `nil` if the task is already disposed. 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) @@ -293,14 +272,6 @@ extension AsyncTask { 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 - } - } } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/ImagePipelineTask.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/ImagePipelineTask.swift deleted file mode 100644 index 1b776f788..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/ImagePipelineTask.swift +++ /dev/null @@ -1,43 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2022 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: AsyncTask { - 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: @Sendable @escaping () -> Void) { - pipeline.queue.async { 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/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/OperationTask.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/OperationTask.swift deleted file mode 100644 index 606f17c3d..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/OperationTask.swift +++ /dev/null @@ -1,35 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// A one-shot task for performing a single () -> T function. -final class OperationTask: AsyncTask { - private let pipeline: ImagePipeline - private let queue: OperationQueue - private let process: () throws -> T - - init(_ pipeline: ImagePipeline, _ queue: OperationQueue, _ process: @escaping () throws -> 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 result = Result(catching: { try self.process() }) - self.pipeline.queue.async { - switch result { - case .success(let value): - self.send(value: value, isCompleted: true) - case .failure(let error): - self.send(error: error) - } - } - } - } - - struct Error: Swift.Error {} -} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImageData.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalData.swift similarity index 76% rename from Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImageData.swift rename to Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalData.swift index 35518ef98..519330f22 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImageData.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalData.swift @@ -1,29 +1,39 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 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 var data = Data() override func start() { - guard let urlRequest = request.urlRequest else { + 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(urlRequest: urlRequest) @@ -41,10 +51,10 @@ final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> // 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 { + guard let self else { return finish() } - self.async { + self.pipeline.queue.async { self.loadData(urlRequest: urlRequest, finish: finish) } } @@ -72,21 +82,21 @@ final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> 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 } + guard let self else { return } signpost(self, "LoadImageData", .end, "Finished with size \(Formatter.bytes(self.data.count))") - self.async { + self.pipeline.queue.async { self.dataTaskDidFinish(error: error) } }) onCancelled = { [weak self] in - guard let self = self else { return } + guard let self else { return } signpost(self, "LoadImageData", .end, "Cancelled") dataTask.cancel() @@ -100,7 +110,7 @@ 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(self, "LoadImageData", .event, "Resumed with data \(Formatter.bytes(resumedDataCount))") @@ -109,7 +119,11 @@ final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> } // 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) @@ -124,7 +138,7 @@ final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> } private func dataTaskDidFinish(error: Swift.Error?) { - if let error = error { + if let error { tryToSaveResumableData() send(error: .dataLoadingFailed(error: error)) return @@ -153,8 +167,9 @@ final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> } } -extension ImagePipelineTask where Value == (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 } @@ -166,14 +181,32 @@ extension ImagePipelineTask where Value == (Data, URLResponse?) { } } + /// 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 { - guard (request.url?.isCacheable ?? false) || (request.publisher != nil) else { + let imageTasks = imageTasks + guard imageTasks.contains(where: { !$0.request.options.contains(.disableDiskCacheWrites) }) else { return false } - let policy = pipeline.configuration.dataCachePolicy - guard imageTasks.contains(where: { !$0.request.options.contains(.disableDiskCacheWrites) }) else { + 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 } - return policy == .storeOriginalData || policy == .storeAll || (policy == .automatic && imageTasks.contains { $0.request.processors.isEmpty }) } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchDecodedImage.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImage.swift similarity index 52% rename from Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchDecodedImage.swift rename to Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImage.swift index 82a36efcd..1f9901de2 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchDecodedImage.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImage.swift @@ -1,20 +1,20 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation -/// Receives data from ``TaskLoadImageData` and decodes it as it arrives. -final class TaskFetchDecodedImage: ImagePipelineTask { +/// 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.makeTaskFetchOriginalImageData(for: request).subscribe(self) { [weak self] in + dependency = pipeline.makeTaskFetchOriginalData(for: request).subscribe(self) { [weak self] in self?.didReceiveData($0.0, urlResponse: $0.1, isCompleted: $1) } } - /// Receiving data from `OriginalDataTask`. + /// Receiving data from `TaskFetchOriginalData`. private func didReceiveData(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool) { guard isCompleted || pipeline.configuration.isProgressiveDecodingEnabled else { return @@ -28,7 +28,7 @@ final class TaskFetchDecodedImage: ImagePipelineTask { operation?.cancel() // Cancel any potential pending progressive decoding tasks } - let context = ImageDecodingContext(request: request, data: data, isCompleted: isCompleted, urlResponse: urlResponse, cacheType: nil) + 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)) @@ -38,35 +38,20 @@ final class TaskFetchDecodedImage: ImagePipelineTask { return } - // Fast-track default decoders, most work is already done during - // initialization anyway. - @Sendable func decode() -> Result { - signpost("DecodeImageData", isCompleted ? "FinalImage" : "ProgressiveImage") { - Result(catching: { try decoder.decode(context) }) - } - } - - if !decoder.isAsynchronous { - didFinishDecoding(decoder: decoder, context: context, result: decode()) - } else { - operation = pipeline.configuration.imageDecodingQueue.add { [weak self] in - guard let self = self else { return } - - let result = decode() - self.async { - self.didFinishDecoding(decoder: decoder, context: context, result: result) - } - } + decode(context, decoder: decoder) { [weak self] in + self?.didFinishDecoding(context: context, result: $0) } } - private func didFinishDecoding(decoder: any ImageDecoding, context: ImageDecodingContext, result: Result) { + 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: .decodingFailed(decoder: decoder, context: context, error: error)) + send(error: error) } } } @@ -74,7 +59,7 @@ final class TaskFetchDecodedImage: ImagePipelineTask { // 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 = self.decoder { + if let decoder { return decoder } let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchWithPublisher.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchWithPublisher.swift index 3fe422a6b..19faec294 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchWithPublisher.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchWithPublisher.swift @@ -1,12 +1,12 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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: ImagePipelineTask<(Data, URLResponse?)> { +final class TaskFetchWithPublisher: AsyncPipelineTask<(Data, URLResponse?)>, @unchecked Sendable { private lazy var data = Data() override func start() { @@ -16,10 +16,10 @@ final class TaskFetchWithPublisher: ImagePipelineTask<(Data, URLResponse?)> { // 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 { + guard let self else { return finish() } - self.async { + self.pipeline.queue.async { self.loadData { finish() } } } @@ -39,13 +39,13 @@ final class TaskFetchWithPublisher: ImagePipelineTask<(Data, URLResponse?)> { let cancellable = publisher.sink(receiveCompletion: { [weak self] result in finish() // Finish the operation! - guard let self = self else { return } - self.async { + guard let self else { return } + self.pipeline.queue.async { self.dataTaskDidFinish(result) } }, receiveValue: { [weak self] data in - guard let self = self else { return } - self.async { + guard let self else { return } + self.pipeline.queue.async { self.data.append(data) } }) diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadData.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadData.swift index 5b849f883..c571c666e 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadData.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadData.swift @@ -1,32 +1,18 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation /// Wrapper for tasks created by `loadData` calls. -final class TaskLoadData: ImagePipelineTask<(Data, URLResponse?)> { +final class TaskLoadData: AsyncPipelineTask, @unchecked Sendable { 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: any DataCaching) { - let data = signpost("ReadCachedImageData") { - pipeline.cache.cachedData(for: request) - } - async { - if let data = data { - self.send(value: (data, nil), isCompleted: true) - } else { - self.loadData() - } + 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() } } @@ -34,14 +20,17 @@ final class TaskLoadData: ImagePipelineTask<(Data, URLResponse?)> { guard !request.options.contains(.returnCacheDataDontLoad) else { return send(error: .dataMissingInCache) } - - let request = self.request.withProcessors([]) - dependency = pipeline.makeTaskFetchOriginalImageData(for: request).subscribe(self) { [weak self] in + 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) { - send(value: (data, urlResponse), isCompleted: isCompleted) + 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/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadImage.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadImage.swift index d4fa9a599..2f4b6b7e2 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadImage.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadImage.swift @@ -1,84 +1,43 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// 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 coalesing for image processing is implemented on demand (extends the +/// The coalescing for image processing is implemented on demand (extends the /// scenarios in which coalescing can kick in). -final class TaskLoadImage: ImagePipelineTask { +final class TaskLoadImage: AsyncPipelineTask, @unchecked Sendable { override func start() { - // Memory cache lookup - if let image = pipeline.cache[request] { - let response = ImageResponse(container: image, request: request, cacheType: .memory) - send(value: response, isCompleted: !image.isPreview) - if !image.isPreview { - return // Already got the result! + 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 } } - - // 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: any DataCaching) { - let data = signpost("ReadCachedProcessedImageData") { - pipeline.cache.cachedData(for: request) - } - async { - if let data = data { - self.didReceiveCachedData(data) - } else { - self.fetchImage() - } + if let data = pipeline.cache.cachedData(for: request) { + decodeCachedData(data) + } else { + fetchImage() } } - private func didReceiveCachedData(_ data: Data) { - guard !isDisposed else { return } - - let context = ImageDecodingContext(request: request, data: data, isCompleted: true, urlResponse: nil, cacheType: .disk) + 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 { - // This shouldn't happen in practice unless encoder/decoder pair - // for data cache is misconfigured. - return fetchImage() + return didFinishDecoding(with: nil) } - - @Sendable func decode() -> ImageResponse? { - signpost("DecodeCachedProcessedImageData") { - try? decoder.decode(context) - } - } - 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) - } - } + decode(context, decoder: decoder) { [weak self] in + self?.didFinishDecoding(with: try? $0.get()) } } - private func didDecodeCachedData(_ response: ImageResponse?) { - if let response = response { - decompressImage(response, isCompleted: true, isFromDiskCache: true) + private func didFinishDecoding(with response: ImageResponse?) { + if let response { + didReceiveImageResponse(response, isCompleted: true) } else { fetchImage() } @@ -87,164 +46,123 @@ final class TaskLoadImage: ImagePipelineTask { // 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: [any 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, request: request, 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) - } - } + guard !request.options.contains(.returnCacheDataDontLoad) else { + return send(error: .dataMissingInCache) } - - let processors: [any ImageProcessing] = request.processors.reversed() - // The only remaining choice is to fetch the image - if request.options.contains(.returnCacheDataDontLoad) { - send(error: .dataMissingInCache) - } else if request.processors.isEmpty { - dependency = pipeline.makeTaskFetchDecodedImage(for: request).subscribe(self) { [weak self] in - self?.process($0, isCompleted: $1, processors: processors) + 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 { - let request = self.request.withProcessors([]) - dependency = pipeline.makeTaskLoadImage(for: request).subscribe(self) { [weak self] in - self?.process($0, isCompleted: $1, processors: processors) + dependency = pipeline.makeTaskFetchOriginalImage(for: request).subscribe(self) { [weak self] in + self?.didReceiveImageResponse($0, isCompleted: $1) } } } // MARK: Processing - /// - parameter processors: Remaining processors to by applied - private func process(_ response: ImageResponse, isCompleted: Bool, processors: [any ImageProcessing]) { + private func process(_ response: ImageResponse, isCompleted: Bool, processor: any ImageProcessing) { + guard !isDisposed else { 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: [any ImageProcessing]) { - guard let processor = processors.last else { - self.decompressImage(response, isCompleted: isCompleted) - return + operation?.cancel() // Cancel any potential pending progressive + } else if operation != nil { + return // Back pressure - already processing another progressive image } - - let key = ImageProcessingKey(image: response, processor: processor) let context = ImageProcessingContext(request: request, response: response, isCompleted: isCompleted) - dependency2 = pipeline.makeTaskProcessImage(key: key, process: { - try signpost("ProcessImage", isCompleted ? "FinalImage" : "ProgressiveImage") { - try response.map { try processor.process($0, context: context) } + 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) + } } - }).subscribe(priority: priority) { [weak self] event in - guard let self = self else { return } - if event.isCompleted { - self.dependency2 = nil + self.pipeline.queue.async { + self.operation = nil + self.didFinishProcessing(result: result, isCompleted: isCompleted) } - switch event { - case .value(let response, _): - self._process(response, isCompleted: isCompleted, processors: processors.dropLast()) - case .error(let error): - if isCompleted { - self.send(error: .processingFailed(processor: processor, context: context, error: error)) - } - case .progress: - break // Do nothing (Not reported by OperationTask) + } + } + + 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 decompressImage(_ response: ImageResponse, isCompleted: Bool, isFromDiskCache: Bool = false) { + private func didReceiveImageResponse(_ response: ImageResponse, isCompleted: Bool) { guard isDecompressionNeeded(for: response) else { - storeImageInCaches(response, isFromDiskCache: isFromDiskCache) - send(value: response, isCompleted: isCompleted) - return + 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: we are receiving data too fast + return // Back-pressure: receiving progressive scans too fast } - - guard !isDisposed else { return } - operation = pipeline.configuration.imageDecompressingQueue.add { [weak self] in - guard let self = self else { return } - - let response = signpost("DecompressImage", isCompleted ? "FinalImage" : "ProgressiveImage") { + guard let self else { return } + let response = signpost(isCompleted ? "DecompressImage" : "DecompressProgressiveImage") { self.pipeline.delegate.decompress(response: response, request: self.request, pipeline: self.pipeline) } - - self.async { - self.storeImageInCaches(response, isFromDiskCache: isFromDiskCache) - self.send(value: response, isCompleted: isCompleted) + self.pipeline.queue.async { + self.operation = nil + self.didReceiveDecompressedImage(response, isCompleted: isCompleted) } } } private func isDecompressionNeeded(for response: ImageResponse) -> Bool { - (ImageDecompression.isDecompressionNeeded(for: response.image) ?? false) && + 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, isFromDiskCache: Bool) { - guard subscribers.contains(where: { $0 is ImageTask }) else { - return // Only store for direct requests + private func storeImageInCaches(_ response: ImageResponse) { + guard hasDirectSubscribers else { + return } - // Memory cache (ImageCaching) pipeline.cache[request] = response.container - // Disk cache (DataCaching) - if !isFromDiskCache { + if shouldStoreResponseInDataCache(response) { 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 { + 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 = pipeline else { return } + guard let pipeline else { return } let encodedData = signpost("EncodeImage") { encoder.encode(response.container, context: context) } - guard let data = encodedData else { return } + guard let data = encodedData, !data.isEmpty else { return } pipeline.delegate.willCache(data: data, image: response.container, for: request, pipeline: pipeline) { - guard let data = $0 else { return } + 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 } @@ -254,11 +172,29 @@ final class TaskLoadImage: ImagePipelineTask { } } - private func shouldStoreFinalImageInDiskCache() -> Bool { - guard request.url?.isCacheable ?? false else { + private func shouldStoreResponseInDataCache(_ response: ImageResponse) -> Bool { + guard !response.container.isPreview, + !(response.cacheType == .disk), + !(request.url?.isLocalResource ?? false) else { return false } - let policy = pipeline.configuration.dataCachePolicy - return ((policy == .automatic || policy == .storeAll) && !request.processors.isEmpty) || policy == .storeEncodedImages + 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/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageLoadingOptions.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageLoadingOptions.swift index 4baf11998..0c3dbbcaf 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageLoadingOptions.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageLoadingOptions.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -15,7 +15,7 @@ import AppKit.NSImage /// A set of options that control how the image is loaded and displayed. struct ImageLoadingOptions { /// Shared options. - static var shared = ImageLoadingOptions() + @MainActor static var shared = ImageLoadingOptions() /// Placeholder to be displayed when the image is loading. `nil` by default. var placeholder: PlatformImage? @@ -23,7 +23,7 @@ struct ImageLoadingOptions { /// Image to be displayed when the request fails. `nil` by default. var failureImage: PlatformImage? - #if os(iOS) || os(tvOS) || os(macOS) +#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. @@ -45,7 +45,7 @@ struct ImageLoadingOptions { } } - #endif +#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 @@ -66,7 +66,7 @@ struct ImageLoadingOptions { /// request. `[]` by default. var processors: [any ImageProcessing] = [] - #if os(iOS) || os(tvOS) +#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). @@ -130,9 +130,9 @@ struct ImageLoadingOptions { } } - #endif +#endif - #if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) /// - parameters: /// - placeholder: Placeholder to be displayed when the image is loading. @@ -153,7 +153,7 @@ struct ImageLoadingOptions { self.tintColors = tintColors } - #elseif os(macOS) +#elseif os(macOS) init(placeholder: NSImage? = nil, transition: Transition? = nil, failureImage: NSImage? = nil, failureImageTransition: Transition? = nil) { self.placeholder = placeholder @@ -162,20 +162,20 @@ struct ImageLoadingOptions { self.failureImageTransition = failureImageTransition } - #elseif os(watchOS) +#elseif os(watchOS) init(placeholder: UIImage? = nil, failureImage: UIImage? = nil) { self.placeholder = placeholder self.failureImage = failureImage } - #endif +#endif /// An animated image transition. struct Transition { var style: Style - #if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) enum Style { // internal representation case fadeIn(parameters: Parameters) case custom((ImageDisplayingView, UIImage) -> Void) @@ -196,7 +196,7 @@ struct ImageLoadingOptions { static func custom(_ closure: @escaping (ImageDisplayingView, UIImage) -> Void) -> Transition { Transition(style: .custom(closure)) } - #elseif os(macOS) +#elseif os(macOS) enum Style { // internal representation case fadeIn(parameters: Parameters) case custom((ImageDisplayingView, NSImage) -> Void) @@ -215,9 +215,9 @@ struct ImageLoadingOptions { static func custom(_ closure: @escaping (ImageDisplayingView, NSImage) -> Void) -> Transition { Transition(style: .custom(closure)) } - #else +#else enum Style {} - #endif +#endif } init() {} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift index 2e6aa4d88..fdb97fdc8 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation @@ -12,7 +12,7 @@ import UIKit.UIColor import AppKit.NSImage #endif -#if os(iOS) || os(tvOS) || os(macOS) +#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. @@ -28,9 +28,9 @@ import AppKit.NSImage /// Display a given image. @objc func nuke_display(image: PlatformImage?, data: Data?) - #if os(macOS) +#if os(macOS) @objc var layer: CALayer? { get } - #endif +#endif } extension Nuke_ImageDisplaying { @@ -45,7 +45,7 @@ extension Nuke_ImageDisplaying { } #endif -#if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) import UIKit /// A `UIView` that implements `ImageDisplaying` protocol. typealias ImageDisplayingView = UIView & Nuke_ImageDisplaying @@ -88,12 +88,12 @@ extension TVPosterView: Nuke_ImageDisplaying { /// See the complete method signature for more information. @MainActor @discardableResult func loadImage( - with request: (any ImageRequestConvertible)?, - options: ImageLoadingOptions = ImageLoadingOptions.shared, + with url: URL?, + options: ImageLoadingOptions? = nil, into view: ImageDisplayingView, completion: @escaping (_ result: Result) -> Void ) -> ImageTask? { - loadImage(with: request, options: options, into: view, progress: nil, completion: completion) + loadImage(with: url, options: options, into: view, progress: nil, completion: completion) } /// Loads an image with the given request and displays it in the view. @@ -121,14 +121,62 @@ extension TVPosterView: Nuke_ImageDisplaying { /// - returns: An image task or `nil` if the image was found in the memory cache. @MainActor @discardableResult func loadImage( - with request: (any ImageRequestConvertible)?, - options: ImageLoadingOptions = ImageLoadingOptions.shared, + 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: request?.asImageRequest(), options: options, progress: progress, completion: completion) + 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. @@ -150,11 +198,11 @@ private final class ImageViewController { private var task: ImageTask? private var options: ImageLoadingOptions - #if os(iOS) || os(tvOS) +#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 +#endif // Automatically cancel the request when the view is deallocated. deinit { @@ -168,20 +216,20 @@ private final class ImageViewController { // MARK: - Associating Controller - static var controllerAK = "ImageViewController.AssociatedKey" +#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 = withUnsafePointer(to: &ImageViewController.controllerAK, { keyPointer in - objc_getAssociatedObject(view, keyPointer) as? ImageViewController - }) { + if let controller = objc_getAssociatedObject(view, controllerAK) as? ImageViewController { return controller } - let controller = ImageViewController(view: view) - withUnsafePointer(to: &ImageViewController.controllerAK) { keyPointer in - objc_setAssociatedObject(view, keyPointer, controller, .OBJC_ASSOCIATION_RETAIN) - } + objc_setAssociatedObject(view, controllerAK, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) return controller } @@ -195,23 +243,23 @@ private final class ImageViewController { ) -> ImageTask? { cancelOutstandingTask() - guard let imageView = imageView else { + guard let imageView else { return nil } self.options = options if options.isPrepareForReuseEnabled { // enabled by default - #if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) imageView.layer.removeAllAnimations() - #elseif os(macOS) +#elseif os(macOS) let layer = (imageView as? NSView)?.layer ?? imageView.layer layer?.removeAllAnimations() - #endif +#endif } // Handle a scenario where request is `nil` (in the same way as a failure) - guard var request = request else { + guard var request else { if options.isPrepareForReuseEnabled { imageView.nuke_display(image: nil, data: nil) } @@ -243,7 +291,7 @@ private final class ImageViewController { } task = pipeline.loadImage(with: request, queue: .main, progress: { [weak self] response, completedCount, totalCount in - if let response = response, options.isProgressiveRenderingEnabled { + if let response, options.isProgressiveRenderingEnabled { self?.handle(partialImage: response) } progress?(response, completedCount, totalCount) @@ -277,21 +325,21 @@ private final class ImageViewController { display(response.container, false, .success) } - #if os(iOS) || os(tvOS) || os(macOS) +#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) private func display(_ image: ImageContainer, _ isFromMemory: Bool, _ response: ImageLoadingOptions.ResponseType) { - guard let imageView = imageView else { + guard let imageView else { return } var image = image - #if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) if let tintColor = options.tintColor(for: response) { image.image = image.image.withRenderingMode(.alwaysTemplate) imageView.tintColor = tintColor } - #endif +#endif if !isFromMemory || options.alwaysTransition, let transition = options.transition(for: response) { switch transition.style { @@ -306,29 +354,29 @@ private final class ImageViewController { imageView.display(image) } - #if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) if let contentMode = options.contentMode(for: response) { imageView.contentMode = contentMode } - #endif +#endif } - #elseif os(watchOS) +#elseif os(watchOS) private func display(_ image: ImageContainer, _ isFromMemory: Bool, _ response: ImageLoadingOptions.ResponseType) { imageView?.display(image) } - #endif +#endif } // MARK: - ImageViewController (Transitions) extension ImageViewController { - #if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) private func runFadeInTransition(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters, response: ImageLoadingOptions.ResponseType) { - guard let imageView = imageView else { + guard let imageView else { return } @@ -342,7 +390,7 @@ extension ImageViewController { } private func runSimpleFadeIn(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters) { - guard let imageView = imageView else { + guard let imageView else { return } @@ -357,7 +405,7 @@ extension ImageViewController { ) } - /// Performs cross-dissolve animation alonside transition to a new content + /// 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) { @@ -367,8 +415,21 @@ extension ImageViewController { // 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 + 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 @@ -383,15 +444,16 @@ extension ImageViewController { transitionView.alpha = 0 imageView.alpha = 1 }, - completion: { isCompleted in - if isCompleted { + completion: { [weak transitionView] isCompleted in + if isCompleted, let transitionView { transitionView.removeFromSuperview() + transitionView.image = nil } } ) } - #elseif os(macOS) +#elseif os(macOS) private func runFadeInTransition(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters, response: ImageLoadingOptions.ResponseType) { let animation = CABasicAnimation(keyPath: "opacity") @@ -403,7 +465,7 @@ extension ImageViewController { imageView?.display(image) } - #endif +#endif } #endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/AnimatedImageView.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/AnimatedImageView.swift deleted file mode 100644 index 44dd5d0d6..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/AnimatedImageView.swift +++ /dev/null @@ -1,26 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - -#if (os(iOS) || os(tvOS)) && !targetEnvironment(macCatalyst) -import UIKit - -final class AnimatedImageView: UIImageView, GIFAnimatable { - /// A lazy animator. - lazy var animator: Animator? = { - return Animator(withDelegate: self) - }() - - /// Layer delegate method called periodically by the layer. **Should not** be called manually. - /// - /// - parameter layer: The delegated layer. - override func display(_ layer: CALayer) { - if UIImageView.instancesRespond(to: #selector(display(_:))) { - super.display(layer) - } - updateImageIfNeeded() - } -} -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/FetchImage.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/FetchImage.swift index 584e463cc..3753b84eb 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/FetchImage.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/FetchImage.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import SwiftUI import Combine @@ -13,11 +13,13 @@ final class FetchImage: ObservableObject, Identifiable { @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 } + var image: Image? { +#if os(macOS) + imageContainer.map { Image(nsImage: $0.image) } +#else + imageContainer.map { Image(uiImage: $0.image) } +#endif + } /// Returns the fetched image. /// @@ -27,39 +29,43 @@ final class FetchImage: ObservableObject, Identifiable { @Published private(set) var imageContainer: ImageContainer? /// Returns `true` if the image is being loaded. - @Published private(set) var isLoading: Bool = false + @Published private(set) var isLoading = 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 progress of the image download. - @Published private(set) var progress = ImageTask.Progress(completed: 0, total: 0) + var transaction = Transaction(animation: nil) - /// 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 } } + /// The progress of the current image download. + var progress: Progress { + if _progress == nil { + _progress = Progress() + } + return _progress! } - /// Gets called when the request is started. - var onStart: ((ImageTask) -> Void)? - - /// Gets called when a progressive image preview is produced. - var onPreview: ((ImageResponse) -> Void)? + private var _progress: Progress? - /// Gets called when the request progress is updated. - var onProgress: ((ImageTask.Progress) -> Void)? + /// The download progress. + final class Progress: ObservableObject { + /// The number of bytes that the task has received. + @Published var completed: Int64 = 0 - /// Gets called when the requests finished successfully. - var onSuccess: ((ImageResponse) -> Void)? + /// A best-guess upper bound on the number of bytes of the resource. + @Published var total: Int64 = 0 - /// Gets called when the requests fails. - var onFailure: ((Error) -> Void)? + /// Returns the fraction of the completion. + var fraction: Float { + guard total > 0 else { return 0 } + return min(1, Float(completed) / Float(total)) + } + } - /// Gets called when the request is completed. - var onCompletion: ((Result) -> Void)? + /// 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 } } + } /// A pipeline used for performing image requests. var pipeline: ImagePipeline = .shared @@ -68,9 +74,13 @@ final class FetchImage: ObservableObject, Identifiable { /// request. `[]` by default. var processors: [any ImageProcessing] = [] - private var imageTask: ImageTask? + /// Gets called when the request is started. + var onStart: ((ImageTask) -> Void)? - // publisher support + /// Gets called when the current request is completed. + var onCompletion: ((Result) -> Void)? + + private var imageTask: ImageTask? private var lastResponse: ImageResponse? private var cancellable: AnyCancellable? @@ -78,7 +88,7 @@ final class FetchImage: ObservableObject, Identifiable { imageTask?.cancel() } - /// Initialiazes the image. To load an image, use one of the `load()` methods. + /// Initializes the image. To load an image, use one of the `load()` methods. init() {} // MARK: Loading Images @@ -94,7 +104,7 @@ final class FetchImage: ObservableObject, Identifiable { reset() - guard var request = request else { + guard var request else { handle(result: .failure(ImagePipeline.Error.imageRequestMissing)) return } @@ -102,7 +112,7 @@ final class FetchImage: ObservableObject, Identifiable { if !processors.isEmpty && request.processors.isEmpty { request.processors = processors } - if let priority = self.priority { + if let priority { request.priority = priority } @@ -118,26 +128,23 @@ final class FetchImage: ObservableObject, Identifiable { } isLoading = true - progress = ImageTask.Progress(completed: 0, total: 0) let task = pipeline.loadImage( with: request, progress: { [weak self] response, completed, total in - guard let self = self else { return } - let progress = ImageTask.Progress(completed: completed, total: total) - if let response = response { - self.onPreview?(response) - withAnimation(self.animation) { + guard let self else { return } + if let response { + withTransaction(self.transaction) { self.handle(preview: response) } } else { - self.progress = progress - self.onProgress?(progress) + self._progress?.completed = completed + self._progress?.total = total } }, completion: { [weak self] result in - guard let self = self else { return } - withAnimation(self.animation) { + guard let self else { return } + withTransaction(self.transaction) { self.handle(result: result.mapError { $0 }) } } @@ -146,12 +153,6 @@ final class FetchImage: ObservableObject, Identifiable { onStart?(task) } - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please use load() methods that work either with URL or ImageRequest.") - func load(_ request: (any ImageRequestConvertible)?) { - load(request?.asImageRequest()) - } - private func handle(preview: ImageResponse) { // Display progressively decoded image self.imageContainer = preview.container @@ -159,18 +160,13 @@ final class FetchImage: ObservableObject, Identifiable { private func handle(result: Result) { isLoading = false + imageTask = nil 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) + self.onCompletion?(result) } // MARK: Load (Async/Await) @@ -185,13 +181,14 @@ final class FetchImage: ObservableObject, Identifiable { let task = Task { do { let response = try await action() - withAnimation(animation) { + withTransaction(transaction) { handle(result: .success(response)) } } catch { handle(result: .failure(error)) } } + cancellable = AnyCancellable { task.cancel() } } @@ -208,7 +205,7 @@ final class FetchImage: ObservableObject, Identifiable { // 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 } + guard let self else { return } self.isLoading = false switch completion { case .finished: @@ -219,7 +216,7 @@ final class FetchImage: ObservableObject, Identifiable { self.result = .failure(error) } }, receiveValue: { [weak self] response in - guard let self = self else { return } + guard let self else { return } self.lastResponse = response self.imageContainer = response.container }) @@ -246,18 +243,7 @@ final class FetchImage: ObservableObject, Identifiable { if isLoading { isLoading = false } if imageContainer != nil { imageContainer = nil } if result != nil { result = nil } + if _progress != nil { _progress = nil } lastResponse = nil // publisher-only - if progress != ImageTask.Progress(completed: 0, total: 0) { progress = ImageTask.Progress(completed: 0, total: 0) } - } - - // MARK: View - - /// Returns an image view displaying a fetched image. - var view: SwiftUI.Image? { -#if os(macOS) - image.map(SwiftUI.Image.init(nsImage:)) -#else - image.map(SwiftUI.Image.init(uiImage:)) -#endif } } diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/AnimatedFrame.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/AnimatedFrame.swift deleted file mode 100644 index 8b6c13ab1..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/AnimatedFrame.swift +++ /dev/null @@ -1,31 +0,0 @@ -#if os(iOS) || os(tvOS) -import UIKit -/// Represents a single frame in a GIF. -struct AnimatedFrame { - - /// The image to display for this frame. Its value is nil when the frame is removed from the buffer. - let image: UIImage? - - /// The duration that this frame should remain active. - let duration: TimeInterval - - /// A placeholder frame with no image assigned. - /// Used to replace frames that are no longer needed in the animation. - var placeholderFrame: AnimatedFrame { - return AnimatedFrame(image: nil, duration: duration) - } - - /// Whether this frame instance contains an image or not. - var isPlaceholder: Bool { - return image == nil - } - - /// Returns a new instance from an optional image. - /// - /// - parameter image: An optional `UIImage` instance to be assigned to the new frame. - /// - returns: An `AnimatedFrame` instance. - func makeAnimatedFrame(with newImage: UIImage?) -> AnimatedFrame { - return AnimatedFrame(image: newImage, duration: duration) - } -} -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/Animator.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/Animator.swift deleted file mode 100644 index 8a8ba92a4..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/Animator.swift +++ /dev/null @@ -1,197 +0,0 @@ -#if os(iOS) || os(tvOS) -import UIKit - -/// Responsible for parsing GIF data and decoding the individual frames. -class Animator { - - /// Total duration of one animation loop - var loopDuration: TimeInterval { - return frameStore?.loopDuration ?? 0 - } - - /// Number of frame to buffer. - var frameBufferCount = 50 - - /// Specifies whether GIF frames should be resized. - var shouldResizeFrames = false - - /// Responsible for loading individual frames and resizing them if necessary. - var frameStore: FrameStore? - - /// Tracks whether the display link is initialized. - private var displayLinkInitialized: Bool = false - - /// A delegate responsible for displaying the GIF frames. - private weak var delegate: GIFAnimatable! - - private var animationBlock: (() -> Void)? = nil - - /// Responsible for starting and stopping the animation. - private lazy var displayLink: CADisplayLink = { [unowned self] in - self.displayLinkInitialized = true - let display = CADisplayLink(target: DisplayLinkProxy(target: self), selector: #selector(DisplayLinkProxy.onScreenUpdate)) - display.isPaused = true - return display - }() - - /// Introspect whether the `displayLink` is paused. - var isAnimating: Bool { - return !displayLink.isPaused - } - - /// Total frame count of the GIF. - var frameCount: Int { - return frameStore?.frameCount ?? 0 - } - - /// Creates a new animator with a delegate. - /// - /// - parameter view: A view object that implements the `GIFAnimatable` protocol. - /// - /// - returns: A new animator instance. - init(withDelegate delegate: GIFAnimatable) { - self.delegate = delegate - } - - /// Checks if there is a new frame to display. - fileprivate func updateFrameIfNeeded() { - guard let store = frameStore else { return } - if store.isFinished { - stopAnimating() - if let animationBlock = animationBlock { - animationBlock() - } - return - } - - store.shouldChangeFrame(with: displayLink.duration) { - if $0 { delegate.animatorHasNewFrame() } - } - } - - /// Prepares the animator instance for animation. - /// - /// - parameter imageName: The file name of the GIF in the specified bundle. - /// - parameter bundle: The bundle where the GIF is located (default Bundle.main). - /// - parameter size: The target size of the individual frames. - /// - parameter contentMode: The view content mode to use for the individual frames. - /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. - /// - parameter completionHandler: Completion callback function - func prepareForAnimation(withGIFNamed imageName: String, inBundle bundle: Bundle = .main, size: CGSize, contentMode: UIView.ContentMode, loopCount: Int = 0, completionHandler: (() -> Void)? = nil) { - guard let extensionRemoved = imageName.components(separatedBy: ".")[safe: 0], - let imagePath = bundle.url(forResource: extensionRemoved, withExtension: "gif"), - let data = try? Data(contentsOf: imagePath) else { return } - - prepareForAnimation(withGIFData: data, - size: size, - contentMode: contentMode, - loopCount: loopCount, - completionHandler: completionHandler) - } - - /// Prepares the animator instance for animation. - /// - /// - parameter imageData: GIF image data. - /// - parameter size: The target size of the individual frames. - /// - parameter contentMode: The view content mode to use for the individual frames. - /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. - /// - parameter completionHandler: Completion callback function - func prepareForAnimation(withGIFData imageData: Data, size: CGSize, contentMode: UIView.ContentMode, loopCount: Int = 0, completionHandler: (() -> Void)? = nil) { - frameStore = FrameStore(data: imageData, - size: size, - contentMode: contentMode, - framePreloadCount: frameBufferCount, - loopCount: loopCount) - frameStore!.shouldResizeFrames = shouldResizeFrames - frameStore!.prepareFrames(completionHandler) - attachDisplayLink() - } - - /// Add the display link to the main run loop. - private func attachDisplayLink() { - displayLink.add(to: .main, forMode: RunLoop.Mode.common) - } - - deinit { - if displayLinkInitialized { - displayLink.invalidate() - } - } - - /// Start animating. - func startAnimating() { - if frameStore?.isAnimatable ?? false { - displayLink.isPaused = false - } - } - - /// Stop animating. - func stopAnimating() { - displayLink.isPaused = true - } - - /// Prepare for animation and start animating immediately. - /// - /// - parameter imageName: The file name of the GIF in the main bundle. - /// - parameter size: The target size of the individual frames. - /// - parameter contentMode: The view content mode to use for the individual frames. - /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. - /// - parameter completionHandler: Completion callback function - func animate(withGIFNamed imageName: String, size: CGSize, contentMode: UIView.ContentMode, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil) { - self.animationBlock = animationBlock - prepareForAnimation(withGIFNamed: imageName, - size: size, - contentMode: contentMode, - loopCount: loopCount, - completionHandler: preparationBlock) - startAnimating() - } - - /// Prepare for animation and start animating immediately. - /// - /// - parameter imageData: GIF image data. - /// - parameter size: The target size of the individual frames. - /// - parameter contentMode: The view content mode to use for the individual frames. - /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. - /// - parameter completionHandler: Completion callback function - func animate(withGIFData imageData: Data, size: CGSize, contentMode: UIView.ContentMode, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil) { - self.animationBlock = animationBlock - prepareForAnimation(withGIFData: imageData, - size: size, - contentMode: contentMode, - loopCount: loopCount, - completionHandler: preparationBlock) - startAnimating() - } - - /// Stop animating and nullify the frame store. - func prepareForReuse() { - stopAnimating() - frameStore = nil - } - - /// Gets the current image from the frame store. - /// - /// - returns: An optional frame image to display. - func activeFrame() -> UIImage? { - return frameStore?.currentFrameImage - } -} - -/// A proxy class to avoid a retain cycle with the display link. -fileprivate class DisplayLinkProxy { - - /// The target animator. - private weak var target: Animator? - - /// Create a new proxy object with a target animator. - /// - /// - parameter target: An animator instance. - /// - /// - returns: A new proxy instance. - init(target: Animator) { self.target = target } - - /// Lets the target update the frame if needed. - @objc func onScreenUpdate() { target?.updateFrameIfNeeded() } -} -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/FrameStore.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/FrameStore.swift deleted file mode 100644 index 25a802e71..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/FrameStore.swift +++ /dev/null @@ -1,284 +0,0 @@ -#if os(iOS) || os(tvOS) -import ImageIO -import UIKit - -/// Responsible for storing and updating the frames of a single GIF. -class FrameStore { - - /// Total duration of one animation loop - var loopDuration: TimeInterval = 0 - - /// Flag indicating if number of loops has been reached - var isFinished: Bool = false - - /// Desired number of loops, <= 0 for infinite loop - let loopCount: Int - - /// Index of current loop - var currentLoop = 0 - - /// Maximum duration to increment the frame timer with. - let maxTimeStep = 1.0 - - /// An array of animated frames from a single GIF image. - var animatedFrames = [AnimatedFrame]() - - /// The target size for all frames. - let size: CGSize - - /// The content mode to use when resizing. - let contentMode: UIView.ContentMode - - /// Maximum number of frames to load at once - let bufferFrameCount: Int - - /// The total number of frames in the GIF. - var frameCount = 0 - - /// A reference to the original image source. - var imageSource: CGImageSource - - /// The index of the current GIF frame. - var currentFrameIndex = 0 { - didSet { - previousFrameIndex = oldValue - } - } - - /// The index of the previous GIF frame. - var previousFrameIndex = 0 { - didSet { - preloadFrameQueue.async { - self.updatePreloadedFrames() - } - } - } - - /// Time elapsed since the last frame change. Used to determine when the frame should be updated. - var timeSinceLastFrameChange: TimeInterval = 0.0 - - /// Specifies whether GIF frames should be resized. - var shouldResizeFrames = true - - /// Dispatch queue used for preloading images. - private lazy var preloadFrameQueue: DispatchQueue = { - return DispatchQueue(label: "co.kaishin.Gifu.preloadQueue") - }() - - /// The current image frame to show. - var currentFrameImage: UIImage? { - return frame(at: currentFrameIndex) - } - - /// The current frame duration - var currentFrameDuration: TimeInterval { - return duration(at: currentFrameIndex) - } - - /// Is this image animatable? - var isAnimatable: Bool { - return imageSource.isAnimatedGIF - } - - private let lock = NSLock() - - /// Creates an animator instance from raw GIF image data and an `Animatable` delegate. - /// - /// - parameter data: The raw GIF image data. - /// - parameter delegate: An `Animatable` delegate. - init(data: Data, size: CGSize, contentMode: UIView.ContentMode, framePreloadCount: Int, loopCount: Int) { - let options = [String(kCGImageSourceShouldCache): kCFBooleanFalse] as CFDictionary - self.imageSource = CGImageSourceCreateWithData(data as CFData, options) ?? CGImageSourceCreateIncremental(options) - self.size = size - self.contentMode = contentMode - self.bufferFrameCount = framePreloadCount - self.loopCount = loopCount - } - - // MARK: - Frames - /// Loads the frames from an image source, resizes them, then caches them in `animatedFrames`. - func prepareFrames(_ completionHandler: (() -> Void)? = nil) { - frameCount = Int(CGImageSourceGetCount(imageSource)) - lock.lock() - animatedFrames.reserveCapacity(frameCount) - lock.unlock() - preloadFrameQueue.async { - self.setupAnimatedFrames() - completionHandler?() - } - } - - /// Returns the frame at a particular index. - /// - /// - parameter index: The index of the frame. - /// - returns: An optional image at a given frame. - func frame(at index: Int) -> UIImage? { - lock.lock() - defer { lock.unlock() } - return animatedFrames[safe: index]?.image - } - - /// Returns the duration at a particular index. - /// - /// - parameter index: The index of the duration. - /// - returns: The duration of the given frame. - func duration(at index: Int) -> TimeInterval { - lock.lock() - defer { lock.unlock() } - return animatedFrames[safe: index]?.duration ?? TimeInterval.infinity - } - - /// Checks whether the frame should be changed and calls a handler with the results. - /// - /// - parameter duration: A `CFTimeInterval` value that will be used to determine whether frame should be changed. - /// - parameter handler: A function that takes a `Bool` and returns nothing. It will be called with the frame change result. - func shouldChangeFrame(with duration: CFTimeInterval, handler: (Bool) -> Void) { - incrementTimeSinceLastFrameChange(with: duration) - - if currentFrameDuration > timeSinceLastFrameChange { - handler(false) - } else { - resetTimeSinceLastFrameChange() - incrementCurrentFrameIndex() - handler(true) - } - } -} - -private extension FrameStore { - /// Whether preloading is needed or not. - var preloadingIsNeeded: Bool { - return bufferFrameCount < frameCount - 1 - } - - /// Optionally loads a single frame from an image source, resizes it if required, then returns an `UIImage`. - /// - /// - parameter index: The index of the frame to load. - /// - returns: An optional `UIImage` instance. - func loadFrame(at index: Int) -> UIImage? { - guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { return nil } - let image = UIImage(cgImage: imageRef) - let scaledImage: UIImage? - - if shouldResizeFrames { - switch self.contentMode { - case .scaleAspectFit: scaledImage = image.constrained(by: size) - case .scaleAspectFill: scaledImage = image.filling(size: size) - default: scaledImage = image.resized(to: size) - } - } else { - scaledImage = image - } - - return scaledImage - } - - /// Updates the frames by preloading new ones and replacing the previous frame with a placeholder. - func updatePreloadedFrames() { - if !preloadingIsNeeded { return } - lock.lock() - animatedFrames[previousFrameIndex] = animatedFrames[previousFrameIndex].placeholderFrame - lock.unlock() - - for index in preloadIndexes(withStartingIndex: currentFrameIndex) { - loadFrameAtIndexIfNeeded(index) - } - } - - func loadFrameAtIndexIfNeeded(_ index: Int) { - let frame: AnimatedFrame - lock.lock() - frame = animatedFrames[index] - lock.unlock() - if !frame.isPlaceholder { return } - let loadedFrame = frame.makeAnimatedFrame(with: loadFrame(at: index)) - lock.lock() - animatedFrames[index] = loadedFrame - lock.unlock() - } - - /// Increments the `timeSinceLastFrameChange` property with a given duration. - /// - /// - parameter duration: An `NSTimeInterval` value to increment the `timeSinceLastFrameChange` property with. - func incrementTimeSinceLastFrameChange(with duration: TimeInterval) { - timeSinceLastFrameChange += min(maxTimeStep, duration) - } - - /// Ensures that `timeSinceLastFrameChange` remains accurate after each frame change by subtracting the `currentFrameDuration`. - func resetTimeSinceLastFrameChange() { - timeSinceLastFrameChange -= currentFrameDuration - } - - /// Increments the `currentFrameIndex` property. - func incrementCurrentFrameIndex() { - currentFrameIndex = increment(frameIndex: currentFrameIndex) - if isLastLoop(loopIndex: currentLoop) && isLastFrame(frameIndex: currentFrameIndex) { - isFinished = true - } else if currentFrameIndex == 0 { - currentLoop = currentLoop + 1 - } - } - - /// Increments a given frame index, taking into account the `frameCount` and looping when necessary. - /// - /// - parameter index: The `Int` value to increment. - /// - parameter byValue: The `Int` value to increment with. - /// - returns: A new `Int` value. - func increment(frameIndex: Int, by value: Int = 1) -> Int { - return (frameIndex + value) % frameCount - } - - /// Indicates if current frame is the last one. - /// - parameter frameIndex: Index of current frame. - /// - returns: True if current frame is the last one. - func isLastFrame(frameIndex: Int) -> Bool { - return frameIndex == frameCount - 1 - } - - /// Indicates if current loop is the last one. Always false for infinite loops. - /// - parameter loopIndex: Index of current loop. - /// - returns: True if current loop is the last one. - func isLastLoop(loopIndex: Int) -> Bool { - return loopIndex == loopCount - 1 - } - - /// Returns the indexes of the frames to preload based on a starting frame index. - /// - /// - parameter index: Starting index. - /// - returns: An array of indexes to preload. - func preloadIndexes(withStartingIndex index: Int) -> [Int] { - let nextIndex = increment(frameIndex: index) - let lastIndex = increment(frameIndex: index, by: bufferFrameCount) - - if lastIndex >= nextIndex { - return [Int](nextIndex...lastIndex) - } else { - return [Int](nextIndex.. bufferFrameCount { return } - loadFrameAtIndexIfNeeded(index) - } - - self.loopDuration = duration - } - - /// Reset animated frames. - func resetAnimatedFrames() { - animatedFrames = [] - } -} -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/GIFAnimatable.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/GIFAnimatable.swift deleted file mode 100644 index 7e40a66a2..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/GIFAnimatable.swift +++ /dev/null @@ -1,208 +0,0 @@ -#if os(iOS) || os(tvOS) -import Foundation -import UIKit - -/// The protocol that view classes need to conform to to enable animated GIF support. -protocol GIFAnimatable: AnyObject { - /// Responsible for managing the animation frames. - var animator: Animator? { get set } - - /// Notifies the instance that it needs display. - var layer: CALayer { get } - - /// View frame used for resizing the frames. - var frame: CGRect { get set } - - /// Content mode used for resizing the frames. - var contentMode: UIView.ContentMode { get set } -} - -/// A single-property protocol that animatable classes can optionally conform to. -protocol _ImageContainer { - /// Used for displaying the animation frames. - var image: UIImage? { get set } -} - -extension GIFAnimatable where Self: _ImageContainer { - /// Returns the intrinsic content size based on the size of the image. - var intrinsicContentSize: CGSize { - return image?.size ?? CGSize.zero - } -} - -extension GIFAnimatable { - /// Total duration of one animation loop - var gifLoopDuration: TimeInterval { - return animator?.loopDuration ?? 0 - } - - /// Returns the active frame if available. - var activeFrame: UIImage? { - return animator?.activeFrame() - } - - /// Total frame count of the GIF. - var frameCount: Int { - return animator?.frameCount ?? 0 - } - - /// Introspect whether the instance is animating. - var isAnimatingGIF: Bool { - return animator?.isAnimating ?? false - } - - /// Prepare for animation and start animating immediately. - /// - /// - parameter imageName: The file name of the GIF in the main bundle. - /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. - /// - parameter completionHandler: Completion callback function - func animate(withGIFNamed imageName: String, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil) { - animator?.animate(withGIFNamed: imageName, - size: frame.size, - contentMode: contentMode, - loopCount: loopCount, - preparationBlock: preparationBlock, - animationBlock: animationBlock) - } - - /// Prepare for animation and start animating immediately. - /// - /// - parameter imageData: GIF image data. - /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. - /// - parameter completionHandler: Completion callback function - func animate(withGIFData imageData: Data, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil) { - animator?.animate(withGIFData: imageData, - size: frame.size, - contentMode: contentMode, - loopCount: loopCount, - preparationBlock: preparationBlock, - animationBlock: animationBlock) - } - - /// Prepare for animation and start animating immediately. - /// - /// - parameter imageURL: GIF image url. - /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. - /// - parameter completionHandler: Completion callback function - func animate(withGIFURL imageURL: URL, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil) { - let session = URLSession.shared - - let task = session.dataTask(with: imageURL) { (data, response, error) in - switch (data, response, error) { - case (.none, _, let error?): - print("Error downloading gif:", error.localizedDescription, "at url:", imageURL.absoluteString) - case (let data?, _, _): - DispatchQueue.main.async { - self.animate(withGIFData: data, loopCount: loopCount, preparationBlock: preparationBlock, animationBlock: animationBlock) - } - default: () - } - } - - task.resume() - } - - /// Prepares the animator instance for animation. - /// - /// - parameter imageName: The file name of the GIF in the main bundle. - /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. - func prepareForAnimation(withGIFNamed imageName: String, - loopCount: Int = 0, - completionHandler: (() -> Void)? = nil) { - animator?.prepareForAnimation(withGIFNamed: imageName, - size: frame.size, - contentMode: contentMode, - loopCount: loopCount, - completionHandler: completionHandler) - } - - /// Prepare for animation and start animating immediately. - /// - /// - parameter imageData: GIF image data. - /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. - func prepareForAnimation(withGIFData imageData: Data, - loopCount: Int = 0, - completionHandler: (() -> Void)? = nil) { - if var imageContainer = self as? _ImageContainer { - imageContainer.image = UIImage(data: imageData) - } - - animator?.prepareForAnimation(withGIFData: imageData, - size: frame.size, - contentMode: contentMode, - loopCount: loopCount, - completionHandler: completionHandler) - } - - /// Prepare for animation and start animating immediately. - /// - /// - parameter imageURL: GIF image url. - /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. - func prepareForAnimation(withGIFURL imageURL: URL, - loopCount: Int = 0, - completionHandler: (() -> Void)? = nil) { - let session = URLSession.shared - let task = session.dataTask(with: imageURL) { (data, response, error) in - switch (data, response, error) { - case (.none, _, let error?): - print("Error downloading gif:", error.localizedDescription, "at url:", imageURL.absoluteString) - case (let data?, _, _): - DispatchQueue.main.async { - self.prepareForAnimation(withGIFData: data, - loopCount: loopCount, - completionHandler: completionHandler) - } - default: () - } - } - - task.resume() - } - - /// Stop animating and free up GIF data from memory. - func prepareForReuse() { - animator?.prepareForReuse() - } - - /// Start animating GIF. - func startAnimatingGIF() { - animator?.startAnimating() - } - - /// Stop animating GIF. - func stopAnimatingGIF() { - animator?.stopAnimating() - } - - /// Whether the frame images should be resized or not. The default is `false`, which means that the frame images retain their original size. - /// - /// - parameter resize: Boolean value indicating whether individual frames should be resized. - func setShouldResizeFrames(_ resize: Bool) { - animator?.shouldResizeFrames = resize - } - - /// Sets the number of frames that should be buffered. Default is 50. A high number will result in more memory usage and less CPU load, and vice versa. - /// - /// - parameter frames: The number of frames to buffer. - func setFrameBufferCount(_ frames: Int) { - animator?.frameBufferCount = frames - } - - /// Updates the image with a new frame if necessary. - func updateImageIfNeeded() { - if var imageContainer = self as? _ImageContainer { - let container = imageContainer - imageContainer.image = activeFrame ?? container.image - } else { - layer.contents = activeFrame?.cgImage - } - } -} - -extension GIFAnimatable { - /// Calls setNeedsDisplay on the layer whenever the animator has a new frame. Should *not* be called directly. - func animatorHasNewFrame() { - layer.setNeedsDisplay() - } -} -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/GIFImageView.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/GIFImageView.swift deleted file mode 100644 index 4b661b4ca..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/GIFImageView.swift +++ /dev/null @@ -1,21 +0,0 @@ -#if os(iOS) || os(tvOS) -import UIKit -/// Example class that conforms to `GIFAnimatable`. Uses default values for the animator frame buffer count and resize behavior. You can either use it directly in your code or use it as a blueprint for your own subclass. -class GIFImageView: UIImageView, GIFAnimatable { - - /// A lazy animator. - lazy var animator: Animator? = { - return Animator(withDelegate: self) - }() - - /// Layer delegate method called periodically by the layer. **Should not** be called manually. - /// - /// - parameter layer: The delegated layer. - override func display(_ layer: CALayer) { - if UIImageView.instancesRespond(to: #selector(display(_:))) { - super.display(layer) - } - updateImageIfNeeded() - } -} -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/Array.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/Array.swift deleted file mode 100644 index e643e09c5..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/Array.swift +++ /dev/null @@ -1,5 +0,0 @@ -extension Array { - subscript(safe index: Int) -> Element? { - return indices ~= index ? self[index] : nil - } -} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/CGSize.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/CGSize.swift deleted file mode 100644 index 68a1e8759..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/CGSize.swift +++ /dev/null @@ -1,43 +0,0 @@ -#if os(iOS) || os(tvOS) -import Foundation -import UIKit -extension CGSize { - /// Calculates the aspect ratio of the size. - /// - /// - returns: aspectRatio The aspect ratio of the size. - var aspectRatio: CGFloat { - if height == 0 { return 1 } - return width / height - } - - /// Finds a new size constrained by a size keeping the aspect ratio. - /// - /// - parameter size: The constraining size. - /// - returns: size A new size that fits inside the constraining size with the same aspect ratio. - func constrained(by size: CGSize) -> CGSize { - let aspectWidth = round(aspectRatio * size.height) - let aspectHeight = round(size.width / aspectRatio) - - if aspectWidth > size.width { - return CGSize(width: size.width, height: aspectHeight) - } else { - return CGSize(width: aspectWidth, height: size.height) - } - } - - /// Finds a new size filling the given size while keeping the aspect ratio. - /// - /// - parameter size: The constraining size. - /// - returns: size A new size that fills the constraining size keeping the same aspect ratio. - func filling(_ size: CGSize) -> CGSize { - let aspectWidth = round(aspectRatio * size.height) - let aspectHeight = round(size.width / aspectRatio) - - if aspectWidth > size.width { - return CGSize(width: aspectWidth, height: size.height) - } else { - return CGSize(width: size.width, height: aspectHeight) - } - } -} -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/UIImage.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/UIImage.swift deleted file mode 100644 index c1368ac8f..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/UIImage.swift +++ /dev/null @@ -1,52 +0,0 @@ -#if os(iOS) || os(tvOS) -import UIKit -/// A `UIImage` extension that makes it easier to resize the image and inspect its size. -extension UIImage { - /// Resizes an image instance. - /// - /// - parameter size: The new size of the image. - /// - returns: A new resized image instance. - func resized(to size: CGSize) -> UIImage { - UIGraphicsBeginImageContextWithOptions(size, false, 0.0) - self.draw(in: CGRect(origin: CGPoint.zero, size: size)) - let newImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - return newImage ?? self - } - - /// Resizes an image instance to fit inside a constraining size while keeping the aspect ratio. - /// - /// - parameter size: The constraining size of the image. - /// - returns: A new resized image instance. - func constrained(by constrainingSize: CGSize) -> UIImage { - let newSize = size.constrained(by: constrainingSize) - return resized(to: newSize) - } - - /// Resizes an image instance to fill a constraining size while keeping the aspect ratio. - /// - /// - parameter size: The constraining size of the image. - /// - returns: A new resized image instance. - func filling(size fillingSize: CGSize) -> UIImage { - let newSize = size.filling(fillingSize) - return resized(to: newSize) - } - - /// Returns a new `UIImage` instance using raw image data and a size. - /// - /// - parameter data: Raw image data. - /// - parameter size: The size to be used to resize the new image instance. - /// - returns: A new image instance from the passed in data. - class func image(with data: Data, size: CGSize) -> UIImage? { - return UIImage(data: data)?.resized(to: size) - } - - /// Returns an image size from raw image data. - /// - /// - parameter data: Raw image data. - /// - returns: The size of the image contained in the data. - class func size(withImageData data: Data) -> CGSize? { - return UIImage(data: data)?.size - } -} -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/UIImageView.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/UIImageView.swift deleted file mode 100644 index 7a0dc2abd..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/UIImageView.swift +++ /dev/null @@ -1,5 +0,0 @@ -#if os(iOS) || os(tvOS) -/// Makes `UIImageView` conform to `ImageContainer` -import UIKit -extension UIImageView: _ImageContainer {} -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Helpers/ImageSourceHelpers.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Helpers/ImageSourceHelpers.swift deleted file mode 100755 index ff1af90fc..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Helpers/ImageSourceHelpers.swift +++ /dev/null @@ -1,85 +0,0 @@ -#if os(iOS) || os(tvOS) -import ImageIO -import MobileCoreServices -import UIKit - -typealias GIFProperties = [String: Double] - -/// Most GIFs run between 15 and 24 Frames per second. -/// -/// If a GIF does not have (frame-)durations stored in its metadata, -/// this default framerate is used to calculate the GIFs duration. -private let defaultFrameRate: Double = 15.0 - -/// Default Fallback Frame-Duration based on `defaultFrameRate` -private let defaultFrameDuration: Double = 1 / defaultFrameRate - -/// Threshold used in `capDuration` for a FrameDuration -private let capDurationThreshold: Double = 0.02 - Double.ulpOfOne - -/// Frameduration used, if a frame-duration is below `capDurationThreshold` -private let minFrameDuration: Double = 0.1 - -/// Returns the duration of a frame at a specific index using an image source (an `CGImageSource` instance). -/// -/// - returns: A frame duration. -func CGImageFrameDuration(with imageSource: CGImageSource, atIndex index: Int) -> TimeInterval { - guard imageSource.isAnimatedGIF else { return 0.0 } - - // Return nil, if the properties do not store a FrameDuration or FrameDuration <= 0 - guard let GIFProperties = imageSource.properties(at: index), - let duration = frameDuration(with: GIFProperties), - duration > 0 else { return defaultFrameDuration } - - return capDuration(with: duration) -} - -/// Ensures that a duration is never smaller than a threshold value. -/// -/// - returns: A capped frame duration. -func capDuration(with duration: Double) -> Double { - let cappedDuration = duration < capDurationThreshold ? 0.1 : duration - return cappedDuration -} - -/// Returns a frame duration from a `GIFProperties` dictionary. -/// -/// - returns: A frame duration. -func frameDuration(with properties: GIFProperties) -> Double? { - guard let unclampedDelayTime = properties[String(kCGImagePropertyGIFUnclampedDelayTime)], - let delayTime = properties[String(kCGImagePropertyGIFDelayTime)] - else { return nil } - - return duration(withUnclampedTime: unclampedDelayTime, andClampedTime: delayTime) -} - -/// Calculates frame duration based on both clamped and unclamped times. -/// -/// - returns: A frame duration. -func duration(withUnclampedTime unclampedDelayTime: Double, andClampedTime delayTime: Double) -> Double? { - let delayArray = [unclampedDelayTime, delayTime] - return delayArray.filter({ $0 >= 0 }).first -} - -/// An extension of `CGImageSourceRef` that adds GIF introspection and easier property retrieval. -extension CGImageSource { - /// Returns whether the image source contains an animated GIF. - /// - /// - returns: A boolean value that is `true` if the image source contains animated GIF data. - var isAnimatedGIF: Bool { - let isTypeGIF = UTTypeConformsTo(CGImageSourceGetType(self) ?? "" as CFString, kUTTypeGIF) - let imageCount = CGImageSourceGetCount(self) - return isTypeGIF != false && imageCount > 1 - } - - /// Returns the GIF properties at a specific index. - /// - /// - parameter index: The index of the GIF properties to retrieve. - /// - returns: A dictionary containing the GIF properties at the passed in index. - func properties(at index: Int) -> GIFProperties? { - guard let imageProperties = CGImageSourceCopyPropertiesAtIndex(self, index, nil) as? [String: AnyObject] else { return nil } - return imageProperties[String(kCGImagePropertyGIFDictionary)] as? GIFProperties - } -} - -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Image.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Image.swift deleted file mode 100644 index 13330fcc3..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Image.swift +++ /dev/null @@ -1,112 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import SwiftUI - -#if os(macOS) -import AppKit -#else -import UIKit -#endif - -#if os(macOS) -/// Displays images. Supports animated images and video playback. -@MainActor -struct NukeImage: NSViewRepresentable { - let imageContainer: ImageContainer - let onCreated: ((ImageView) -> Void)? - var isAnimatedImageRenderingEnabled: Bool? - var isVideoRenderingEnabled: Bool? - var isVideoLooping: Bool? - var resizingMode: ImageResizingMode? - - init(_ image: NSImage) { - self.init(ImageContainer(image: image)) - } - - init(_ imageContainer: ImageContainer, onCreated: ((ImageView) -> Void)? = nil) { - self.imageContainer = imageContainer - self.onCreated = onCreated - } - - func makeNSView(context: Context) -> ImageView { - let view = ImageView() - onCreated?(view) - return view - } - - func updateNSView(_ imageView: ImageView, context: Context) { - updateImageView(imageView) - } -} -#elseif os(iOS) || os(tvOS) -/// Displays images. Supports animated images and video playback. -@MainActor -struct NukeImage: UIViewRepresentable { - let imageContainer: ImageContainer - let onCreated: ((ImageView) -> Void)? - var isAnimatedImageRenderingEnabled: Bool? - var isVideoRenderingEnabled: Bool? - var isVideoLooping: Bool? - var resizingMode: ImageResizingMode? - - init(_ image: UIImage) { - self.init(ImageContainer(image: image)) - } - - init(_ imageContainer: ImageContainer, onCreated: ((ImageView) -> Void)? = nil) { - self.imageContainer = imageContainer - self.onCreated = onCreated - } - - func makeUIView(context: Context) -> ImageView { - let imageView = ImageView() - onCreated?(imageView) - return imageView - } - - func updateUIView(_ imageView: ImageView, context: Context) { - updateImageView(imageView) - } -} -#endif - -#if os(macOS) || os(iOS) || os(tvOS) -extension NukeImage { - func updateImageView(_ imageView: ImageView) { - if imageView.imageContainer?.image !== imageContainer.image { - imageView.imageContainer = imageContainer - } - if let value = resizingMode { imageView.resizingMode = value } - if let value = isVideoRenderingEnabled { imageView.isVideoRenderingEnabled = value } - if let value = isAnimatedImageRenderingEnabled { imageView.isAnimatedImageRenderingEnabled = value } - if let value = isVideoLooping { imageView.isVideoLooping = value } - } - - /// Sets the resizing mode for the image. - func resizingMode(_ mode: ImageResizingMode) -> Self { - var copy = self - copy.resizingMode = mode - return copy - } - - func videoRenderingEnabled(_ isEnabled: Bool) -> Self { - var copy = self - copy.isVideoRenderingEnabled = isEnabled - return copy - } - - func videoLoopingEnabled(_ isEnabled: Bool) -> Self { - var copy = self - copy.isVideoLooping = isEnabled - return copy - } - - func animatedImageRenderingEnabled(_ isEnabled: Bool) -> Self { - var copy = self - copy.isAnimatedImageRenderingEnabled = isEnabled - return copy - } -} -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/ImageView.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/ImageView.swift deleted file mode 100644 index 5fa66e6ba..000000000 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/ImageView.swift +++ /dev/null @@ -1,237 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). - -import Foundation - - -#if !os(watchOS) - -#if os(macOS) -import AppKit -#else -import UIKit -#endif - -/// Displays images. Supports animated images and video playback. -@MainActor -class ImageView: _PlatformBaseView { - - // MARK: Underlying Views - - /// Returns an underlying image view. - let imageView = _PlatformImageView() - -#if os(iOS) || os(tvOS) - /// Sets the content mode for all container views. - var resizingMode: ImageResizingMode = .aspectFill { - didSet { - imageView.contentMode = .init(resizingMode: resizingMode) -#if !targetEnvironment(macCatalyst) - _animatedImageView?.contentMode = .init(resizingMode: resizingMode) -#endif - _videoPlayerView?.videoGravity = .init(resizingMode) - } - } -#else - /// - warning: This option currently does nothing on macOS. - var resizingMode: ImageResizingMode = .aspectFill -#endif - -#if (os(iOS) || os(tvOS)) && !targetEnvironment(macCatalyst) - /// Returns an underlying animated image view used for rendering animated images. - var animatedImageView: AnimatedImageView { - if let view = _animatedImageView { - return view - } - let view = makeAnimatedImageView() - addContentView(view) - _animatedImageView = view - return view - } - - private func makeAnimatedImageView() -> AnimatedImageView { - let view = AnimatedImageView() - view.contentMode = .init(resizingMode: resizingMode) - return view - } - - private var _animatedImageView: AnimatedImageView? -#endif - - /// Returns an underlying video player view. - var videoPlayerView: NukeVideoPlayerView { - if let view = _videoPlayerView { - return view - } - let view = makeVideoPlayerView() - addContentView(view) - _videoPlayerView = view - return view - } - - private func makeVideoPlayerView() -> NukeVideoPlayerView { - let view = NukeVideoPlayerView() -#if os(macOS) - view.videoGravity = .resizeAspect -#else - view.videoGravity = .init(resizingMode) -#endif - return view - } - - private var _videoPlayerView: NukeVideoPlayerView? - - private(set) var customContentView: _PlatformBaseView? { - get { _customContentView } - set { - _customContentView?.removeFromSuperview() - _customContentView = newValue - if let customView = _customContentView { - addContentView(customView) - customView.isHidden = false - } - } - } - - private var _customContentView: _PlatformBaseView? - - /// `true` by default. If disabled, animated image rendering will be disabled. - var isAnimatedImageRenderingEnabled = true - - /// `true` by default. Set to `true` to enable video support. - var isVideoRenderingEnabled = true - - // MARK: Initializers - - override init(frame: CGRect) { - super.init(frame: frame) - didInit() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - didInit() - } - - private func didInit() { - addContentView(imageView) - -#if !os(macOS) - clipsToBounds = true - imageView.contentMode = .scaleAspectFill -#else - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - imageView.animates = true // macOS supports animated images out of the box -#endif - } - - /// Displays the given image. - /// - /// Supports platform images (`UIImage`) and `ImageContainer`. Use `ImageContainer` - /// if you need to pass additional parameters alongside the image, like - /// original image data for GIF rendering. - var imageContainer: ImageContainer? { - get { _imageContainer } - set { - _imageContainer = newValue - if let imageContainer = newValue { - display(imageContainer) - } else { - reset() - } - } - } - var _imageContainer: ImageContainer? - - var isVideoLooping: Bool = true { - didSet { - _videoPlayerView?.isLooping = isVideoLooping - } - } - - var image: PlatformImage? { - get { imageContainer?.image } - set { imageContainer = newValue.map { ImageContainer(image: $0) } } - } - - private func display(_ container: ImageContainer) { - if let customView = makeCustomContentView(for: container) { - customContentView = customView - return - } -#if (os(iOS) || os(tvOS)) && !targetEnvironment(macCatalyst) - if isAnimatedImageRenderingEnabled, let data = container.data, container.type == .gif { - animatedImageView.animate(withGIFData: data) - animatedImageView.isHidden = false - return - } -#endif - if isVideoRenderingEnabled, let asset = container.asset { - videoPlayerView.isHidden = false - videoPlayerView.isLooping = isVideoLooping - videoPlayerView.asset = asset - videoPlayerView.play() - return - } - - imageView.image = container.image - imageView.isHidden = false - } - - private func makeCustomContentView(for container: ImageContainer) -> _PlatformBaseView? { - for closure in ImageView.registersContentViews { - if let view = closure(container) { - return view - } - } - return nil - } - - /// Cancels current request and prepares the view for reuse. - func reset() { - _imageContainer = nil - - imageView.isHidden = true - imageView.image = nil - -#if (os(iOS) || os(tvOS)) && !targetEnvironment(macCatalyst) - _animatedImageView?.isHidden = true - _animatedImageView?.image = nil -#endif - - _videoPlayerView?.isHidden = true - _videoPlayerView?.reset() - - _customContentView?.removeFromSuperview() - _customContentView = nil - } - - // MARK: Extending Rendering System - - /// Registers a custom content view to be used for displaying the given image. - /// - /// - parameter closure: A closure to get called when the image needs to be - /// displayed. The view gets added to the `contentView`. You can return `nil` - /// if you want the default rendering to happen. - static func registerContentView(_ closure: @escaping (ImageContainer) -> _PlatformBaseView?) { - registersContentViews.append(closure) - } - - static func removeAllRegisteredContentViews() { - registersContentViews.removeAll() - } - - private static var registersContentViews: [(ImageContainer) -> _PlatformBaseView?] = [] - - // MARK: Misc - - private func addContentView(_ view: _PlatformBaseView) { - addSubview(view) - view.pinToSuperview() - view.isHidden = true - } -} -#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Internal.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Internal.swift index 75f804d10..0dc3deb5e 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Internal.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Internal.swift @@ -1,9 +1,10 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation + #if !os(watchOS) #if os(macOS) @@ -28,12 +29,14 @@ typealias _PlatformColor = UIColor extension _PlatformBaseView { @discardableResult func pinToSuperview() -> [NSLayoutConstraint] { + guard let superview else { return [] } + translatesAutoresizingMaskIntoConstraints = false let constraints = [ - topAnchor.constraint(equalTo: superview!.topAnchor), - bottomAnchor.constraint(equalTo: superview!.bottomAnchor), - leftAnchor.constraint(equalTo: superview!.leftAnchor), - rightAnchor.constraint(equalTo: superview!.rightAnchor) + topAnchor.constraint(equalTo: superview.topAnchor), + bottomAnchor.constraint(equalTo: superview.bottomAnchor), + leftAnchor.constraint(equalTo: superview.leftAnchor), + rightAnchor.constraint(equalTo: superview.rightAnchor) ] NSLayoutConstraint.activate(constraints) return constraints @@ -41,10 +44,12 @@ extension _PlatformBaseView { @discardableResult func centerInSuperview() -> [NSLayoutConstraint] { + guard let superview else { return [] } + translatesAutoresizingMaskIntoConstraints = false let constraints = [ - centerXAnchor.constraint(equalTo: superview!.centerXAnchor), - centerYAnchor.constraint(equalTo: superview!.centerYAnchor) + centerXAnchor.constraint(equalTo: superview.centerXAnchor), + centerYAnchor.constraint(equalTo: superview.centerYAnchor) ] NSLayoutConstraint.activate(constraints) return constraints @@ -87,28 +92,6 @@ extension NSColor { } #endif -#if os(iOS) || os(tvOS) -extension UIView.ContentMode { - // swiftlint:disable:next cyclomatic_complexity - init(resizingMode: ImageResizingMode) { - switch resizingMode { - case .fill: self = .scaleToFill - case .aspectFill: self = .scaleAspectFill - case .aspectFit: self = .scaleAspectFit - case .center: self = .center - case .top: self = .top - case .bottom: self = .bottom - case .left: self = .left - case .right: self = .right - case .topLeft: self = .topLeft - case .topRight: self = .topRight - case .bottomLeft: self = .bottomLeft - case .bottomRight: self = .bottomRight - } - } -} -#endif - #endif #if os(tvOS) || os(watchOS) diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImage.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImage.swift index 37fb2c32e..74e85b78b 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImage.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImage.swift @@ -1,116 +1,59 @@ // 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 SwiftUI import Combine -private struct HashableRequest: Hashable { - let request: ImageRequest - - func hash(into hasher: inout Hasher) { - hasher.combine(request.imageId) - hasher.combine(request.options) - hasher.combine(request.priority) - } - static func == (lhs: HashableRequest, rhs: HashableRequest) -> Bool { - let lhs = lhs.request - let rhs = rhs.request - return lhs.imageId == rhs.imageId && - lhs.priority == rhs.priority && - lhs.options == rhs.options - } -} -/// Lazily loads and displays images. +/// A view that asynchronously loads and displays an image. /// -/// ``LazyImage`` is designed similar to the native [`AsyncImage`](https://developer.apple.com/documentation/SwiftUI/AsyncImage), -/// but it uses [Nuke](https://github.com/kean/Nuke) for loading images so you +/// ``LazyImage`` is designed to be similar to the native [`AsyncImage`](https://developer.apple.com/documentation/SwiftUI/AsyncImage), +/// but it uses [Nuke](https://github.com/kean/Nuke) for loading images. You /// can take advantage of all of its features, such as caching, prefetching, /// task coalescing, smart background decompression, request priorities, and more. @MainActor @available(iOS 14.0, tvOS 14.0, watchOS 7.0, macOS 10.16, *) struct LazyImage: View { - @StateObject private var model = FetchImage() - - private let request: HashableRequest? - -#if !os(watchOS) - private var onCreated: ((ImageView) -> Void)? -#endif + @StateObject private var viewModel = FetchImage() - // Options + private var context: LazyImageContext? private var makeContent: ((LazyImageState) -> Content)? - private var animation: Animation? = .default - private var processors: [any ImageProcessing]? - private var priority: ImageRequest.Priority? + private var transaction: Transaction private var pipeline: ImagePipeline = .shared - private var onDisappearBehavior: DisappearBehavior? = .cancel private var onStart: ((ImageTask) -> Void)? - private var onPreview: ((ImageResponse) -> Void)? - private var onProgress: ((ImageTask.Progress) -> Void)? - private var onSuccess: ((ImageResponse) -> Void)? - private var onFailure: ((Error) -> Void)? + private var onDisappearBehavior: DisappearBehavior? = .cancel private var onCompletion: ((Result) -> Void)? - private var resizingMode: ImageResizingMode? // MARK: Initializers -#if !os(macOS) - /// Loads and displays an image using ``Image``. - /// - /// - Parameters: - /// - url: The image URL. - /// - resizingMode: The displayed image resizing mode. - init(url: URL?, resizingMode: ImageResizingMode = .aspectFill) where Content == NukeImage { - self.init(request: url.map { ImageRequest(url: $0) }, resizingMode: resizingMode) - } - - /// Loads and displays an image using ``Image``. - /// - /// - Parameters: - /// - request: The image request. - /// - resizingMode: The displayed image resizing mode. - init(request: ImageRequest?, resizingMode: ImageResizingMode = .aspectFill) where Content == NukeImage { - self.request = request.map { HashableRequest(request: $0) } - self.resizingMode = resizingMode - } - - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please use init(request:) or init(url).") - init(source: (any ImageRequestConvertible)?, resizingMode: ImageResizingMode = .aspectFill) where Content == NukeImage { - self.init(request: source?.asImageRequest(), resizingMode: resizingMode) - } -#else - /// Loads and displays an image using ``Image``. + /// Loads and displays an image using `SwiftUI.Image`. /// /// - Parameters: /// - url: The image URL. - init(url: URL?) where Content == NukeImage { + init(url: URL?) where Content == Image { self.init(request: url.map { ImageRequest(url: $0) }) } - /// Loads and displays an image using ``Image``. + /// Loads and displays an image using `SwiftUI.Image`. /// /// - Parameters: /// - request: The image request. - init(request: ImageRequest?) where Content == NukeImage { - self.request = request.map { HashableRequest(request: $0) } + init(request: ImageRequest?) where Content == Image { + self.context = request.map(LazyImageContext.init) + self.transaction = Transaction(animation: nil) } - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please use init(request:) or init(url).") - init(source: (any ImageRequestConvertible)?) where Content == NukeImage { - self.request = source.map { HashableRequest(request: $0.asImageRequest()) } - } -#endif /// Loads an images and displays custom content for each state. /// - /// See also ``init(request:content:)`` - init(url: URL?, @ViewBuilder content: @escaping (LazyImageState) -> Content) { - self.init(request: url.map { ImageRequest(url: $0) }, content: content) + /// See also ``init(request:transaction:content:)`` + init(url: URL?, + transaction: Transaction = Transaction(animation: nil), + @ViewBuilder content: @escaping (LazyImageState) -> Content) { + self.init(request: url.map { ImageRequest(url: $0) }, transaction: transaction, content: content) } /// Loads an images and displays custom content for each state. @@ -130,40 +73,27 @@ struct LazyImage: View { /// } /// } /// ``` - init(request: ImageRequest?, @ViewBuilder content: @escaping (LazyImageState) -> Content) { - self.request = request.map { HashableRequest(request: $0) } - self.makeContent = content - } - - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please use init(request:) or init(url).") - init(source: (any ImageRequestConvertible)?, @ViewBuilder content: @escaping (LazyImageState) -> Content) { - self.request = source.map { HashableRequest(request: $0.asImageRequest()) } + init(request: ImageRequest?, + transaction: Transaction = Transaction(animation: nil), + @ViewBuilder content: @escaping (LazyImageState) -> Content) { + self.context = request.map { LazyImageContext(request: $0) } + self.transaction = transaction self.makeContent = content } - // MARK: Animation - - /// Animations to be used when displaying the loaded images. By default, `.default`. - /// - /// - note: Animation isn't used when image is available in memory cache. - func animation(_ animation: Animation?) -> Self { - map { $0.animation = animation } - } - - // MARK: Managing Image Tasks + // MARK: Options /// Sets processors to be applied to the image. /// /// If you pass an image requests with a non-empty list of processors as /// a source, your processors will be applied instead. func processors(_ processors: [any ImageProcessing]?) -> Self { - map { $0.processors = processors } + map { $0.context?.request.processors = processors ?? [] } } /// Sets the priority of the requests. func priority(_ priority: ImageRequest.Priority?) -> Self { - map { $0.priority = priority } + map { $0.context?.request.priority = priority ?? .normal } } /// Changes the underlying pipeline used for image loading. @@ -179,151 +109,141 @@ struct LazyImage: View { case lowerPriority } - /// Override the behavior on disappear. By default, the view is reset. - func onDisappear(_ behavior: DisappearBehavior?) -> Self { - map { $0.onDisappearBehavior = behavior } - } - - // MARK: Callbacks - /// Gets called when the request is started. func onStart(_ closure: @escaping (ImageTask) -> Void) -> Self { map { $0.onStart = closure } } - /// Gets called when the request progress is updated. - func onPreview(_ closure: @escaping (ImageResponse) -> Void) -> Self { - map { $0.onPreview = closure } - } - - /// Gets called when the request progress is updated. - func onProgress(_ closure: @escaping (ImageTask.Progress) -> Void) -> Self { - map { $0.onProgress = closure } - } - - /// Gets called when the requests finished successfully. - func onSuccess(_ closure: @escaping (ImageResponse) -> Void) -> Self { - map { $0.onSuccess = closure } - } - - /// Gets called when the requests fails. - func onFailure(_ closure: @escaping (Error) -> Void) -> Self { - map { $0.onFailure = closure } + /// Override the behavior on disappear. By default, the view is reset. + func onDisappear(_ behavior: DisappearBehavior?) -> Self { + map { $0.onDisappearBehavior = behavior } } - /// Gets called when the request is completed. + /// Gets called when the current request is completed. func onCompletion(_ closure: @escaping (Result) -> Void) -> Self { map { $0.onCompletion = closure } } -#if !os(watchOS) - - /// Returns an underlying image view. - /// - /// - parameter configure: A closure that gets called once when the view is - /// created and allows you to configure it based on your needs. - func onCreated(_ configure: ((ImageView) -> Void)?) -> Self { - map { $0.onCreated = configure } + private func map(_ closure: (inout LazyImage) -> Void) -> Self { + var copy = self + closure(©) + return copy } -#endif // MARK: Body var body: some View { - // Using ZStack to add an identity to the view to prevent onAppear from - // getting called whenever the content changes. ZStack { - content + if let makeContent { + makeContent(viewModel) + } else { + makeDefaultContent(for: viewModel) + } } - .onAppear(perform: { onAppear() }) - .onDisappear(perform: { onDisappear() }) - .onChange(of: request, perform: { load($0) }) - } - - @ViewBuilder private var content: some View { - if let makeContent = makeContent { - makeContent(LazyImageState(model)) - } else { - makeDefaultContent() + .onAppear { onAppear() } + .onDisappear { onDisappear() } + .onChange(of: context) { + viewModel.load($0?.request) } } - @ViewBuilder private func makeDefaultContent() -> some View { - if let imageContainer = model.imageContainer { -#if os(watchOS) - switch resizingMode ?? ImageResizingMode.aspectFill { - case .aspectFit, .aspectFill: - model.view? - .resizable() - .aspectRatio(contentMode: resizingMode == .aspectFit ? .fit : .fill) - case .fill: - model.view? - .resizable() - default: - model.view - } -#else - NukeImage(imageContainer) { -#if os(iOS) || os(tvOS) - if let resizingMode = self.resizingMode { - $0.resizingMode = resizingMode - } -#endif - onCreated?($0) - } -#endif + @ViewBuilder + private func makeDefaultContent(for state: LazyImageState) -> some View { + if let image = state.image { + image } else { - Rectangle().foregroundColor(Color(.secondarySystemBackground)) + Color(.secondarySystemBackground) } } private func onAppear() { - // Unfortunately, you can't modify @State directly in the properties - // that set these options. - model.animation = animation - if let processors = processors { model.processors = processors } - if let priority = priority { model.priority = priority } - model.pipeline = pipeline - model.onStart = onStart - model.onPreview = onPreview - model.onProgress = onProgress - model.onSuccess = onSuccess - model.onFailure = onFailure - model.onCompletion = onCompletion - - load(request) - } - - private func load(_ request: HashableRequest?) { - model.load(request?.request) + viewModel.transaction = transaction + viewModel.pipeline = pipeline + viewModel.onStart = onStart + viewModel.onCompletion = onCompletion + viewModel.load(context?.request) } private func onDisappear() { guard let behavior = onDisappearBehavior else { return } switch behavior { - case .cancel: model.cancel() - case .lowerPriority: model.priority = .veryLow + case .cancel: + viewModel.cancel() + case .lowerPriority: + viewModel.priority = .veryLow } } +} - private func map(_ closure: (inout LazyImage) -> Void) -> Self { - var copy = self - closure(©) - return copy +private struct LazyImageContext: Equatable { + var request: ImageRequest + + static func == (lhs: LazyImageContext, rhs: LazyImageContext) -> Bool { + let lhs = lhs.request + let rhs = rhs.request + return lhs.preferredImageId == rhs.preferredImageId && + lhs.priority == rhs.priority && + lhs.processors == rhs.processors && + lhs.priority == rhs.priority && + lhs.options == rhs.options + } +} + +#if DEBUG +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) +struct LazyImage_Previews: PreviewProvider { + static var previews: some View { + Group { + LazyImageDemoView() + .previewDisplayName("LazyImage") + + LazyImage(url: URL(string: "https://kean.blog/images/pulse/01.png")) + .previewDisplayName("LazyImage (Default)") + + AsyncImage(url: URL(string: "https://kean.blog/images/pulse/01.png")) + .previewDisplayName("AsyncImage") + } } } -enum ImageResizingMode { - case fill - case aspectFit - case aspectFill - case center - case top - case bottom - case left - case right - case topLeft - case topRight - case bottomLeft - case bottomRight +// This demonstrates that the view reacts correctly to the URL changes. +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) +private struct LazyImageDemoView: View { + @State var url = URL(string: "https://kean.blog/images/pulse/01.png") + @State var isBlured = false + @State var imageViewId = UUID() + + var body: some View { + VStack { + Spacer() + + LazyImage(url: url) { state in + if let image = state.image { + image.resizable().aspectRatio(contentMode: .fit) + } + } +#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) + .processors(isBlured ? [ImageProcessors.GaussianBlur()] : []) +#endif + .id(imageViewId) // Example of how to implement retry + + Spacer() + VStack(alignment: .leading, spacing: 16) { + Button("Change Image") { + if url == URL(string: "https://kean.blog/images/pulse/01.png") { + url = URL(string: "https://kean.blog/images/pulse/02.png") + } else { + url = URL(string: "https://kean.blog/images/pulse/01.png") + } + } + Button("Retry") { imageViewId = UUID() } + Toggle("Apply Blur", isOn: $isBlured) + } + .padding() +#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) + .background(Material.ultraThick) +#endif + } + } } +#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageState.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageState.swift index 5f18710ad..b930b01b8 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageState.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageState.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 @@ -8,11 +8,27 @@ import SwiftUI import Combine /// Describes current image state. -struct LazyImageState { +@MainActor +protocol LazyImageState { /// Returns the current fetch result. - let result: Result? + var result: Result? { get } - /// Returns a current error. + /// 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 imageContainer: ImageContainer? { get } + + /// Returns `true` if the image is being loaded. + var isLoading: Bool { get } + + /// The progress of the image download. + var progress: FetchImage.Progress { get } +} + +extension LazyImageState { + /// Returns the current error. var error: Error? { if case .failure(let error) = result { return error @@ -21,35 +37,13 @@ struct LazyImageState { } /// Returns an image view. - @MainActor - var image: NukeImage? { + var image: Image? { #if os(macOS) - return imageContainer.map { NukeImage($0) } -#elseif os(watchOS) - return imageContainer.map { NukeImage(uiImage: $0.image) } + imageContainer.map { Image(nsImage: $0.image) } #else - return imageContainer.map { NukeImage($0) } + imageContainer.map { Image(uiImage: $0.image) } #endif } - - /// 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. - let imageContainer: ImageContainer? - - /// Returns `true` if the image is being loaded. - let isLoading: Bool - - /// The progress of the image download. - let progress: ImageTask.Progress - - @MainActor - init(_ fetchImage: FetchImage) { - self.result = fetchImage.result - self.imageContainer = fetchImage.imageContainer - self.isLoading = fetchImage.isLoading - self.progress = fetchImage.progress - } } + +extension FetchImage: LazyImageState {} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageView.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageView.swift index dcb178ff7..218ce8383 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageView.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageView.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 @@ -53,6 +53,10 @@ final class LazyImageView: _PlatformBaseView { } } + /// Displays the placeholder image or view in the case of a failure. + /// `false` by default. + var showPlaceholderOnFailure = false + private var placeholderViewConstraints: [NSLayoutConstraint] = [] // MARK: Failure View @@ -99,8 +103,19 @@ final class LazyImageView: _PlatformBaseView { // MARK: Underlying Views +#if os(macOS) /// Returns the underlying image view. - let imageView = ImageView() + let imageView = NSImageView() +#else + let imageView = UIImageView() +#endif + + /// Creates a custom view for displaying the given image response. + /// + /// Return `nil` to use the default platform image view. + var makeImageView: ((ImageContainer) -> _PlatformBaseView?)? + + private var customImageView: _PlatformBaseView? // MARK: Managing Image Tasks @@ -114,7 +129,7 @@ final class LazyImageView: _PlatformBaseView { /// dynamically. `nil` by default. var priority: ImageRequest.Priority? { didSet { - if let priority = self.priority { + if let priority { imageTask?.priority = priority } } @@ -149,8 +164,6 @@ final class LazyImageView: _PlatformBaseView { // MARK: Other Options /// `true` by default. If disabled, progressive image scans will be ignored. - /// - /// This option also affects the previews for animated images or videos. var isProgressiveImageRenderingEnabled = true /// `true` by default. If enabled, the image view will be cleared before the @@ -185,12 +198,7 @@ final class LazyImageView: _PlatformBaseView { placeholderView = { let view = _PlatformBaseView() - let color: _PlatformColor - if #available(iOS 13.0, *) { - color = .secondarySystemBackground - } else { - color = _PlatformColor.lightGray.withAlphaComponent(0.5) - } + let color = _PlatformColor.secondarySystemBackground #if os(macOS) view.wantsLayer = true view.layer?.backgroundColor = color.cgColor @@ -214,13 +222,6 @@ final class LazyImageView: _PlatformBaseView { var request: ImageRequest? { didSet { load(request) } } - /// - // Deprecated in Nuke 11.0 - @available(*, deprecated, message: "Please `request` or `url` properties instead") - var source: (any ImageRequestConvertible)? { - get { request } - set { request = newValue?.asImageRequest() } - } override func updateConstraints() { super.updateConstraints() @@ -233,9 +234,11 @@ final class LazyImageView: _PlatformBaseView { func reset() { cancel() - imageView.imageContainer = nil + imageView.image = nil imageView.isHidden = true + customImageView?.removeFromSuperview() + setPlaceholderViewHidden(true) setFailureViewHidden(true) @@ -262,15 +265,15 @@ final class LazyImageView: _PlatformBaseView { isResetNeeded = true } - guard var request = request else { + guard var request else { handle(result: .failure(ImagePipeline.Error.imageRequestMissing), isSync: true) return } - if let processors = self.processors, !processors.isEmpty, !request.processors.isEmpty { + if let processors, !processors.isEmpty, request.processors.isEmpty { request.processors = processors } - if let priority = self.priority { + if let priority { request.priority = priority } @@ -291,9 +294,9 @@ final class LazyImageView: _PlatformBaseView { with: request, queue: .main, progress: { [weak self] response, completed, total in - guard let self = self else { return } + guard let self else { return } let progress = ImageTask.Progress(completed: completed, total: total) - if let response = response { + if let response { self.handle(preview: response) self.onPreview?(response) } else { @@ -324,7 +327,11 @@ final class LazyImageView: _PlatformBaseView { case let .success(response): display(response.container, isFromMemory: isSync) case .failure: - setFailureViewHidden(false) + if showPlaceholderOnFailure { + setPlaceholderViewHidden(false) + } else { + setFailureViewHidden(false) + } } imageTask = nil @@ -338,8 +345,14 @@ final class LazyImageView: _PlatformBaseView { private func display(_ container: ImageContainer, isFromMemory: Bool) { resetIfNeeded() - imageView.imageContainer = container - imageView.isHidden = false + if let view = makeImageView?(container) { + addSubview(view) + view.pinToSuperview() + customImageView = view + } else { + imageView.image = container.image + imageView.isHidden = false + } if !isFromMemory, let transition = transition { runTransition(transition, container) @@ -353,7 +366,7 @@ final class LazyImageView: _PlatformBaseView { } private func setPlaceholderImage(_ placeholderImage: PlatformImage?) { - guard let placeholderImage = placeholderImage else { + guard let placeholderImage else { placeholderView = nil return } @@ -361,14 +374,14 @@ final class LazyImageView: _PlatformBaseView { } private func setPlaceholderView(_ oldView: _PlatformBaseView?, _ newView: _PlatformBaseView?) { - if let oldView = oldView { + if let oldView { oldView.removeFromSuperview() } - if let newView = newView { + if let newView { newView.isHidden = !imageView.isHidden insertSubview(newView, at: 0) setNeedsUpdateConstraints() -#if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) if let spinner = newView as? UIActivityIndicatorView { spinner.startAnimating() } @@ -388,7 +401,7 @@ final class LazyImageView: _PlatformBaseView { } private func setFailureImage(_ failureImage: PlatformImage?) { - guard let failureImage = failureImage else { + guard let failureImage else { failureView = nil return } @@ -396,10 +409,10 @@ final class LazyImageView: _PlatformBaseView { } private func setFailureView(_ oldView: _PlatformBaseView?, _ newView: _PlatformBaseView?) { - if let oldView = oldView { + if let oldView { oldView.removeFromSuperview() } - if let newView = newView { + if let newView { newView.isHidden = true insertSubview(newView, at: 0) setNeedsUpdateConstraints() @@ -422,8 +435,12 @@ final class LazyImageView: _PlatformBaseView { } } -#if os(iOS) || os(tvOS) - +#if os(macOS) + private func runFadeInTransition(duration: TimeInterval) { + guard !imageView.isHidden else { return } + imageView.layer?.animateOpacity(duration: duration) + } +#else private func runFadeInTransition(duration: TimeInterval) { guard !imageView.isHidden else { return } imageView.alpha = 0 @@ -431,14 +448,6 @@ final class LazyImageView: _PlatformBaseView { self.imageView.alpha = 1 } } - -#elseif os(macOS) - - private func runFadeInTransition(duration: TimeInterval) { - guard !imageView.isHidden else { return } - imageView.layer?.animateOpacity(duration: duration) - } - #endif // MARK: Misc diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/AVDataAsset.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeVideo/AVDataAsset.swift similarity index 88% rename from Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/AVDataAsset.swift rename to Sources/StreamChatSwiftUI/StreamNuke/NukeVideo/AVDataAsset.swift index e575498c4..b155a9aa4 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/AVDataAsset.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeVideo/AVDataAsset.swift @@ -1,10 +1,18 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import AVKit import Foundation + +extension NukeAssetType { + /// Returns `true` if the asset represents a video file + var isVideo: Bool { + self == .mp4 || self == .m4v || self == .mov + } +} + #if !os(watchOS) private extension NukeAssetType { @@ -19,7 +27,7 @@ private extension NukeAssetType { } // This class keeps strong pointer to DataAssetResourceLoader -final class AVDataAsset: AVURLAsset { +final class AVDataAsset: AVURLAsset, @unchecked Sendable { private let resourceLoaderDelegate: DataAssetResourceLoader init(data: Data, type: NukeAssetType?) { diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Video.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeVideo/ImageDecoders+Video.swift similarity index 64% rename from Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Video.swift rename to Sources/StreamChatSwiftUI/StreamNuke/NukeVideo/ImageDecoders+Video.swift index 6529e1fcc..5c4ab3c6d 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Video.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeVideo/ImageDecoders+Video.swift @@ -1,13 +1,22 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -#if !os(watchOS) +#if !os(watchOS) && !os(visionOS) import Foundation import AVKit +import AVFoundation + extension ImageDecoders { + /// The video decoder. + /// + /// To enable the video decoder, register it with a shared registry: + /// + /// ```swift + /// ImageDecoderRegistry.shared.register(ImageDecoders.Video.init) + /// ``` final class Video: ImageDecoding, @unchecked Sendable { private var didProducePreview = false private let type: NukeAssetType @@ -21,7 +30,9 @@ extension ImageDecoders { } func decode(_ data: Data) throws -> ImageContainer { - ImageContainer(image: PlatformImage(), type: type, data: data) + ImageContainer(image: PlatformImage(), type: type, data: data, userInfo: [ + .videoAssetKey: AVDataAsset(data: data, type: type) + ]) } func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { @@ -36,11 +47,18 @@ extension ImageDecoders { return nil } didProducePreview = true - return ImageContainer(image: preview, type: type, isPreview: true, data: data) + return ImageContainer(image: preview, type: type, isPreview: true, data: data, userInfo: [ + .videoAssetKey: AVDataAsset(data: data, type: type) + ]) } } } +extension ImageContainer.UserInfoKey { + /// A key for a video asset (`AVAsset`) + static let videoAssetKey: ImageContainer.UserInfoKey = "com.github/kean/nuke/video-asset" +} + private func makePreview(for data: Data, type: NukeAssetType) -> PlatformImage? { let asset = AVDataAsset(data: data, type: type) let generator = AVAssetImageGenerator(asset: asset) @@ -51,3 +69,11 @@ private func makePreview(for data: Data, type: NukeAssetType) -> PlatformImage? } #endif + +#if os(macOS) +extension NSImage { + convenience init(cgImage: CGImage) { + self.init(cgImage: cgImage, size: .zero) + } +} +#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/NukeVideoPlayerView.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeVideo/NukeVideoPlayerView.swift similarity index 83% rename from Sources/StreamChatSwiftUI/StreamNuke/NukeUI/NukeVideoPlayerView.swift rename to Sources/StreamChatSwiftUI/StreamNuke/NukeVideo/NukeVideoPlayerView.swift index f85a0f625..13b5bb17d 100644 --- a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/NukeVideoPlayerView.swift +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeVideo/NukeVideoPlayerView.swift @@ -1,11 +1,14 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +#if swift(>=6.0) import AVKit -import Foundation +#else +@preconcurrency import AVKit +#endif -#if !os(watchOS) +import Foundation @MainActor final class NukeVideoPlayerView: _PlatformBaseView { @@ -18,6 +21,9 @@ final class NukeVideoPlayerView: _PlatformBaseView { } } + /// `true` by default. If disabled, the video will resize with the frame without animations + var animatesFrameChanges = true + /// `true` by default. If disabled, will only play a video once. var isLooping = true { didSet { @@ -53,17 +59,23 @@ final class NukeVideoPlayerView: _PlatformBaseView { private var _playerLayer: AVPlayerLayer? - #if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) override func layoutSubviews() { super.layoutSubviews() + CATransaction.begin() + CATransaction.setDisableActions(!animatesFrameChanges) _playerLayer?.frame = bounds + CATransaction.commit() } - #elseif os(macOS) +#elseif os(macOS) override func layout() { super.layout() + CATransaction.begin() + CATransaction.setDisableActions(!animatesFrameChanges) _playerLayer?.frame = bounds + CATransaction.commit() } #endif @@ -101,7 +113,7 @@ final class NukeVideoPlayerView: _PlatformBaseView { object: player?.currentItem ) -#if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) NotificationCenter.default.addObserver( self, selector: #selector(applicationWillEnterForeground), @@ -117,14 +129,18 @@ final class NukeVideoPlayerView: _PlatformBaseView { } func play() { - guard let asset = asset else { + guard let asset else { return } let playerItem = AVPlayerItem(asset: asset) let player = AVQueuePlayer(playerItem: playerItem) player.isMuted = true - player.preventsDisplaySleepDuringVideoPlayback = false +#if os(visionOS) + player.preventsAutomaticBackgroundingDuringVideoPlayback = false +#else + player.preventsDisplaySleepDuringVideoPlayback = false +#endif player.actionAtItemEnd = isLooping ? .none : .pause self.player = player @@ -156,7 +172,7 @@ final class NukeVideoPlayerView: _PlatformBaseView { } } -#if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) override func willMove(toWindow newWindow: UIWindow?) { if newWindow != nil && shouldResumeOnInterruption { player?.play() @@ -171,21 +187,9 @@ final class NukeVideoPlayerView: _PlatformBaseView { } } -extension AVLayerVideoGravity { - init(_ contentMode: ImageResizingMode) { - switch contentMode { - case .fill: self = .resize - case .aspectFill: self = .resizeAspectFill - default: self = .resizeAspect - } - } -} - @MainActor extension AVPlayer { var nowPlaying: Bool { rate != 0 && error == nil } } - -#endif diff --git a/Sources/StreamChatSwiftUI/StreamSwiftyGif/NSImageView+SwiftyGif.swift b/Sources/StreamChatSwiftUI/StreamSwiftyGif/NSImageView+SwiftyGif.swift index 26792b6bb..886a72c88 100755 --- a/Sources/StreamChatSwiftUI/StreamSwiftyGif/NSImageView+SwiftyGif.swift +++ b/Sources/StreamChatSwiftUI/StreamSwiftyGif/NSImageView+SwiftyGif.swift @@ -225,7 +225,7 @@ extension NSImageView { /// Check if this imageView is currently playing a gif /// - /// - Returns wether the gif is currently playing + /// - Returns whether the gif is currently playing func isAnimatingGif() -> Bool{ return isPlaying } diff --git a/Sources/StreamChatSwiftUI/StreamSwiftyGif/SwiftyGifManager.swift b/Sources/StreamChatSwiftUI/StreamSwiftyGif/SwiftyGifManager.swift index 3d0f0692f..d5e6c0957 100755 --- a/Sources/StreamChatSwiftUI/StreamSwiftyGif/SwiftyGifManager.swift +++ b/Sources/StreamChatSwiftUI/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? @@ -134,14 +134,14 @@ class SwiftyGifManager { /// Check if an imageView is already managed by this manager /// - Parameter imageView: The image view we're searching - /// - Returns : a boolean for wether the imageView was found + /// - Returns : a boolean for whether the imageView was found func containsImageView(_ imageView: PlatformImageView) -> Bool{ return displayViews.contains(imageView) } /// Check if this manager has cache for an imageView /// - Parameter imageView: The image view we're searching cache for - /// - Returns : a boolean for wether we have cache for the imageView + /// - Returns : a boolean for whether we have cache for the imageView func hasCache(_ imageView: PlatformImageView) -> Bool { return imageView.displaying && (imageView.loopCount == -1 || imageView.loopCount >= 5) ? haveCache : false } @@ -161,7 +161,7 @@ class SwiftyGifManager { #endif for imageView in displayViews { - queue.sync { + MainActor.ensureIsolated { imageView.image = imageView.currentImage } diff --git a/Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImage+SwiftyGif.swift b/Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImage+SwiftyGif.swift index 96fe8fe2d..e341cc087 100755 --- a/Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImage+SwiftyGif.swift +++ b/Sources/StreamChatSwiftUI/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/StreamChatSwiftUI/StreamSwiftyGif/UIImageView+SwiftyGif.swift b/Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImageView+SwiftyGif.swift index 88d6c7d36..25c9787f3 100755 --- a/Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImageView+SwiftyGif.swift +++ b/Sources/StreamChatSwiftUI/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() } @@ -220,8 +220,8 @@ extension UIImageView { /// Check if this imageView is currently playing a gif /// - /// - Returns wether the gif is currently playing - func isAnimatingGif() -> Bool{ + /// - Returns whether the gif is currently playing + 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 { + MainActor.ensureIsolated { + 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 { + MainActor.ensureIsolated { + 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 + MainActor.ensureIsolated { + 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/StreamChatSwiftUI/Utils.swift b/Sources/StreamChatSwiftUI/Utils.swift index c36970e32..95cbe650e 100644 --- a/Sources/StreamChatSwiftUI/Utils.swift +++ b/Sources/StreamChatSwiftUI/Utils.swift @@ -57,6 +57,7 @@ public class Utils { } } + @preconcurrency @MainActor public lazy var audioSessionFeedbackGenerator: AudioSessionFeedbackGenerator = StreamAudioSessionFeedbackGenerator() var messageCachingUtils = MessageCachingUtils() diff --git a/Sources/StreamChatSwiftUI/Utils/Common/AutoLayoutHelpers.swift b/Sources/StreamChatSwiftUI/Utils/Common/AutoLayoutHelpers.swift index 5c24e6e2f..a641aab18 100644 --- a/Sources/StreamChatSwiftUI/Utils/Common/AutoLayoutHelpers.swift +++ b/Sources/StreamChatSwiftUI/Utils/Common/AutoLayoutHelpers.swift @@ -61,7 +61,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) @@ -90,7 +90,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) @@ -118,7 +118,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/StreamChatSwiftUI/Utils/Common/Cache.swift b/Sources/StreamChatSwiftUI/Utils/Common/Cache.swift index 887baa429..02bb2ebb4 100644 --- a/Sources/StreamChatSwiftUI/Utils/Common/Cache.swift +++ b/Sources/StreamChatSwiftUI/Utils/Common/Cache.swift @@ -4,7 +4,7 @@ import Foundation -final class Cache { +final class Cache: @unchecked Sendable { private let wrapped: NSCache init(countLimit: Int = 0) { diff --git a/Sources/StreamChatSwiftUI/Utils/Common/DateFormatter+Extensions.swift b/Sources/StreamChatSwiftUI/Utils/Common/DateFormatter+Extensions.swift index f111148d1..7241b4ace 100644 --- a/Sources/StreamChatSwiftUI/Utils/Common/DateFormatter+Extensions.swift +++ b/Sources/StreamChatSwiftUI/Utils/Common/DateFormatter+Extensions.swift @@ -15,7 +15,7 @@ extension DateFormatter { /// Formatter that is used to format date for scrolling overlay that should display /// day when message below was sent - public static var messageListDateOverlay: DateFormatter = { + @preconcurrency @MainActor public static var messageListDateOverlay: DateFormatter = { let df = DateFormatter() df.setLocalizedDateFormatFromTemplate("MMMdd") df.locale = .autoupdatingCurrent @@ -24,7 +24,7 @@ extension DateFormatter { } extension DateComponentsFormatter { - static var minutes: DateComponentsFormatter = { + @preconcurrency @MainActor static var minutes: DateComponentsFormatter = { let df = DateComponentsFormatter() df.allowedUnits = [.minute] df.unitsStyle = .full diff --git a/Sources/StreamChatSwiftUI/Utils/Common/ImageCDN.swift b/Sources/StreamChatSwiftUI/Utils/Common/ImageCDN.swift index 6774c4dca..ace5f9e25 100644 --- a/Sources/StreamChatSwiftUI/Utils/Common/ImageCDN.swift +++ b/Sources/StreamChatSwiftUI/Utils/Common/ImageCDN.swift @@ -31,7 +31,7 @@ extension ImageCDN { } open class StreamImageCDN: ImageCDN { - public static var streamCDNURL = "stream-io-cdn.com" + public static let streamCDNURL = "stream-io-cdn.com" // Initializer required for subclasses public init() { @@ -68,7 +68,7 @@ open class StreamImageCDN: ImageCDN { host.contains(StreamImageCDN.streamCDNURL) else { return originalURL } - let scale = UIScreen.main.scale + let scale = Screen.scale components.queryItems = components.queryItems ?? [] components.queryItems?.append(contentsOf: [ URLQueryItem(name: "w", value: String(format: "%.0f", preferredSize.width * scale)), diff --git a/Sources/StreamChatSwiftUI/Utils/Common/ImageMerger.swift b/Sources/StreamChatSwiftUI/Utils/Common/ImageMerger.swift index f66b9aec9..cb0d8c552 100644 --- a/Sources/StreamChatSwiftUI/Utils/Common/ImageMerger.swift +++ b/Sources/StreamChatSwiftUI/Utils/Common/ImageMerger.swift @@ -52,7 +52,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 { @@ -77,7 +77,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/StreamChatSwiftUI/Utils/Common/InputTextView.swift b/Sources/StreamChatSwiftUI/Utils/Common/InputTextView.swift index daa44079d..6d0ab8860 100644 --- a/Sources/StreamChatSwiftUI/Utils/Common/InputTextView.swift +++ b/Sources/StreamChatSwiftUI/Utils/Common/InputTextView.swift @@ -5,7 +5,7 @@ import UIKit struct TextSizeConstants { - static let composerConfig = InjectedValues[\.utils].composerConfig + static var composerConfig: ComposerConfig { InjectedValues[\.utils].composerConfig } static let defaultInputViewHeight: CGFloat = 38.0 static var minimumHeight: CGFloat { composerConfig.inputViewMinHeight diff --git a/Sources/StreamChatSwiftUI/Utils/Common/UIView+AccessibilityIdentifier.swift b/Sources/StreamChatSwiftUI/Utils/Common/UIView+AccessibilityIdentifier.swift index cfc8deba4..941677dff 100644 --- a/Sources/StreamChatSwiftUI/Utils/Common/UIView+AccessibilityIdentifier.swift +++ b/Sources/StreamChatSwiftUI/Utils/Common/UIView+AccessibilityIdentifier.swift @@ -21,7 +21,7 @@ extension NSObject { } // Protocol that provides accessibility features -protocol AccessibilityView { +@MainActor protocol AccessibilityView { // Identifier for view var accessibilityViewIdentifier: String { get } diff --git a/Sources/StreamChatSwiftUI/Utils/Common/VideoPreviewLoader.swift b/Sources/StreamChatSwiftUI/Utils/Common/VideoPreviewLoader.swift index 7567088d4..e059fa329 100644 --- a/Sources/StreamChatSwiftUI/Utils/Common/VideoPreviewLoader.swift +++ b/Sources/StreamChatSwiftUI/Utils/Common/VideoPreviewLoader.swift @@ -12,7 +12,7 @@ public protocol VideoPreviewLoader: 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) } /// The `VideoPreviewLoader` implemenation used by default. @@ -36,19 +36,19 @@ public final class DefaultVideoPreviewLoader: VideoPreviewLoader { NotificationCenter.default.removeObserver(self) } - public func loadPreviewForVideo(at url: URL, completion: @escaping (Result) -> Void) { + public func loadPreviewForVideo(at url: URL, completion: @escaping @Sendable(Result) -> Void) { if let cached = cache[url] { - return call(completion, with: .success(cached)) + return Self.call(completion, with: .success(cached)) } - utils.fileCDN.adjustedURL(for: url) { result in + utils.fileCDN.adjustedURL(for: url) { [cache] result in let adjustedUrl: URL switch result { case let .success(url): adjustedUrl = url case let .failure(error): - self.call(completion, with: .failure(error)) + Self.call(completion, with: .failure(error)) return } @@ -57,9 +57,7 @@ public final class DefaultVideoPreviewLoader: VideoPreviewLoader { let frameTime = CMTime(seconds: 0.1, preferredTimescale: 600) imageGenerator.appliesPreferredTrackTransform = true - imageGenerator.generateCGImagesAsynchronously(forTimes: [.init(time: frameTime)]) { [weak self] _, image, _, _, error in - guard let self = self else { return } - + imageGenerator.generateCGImagesAsynchronously(forTimes: [.init(time: frameTime)]) { [cache] _, image, _, _, error in let result: Result if let thumbnail = image { result = .success(.init(cgImage: thumbnail)) @@ -70,19 +68,18 @@ public final class DefaultVideoPreviewLoader: VideoPreviewLoader { return } - self.cache[url] = try? result.get() - self.call(completion, with: result) + cache[url] = try? result.get() + Self.call(completion, with: result) } } } - private func call(_ completion: @escaping (Result) -> Void, with result: Result) { - if Thread.current.isMainThread { + private static func call( + _ completion: @escaping @Sendable(Result) -> Void, + with result: Result + ) { + MainActor.ensureIsolated { completion(result) - } else { - DispatchQueue.main.async { - completion(result) - } } } diff --git a/Sources/StreamChatSwiftUI/Utils/Errors.swift b/Sources/StreamChatSwiftUI/Utils/Errors.swift index 86b5cf516..7ca5708eb 100644 --- a/Sources/StreamChatSwiftUI/Utils/Errors.swift +++ b/Sources/StreamChatSwiftUI/Utils/Errors.swift @@ -14,7 +14,7 @@ public struct StreamChatError: Error { public let description: String? /// The additional information dictionary. - public let additionalInfo: [String: Any]? + public nonisolated(unsafe) let additionalInfo: [String: Any]? public static let unknown = StreamChatError( errorCode: StreamChatErrorCode.unknown, @@ -58,7 +58,7 @@ extension StreamChatError { } /// Error codes for errors happening in the app. -public enum StreamChatErrorCode: Int { +public enum StreamChatErrorCode: Int, Sendable { case unknown = 101_000 case missingData = 101_001 case wrongConfig = 101_002 diff --git a/Sources/StreamChatSwiftUI/Utils/KeyboardHandling.swift b/Sources/StreamChatSwiftUI/Utils/KeyboardHandling.swift index ec483dbff..b26824d60 100644 --- a/Sources/StreamChatSwiftUI/Utils/KeyboardHandling.swift +++ b/Sources/StreamChatSwiftUI/Utils/KeyboardHandling.swift @@ -89,7 +89,7 @@ public struct HideKeyboardOnTapGesture: ViewModifier { } /// Resigns first responder and hides the keyboard. -public func resignFirstResponder() { +@preconcurrency @MainActor public func resignFirstResponder() { UIApplication.shared.sendAction( #selector(UIResponder.resignFirstResponder), to: nil, diff --git a/Sources/StreamChatSwiftUI/Utils/LazyImageExtensions.swift b/Sources/StreamChatSwiftUI/Utils/LazyImageExtensions.swift index cd753515c..9cba1468a 100644 --- a/Sources/StreamChatSwiftUI/Utils/LazyImageExtensions.swift +++ b/Sources/StreamChatSwiftUI/Utils/LazyImageExtensions.swift @@ -6,25 +6,6 @@ import SwiftUI extension LazyImage { - init(imageURL: URL?) where Content == NukeImage { - let imageCDN = InjectedValues[\.utils].imageCDN - guard let imageURL = imageURL else { - #if COCOAPODS - self.init(source: imageURL) - #else - self.init(url: imageURL, resizingMode: .aspectFill) - #endif - return - } - let urlRequest = imageCDN.urlRequest(forImage: imageURL) - let imageRequest = ImageRequest(urlRequest: urlRequest) - #if COCOAPODS - self.init(source: imageRequest) - #else - self.init(request: imageRequest, resizingMode: .aspectFill) - #endif - } - init(imageURL: URL?, @ViewBuilder content: @escaping (LazyImageState) -> Content) { let imageCDN = InjectedValues[\.utils].imageCDN guard let imageURL = imageURL else { diff --git a/Sources/StreamChatSwiftUI/Utils/MainActor+Extensions.swift b/Sources/StreamChatSwiftUI/Utils/MainActor+Extensions.swift new file mode 100644 index 000000000..459d000e8 --- /dev/null +++ b/Sources/StreamChatSwiftUI/Utils/MainActor+Extensions.swift @@ -0,0 +1,26 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension MainActor { + /// 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. + static func ensureIsolated(_ action: @MainActor @Sendable() throws -> T) rethrows -> T where T: Sendable { + if Thread.current.isMainThread { + return try MainActor.assumeIsolated { + try action() + } + } else { + return try DispatchQueue.main.sync { + return try MainActor.assumeIsolated { + try action() + } + } + } + } +} diff --git a/Sources/StreamChatSwiftUI/Utils/SnapshotCreator.swift b/Sources/StreamChatSwiftUI/Utils/SnapshotCreator.swift index 624475bca..b2cf42023 100644 --- a/Sources/StreamChatSwiftUI/Utils/SnapshotCreator.swift +++ b/Sources/StreamChatSwiftUI/Utils/SnapshotCreator.swift @@ -10,7 +10,7 @@ public protocol SnapshotCreator { /// Creates a snapshot of the provided SwiftUI view. /// - Parameter view: the view whose snapshot would be created. /// - Returns: `UIImage` representing the snapshot of the view. - func makeSnapshot(for view: AnyView) -> UIImage + @preconcurrency @MainActor func makeSnapshot(for view: AnyView) -> UIImage } /// Default implementation of the `SnapshotCreator`. @@ -27,7 +27,7 @@ public class DefaultSnapshotCreator: SnapshotCreator { return makeSnapshot(from: uiView) } - func makeSnapshot(from view: UIView) -> UIImage { + @MainActor func makeSnapshot(from view: UIView) -> UIImage { let renderer = UIGraphicsImageRenderer(size: view.bounds.size) return renderer.image { _ in view.drawHierarchy(in: view.bounds, afterScreenUpdates: true) diff --git a/Sources/StreamChatSwiftUI/Utils/StreamLazyImage.swift b/Sources/StreamChatSwiftUI/Utils/StreamLazyImage.swift index d53fa9a8a..615e6dc5b 100644 --- a/Sources/StreamChatSwiftUI/Utils/StreamLazyImage.swift +++ b/Sources/StreamChatSwiftUI/Utils/StreamLazyImage.swift @@ -15,12 +15,17 @@ public struct StreamLazyImage: View { } public var body: some View { - LazyImage(url: url) - .onDisappear(.cancel) - .clipShape(Circle()) - .frame( - width: size.width, - height: size.height - ) + LazyImage(url: url) { state in + if let image = state.image { + image.resizable().aspectRatio(contentMode: .fill) + } + } + .processors([ImageProcessors.Resize(size: size, contentMode: .aspectFill, crop: true)]) + .onDisappear(.cancel) + .clipShape(Circle()) + .frame( + width: size.width, + height: size.height + ) } } diff --git a/Sources/StreamChatSwiftUI/Utils/SwiftUI+UIAlertController.swift b/Sources/StreamChatSwiftUI/Utils/SwiftUI+UIAlertController.swift index 20dde3ee2..636b111ce 100644 --- a/Sources/StreamChatSwiftUI/Utils/SwiftUI+UIAlertController.swift +++ b/Sources/StreamChatSwiftUI/Utils/SwiftUI+UIAlertController.swift @@ -14,7 +14,7 @@ extension View { message: String = "", text: Binding, placeholder: String = "", - validation: @escaping (String) -> Bool = UIAlertControllerView.defaultActionValidation, + validation: @escaping (String) -> Bool = { UIAlertControllerView.defaultActionValidation($0) }, cancel: String = L10n.Alert.Actions.cancel, accept: String, action: @escaping () -> Void diff --git a/Sources/StreamChatSwiftUI/ViewFactory.swift b/Sources/StreamChatSwiftUI/ViewFactory.swift index 762eaadc8..ee8a232a8 100644 --- a/Sources/StreamChatSwiftUI/ViewFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewFactory.swift @@ -8,7 +8,7 @@ import StreamChat import SwiftUI /// Factory used to create views. -public protocol ViewFactory: AnyObject { +@preconcurrency @MainActor public protocol ViewFactory: AnyObject { var chatClient: ChatClient { get } /// Returns the navigation bar display mode. @@ -100,8 +100,8 @@ public protocol ViewFactory: AnyObject { func makeMoreChannelActionsView( for channel: ChatChannel, swipedChannelId: Binding, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MoreActionsView /// Returns the supported channel actions. @@ -112,8 +112,8 @@ public protocol ViewFactory: AnyObject { /// - Returns: list of `ChannelAction` items. func supportedMoreChannelActions( for channel: ChatChannel, - onDismiss: @escaping () -> Void, - onError: @escaping (Error) -> Void + onDismiss: @escaping @MainActor() -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> [ChannelAction] associatedtype TrailingSwipeActionsViewType: View @@ -846,8 +846,8 @@ public protocol ViewFactory: AnyObject { func supportedMessageActions( for message: ChatMessage, channel: ChatChannel, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> [MessageAction] associatedtype SendInChannelViewType: View @@ -871,8 +871,8 @@ public protocol ViewFactory: AnyObject { func makeMessageActionsView( for message: ChatMessage, channel: ChatChannel, - onFinish: @escaping (MessageActionInfo) -> Void, - onError: @escaping (Error) -> Void + onFinish: @escaping @MainActor(MessageActionInfo) -> Void, + onError: @escaping @MainActor(Error) -> Void ) -> MessageActionsViewType associatedtype ReactionsUsersViewType: View diff --git a/Sources/StreamChatSwiftUI/ViewModelsFactory.swift b/Sources/StreamChatSwiftUI/ViewModelsFactory.swift index e5ac1d986..2a429ba7e 100644 --- a/Sources/StreamChatSwiftUI/ViewModelsFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewModelsFactory.swift @@ -7,7 +7,7 @@ import StreamChat import SwiftUI /// Factory used to create view models. -public class ViewModelsFactory { +@preconcurrency @MainActor public class ViewModelsFactory { private init() { /* Private init */ } /// Creates the `ChannelListViewModel`. diff --git a/StreamChatSwiftUI-XCFramework.podspec b/StreamChatSwiftUI-XCFramework.podspec index 388996a85..7987de23f 100644 --- a/StreamChatSwiftUI-XCFramework.podspec +++ b/StreamChatSwiftUI-XCFramework.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |spec| spec.license = { type: 'BSD-3', file: 'LICENSE' } spec.author = { 'getstream.io' => 'support@getstream.io' } spec.social_media_url = 'https://getstream.io' - spec.swift_version = '5.9' + spec.swift_version = '6.0' spec.platform = :ios, '14.0' spec.requires_arc = true diff --git a/StreamChatSwiftUI.podspec b/StreamChatSwiftUI.podspec index 4de9e323b..b4e50bdcc 100644 --- a/StreamChatSwiftUI.podspec +++ b/StreamChatSwiftUI.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |spec| spec.license = { type: 'BSD-3', file: 'LICENSE' } spec.author = { 'getstream.io' => 'support@getstream.io' } spec.social_media_url = 'https://getstream.io' - spec.swift_version = '5.9' + spec.swift_version = '6.0' spec.platform = :ios, '14.0' spec.source = { git: 'https://github.com/GetStream/stream-chat-swiftui.git', tag: spec.version } spec.requires_arc = true diff --git a/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj index 582fad6af..5e55574ad 100644 --- a/StreamChatSwiftUI.xcodeproj/project.pbxproj +++ b/StreamChatSwiftUI.xcodeproj/project.pbxproj @@ -9,7 +9,73 @@ /* Begin PBXBuildFile section */ 402C54482B6AAC0100672BFB /* StreamChatSwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */; }; 402C54492B6AAC0100672BFB /* StreamChatSwiftUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 4F0695102D96A58300DB7E3B /* MainActor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F06950F2D96A57C00DB7E3B /* MainActor+Extensions.swift */; }; 4F077EF82C85E05700F06D83 /* DelayedRenderingViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F077EF72C85E05700F06D83 /* DelayedRenderingViewModifier.swift */; }; + 4F0AC7F82DCA2CCE00ACB1AC /* ImageProcessors+Anonymous.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7D62DCA2CCE00ACB1AC /* ImageProcessors+Anonymous.swift */; }; + 4F0AC7F92DCA2CCE00ACB1AC /* ImageViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7EC2DCA2CCE00ACB1AC /* ImageViewExtensions.swift */; }; + 4F0AC7FA2DCA2CCE00ACB1AC /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7C22DCA2CCE00ACB1AC /* Log.swift */; }; + 4F0AC7FB2DCA2CCE00ACB1AC /* NukeVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7F62DCA2CCE00ACB1AC /* NukeVideoPlayerView.swift */; }; + 4F0AC7FC2DCA2CCE00ACB1AC /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7D32DCA2CCE00ACB1AC /* ImageProcessing.swift */; }; + 4F0AC7FD2DCA2CCE00ACB1AC /* ImageDecompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7D22DCA2CCE00ACB1AC /* ImageDecompression.swift */; }; + 4F0AC7FE2DCA2CCE00ACB1AC /* ImagePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7CA2DCA2CCE00ACB1AC /* ImagePipeline.swift */; }; + 4F0AC7FF2DCA2CCE00ACB1AC /* FetchImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7EE2DCA2CCE00ACB1AC /* FetchImage.swift */; }; + 4F0AC8002DCA2CCE00ACB1AC /* RateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7C42DCA2CCE00ACB1AC /* RateLimiter.swift */; }; + 4F0AC8012DCA2CCE00ACB1AC /* DataCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7AB2DCA2CCE00ACB1AC /* DataCaching.swift */; }; + 4F0AC8022DCA2CCE00ACB1AC /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7BD2DCA2CCE00ACB1AC /* Extensions.swift */; }; + 4F0AC8032DCA2CCE00ACB1AC /* AssetType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7B02DCA2CCE00ACB1AC /* AssetType.swift */; }; + 4F0AC8042DCA2CCE00ACB1AC /* ImageProcessingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7D42DCA2CCE00ACB1AC /* ImageProcessingOptions.swift */; }; + 4F0AC8052DCA2CCE00ACB1AC /* ImagePipeline+Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7CD2DCA2CCE00ACB1AC /* ImagePipeline+Delegate.swift */; }; + 4F0AC8062DCA2CCE00ACB1AC /* Graphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7BE2DCA2CCE00ACB1AC /* Graphics.swift */; }; + 4F0AC8072DCA2CCE00ACB1AC /* Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7EF2DCA2CCE00ACB1AC /* Internal.swift */; }; + 4F0AC8082DCA2CCE00ACB1AC /* ImageProcessors+RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7DC2DCA2CCE00ACB1AC /* ImageProcessors+RoundedCorners.swift */; }; + 4F0AC8092DCA2CCE00ACB1AC /* ImageDecoderRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7B12DCA2CCE00ACB1AC /* ImageDecoderRegistry.swift */; }; + 4F0AC80A2DCA2CCE00ACB1AC /* ImagePrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7D02DCA2CCE00ACB1AC /* ImagePrefetcher.swift */; }; + 4F0AC80B2DCA2CCE00ACB1AC /* NukeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7AE2DCA2CCE00ACB1AC /* NukeCache.swift */; }; + 4F0AC80C2DCA2CCE00ACB1AC /* ImageEncoders+ImageIO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7B82DCA2CCE00ACB1AC /* ImageEncoders+ImageIO.swift */; }; + 4F0AC80D2DCA2CCE00ACB1AC /* ImagePipeline+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7CB2DCA2CCE00ACB1AC /* ImagePipeline+Cache.swift */; }; + 4F0AC80E2DCA2CCE00ACB1AC /* ImageProcessors+Resize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7DB2DCA2CCE00ACB1AC /* ImageProcessors+Resize.swift */; }; + 4F0AC80F2DCA2CCE00ACB1AC /* ImageDecoders+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7F52DCA2CCE00ACB1AC /* ImageDecoders+Video.swift */; }; + 4F0AC8102DCA2CCE00ACB1AC /* LinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7C12DCA2CCE00ACB1AC /* LinkedList.swift */; }; + 4F0AC8112DCA2CCE00ACB1AC /* ImageProcessors+Composition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7D82DCA2CCE00ACB1AC /* ImageProcessors+Composition.swift */; }; + 4F0AC8122DCA2CCE00ACB1AC /* ImageProcessors+CoreImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7D92DCA2CCE00ACB1AC /* ImageProcessors+CoreImage.swift */; }; + 4F0AC8132DCA2CCE00ACB1AC /* ImagePipeline+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7CC2DCA2CCE00ACB1AC /* ImagePipeline+Configuration.swift */; }; + 4F0AC8142DCA2CCE00ACB1AC /* TaskLoadData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7E32DCA2CCE00ACB1AC /* TaskLoadData.swift */; }; + 4F0AC8152DCA2CCE00ACB1AC /* ImageRequestKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7C02DCA2CCE00ACB1AC /* ImageRequestKeys.swift */; }; + 4F0AC8162DCA2CCE00ACB1AC /* ImageEncoders+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7B72DCA2CCE00ACB1AC /* ImageEncoders+Default.swift */; }; + 4F0AC8172DCA2CCE00ACB1AC /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7C32DCA2CCE00ACB1AC /* Operation.swift */; }; + 4F0AC8182DCA2CCE00ACB1AC /* TaskFetchWithPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7E22DCA2CCE00ACB1AC /* TaskFetchWithPublisher.swift */; }; + 4F0AC8192DCA2CCE00ACB1AC /* ImageProcessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7D52DCA2CCE00ACB1AC /* ImageProcessors.swift */; }; + 4F0AC81A2DCA2CCE00ACB1AC /* ImageProcessors+Circle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7D72DCA2CCE00ACB1AC /* ImageProcessors+Circle.swift */; }; + 4F0AC81B2DCA2CCE00ACB1AC /* ImagePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7BF2DCA2CCE00ACB1AC /* ImagePublisher.swift */; }; + 4F0AC81C2DCA2CCE00ACB1AC /* LazyImageState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7F12DCA2CCE00ACB1AC /* LazyImageState.swift */; }; + 4F0AC81D2DCA2CCE00ACB1AC /* ImageDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7B42DCA2CCE00ACB1AC /* ImageDecoding.swift */; }; + 4F0AC81E2DCA2CCE00ACB1AC /* AsyncTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7DF2DCA2CCE00ACB1AC /* AsyncTask.swift */; }; + 4F0AC81F2DCA2CCE00ACB1AC /* DataLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7C82DCA2CCE00ACB1AC /* DataLoading.swift */; }; + 4F0AC8202DCA2CCE00ACB1AC /* ImageEncoders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7B62DCA2CCE00ACB1AC /* ImageEncoders.swift */; }; + 4F0AC8212DCA2CCE00ACB1AC /* TaskFetchOriginalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7E02DCA2CCE00ACB1AC /* TaskFetchOriginalData.swift */; }; + 4F0AC8222DCA2CCE00ACB1AC /* AVDataAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7F42DCA2CCE00ACB1AC /* AVDataAsset.swift */; }; + 4F0AC8232DCA2CCE00ACB1AC /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7E72DCA2CCE00ACB1AC /* ImageRequest.swift */; }; + 4F0AC8242DCA2CCE00ACB1AC /* ImageTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7E92DCA2CCE00ACB1AC /* ImageTask.swift */; }; + 4F0AC8252DCA2CCE00ACB1AC /* ImageCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7AD2DCA2CCE00ACB1AC /* ImageCaching.swift */; }; + 4F0AC8262DCA2CCE00ACB1AC /* TaskFetchOriginalImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7E12DCA2CCE00ACB1AC /* TaskFetchOriginalImage.swift */; }; + 4F0AC8272DCA2CCE00ACB1AC /* AsyncPipelineTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7DE2DCA2CCE00ACB1AC /* AsyncPipelineTask.swift */; }; + 4F0AC8282DCA2CCE00ACB1AC /* ImageEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7B92DCA2CCE00ACB1AC /* ImageEncoding.swift */; }; + 4F0AC8292DCA2CCE00ACB1AC /* TaskLoadImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7E42DCA2CCE00ACB1AC /* TaskLoadImage.swift */; }; + 4F0AC82A2DCA2CCE00ACB1AC /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7C72DCA2CCE00ACB1AC /* DataLoader.swift */; }; + 4F0AC82B2DCA2CCE00ACB1AC /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7BB2DCA2CCE00ACB1AC /* Atomic.swift */; }; + 4F0AC82C2DCA2CCE00ACB1AC /* ImageDecoders+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7B22DCA2CCE00ACB1AC /* ImageDecoders+Default.swift */; }; + 4F0AC82D2DCA2CCE00ACB1AC /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7AC2DCA2CCE00ACB1AC /* ImageCache.swift */; }; + 4F0AC82E2DCA2CCE00ACB1AC /* ImageDecoders+Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7B32DCA2CCE00ACB1AC /* ImageDecoders+Empty.swift */; }; + 4F0AC82F2DCA2CCE00ACB1AC /* DataPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7BC2DCA2CCE00ACB1AC /* DataPublisher.swift */; }; + 4F0AC8302DCA2CCE00ACB1AC /* LazyImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7F02DCA2CCE00ACB1AC /* LazyImage.swift */; }; + 4F0AC8312DCA2CCE00ACB1AC /* ImagePipeline+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7CE2DCA2CCE00ACB1AC /* ImagePipeline+Error.swift */; }; + 4F0AC8322DCA2CCE00ACB1AC /* ImageContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7E62DCA2CCE00ACB1AC /* ImageContainer.swift */; }; + 4F0AC8332DCA2CCE00ACB1AC /* ResumableData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7C52DCA2CCE00ACB1AC /* ResumableData.swift */; }; + 4F0AC8342DCA2CCE00ACB1AC /* ImageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7E82DCA2CCE00ACB1AC /* ImageResponse.swift */; }; + 4F0AC8352DCA2CCE00ACB1AC /* LazyImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7F22DCA2CCE00ACB1AC /* LazyImageView.swift */; }; + 4F0AC8362DCA2CCE00ACB1AC /* ImageProcessors+GaussianBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7DA2DCA2CCE00ACB1AC /* ImageProcessors+GaussianBlur.swift */; }; + 4F0AC8372DCA2CCE00ACB1AC /* ImageLoadingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7EB2DCA2CCE00ACB1AC /* ImageLoadingOptions.swift */; }; + 4F0AC8382DCA2CCE00ACB1AC /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0AC7AA2DCA2CCE00ACB1AC /* DataCache.swift */; }; 4F198FDD2C0480EC00148F49 /* Publisher+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F198FDC2C0480EC00148F49 /* Publisher+Extensions.swift */; }; 4F65F1862D06EEA7009F69A8 /* ChooseChannelQueryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F65F1852D06EEA5009F69A8 /* ChooseChannelQueryView.swift */; }; 4F65F18A2D071798009F69A8 /* ChannelListQueryIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F65F1892D071798009F69A8 /* ChannelListQueryIdentifier.swift */; }; @@ -51,86 +117,6 @@ 82D64B692AD7E5AC00C5C79E /* UIImage+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B632AD7E5AC00C5C79E /* UIImage+SwiftyGif.swift */; }; 82D64B6A2AD7E5AC00C5C79E /* SwiftyGifManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B642AD7E5AC00C5C79E /* SwiftyGifManager.swift */; }; 82D64B6B2AD7E5AC00C5C79E /* NSImageView+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B652AD7E5AC00C5C79E /* NSImageView+SwiftyGif.swift */; }; - 82D64BCD2AD7E5B700C5C79E /* ImageLoadingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B6E2AD7E5B600C5C79E /* ImageLoadingOptions.swift */; }; - 82D64BCE2AD7E5B700C5C79E /* ImageViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B6F2AD7E5B600C5C79E /* ImageViewExtensions.swift */; }; - 82D64BCF2AD7E5B700C5C79E /* LazyImageState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B712AD7E5B600C5C79E /* LazyImageState.swift */; }; - 82D64BD02AD7E5B700C5C79E /* NukeVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B722AD7E5B600C5C79E /* NukeVideoPlayerView.swift */; }; - 82D64BD12AD7E5B700C5C79E /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B732AD7E5B600C5C79E /* Image.swift */; }; - 82D64BD22AD7E5B700C5C79E /* FetchImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B742AD7E5B600C5C79E /* FetchImage.swift */; }; - 82D64BD32AD7E5B700C5C79E /* FrameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B772AD7E5B600C5C79E /* FrameStore.swift */; }; - 82D64BD42AD7E5B700C5C79E /* GIFAnimatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B782AD7E5B600C5C79E /* GIFAnimatable.swift */; }; - 82D64BD52AD7E5B700C5C79E /* AnimatedFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B792AD7E5B600C5C79E /* AnimatedFrame.swift */; }; - 82D64BD62AD7E5B700C5C79E /* Animator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B7A2AD7E5B600C5C79E /* Animator.swift */; }; - 82D64BD72AD7E5B700C5C79E /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B7B2AD7E5B600C5C79E /* GIFImageView.swift */; }; - 82D64BD82AD7E5B700C5C79E /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B7D2AD7E5B600C5C79E /* Array.swift */; }; - 82D64BD92AD7E5B700C5C79E /* CGSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B7E2AD7E5B600C5C79E /* CGSize.swift */; }; - 82D64BDA2AD7E5B700C5C79E /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B7F2AD7E5B600C5C79E /* UIImage.swift */; }; - 82D64BDB2AD7E5B700C5C79E /* UIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B802AD7E5B600C5C79E /* UIImageView.swift */; }; - 82D64BDC2AD7E5B700C5C79E /* ImageSourceHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B822AD7E5B600C5C79E /* ImageSourceHelpers.swift */; }; - 82D64BDD2AD7E5B700C5C79E /* AnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B832AD7E5B600C5C79E /* AnimatedImageView.swift */; }; - 82D64BDE2AD7E5B700C5C79E /* Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B842AD7E5B600C5C79E /* Internal.swift */; }; - 82D64BDF2AD7E5B700C5C79E /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B852AD7E5B600C5C79E /* ImageView.swift */; }; - 82D64BE02AD7E5B700C5C79E /* LazyImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B862AD7E5B600C5C79E /* LazyImage.swift */; }; - 82D64BE12AD7E5B700C5C79E /* LazyImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B872AD7E5B600C5C79E /* LazyImageView.swift */; }; - 82D64BE22AD7E5B700C5C79E /* ImagePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B8A2AD7E5B600C5C79E /* ImagePipeline.swift */; }; - 82D64BE32AD7E5B700C5C79E /* ImagePipelineError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B8B2AD7E5B600C5C79E /* ImagePipelineError.swift */; }; - 82D64BE42AD7E5B700C5C79E /* ImagePipelineConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B8C2AD7E5B600C5C79E /* ImagePipelineConfiguration.swift */; }; - 82D64BE52AD7E5B700C5C79E /* ImagePipelineCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B8D2AD7E5B600C5C79E /* ImagePipelineCache.swift */; }; - 82D64BE62AD7E5B700C5C79E /* ImagePipelineDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B8E2AD7E5B600C5C79E /* ImagePipelineDelegate.swift */; }; - 82D64BE72AD7E5B700C5C79E /* ImageTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B8F2AD7E5B600C5C79E /* ImageTask.swift */; }; - 82D64BE82AD7E5B700C5C79E /* TaskFetchDecodedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B912AD7E5B600C5C79E /* TaskFetchDecodedImage.swift */; }; - 82D64BE92AD7E5B700C5C79E /* TaskLoadData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B922AD7E5B600C5C79E /* TaskLoadData.swift */; }; - 82D64BEA2AD7E5B700C5C79E /* TaskFetchOriginalImageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B932AD7E5B600C5C79E /* TaskFetchOriginalImageData.swift */; }; - 82D64BEB2AD7E5B700C5C79E /* ImagePipelineTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B942AD7E5B600C5C79E /* ImagePipelineTask.swift */; }; - 82D64BEC2AD7E5B700C5C79E /* OperationTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B952AD7E5B600C5C79E /* OperationTask.swift */; }; - 82D64BED2AD7E5B700C5C79E /* TaskLoadImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B962AD7E5B600C5C79E /* TaskLoadImage.swift */; }; - 82D64BEE2AD7E5B700C5C79E /* AsyncTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B972AD7E5B600C5C79E /* AsyncTask.swift */; }; - 82D64BEF2AD7E5B700C5C79E /* TaskFetchWithPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B982AD7E5B600C5C79E /* TaskFetchWithPublisher.swift */; }; - 82D64BF02AD7E5B700C5C79E /* DataLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B9A2AD7E5B600C5C79E /* DataLoading.swift */; }; - 82D64BF12AD7E5B700C5C79E /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B9B2AD7E5B600C5C79E /* DataLoader.swift */; }; - 82D64BF22AD7E5B700C5C79E /* ImageProcessors+RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B9D2AD7E5B600C5C79E /* ImageProcessors+RoundedCorners.swift */; }; - 82D64BF32AD7E5B700C5C79E /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B9E2AD7E5B600C5C79E /* ImageProcessing.swift */; }; - 82D64BF42AD7E5B700C5C79E /* ImageProcessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B9F2AD7E5B600C5C79E /* ImageProcessors.swift */; }; - 82D64BF52AD7E5B700C5C79E /* ImageProcessors+GaussianBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BA02AD7E5B600C5C79E /* ImageProcessors+GaussianBlur.swift */; }; - 82D64BF62AD7E5B700C5C79E /* ImageProcessors+CoreImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BA12AD7E5B600C5C79E /* ImageProcessors+CoreImage.swift */; }; - 82D64BF72AD7E5B700C5C79E /* ImageProcessingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BA22AD7E5B600C5C79E /* ImageProcessingOptions.swift */; }; - 82D64BF82AD7E5B700C5C79E /* ImageProcessors+Circle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BA32AD7E5B700C5C79E /* ImageProcessors+Circle.swift */; }; - 82D64BF92AD7E5B700C5C79E /* ImageProcessors+Resize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BA42AD7E5B700C5C79E /* ImageProcessors+Resize.swift */; }; - 82D64BFA2AD7E5B700C5C79E /* ImageProcessors+Anonymous.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BA52AD7E5B700C5C79E /* ImageProcessors+Anonymous.swift */; }; - 82D64BFB2AD7E5B700C5C79E /* ImageProcessors+Composition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BA62AD7E5B700C5C79E /* ImageProcessors+Composition.swift */; }; - 82D64BFC2AD7E5B700C5C79E /* ImageDecompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BA72AD7E5B700C5C79E /* ImageDecompression.swift */; }; - 82D64BFD2AD7E5B700C5C79E /* ImagePrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BA92AD7E5B700C5C79E /* ImagePrefetcher.swift */; }; - 82D64BFE2AD7E5B700C5C79E /* ResumableData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BAB2AD7E5B700C5C79E /* ResumableData.swift */; }; - 82D64BFF2AD7E5B700C5C79E /* Allocations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BAC2AD7E5B700C5C79E /* Allocations.swift */; }; - 82D64C002AD7E5B700C5C79E /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BAD2AD7E5B700C5C79E /* Log.swift */; }; - 82D64C012AD7E5B700C5C79E /* DataPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BAE2AD7E5B700C5C79E /* DataPublisher.swift */; }; - 82D64C022AD7E5B700C5C79E /* AVDataAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BAF2AD7E5B700C5C79E /* AVDataAsset.swift */; }; - 82D64C032AD7E5B700C5C79E /* RateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BB02AD7E5B700C5C79E /* RateLimiter.swift */; }; - 82D64C042AD7E5B700C5C79E /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BB12AD7E5B700C5C79E /* Extensions.swift */; }; - 82D64C052AD7E5B700C5C79E /* Deprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BB22AD7E5B700C5C79E /* Deprecated.swift */; }; - 82D64C062AD7E5B700C5C79E /* Graphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BB32AD7E5B700C5C79E /* Graphics.swift */; }; - 82D64C072AD7E5B700C5C79E /* ImagePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BB42AD7E5B700C5C79E /* ImagePublisher.swift */; }; - 82D64C082AD7E5B700C5C79E /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BB52AD7E5B700C5C79E /* Operation.swift */; }; - 82D64C092AD7E5B700C5C79E /* ImageRequestKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BB62AD7E5B700C5C79E /* ImageRequestKeys.swift */; }; - 82D64C0A2AD7E5B700C5C79E /* LinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BB72AD7E5B700C5C79E /* LinkedList.swift */; }; - 82D64C0B2AD7E5B700C5C79E /* ImageEncoders+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BB92AD7E5B700C5C79E /* ImageEncoders+Default.swift */; }; - 82D64C0C2AD7E5B700C5C79E /* ImageEncoders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BBA2AD7E5B700C5C79E /* ImageEncoders.swift */; }; - 82D64C0D2AD7E5B700C5C79E /* ImageEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BBB2AD7E5B700C5C79E /* ImageEncoding.swift */; }; - 82D64C0E2AD7E5B700C5C79E /* ImageEncoders+ImageIO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BBC2AD7E5B700C5C79E /* ImageEncoders+ImageIO.swift */; }; - 82D64C0F2AD7E5B700C5C79E /* ImageDecoders+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BBE2AD7E5B700C5C79E /* ImageDecoders+Video.swift */; }; - 82D64C102AD7E5B700C5C79E /* ImageDecoders+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BBF2AD7E5B700C5C79E /* ImageDecoders+Default.swift */; }; - 82D64C112AD7E5B700C5C79E /* AssetType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BC02AD7E5B700C5C79E /* AssetType.swift */; }; - 82D64C122AD7E5B700C5C79E /* ImageDecoders+Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BC12AD7E5B700C5C79E /* ImageDecoders+Empty.swift */; }; - 82D64C132AD7E5B700C5C79E /* ImageDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BC22AD7E5B700C5C79E /* ImageDecoding.swift */; }; - 82D64C142AD7E5B700C5C79E /* ImageDecoderRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BC32AD7E5B700C5C79E /* ImageDecoderRegistry.swift */; }; - 82D64C152AD7E5B700C5C79E /* ImageContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BC42AD7E5B700C5C79E /* ImageContainer.swift */; }; - 82D64C162AD7E5B700C5C79E /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BC52AD7E5B700C5C79E /* ImageRequest.swift */; }; - 82D64C172AD7E5B700C5C79E /* ImageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BC62AD7E5B700C5C79E /* ImageResponse.swift */; }; - 82D64C182AD7E5B700C5C79E /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BC82AD7E5B700C5C79E /* ImageCache.swift */; }; - 82D64C192AD7E5B700C5C79E /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BC92AD7E5B700C5C79E /* DataCache.swift */; }; - 82D64C1A2AD7E5B700C5C79E /* NukeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BCA2AD7E5B700C5C79E /* NukeCache.swift */; }; - 82D64C1B2AD7E5B700C5C79E /* ImageCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BCB2AD7E5B700C5C79E /* ImageCaching.swift */; }; - 82D64C1C2AD7E5B700C5C79E /* DataCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64BCC2AD7E5B700C5C79E /* DataCaching.swift */; }; 82FA42442AE67FF900C7390B /* SystemEnvironment+Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FA42432AE67FF900C7390B /* SystemEnvironment+Version.swift */; }; 82FF61EC2B6AB789007185B6 /* StreamChat in Frameworks */ = {isa = PBXBuildFile; productRef = 82FF61EB2B6AB789007185B6 /* StreamChat */; }; 8400A345282C05F60067D3A0 /* StreamChatWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400A344282C05F60067D3A0 /* StreamChatWrapper.swift */; }; @@ -605,7 +591,73 @@ /* Begin PBXFileReference section */ 4A65451E274BA170003C5FA8 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 4F06950F2D96A57C00DB7E3B /* MainActor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainActor+Extensions.swift"; sourceTree = ""; }; 4F077EF72C85E05700F06D83 /* DelayedRenderingViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelayedRenderingViewModifier.swift; sourceTree = ""; }; + 4F0AC7AA2DCA2CCE00ACB1AC /* DataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = ""; }; + 4F0AC7AB2DCA2CCE00ACB1AC /* DataCaching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCaching.swift; sourceTree = ""; }; + 4F0AC7AC2DCA2CCE00ACB1AC /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; + 4F0AC7AD2DCA2CCE00ACB1AC /* ImageCaching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCaching.swift; sourceTree = ""; }; + 4F0AC7AE2DCA2CCE00ACB1AC /* NukeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeCache.swift; sourceTree = ""; }; + 4F0AC7B02DCA2CCE00ACB1AC /* AssetType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetType.swift; sourceTree = ""; }; + 4F0AC7B12DCA2CCE00ACB1AC /* ImageDecoderRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecoderRegistry.swift; sourceTree = ""; }; + 4F0AC7B22DCA2CCE00ACB1AC /* ImageDecoders+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+Default.swift"; sourceTree = ""; }; + 4F0AC7B32DCA2CCE00ACB1AC /* ImageDecoders+Empty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+Empty.swift"; sourceTree = ""; }; + 4F0AC7B42DCA2CCE00ACB1AC /* ImageDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecoding.swift; sourceTree = ""; }; + 4F0AC7B62DCA2CCE00ACB1AC /* ImageEncoders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEncoders.swift; sourceTree = ""; }; + 4F0AC7B72DCA2CCE00ACB1AC /* ImageEncoders+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageEncoders+Default.swift"; sourceTree = ""; }; + 4F0AC7B82DCA2CCE00ACB1AC /* ImageEncoders+ImageIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageEncoders+ImageIO.swift"; sourceTree = ""; }; + 4F0AC7B92DCA2CCE00ACB1AC /* ImageEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEncoding.swift; sourceTree = ""; }; + 4F0AC7BB2DCA2CCE00ACB1AC /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; + 4F0AC7BC2DCA2CCE00ACB1AC /* DataPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPublisher.swift; sourceTree = ""; }; + 4F0AC7BD2DCA2CCE00ACB1AC /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + 4F0AC7BE2DCA2CCE00ACB1AC /* Graphics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Graphics.swift; sourceTree = ""; }; + 4F0AC7BF2DCA2CCE00ACB1AC /* ImagePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePublisher.swift; sourceTree = ""; }; + 4F0AC7C02DCA2CCE00ACB1AC /* ImageRequestKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequestKeys.swift; sourceTree = ""; }; + 4F0AC7C12DCA2CCE00ACB1AC /* LinkedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedList.swift; sourceTree = ""; }; + 4F0AC7C22DCA2CCE00ACB1AC /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; + 4F0AC7C32DCA2CCE00ACB1AC /* Operation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = ""; }; + 4F0AC7C42DCA2CCE00ACB1AC /* RateLimiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimiter.swift; sourceTree = ""; }; + 4F0AC7C52DCA2CCE00ACB1AC /* ResumableData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumableData.swift; sourceTree = ""; }; + 4F0AC7C72DCA2CCE00ACB1AC /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = ""; }; + 4F0AC7C82DCA2CCE00ACB1AC /* DataLoading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoading.swift; sourceTree = ""; }; + 4F0AC7CA2DCA2CCE00ACB1AC /* ImagePipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipeline.swift; sourceTree = ""; }; + 4F0AC7CB2DCA2CCE00ACB1AC /* ImagePipeline+Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Cache.swift"; sourceTree = ""; }; + 4F0AC7CC2DCA2CCE00ACB1AC /* ImagePipeline+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Configuration.swift"; sourceTree = ""; }; + 4F0AC7CD2DCA2CCE00ACB1AC /* ImagePipeline+Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Delegate.swift"; sourceTree = ""; }; + 4F0AC7CE2DCA2CCE00ACB1AC /* ImagePipeline+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Error.swift"; sourceTree = ""; }; + 4F0AC7D02DCA2CCE00ACB1AC /* ImagePrefetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePrefetcher.swift; sourceTree = ""; }; + 4F0AC7D22DCA2CCE00ACB1AC /* ImageDecompression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecompression.swift; sourceTree = ""; }; + 4F0AC7D32DCA2CCE00ACB1AC /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = ""; }; + 4F0AC7D42DCA2CCE00ACB1AC /* ImageProcessingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessingOptions.swift; sourceTree = ""; }; + 4F0AC7D52DCA2CCE00ACB1AC /* ImageProcessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessors.swift; sourceTree = ""; }; + 4F0AC7D62DCA2CCE00ACB1AC /* ImageProcessors+Anonymous.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Anonymous.swift"; sourceTree = ""; }; + 4F0AC7D72DCA2CCE00ACB1AC /* ImageProcessors+Circle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Circle.swift"; sourceTree = ""; }; + 4F0AC7D82DCA2CCE00ACB1AC /* ImageProcessors+Composition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Composition.swift"; sourceTree = ""; }; + 4F0AC7D92DCA2CCE00ACB1AC /* ImageProcessors+CoreImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+CoreImage.swift"; sourceTree = ""; }; + 4F0AC7DA2DCA2CCE00ACB1AC /* ImageProcessors+GaussianBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+GaussianBlur.swift"; sourceTree = ""; }; + 4F0AC7DB2DCA2CCE00ACB1AC /* ImageProcessors+Resize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Resize.swift"; sourceTree = ""; }; + 4F0AC7DC2DCA2CCE00ACB1AC /* ImageProcessors+RoundedCorners.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+RoundedCorners.swift"; sourceTree = ""; }; + 4F0AC7DE2DCA2CCE00ACB1AC /* AsyncPipelineTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPipelineTask.swift; sourceTree = ""; }; + 4F0AC7DF2DCA2CCE00ACB1AC /* AsyncTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTask.swift; sourceTree = ""; }; + 4F0AC7E02DCA2CCE00ACB1AC /* TaskFetchOriginalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchOriginalData.swift; sourceTree = ""; }; + 4F0AC7E12DCA2CCE00ACB1AC /* TaskFetchOriginalImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchOriginalImage.swift; sourceTree = ""; }; + 4F0AC7E22DCA2CCE00ACB1AC /* TaskFetchWithPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchWithPublisher.swift; sourceTree = ""; }; + 4F0AC7E32DCA2CCE00ACB1AC /* TaskLoadData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLoadData.swift; sourceTree = ""; }; + 4F0AC7E42DCA2CCE00ACB1AC /* TaskLoadImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLoadImage.swift; sourceTree = ""; }; + 4F0AC7E62DCA2CCE00ACB1AC /* ImageContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContainer.swift; sourceTree = ""; }; + 4F0AC7E72DCA2CCE00ACB1AC /* ImageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = ""; }; + 4F0AC7E82DCA2CCE00ACB1AC /* ImageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageResponse.swift; sourceTree = ""; }; + 4F0AC7E92DCA2CCE00ACB1AC /* ImageTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTask.swift; sourceTree = ""; }; + 4F0AC7EB2DCA2CCE00ACB1AC /* ImageLoadingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoadingOptions.swift; sourceTree = ""; }; + 4F0AC7EC2DCA2CCE00ACB1AC /* ImageViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewExtensions.swift; sourceTree = ""; }; + 4F0AC7EE2DCA2CCE00ACB1AC /* FetchImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchImage.swift; sourceTree = ""; }; + 4F0AC7EF2DCA2CCE00ACB1AC /* Internal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Internal.swift; sourceTree = ""; }; + 4F0AC7F02DCA2CCE00ACB1AC /* LazyImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyImage.swift; sourceTree = ""; }; + 4F0AC7F12DCA2CCE00ACB1AC /* LazyImageState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyImageState.swift; sourceTree = ""; }; + 4F0AC7F22DCA2CCE00ACB1AC /* LazyImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyImageView.swift; sourceTree = ""; }; + 4F0AC7F42DCA2CCE00ACB1AC /* AVDataAsset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVDataAsset.swift; sourceTree = ""; }; + 4F0AC7F52DCA2CCE00ACB1AC /* ImageDecoders+Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+Video.swift"; sourceTree = ""; }; + 4F0AC7F62DCA2CCE00ACB1AC /* NukeVideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeVideoPlayerView.swift; sourceTree = ""; }; 4F198FDC2C0480EC00148F49 /* Publisher+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Extensions.swift"; sourceTree = ""; }; 4F65F1852D06EEA5009F69A8 /* ChooseChannelQueryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseChannelQueryView.swift; sourceTree = ""; }; 4F65F1892D071798009F69A8 /* ChannelListQueryIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListQueryIdentifier.swift; sourceTree = ""; }; @@ -644,86 +696,6 @@ 82D64B632AD7E5AC00C5C79E /* UIImage+SwiftyGif.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+SwiftyGif.swift"; sourceTree = ""; }; 82D64B642AD7E5AC00C5C79E /* SwiftyGifManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftyGifManager.swift; sourceTree = ""; }; 82D64B652AD7E5AC00C5C79E /* NSImageView+SwiftyGif.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSImageView+SwiftyGif.swift"; sourceTree = ""; }; - 82D64B6E2AD7E5B600C5C79E /* ImageLoadingOptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageLoadingOptions.swift; sourceTree = ""; }; - 82D64B6F2AD7E5B600C5C79E /* ImageViewExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageViewExtensions.swift; sourceTree = ""; }; - 82D64B712AD7E5B600C5C79E /* LazyImageState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LazyImageState.swift; sourceTree = ""; }; - 82D64B722AD7E5B600C5C79E /* NukeVideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NukeVideoPlayerView.swift; sourceTree = ""; }; - 82D64B732AD7E5B600C5C79E /* Image.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; - 82D64B742AD7E5B600C5C79E /* FetchImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchImage.swift; sourceTree = ""; }; - 82D64B772AD7E5B600C5C79E /* FrameStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameStore.swift; sourceTree = ""; }; - 82D64B782AD7E5B600C5C79E /* GIFAnimatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GIFAnimatable.swift; sourceTree = ""; }; - 82D64B792AD7E5B600C5C79E /* AnimatedFrame.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatedFrame.swift; sourceTree = ""; }; - 82D64B7A2AD7E5B600C5C79E /* Animator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Animator.swift; sourceTree = ""; }; - 82D64B7B2AD7E5B600C5C79E /* GIFImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = ""; }; - 82D64B7D2AD7E5B600C5C79E /* Array.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; - 82D64B7E2AD7E5B600C5C79E /* CGSize.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGSize.swift; sourceTree = ""; }; - 82D64B7F2AD7E5B600C5C79E /* UIImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; - 82D64B802AD7E5B600C5C79E /* UIImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIImageView.swift; sourceTree = ""; }; - 82D64B822AD7E5B600C5C79E /* ImageSourceHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageSourceHelpers.swift; sourceTree = ""; }; - 82D64B832AD7E5B600C5C79E /* AnimatedImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatedImageView.swift; sourceTree = ""; }; - 82D64B842AD7E5B600C5C79E /* Internal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Internal.swift; sourceTree = ""; }; - 82D64B852AD7E5B600C5C79E /* ImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; - 82D64B862AD7E5B600C5C79E /* LazyImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LazyImage.swift; sourceTree = ""; }; - 82D64B872AD7E5B600C5C79E /* LazyImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LazyImageView.swift; sourceTree = ""; }; - 82D64B8A2AD7E5B600C5C79E /* ImagePipeline.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipeline.swift; sourceTree = ""; }; - 82D64B8B2AD7E5B600C5C79E /* ImagePipelineError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipelineError.swift; sourceTree = ""; }; - 82D64B8C2AD7E5B600C5C79E /* ImagePipelineConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipelineConfiguration.swift; sourceTree = ""; }; - 82D64B8D2AD7E5B600C5C79E /* ImagePipelineCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipelineCache.swift; sourceTree = ""; }; - 82D64B8E2AD7E5B600C5C79E /* ImagePipelineDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipelineDelegate.swift; sourceTree = ""; }; - 82D64B8F2AD7E5B600C5C79E /* ImageTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageTask.swift; sourceTree = ""; }; - 82D64B912AD7E5B600C5C79E /* TaskFetchDecodedImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TaskFetchDecodedImage.swift; sourceTree = ""; }; - 82D64B922AD7E5B600C5C79E /* TaskLoadData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TaskLoadData.swift; sourceTree = ""; }; - 82D64B932AD7E5B600C5C79E /* TaskFetchOriginalImageData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TaskFetchOriginalImageData.swift; sourceTree = ""; }; - 82D64B942AD7E5B600C5C79E /* ImagePipelineTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipelineTask.swift; sourceTree = ""; }; - 82D64B952AD7E5B600C5C79E /* OperationTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationTask.swift; sourceTree = ""; }; - 82D64B962AD7E5B600C5C79E /* TaskLoadImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TaskLoadImage.swift; sourceTree = ""; }; - 82D64B972AD7E5B600C5C79E /* AsyncTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncTask.swift; sourceTree = ""; }; - 82D64B982AD7E5B600C5C79E /* TaskFetchWithPublisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TaskFetchWithPublisher.swift; sourceTree = ""; }; - 82D64B9A2AD7E5B600C5C79E /* DataLoading.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataLoading.swift; sourceTree = ""; }; - 82D64B9B2AD7E5B600C5C79E /* DataLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = ""; }; - 82D64B9D2AD7E5B600C5C79E /* ImageProcessors+RoundedCorners.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+RoundedCorners.swift"; sourceTree = ""; }; - 82D64B9E2AD7E5B600C5C79E /* ImageProcessing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = ""; }; - 82D64B9F2AD7E5B600C5C79E /* ImageProcessors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageProcessors.swift; sourceTree = ""; }; - 82D64BA02AD7E5B600C5C79E /* ImageProcessors+GaussianBlur.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+GaussianBlur.swift"; sourceTree = ""; }; - 82D64BA12AD7E5B600C5C79E /* ImageProcessors+CoreImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+CoreImage.swift"; sourceTree = ""; }; - 82D64BA22AD7E5B600C5C79E /* ImageProcessingOptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageProcessingOptions.swift; sourceTree = ""; }; - 82D64BA32AD7E5B700C5C79E /* ImageProcessors+Circle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Circle.swift"; sourceTree = ""; }; - 82D64BA42AD7E5B700C5C79E /* ImageProcessors+Resize.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Resize.swift"; sourceTree = ""; }; - 82D64BA52AD7E5B700C5C79E /* ImageProcessors+Anonymous.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Anonymous.swift"; sourceTree = ""; }; - 82D64BA62AD7E5B700C5C79E /* ImageProcessors+Composition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Composition.swift"; sourceTree = ""; }; - 82D64BA72AD7E5B700C5C79E /* ImageDecompression.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageDecompression.swift; sourceTree = ""; }; - 82D64BA92AD7E5B700C5C79E /* ImagePrefetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePrefetcher.swift; sourceTree = ""; }; - 82D64BAB2AD7E5B700C5C79E /* ResumableData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResumableData.swift; sourceTree = ""; }; - 82D64BAC2AD7E5B700C5C79E /* Allocations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Allocations.swift; sourceTree = ""; }; - 82D64BAD2AD7E5B700C5C79E /* Log.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; - 82D64BAE2AD7E5B700C5C79E /* DataPublisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataPublisher.swift; sourceTree = ""; }; - 82D64BAF2AD7E5B700C5C79E /* AVDataAsset.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AVDataAsset.swift; sourceTree = ""; }; - 82D64BB02AD7E5B700C5C79E /* RateLimiter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RateLimiter.swift; sourceTree = ""; }; - 82D64BB12AD7E5B700C5C79E /* Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; - 82D64BB22AD7E5B700C5C79E /* Deprecated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Deprecated.swift; sourceTree = ""; }; - 82D64BB32AD7E5B700C5C79E /* Graphics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Graphics.swift; sourceTree = ""; }; - 82D64BB42AD7E5B700C5C79E /* ImagePublisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePublisher.swift; sourceTree = ""; }; - 82D64BB52AD7E5B700C5C79E /* Operation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = ""; }; - 82D64BB62AD7E5B700C5C79E /* ImageRequestKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRequestKeys.swift; sourceTree = ""; }; - 82D64BB72AD7E5B700C5C79E /* LinkedList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkedList.swift; sourceTree = ""; }; - 82D64BB92AD7E5B700C5C79E /* ImageEncoders+Default.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageEncoders+Default.swift"; sourceTree = ""; }; - 82D64BBA2AD7E5B700C5C79E /* ImageEncoders.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEncoders.swift; sourceTree = ""; }; - 82D64BBB2AD7E5B700C5C79E /* ImageEncoding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEncoding.swift; sourceTree = ""; }; - 82D64BBC2AD7E5B700C5C79E /* ImageEncoders+ImageIO.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageEncoders+ImageIO.swift"; sourceTree = ""; }; - 82D64BBE2AD7E5B700C5C79E /* ImageDecoders+Video.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+Video.swift"; sourceTree = ""; }; - 82D64BBF2AD7E5B700C5C79E /* ImageDecoders+Default.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+Default.swift"; sourceTree = ""; }; - 82D64BC02AD7E5B700C5C79E /* AssetType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssetType.swift; sourceTree = ""; }; - 82D64BC12AD7E5B700C5C79E /* ImageDecoders+Empty.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+Empty.swift"; sourceTree = ""; }; - 82D64BC22AD7E5B700C5C79E /* ImageDecoding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageDecoding.swift; sourceTree = ""; }; - 82D64BC32AD7E5B700C5C79E /* ImageDecoderRegistry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageDecoderRegistry.swift; sourceTree = ""; }; - 82D64BC42AD7E5B700C5C79E /* ImageContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageContainer.swift; sourceTree = ""; }; - 82D64BC52AD7E5B700C5C79E /* ImageRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = ""; }; - 82D64BC62AD7E5B700C5C79E /* ImageResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageResponse.swift; sourceTree = ""; }; - 82D64BC82AD7E5B700C5C79E /* ImageCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; - 82D64BC92AD7E5B700C5C79E /* DataCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = ""; }; - 82D64BCA2AD7E5B700C5C79E /* NukeCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NukeCache.swift; sourceTree = ""; }; - 82D64BCB2AD7E5B700C5C79E /* ImageCaching.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCaching.swift; sourceTree = ""; }; - 82D64BCC2AD7E5B700C5C79E /* DataCaching.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataCaching.swift; sourceTree = ""; }; 82E8D79C2902B949008A8F78 /* StreamChatSwiftUITestsApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = StreamChatSwiftUITestsApp.entitlements; sourceTree = ""; }; 82FA42432AE67FF900C7390B /* SystemEnvironment+Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemEnvironment+Version.swift"; sourceTree = ""; }; 840008BA27E8D64A00282D88 /* MessageActions_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageActions_Tests.swift; sourceTree = ""; }; @@ -1189,275 +1161,236 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 4F65F18B2D0724F3009F69A8 /* ChannelHeader */ = { - isa = PBXGroup; - children = ( - 846B8E2B2C5B8117006A6249 /* BlockedUsersView.swift */, - 846B8E2D2C5B8130006A6249 /* BlockedUsersViewModel.swift */, - 4F65F1892D071798009F69A8 /* ChannelListQueryIdentifier.swift */, - 4F65F1852D06EEA5009F69A8 /* ChooseChannelQueryView.swift */, - 8465FCD7274694D200AF091E /* CustomChannelHeader.swift */, - 84335015274BABF3007A1B81 /* NewChatView.swift */, - 84335017274BAD4B007A1B81 /* NewChatViewModel.swift */, - ); - path = ChannelHeader; - sourceTree = ""; - }; - 4F6D83522C108D470098C298 /* CommonViews */ = { - isa = PBXGroup; - children = ( - 4F6D83532C1094220098C298 /* AlertBannerViewModifier_Tests.swift */, - ); - path = CommonViews; - sourceTree = ""; - }; - 4FA3741B2D799F9E00294721 /* AppConfiguration */ = { + 4F0AC7AF2DCA2CCE00ACB1AC /* Caching */ = { isa = PBXGroup; children = ( - 4FA3741E2D79A64900294721 /* AppConfiguration.swift */, - 4FA3741C2D799FC300294721 /* AppConfigurationTranslationView.swift */, - 4FA374192D799CA400294721 /* AppConfigurationView.swift */, + 4F0AC7AA2DCA2CCE00ACB1AC /* DataCache.swift */, + 4F0AC7AB2DCA2CCE00ACB1AC /* DataCaching.swift */, + 4F0AC7AC2DCA2CCE00ACB1AC /* ImageCache.swift */, + 4F0AC7AD2DCA2CCE00ACB1AC /* ImageCaching.swift */, + 4F0AC7AE2DCA2CCE00ACB1AC /* NukeCache.swift */, ); - path = AppConfiguration; + path = Caching; sourceTree = ""; }; - 82A1814528FD69E8005F9D43 /* Message Delivery Status */ = { + 4F0AC7B52DCA2CCE00ACB1AC /* Decoding */ = { isa = PBXGroup; children = ( - 82A1814628FD69F8005F9D43 /* MessageDeliveryStatus_Tests.swift */, - 82A1814828FD6A0C005F9D43 /* MessageDeliveryStatus+ChannelList_Tests.swift */, + 4F0AC7B02DCA2CCE00ACB1AC /* AssetType.swift */, + 4F0AC7B12DCA2CCE00ACB1AC /* ImageDecoderRegistry.swift */, + 4F0AC7B22DCA2CCE00ACB1AC /* ImageDecoders+Default.swift */, + 4F0AC7B32DCA2CCE00ACB1AC /* ImageDecoders+Empty.swift */, + 4F0AC7B42DCA2CCE00ACB1AC /* ImageDecoding.swift */, ); - path = "Message Delivery Status"; + path = Decoding; sourceTree = ""; }; - 82D64B5F2AD7E5AC00C5C79E /* StreamSwiftyGif */ = { + 4F0AC7BA2DCA2CCE00ACB1AC /* Encoding */ = { isa = PBXGroup; children = ( - 82D64B602AD7E5AC00C5C79E /* UIImageView+SwiftyGif.swift */, - 82D64B612AD7E5AC00C5C79E /* NSImage+SwiftyGif.swift */, - 82D64B622AD7E5AC00C5C79E /* ObjcAssociatedWeakObject.swift */, - 82D64B632AD7E5AC00C5C79E /* UIImage+SwiftyGif.swift */, - 82D64B642AD7E5AC00C5C79E /* SwiftyGifManager.swift */, - 82D64B652AD7E5AC00C5C79E /* NSImageView+SwiftyGif.swift */, + 4F0AC7B62DCA2CCE00ACB1AC /* ImageEncoders.swift */, + 4F0AC7B72DCA2CCE00ACB1AC /* ImageEncoders+Default.swift */, + 4F0AC7B82DCA2CCE00ACB1AC /* ImageEncoders+ImageIO.swift */, + 4F0AC7B92DCA2CCE00ACB1AC /* ImageEncoding.swift */, ); - path = StreamSwiftyGif; + path = Encoding; sourceTree = ""; }; - 82D64B6C2AD7E5B600C5C79E /* StreamNuke */ = { - isa = PBXGroup; - children = ( - 82D64B6D2AD7E5B600C5C79E /* NukeExtensions */, - 82D64B702AD7E5B600C5C79E /* NukeUI */, - 82D64B882AD7E5B600C5C79E /* Nuke */, - ); - name = StreamNuke; - path = Sources/StreamChatSwiftUI/StreamNuke; - sourceTree = SOURCE_ROOT; - }; - 82D64B6D2AD7E5B600C5C79E /* NukeExtensions */ = { + 4F0AC7C62DCA2CCE00ACB1AC /* Internal */ = { isa = PBXGroup; children = ( - 82D64B6E2AD7E5B600C5C79E /* ImageLoadingOptions.swift */, - 82D64B6F2AD7E5B600C5C79E /* ImageViewExtensions.swift */, + 4F0AC7BB2DCA2CCE00ACB1AC /* Atomic.swift */, + 4F0AC7BC2DCA2CCE00ACB1AC /* DataPublisher.swift */, + 4F0AC7BD2DCA2CCE00ACB1AC /* Extensions.swift */, + 4F0AC7BE2DCA2CCE00ACB1AC /* Graphics.swift */, + 4F0AC7BF2DCA2CCE00ACB1AC /* ImagePublisher.swift */, + 4F0AC7C02DCA2CCE00ACB1AC /* ImageRequestKeys.swift */, + 4F0AC7C12DCA2CCE00ACB1AC /* LinkedList.swift */, + 4F0AC7C22DCA2CCE00ACB1AC /* Log.swift */, + 4F0AC7C32DCA2CCE00ACB1AC /* Operation.swift */, + 4F0AC7C42DCA2CCE00ACB1AC /* RateLimiter.swift */, + 4F0AC7C52DCA2CCE00ACB1AC /* ResumableData.swift */, ); - path = NukeExtensions; + path = Internal; sourceTree = ""; }; - 82D64B702AD7E5B600C5C79E /* NukeUI */ = { + 4F0AC7C92DCA2CCE00ACB1AC /* Loading */ = { isa = PBXGroup; children = ( - 82D64B712AD7E5B600C5C79E /* LazyImageState.swift */, - 82D64B722AD7E5B600C5C79E /* NukeVideoPlayerView.swift */, - 82D64B732AD7E5B600C5C79E /* Image.swift */, - 82D64B742AD7E5B600C5C79E /* FetchImage.swift */, - 82D64B752AD7E5B600C5C79E /* Gifu */, - 82D64B832AD7E5B600C5C79E /* AnimatedImageView.swift */, - 82D64B842AD7E5B600C5C79E /* Internal.swift */, - 82D64B852AD7E5B600C5C79E /* ImageView.swift */, - 82D64B862AD7E5B600C5C79E /* LazyImage.swift */, - 82D64B872AD7E5B600C5C79E /* LazyImageView.swift */, + 4F0AC7C72DCA2CCE00ACB1AC /* DataLoader.swift */, + 4F0AC7C82DCA2CCE00ACB1AC /* DataLoading.swift */, ); - path = NukeUI; + path = Loading; sourceTree = ""; }; - 82D64B752AD7E5B600C5C79E /* Gifu */ = { + 4F0AC7CF2DCA2CCE00ACB1AC /* Pipeline */ = { isa = PBXGroup; children = ( - 82D64B762AD7E5B600C5C79E /* Classes */, - 82D64B7C2AD7E5B600C5C79E /* Extensions */, - 82D64B812AD7E5B600C5C79E /* Helpers */, + 4F0AC7CA2DCA2CCE00ACB1AC /* ImagePipeline.swift */, + 4F0AC7CB2DCA2CCE00ACB1AC /* ImagePipeline+Cache.swift */, + 4F0AC7CC2DCA2CCE00ACB1AC /* ImagePipeline+Configuration.swift */, + 4F0AC7CD2DCA2CCE00ACB1AC /* ImagePipeline+Delegate.swift */, + 4F0AC7CE2DCA2CCE00ACB1AC /* ImagePipeline+Error.swift */, ); - path = Gifu; + path = Pipeline; sourceTree = ""; }; - 82D64B762AD7E5B600C5C79E /* Classes */ = { + 4F0AC7D12DCA2CCE00ACB1AC /* Prefetching */ = { isa = PBXGroup; children = ( - 82D64B772AD7E5B600C5C79E /* FrameStore.swift */, - 82D64B782AD7E5B600C5C79E /* GIFAnimatable.swift */, - 82D64B792AD7E5B600C5C79E /* AnimatedFrame.swift */, - 82D64B7A2AD7E5B600C5C79E /* Animator.swift */, - 82D64B7B2AD7E5B600C5C79E /* GIFImageView.swift */, + 4F0AC7D02DCA2CCE00ACB1AC /* ImagePrefetcher.swift */, ); - path = Classes; + path = Prefetching; sourceTree = ""; }; - 82D64B7C2AD7E5B600C5C79E /* Extensions */ = { + 4F0AC7DD2DCA2CCE00ACB1AC /* Processing */ = { isa = PBXGroup; children = ( - 82D64B7D2AD7E5B600C5C79E /* Array.swift */, - 82D64B7E2AD7E5B600C5C79E /* CGSize.swift */, - 82D64B7F2AD7E5B600C5C79E /* UIImage.swift */, - 82D64B802AD7E5B600C5C79E /* UIImageView.swift */, + 4F0AC7D22DCA2CCE00ACB1AC /* ImageDecompression.swift */, + 4F0AC7D32DCA2CCE00ACB1AC /* ImageProcessing.swift */, + 4F0AC7D42DCA2CCE00ACB1AC /* ImageProcessingOptions.swift */, + 4F0AC7D52DCA2CCE00ACB1AC /* ImageProcessors.swift */, + 4F0AC7D62DCA2CCE00ACB1AC /* ImageProcessors+Anonymous.swift */, + 4F0AC7D72DCA2CCE00ACB1AC /* ImageProcessors+Circle.swift */, + 4F0AC7D82DCA2CCE00ACB1AC /* ImageProcessors+Composition.swift */, + 4F0AC7D92DCA2CCE00ACB1AC /* ImageProcessors+CoreImage.swift */, + 4F0AC7DA2DCA2CCE00ACB1AC /* ImageProcessors+GaussianBlur.swift */, + 4F0AC7DB2DCA2CCE00ACB1AC /* ImageProcessors+Resize.swift */, + 4F0AC7DC2DCA2CCE00ACB1AC /* ImageProcessors+RoundedCorners.swift */, ); - path = Extensions; + path = Processing; sourceTree = ""; }; - 82D64B812AD7E5B600C5C79E /* Helpers */ = { + 4F0AC7E52DCA2CCE00ACB1AC /* Tasks */ = { isa = PBXGroup; children = ( - 82D64B822AD7E5B600C5C79E /* ImageSourceHelpers.swift */, + 4F0AC7DE2DCA2CCE00ACB1AC /* AsyncPipelineTask.swift */, + 4F0AC7DF2DCA2CCE00ACB1AC /* AsyncTask.swift */, + 4F0AC7E02DCA2CCE00ACB1AC /* TaskFetchOriginalData.swift */, + 4F0AC7E12DCA2CCE00ACB1AC /* TaskFetchOriginalImage.swift */, + 4F0AC7E22DCA2CCE00ACB1AC /* TaskFetchWithPublisher.swift */, + 4F0AC7E32DCA2CCE00ACB1AC /* TaskLoadData.swift */, + 4F0AC7E42DCA2CCE00ACB1AC /* TaskLoadImage.swift */, ); - path = Helpers; + path = Tasks; sourceTree = ""; }; - 82D64B882AD7E5B600C5C79E /* Nuke */ = { + 4F0AC7EA2DCA2CCE00ACB1AC /* Nuke */ = { isa = PBXGroup; children = ( - 82D64B892AD7E5B600C5C79E /* Pipeline */, - 82D64B8F2AD7E5B600C5C79E /* ImageTask.swift */, - 82D64B902AD7E5B600C5C79E /* Tasks */, - 82D64B992AD7E5B600C5C79E /* Loading */, - 82D64B9C2AD7E5B600C5C79E /* Processing */, - 82D64BA82AD7E5B700C5C79E /* Prefetching */, - 82D64BAA2AD7E5B700C5C79E /* Internal */, - 82D64BB82AD7E5B700C5C79E /* Encoding */, - 82D64BBD2AD7E5B700C5C79E /* Decoding */, - 82D64BC42AD7E5B700C5C79E /* ImageContainer.swift */, - 82D64BC52AD7E5B700C5C79E /* ImageRequest.swift */, - 82D64BC62AD7E5B700C5C79E /* ImageResponse.swift */, - 82D64BC72AD7E5B700C5C79E /* Caching */, + 4F0AC7AF2DCA2CCE00ACB1AC /* Caching */, + 4F0AC7B52DCA2CCE00ACB1AC /* Decoding */, + 4F0AC7BA2DCA2CCE00ACB1AC /* Encoding */, + 4F0AC7C62DCA2CCE00ACB1AC /* Internal */, + 4F0AC7C92DCA2CCE00ACB1AC /* Loading */, + 4F0AC7CF2DCA2CCE00ACB1AC /* Pipeline */, + 4F0AC7D12DCA2CCE00ACB1AC /* Prefetching */, + 4F0AC7DD2DCA2CCE00ACB1AC /* Processing */, + 4F0AC7E52DCA2CCE00ACB1AC /* Tasks */, + 4F0AC7E62DCA2CCE00ACB1AC /* ImageContainer.swift */, + 4F0AC7E72DCA2CCE00ACB1AC /* ImageRequest.swift */, + 4F0AC7E82DCA2CCE00ACB1AC /* ImageResponse.swift */, + 4F0AC7E92DCA2CCE00ACB1AC /* ImageTask.swift */, ); path = Nuke; sourceTree = ""; }; - 82D64B892AD7E5B600C5C79E /* Pipeline */ = { + 4F0AC7ED2DCA2CCE00ACB1AC /* NukeExtensions */ = { isa = PBXGroup; children = ( - 82D64B8A2AD7E5B600C5C79E /* ImagePipeline.swift */, - 82D64B8B2AD7E5B600C5C79E /* ImagePipelineError.swift */, - 82D64B8C2AD7E5B600C5C79E /* ImagePipelineConfiguration.swift */, - 82D64B8D2AD7E5B600C5C79E /* ImagePipelineCache.swift */, - 82D64B8E2AD7E5B600C5C79E /* ImagePipelineDelegate.swift */, + 4F0AC7EB2DCA2CCE00ACB1AC /* ImageLoadingOptions.swift */, + 4F0AC7EC2DCA2CCE00ACB1AC /* ImageViewExtensions.swift */, ); - path = Pipeline; + path = NukeExtensions; sourceTree = ""; }; - 82D64B902AD7E5B600C5C79E /* Tasks */ = { + 4F0AC7F32DCA2CCE00ACB1AC /* NukeUI */ = { isa = PBXGroup; children = ( - 82D64B912AD7E5B600C5C79E /* TaskFetchDecodedImage.swift */, - 82D64B922AD7E5B600C5C79E /* TaskLoadData.swift */, - 82D64B932AD7E5B600C5C79E /* TaskFetchOriginalImageData.swift */, - 82D64B942AD7E5B600C5C79E /* ImagePipelineTask.swift */, - 82D64B952AD7E5B600C5C79E /* OperationTask.swift */, - 82D64B962AD7E5B600C5C79E /* TaskLoadImage.swift */, - 82D64B972AD7E5B600C5C79E /* AsyncTask.swift */, - 82D64B982AD7E5B600C5C79E /* TaskFetchWithPublisher.swift */, + 4F0AC7EE2DCA2CCE00ACB1AC /* FetchImage.swift */, + 4F0AC7EF2DCA2CCE00ACB1AC /* Internal.swift */, + 4F0AC7F02DCA2CCE00ACB1AC /* LazyImage.swift */, + 4F0AC7F12DCA2CCE00ACB1AC /* LazyImageState.swift */, + 4F0AC7F22DCA2CCE00ACB1AC /* LazyImageView.swift */, ); - path = Tasks; + path = NukeUI; sourceTree = ""; }; - 82D64B992AD7E5B600C5C79E /* Loading */ = { + 4F0AC7F72DCA2CCE00ACB1AC /* NukeVideo */ = { isa = PBXGroup; children = ( - 82D64B9A2AD7E5B600C5C79E /* DataLoading.swift */, - 82D64B9B2AD7E5B600C5C79E /* DataLoader.swift */, + 4F0AC7F42DCA2CCE00ACB1AC /* AVDataAsset.swift */, + 4F0AC7F52DCA2CCE00ACB1AC /* ImageDecoders+Video.swift */, + 4F0AC7F62DCA2CCE00ACB1AC /* NukeVideoPlayerView.swift */, ); - path = Loading; + path = NukeVideo; sourceTree = ""; }; - 82D64B9C2AD7E5B600C5C79E /* Processing */ = { + 4F65F18B2D0724F3009F69A8 /* ChannelHeader */ = { isa = PBXGroup; children = ( - 82D64B9D2AD7E5B600C5C79E /* ImageProcessors+RoundedCorners.swift */, - 82D64B9E2AD7E5B600C5C79E /* ImageProcessing.swift */, - 82D64B9F2AD7E5B600C5C79E /* ImageProcessors.swift */, - 82D64BA02AD7E5B600C5C79E /* ImageProcessors+GaussianBlur.swift */, - 82D64BA12AD7E5B600C5C79E /* ImageProcessors+CoreImage.swift */, - 82D64BA22AD7E5B600C5C79E /* ImageProcessingOptions.swift */, - 82D64BA32AD7E5B700C5C79E /* ImageProcessors+Circle.swift */, - 82D64BA42AD7E5B700C5C79E /* ImageProcessors+Resize.swift */, - 82D64BA52AD7E5B700C5C79E /* ImageProcessors+Anonymous.swift */, - 82D64BA62AD7E5B700C5C79E /* ImageProcessors+Composition.swift */, - 82D64BA72AD7E5B700C5C79E /* ImageDecompression.swift */, + 846B8E2B2C5B8117006A6249 /* BlockedUsersView.swift */, + 846B8E2D2C5B8130006A6249 /* BlockedUsersViewModel.swift */, + 4F65F1892D071798009F69A8 /* ChannelListQueryIdentifier.swift */, + 4F65F1852D06EEA5009F69A8 /* ChooseChannelQueryView.swift */, + 8465FCD7274694D200AF091E /* CustomChannelHeader.swift */, + 84335015274BABF3007A1B81 /* NewChatView.swift */, + 84335017274BAD4B007A1B81 /* NewChatViewModel.swift */, ); - path = Processing; + path = ChannelHeader; sourceTree = ""; }; - 82D64BA82AD7E5B700C5C79E /* Prefetching */ = { + 4F6D83522C108D470098C298 /* CommonViews */ = { isa = PBXGroup; children = ( - 82D64BA92AD7E5B700C5C79E /* ImagePrefetcher.swift */, + 4F6D83532C1094220098C298 /* AlertBannerViewModifier_Tests.swift */, ); - path = Prefetching; + path = CommonViews; sourceTree = ""; }; - 82D64BAA2AD7E5B700C5C79E /* Internal */ = { + 4FA3741B2D799F9E00294721 /* AppConfiguration */ = { isa = PBXGroup; children = ( - 82D64BAB2AD7E5B700C5C79E /* ResumableData.swift */, - 82D64BAC2AD7E5B700C5C79E /* Allocations.swift */, - 82D64BAD2AD7E5B700C5C79E /* Log.swift */, - 82D64BAE2AD7E5B700C5C79E /* DataPublisher.swift */, - 82D64BAF2AD7E5B700C5C79E /* AVDataAsset.swift */, - 82D64BB02AD7E5B700C5C79E /* RateLimiter.swift */, - 82D64BB12AD7E5B700C5C79E /* Extensions.swift */, - 82D64BB22AD7E5B700C5C79E /* Deprecated.swift */, - 82D64BB32AD7E5B700C5C79E /* Graphics.swift */, - 82D64BB42AD7E5B700C5C79E /* ImagePublisher.swift */, - 82D64BB52AD7E5B700C5C79E /* Operation.swift */, - 82D64BB62AD7E5B700C5C79E /* ImageRequestKeys.swift */, - 82D64BB72AD7E5B700C5C79E /* LinkedList.swift */, + 4FA3741E2D79A64900294721 /* AppConfiguration.swift */, + 4FA3741C2D799FC300294721 /* AppConfigurationTranslationView.swift */, + 4FA374192D799CA400294721 /* AppConfigurationView.swift */, ); - path = Internal; + path = AppConfiguration; sourceTree = ""; }; - 82D64BB82AD7E5B700C5C79E /* Encoding */ = { + 82A1814528FD69E8005F9D43 /* Message Delivery Status */ = { isa = PBXGroup; children = ( - 82D64BB92AD7E5B700C5C79E /* ImageEncoders+Default.swift */, - 82D64BBA2AD7E5B700C5C79E /* ImageEncoders.swift */, - 82D64BBB2AD7E5B700C5C79E /* ImageEncoding.swift */, - 82D64BBC2AD7E5B700C5C79E /* ImageEncoders+ImageIO.swift */, + 82A1814628FD69F8005F9D43 /* MessageDeliveryStatus_Tests.swift */, + 82A1814828FD6A0C005F9D43 /* MessageDeliveryStatus+ChannelList_Tests.swift */, ); - path = Encoding; + path = "Message Delivery Status"; sourceTree = ""; }; - 82D64BBD2AD7E5B700C5C79E /* Decoding */ = { + 82D64B5F2AD7E5AC00C5C79E /* StreamSwiftyGif */ = { isa = PBXGroup; children = ( - 82D64BBE2AD7E5B700C5C79E /* ImageDecoders+Video.swift */, - 82D64BBF2AD7E5B700C5C79E /* ImageDecoders+Default.swift */, - 82D64BC02AD7E5B700C5C79E /* AssetType.swift */, - 82D64BC12AD7E5B700C5C79E /* ImageDecoders+Empty.swift */, - 82D64BC22AD7E5B700C5C79E /* ImageDecoding.swift */, - 82D64BC32AD7E5B700C5C79E /* ImageDecoderRegistry.swift */, + 82D64B602AD7E5AC00C5C79E /* UIImageView+SwiftyGif.swift */, + 82D64B612AD7E5AC00C5C79E /* NSImage+SwiftyGif.swift */, + 82D64B622AD7E5AC00C5C79E /* ObjcAssociatedWeakObject.swift */, + 82D64B632AD7E5AC00C5C79E /* UIImage+SwiftyGif.swift */, + 82D64B642AD7E5AC00C5C79E /* SwiftyGifManager.swift */, + 82D64B652AD7E5AC00C5C79E /* NSImageView+SwiftyGif.swift */, ); - path = Decoding; + path = StreamSwiftyGif; sourceTree = ""; }; - 82D64BC72AD7E5B700C5C79E /* Caching */ = { + 82D64B6C2AD7E5B600C5C79E /* StreamNuke */ = { isa = PBXGroup; children = ( - 82D64BC82AD7E5B700C5C79E /* ImageCache.swift */, - 82D64BC92AD7E5B700C5C79E /* DataCache.swift */, - 82D64BCA2AD7E5B700C5C79E /* NukeCache.swift */, - 82D64BCB2AD7E5B700C5C79E /* ImageCaching.swift */, - 82D64BCC2AD7E5B700C5C79E /* DataCaching.swift */, + 4F0AC7EA2DCA2CCE00ACB1AC /* Nuke */, + 4F0AC7ED2DCA2CCE00ACB1AC /* NukeExtensions */, + 4F0AC7F32DCA2CCE00ACB1AC /* NukeUI */, + 4F0AC7F72DCA2CCE00ACB1AC /* NukeVideo */, ); - path = Caching; - sourceTree = ""; + name = StreamNuke; + path = Sources/StreamChatSwiftUI/StreamNuke; + sourceTree = SOURCE_ROOT; }; 8400A352282E6BE30067D3A0 /* StreamChatSwiftUITestsAppTests */ = { isa = PBXGroup; @@ -1903,6 +1836,7 @@ 8465FD362746A95600AF091E /* KeyboardHandling.swift */, 842ADEA828EB018C00F2BE36 /* LazyImageExtensions.swift */, 8465FD352746A95600AF091E /* LazyView.swift */, + 4F06950F2D96A57C00DB7E3B /* MainActor+Extensions.swift */, 4FCD7DA62D63211B000EEB0F /* MarkdownFormatter.swift */, 847CEFED27C38ABE00606257 /* MessageCachingUtils.swift */, AD3AB6592CB59A660014D4D7 /* MessagePreviewFormatter.swift */, @@ -2631,9 +2565,7 @@ 84EADEC32B2B24E60046B50C /* AudioSessionFeedbackGenerator.swift in Sources */, 84EADEB92B28B05C0046B50C /* LockedView.swift in Sources */, 8465FD962746A95700AF091E /* ReactionsOverlayViewModel.swift in Sources */, - 82D64BE92AD7E5B700C5C79E /* TaskLoadData.swift in Sources */, 847CEFEE27C38ABE00606257 /* MessageCachingUtils.swift in Sources */, - 82D64BF62AD7E5B700C5C79E /* ImageProcessors+CoreImage.swift in Sources */, ADE0F55E2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift in Sources */, 8451C4912BD7096000849955 /* PollAttachmentView.swift in Sources */, 8465FD792746A95700AF091E /* DeletedMessageView.swift in Sources */, @@ -2645,45 +2577,30 @@ 84EADEC52B2C4A5B0046B50C /* AddedVoiceRecordingsView.swift in Sources */, 8465FDBF2746A95700AF091E /* DefaultChannelActions.swift in Sources */, 8465FD772746A95700AF091E /* FileAttachmentPreview.swift in Sources */, - 82D64BD82AD7E5B700C5C79E /* Array.swift in Sources */, 8465FD862746A95700AF091E /* MessageComposerViewModel.swift in Sources */, 84289BE72807214200282ABE /* PinnedMessagesViewModel.swift in Sources */, 8465FD7C2746A95700AF091E /* MessageContainerView.swift in Sources */, - 82D64BD92AD7E5B700C5C79E /* CGSize.swift in Sources */, 841B64D8277B14440016FF3B /* MuteCommandHandler.swift in Sources */, 8465FDB42746A95700AF091E /* ChatMessage+Extensions.swift in Sources */, - 82D64C0D2AD7E5B700C5C79E /* ImageEncoding.swift in Sources */, - 82D64BCE2AD7E5B700C5C79E /* ImageViewExtensions.swift in Sources */, 8465FD8C2746A95700AF091E /* ImagePickerView.swift in Sources */, - 82D64BFB2AD7E5B700C5C79E /* ImageProcessors+Composition.swift in Sources */, 4F077EF82C85E05700F06D83 /* DelayedRenderingViewModifier.swift in Sources */, - 82D64BD32AD7E5B700C5C79E /* FrameStore.swift in Sources */, 8465FDB22746A95700AF091E /* InputTextView.swift in Sources */, 8465FDB32746A95700AF091E /* NSLayoutConstraint+Extensions.swift in Sources */, - 82D64BCF2AD7E5B700C5C79E /* LazyImageState.swift in Sources */, ADE0F5642CB9609E0053B8B9 /* ChatThreadListHeaderView.swift in Sources */, 8465FD912746A95700AF091E /* MessageComposerView.swift in Sources */, - 82D64BE52AD7E5B700C5C79E /* ImagePipelineCache.swift in Sources */, 8465FD6A2746A95700AF091E /* L10n.swift in Sources */, - 82D64BED2AD7E5B700C5C79E /* TaskLoadImage.swift in Sources */, 8465FD922746A95700AF091E /* AttachmentPickerView.swift in Sources */, 8465FD952746A95700AF091E /* ReactionsOverlayContainer.swift in Sources */, 84471C182BE98BC400D6721E /* PollAllOptionsView.swift in Sources */, - 82D64C032AD7E5B700C5C79E /* RateLimiter.swift in Sources */, 84B738402BE8EE1800EC66EC /* PollCommentsView.swift in Sources */, AD3AB6562CB54F720014D4D7 /* ChatThreadListNavigatableItem.swift in Sources */, - 82D64BEA2AD7E5B700C5C79E /* TaskFetchOriginalImageData.swift in Sources */, - 82D64BDB2AD7E5B700C5C79E /* UIImageView.swift in Sources */, - 82D64BD02AD7E5B700C5C79E /* NukeVideoPlayerView.swift in Sources */, 84EADEB52B28935B0046B50C /* RecordingView.swift in Sources */, 84AB7B262773619F00631A10 /* MentionUsersView.swift in Sources */, 8465FDA82746A95700AF091E /* ImageLoading.swift in Sources */, 8465FDBE2746A95700AF091E /* MoreChannelActionsView.swift in Sources */, 84289BEB2807239B00282ABE /* MediaAttachmentsViewModel.swift in Sources */, 8465FD902746A95700AF091E /* ComposerHelperViews.swift in Sources */, - 82D64C142AD7E5B700C5C79E /* ImageDecoderRegistry.swift in Sources */, AD3AB6602CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift in Sources */, - 82D64C182AD7E5B700C5C79E /* ImageCache.swift in Sources */, 849CDD942768E0E1003C7A51 /* MessageActionsResolver.swift in Sources */, 84EADEB72B28A17B0046B50C /* RecordingConstants.swift in Sources */, 84F2908E276B92A40045472D /* GalleryHeaderView.swift in Sources */, @@ -2694,37 +2611,24 @@ 8465FDAA2746A95700AF091E /* DateFormatter+Extensions.swift in Sources */, 842FA0E72C05DCDB00AD1F3C /* PollsConfig.swift in Sources */, 841B64D42775F5540016FF3B /* GiphyCommandHandler.swift in Sources */, - 82D64BDD2AD7E5B700C5C79E /* AnimatedImageView.swift in Sources */, 8434E58127707F19001E1B83 /* GridPhotosView.swift in Sources */, ADE0F5662CB962470053B8B9 /* ActionBannerView.swift in Sources */, 84BB4C4C2841104700CBE004 /* MessageListDateUtils.swift in Sources */, - 82D64BE72AD7E5B700C5C79E /* ImageTask.swift in Sources */, 8465FD742746A95700AF091E /* ViewFactory.swift in Sources */, 8465FDC12746A95700AF091E /* NoChannelsView.swift in Sources */, - 82D64BDC2AD7E5B700C5C79E /* ImageSourceHelpers.swift in Sources */, - 82D64BD22AD7E5B700C5C79E /* FetchImage.swift in Sources */, - 82D64C042AD7E5B700C5C79E /* Extensions.swift in Sources */, C14A465B284665B100EF498E /* SDKIdentifier.swift in Sources */, - 82D64C122AD7E5B700C5C79E /* ImageDecoders+Empty.swift in Sources */, 8465FDA32746A95700AF091E /* ViewExtensions.swift in Sources */, - 82D64BFE2AD7E5B700C5C79E /* ResumableData.swift in Sources */, - 82D64BE62AD7E5B700C5C79E /* ImagePipelineDelegate.swift in Sources */, 8465FDA22746A95700AF091E /* ChatChannelViewModel.swift in Sources */, 8465FD982746A95700AF091E /* ReactionsOverlayView.swift in Sources */, 8465FDCD2746A95700AF091E /* Fonts.swift in Sources */, - 82D64C022AD7E5B700C5C79E /* AVDataAsset.swift in Sources */, 8465FD9A2746A95700AF091E /* ReactionsHelperViews.swift in Sources */, 8465FDC02746A95700AF091E /* ChatChannelList.swift in Sources */, 82D64B682AD7E5AC00C5C79E /* ObjcAssociatedWeakObject.swift in Sources */, 82FA42442AE67FF900C7390B /* SystemEnvironment+Version.swift in Sources */, 4FEAB3182BFF71F70057E511 /* SwiftUI+UIAlertController.swift in Sources */, - 82D64BE02AD7E5B700C5C79E /* LazyImage.swift in Sources */, - 82D64C052AD7E5B700C5C79E /* Deprecated.swift in Sources */, 84DEC8EC27611CAE00172876 /* SendInChannelView.swift in Sources */, 84F130C12AEAA957006E7B52 /* StreamLazyImage.swift in Sources */, - 82D64BD12AD7E5B700C5C79E /* Image.swift in Sources */, ADE0F5602CB846EC0053B8B9 /* FloatingBannerViewModifier.swift in Sources */, - 82D64BD52AD7E5B700C5C79E /* AnimatedFrame.swift in Sources */, 8465FD9F2746A95700AF091E /* ChatChannelExtensions.swift in Sources */, 844D1D6628510304000CCCB9 /* ChannelControllerFactory.swift in Sources */, 8465FD882746A95700AF091E /* SendMessageButton.swift in Sources */, @@ -2732,21 +2636,14 @@ 8451C4932BD713D600849955 /* PollAttachmentViewModel.swift in Sources */, 8465FDA62746A95700AF091E /* LazyView.swift in Sources */, A3D7B0DF2840E23100E308B3 /* UIView+AccessibilityIdentifier.swift in Sources */, - 82D64C002AD7E5B700C5C79E /* Log.swift in Sources */, - 82D64BFF2AD7E5B700C5C79E /* Allocations.swift in Sources */, - 82D64BF12AD7E5B700C5C79E /* DataLoader.swift in Sources */, 84EADEAA2B2767E20046B50C /* WaveformView.swift in Sources */, 8434E583277088D9001E1B83 /* TitleWithCloseButton.swift in Sources */, - 82D64BD62AD7E5B700C5C79E /* Animator.swift in Sources */, 8465FDB52746A95700AF091E /* Cache.swift in Sources */, 84A1CAD12816C6900046595A /* AddUsersViewModel.swift in Sources */, - 82D64BDA2AD7E5B700C5C79E /* UIImage.swift in Sources */, 4F198FDD2C0480EC00148F49 /* Publisher+Extensions.swift in Sources */, 84289BEF2807246E00282ABE /* FileAttachmentsViewModel.swift in Sources */, - 82D64C192AD7E5B700C5C79E /* DataCache.swift in Sources */, 84AB7B2A2773D97E00631A10 /* MentionsCommandHandler.swift in Sources */, 84DEC8EA2761089A00172876 /* MessageThreadHeaderViewModifier.swift in Sources */, - 82D64C012AD7E5B700C5C79E /* DataPublisher.swift in Sources */, 8465FD9B2746A95700AF091E /* DefaultMessageActions.swift in Sources */, 84F2908C276B91700045472D /* ZoomableScrollView.swift in Sources */, 842383E427678A4D00888CFC /* QuotedMessageView.swift in Sources */, @@ -2763,7 +2660,6 @@ 8465FDC72746A95700AF091E /* ChatChannelListViewModel.swift in Sources */, 8465FDD02746A95700AF091E /* DefaultViewFactory.swift in Sources */, 8465FD822746A95700AF091E /* LinkTextView.swift in Sources */, - 82D64C172AD7E5B700C5C79E /* ImageResponse.swift in Sources */, 84B55F6A2798154C00B99B01 /* MessageListConfig.swift in Sources */, 8421BCF027A44EAE000F977D /* SearchResultsView.swift in Sources */, 841B64CC2775C6300016FF3B /* CommandsConfig.swift in Sources */, @@ -2784,21 +2680,14 @@ 8465FDBC2746A95700AF091E /* ChannelAvatarsMerger.swift in Sources */, ADE0F5622CB8556F0053B8B9 /* ChatThreadListFooterView.swift in Sources */, 8465FDB82746A95700AF091E /* ImageMerger.swift in Sources */, - 82D64BF22AD7E5B700C5C79E /* ImageProcessors+RoundedCorners.swift in Sources */, 841B64C427744DB60016FF3B /* ComposerModels.swift in Sources */, 8465FD752746A95700AF091E /* ImageAttachmentView.swift in Sources */, - 82D64C0C2AD7E5B700C5C79E /* ImageEncoders.swift in Sources */, 8465FD832746A95700AF091E /* LinkAttachmentView.swift in Sources */, 4FCD7DA72D632121000EEB0F /* MarkdownFormatter.swift in Sources */, - 82D64C082AD7E5B700C5C79E /* Operation.swift in Sources */, - 82D64BEB2AD7E5B700C5C79E /* ImagePipelineTask.swift in Sources */, AD2DDA612CB040EA0040B8D4 /* NoThreadsView.swift in Sources */, - 82D64BF92AD7E5B700C5C79E /* ImageProcessors+Resize.swift in Sources */, 8465FDC22746A95700AF091E /* ChatChannelNavigatableListItem.swift in Sources */, 8465FDAD2746A95700AF091E /* ImageCDN.swift in Sources */, - 82D64C0B2AD7E5B700C5C79E /* ImageEncoders+Default.swift in Sources */, 84289BE12807190500282ABE /* ChatChannelInfoView.swift in Sources */, - 82D64BF72AD7E5B700C5C79E /* ImageProcessingOptions.swift in Sources */, 841B64D02775EDFE0016FF3B /* InstantCommandsView.swift in Sources */, 8465FD762746A95700AF091E /* MessageListView.swift in Sources */, 84EADEA82B27637A0046B50C /* AudioVisualizationView.swift in Sources */, @@ -2808,25 +2697,18 @@ 84D6E4F62B2CA4E300D0056C /* RecordingTipView.swift in Sources */, 846608E3278C303800D3D7B3 /* TypingIndicatorView.swift in Sources */, 84A1CACF2816BCF00046595A /* AddUsersView.swift in Sources */, - 82D64BF02AD7E5B700C5C79E /* DataLoading.swift in Sources */, 8465FD9C2746A95700AF091E /* MessageActionsView.swift in Sources */, - 82D64C092AD7E5B700C5C79E /* ImageRequestKeys.swift in Sources */, 849F6BF02B1A06D10032303E /* JumpToUnreadButton.swift in Sources */, 8465FD6E2746A95700AF091E /* DependencyInjection.swift in Sources */, 841B64D62775FDA00016FF3B /* InstantCommandsHandler.swift in Sources */, 8465FDC92746A95700AF091E /* ChatChannelSwipeableListItem.swift in Sources */, - 82D64C1C2AD7E5B700C5C79E /* DataCaching.swift in Sources */, 82D64B6A2AD7E5AC00C5C79E /* SwiftyGifManager.swift in Sources */, 84EADEA62B2748AD0046B50C /* AudioRecordingNameFormatter.swift in Sources */, 841BA9F12BCD6FBD000C73E4 /* CreatePollView.swift in Sources */, - 82D64C162AD7E5B700C5C79E /* ImageRequest.swift in Sources */, - 82D64BF82AD7E5B700C5C79E /* ImageProcessors+Circle.swift in Sources */, 8465FDD32746A95800AF091E /* ColorPalette.swift in Sources */, 8465FD782746A95700AF091E /* FileAttachmentView.swift in Sources */, AD5C0A5F2D6FDD9700E1E500 /* BouncedMessageActionsModifier.swift in Sources */, - 82D64C152AD7E5B700C5C79E /* ImageContainer.swift in Sources */, 84EADEA22B2735D80046B50C /* VoiceRecordingContainerView.swift in Sources */, - 82D64C0F2AD7E5B700C5C79E /* ImageDecoders+Video.swift in Sources */, 82D64B692AD7E5AC00C5C79E /* UIImage+SwiftyGif.swift in Sources */, 84AB7B1D2771F4AA00631A10 /* DiscardButtonView.swift in Sources */, 849FD5112811B05C00952934 /* ChatInfoParticipantsView.swift in Sources */, @@ -2836,22 +2718,16 @@ 8465FD8F2746A95700AF091E /* AttachmentUploadingStateView.swift in Sources */, AD2DDA632CB04AD60040B8D4 /* ChatThreadListView.swift in Sources */, 8465FD732746A95700AF091E /* ActionItemView.swift in Sources */, - 82D64BD42AD7E5B700C5C79E /* GIFAnimatable.swift in Sources */, 844CC60E2811378D0006548D /* ComposerConfig.swift in Sources */, 846608E5278C865200D3D7B3 /* TypingIndicatorPlacement.swift in Sources */, 82D64B6B2AD7E5AC00C5C79E /* NSImageView+SwiftyGif.swift in Sources */, 8465FDA72746A95700AF091E /* KeyboardHandling.swift in Sources */, - 82D64C1A2AD7E5B700C5C79E /* NukeCache.swift in Sources */, 8465FDD72746A95800AF091E /* Appearance.swift in Sources */, - 82D64BE22AD7E5B700C5C79E /* ImagePipeline.swift in Sources */, - 82D64BF42AD7E5B700C5C79E /* ImageProcessors.swift in Sources */, 8465FD8A2746A95700AF091E /* DiscardAttachmentButton.swift in Sources */, 8482094E2ACFFCD900EF3261 /* Throttler.swift in Sources */, 84EADEBD2B28C2EC0046B50C /* RecordingState.swift in Sources */, 4F7DD9A02BFC7C6100599AA6 /* ChatClient+Extensions.swift in Sources */, 9D9A54512CB89EAA00A76D9E /* FileCDN.swift in Sources */, - 82D64C1B2AD7E5B700C5C79E /* ImageCaching.swift in Sources */, - 82D64C062AD7E5B700C5C79E /* Graphics.swift in Sources */, 8465FDCB2746A95700AF091E /* ChatChannelListView.swift in Sources */, 841B2EF4278DB9E500ED619E /* MessageListHelperViews.swift in Sources */, 84EADEB22B2883C60046B50C /* TrailingComposerView.swift in Sources */, @@ -2871,25 +2747,81 @@ 91B79FD7284E21E0005B6E4F /* ChatUserNamer.swift in Sources */, 84F29090276CC1280045472D /* ShareButtonView.swift in Sources */, 84AB7B282773D4FE00631A10 /* TypingSuggester.swift in Sources */, - 82D64BFD2AD7E5B700C5C79E /* ImagePrefetcher.swift in Sources */, 91B763A4283EB19900B458A9 /* MoreChannelActionsFullScreenWrappingView.swift in Sources */, 8465FD852746A95700AF091E /* MessageView.swift in Sources */, 8465FDCA2746A95700AF091E /* MoreChannelActionsViewModel.swift in Sources */, - 82D64BEE2AD7E5B700C5C79E /* AsyncTask.swift in Sources */, 8465FDD12746A95700AF091E /* Images.swift in Sources */, 844EF8ED2809AACD00CC82F9 /* NoContentView.swift in Sources */, 4FCD7DBD2D633F72000EEB0F /* AttributedString+Extensions.swift in Sources */, 84EADEA42B2746B70046B50C /* VideoDurationFormatter.swift in Sources */, - 82D64C0E2AD7E5B700C5C79E /* ImageEncoders+ImageIO.swift in Sources */, 84A1CACD2816BC420046595A /* ChatChannelInfoHelperViews.swift in Sources */, - 82D64BEF2AD7E5B700C5C79E /* TaskFetchWithPublisher.swift in Sources */, - 82D64BD72AD7E5B700C5C79E /* GIFImageView.swift in Sources */, - 82D64BF32AD7E5B700C5C79E /* ImageProcessing.swift in Sources */, - 82D64BE32AD7E5B700C5C79E /* ImagePipelineError.swift in Sources */, 8465FD992746A95700AF091E /* ReactionsBubbleView.swift in Sources */, 8417AE902ADED28800445021 /* ReactionsIconProvider.swift in Sources */, - 82D64BDF2AD7E5B700C5C79E /* ImageView.swift in Sources */, - 82D64BE82AD7E5B700C5C79E /* TaskFetchDecodedImage.swift in Sources */, + 4F0AC7F82DCA2CCE00ACB1AC /* ImageProcessors+Anonymous.swift in Sources */, + 4F0AC7F92DCA2CCE00ACB1AC /* ImageViewExtensions.swift in Sources */, + 4F0AC7FA2DCA2CCE00ACB1AC /* Log.swift in Sources */, + 4F0AC7FB2DCA2CCE00ACB1AC /* NukeVideoPlayerView.swift in Sources */, + 4F0AC7FC2DCA2CCE00ACB1AC /* ImageProcessing.swift in Sources */, + 4F0AC7FD2DCA2CCE00ACB1AC /* ImageDecompression.swift in Sources */, + 4F0AC7FE2DCA2CCE00ACB1AC /* ImagePipeline.swift in Sources */, + 4F0AC7FF2DCA2CCE00ACB1AC /* FetchImage.swift in Sources */, + 4F0AC8002DCA2CCE00ACB1AC /* RateLimiter.swift in Sources */, + 4F0AC8012DCA2CCE00ACB1AC /* DataCaching.swift in Sources */, + 4F0AC8022DCA2CCE00ACB1AC /* Extensions.swift in Sources */, + 4F0AC8032DCA2CCE00ACB1AC /* AssetType.swift in Sources */, + 4F0AC8042DCA2CCE00ACB1AC /* ImageProcessingOptions.swift in Sources */, + 4F0AC8052DCA2CCE00ACB1AC /* ImagePipeline+Delegate.swift in Sources */, + 4F0AC8062DCA2CCE00ACB1AC /* Graphics.swift in Sources */, + 4F0AC8072DCA2CCE00ACB1AC /* Internal.swift in Sources */, + 4F0AC8082DCA2CCE00ACB1AC /* ImageProcessors+RoundedCorners.swift in Sources */, + 4F0AC8092DCA2CCE00ACB1AC /* ImageDecoderRegistry.swift in Sources */, + 4F0AC80A2DCA2CCE00ACB1AC /* ImagePrefetcher.swift in Sources */, + 4F0AC80B2DCA2CCE00ACB1AC /* NukeCache.swift in Sources */, + 4F0AC80C2DCA2CCE00ACB1AC /* ImageEncoders+ImageIO.swift in Sources */, + 4F0AC80D2DCA2CCE00ACB1AC /* ImagePipeline+Cache.swift in Sources */, + 4F0AC80E2DCA2CCE00ACB1AC /* ImageProcessors+Resize.swift in Sources */, + 4F0AC80F2DCA2CCE00ACB1AC /* ImageDecoders+Video.swift in Sources */, + 4F0AC8102DCA2CCE00ACB1AC /* LinkedList.swift in Sources */, + 4F0AC8112DCA2CCE00ACB1AC /* ImageProcessors+Composition.swift in Sources */, + 4F0AC8122DCA2CCE00ACB1AC /* ImageProcessors+CoreImage.swift in Sources */, + 4F0AC8132DCA2CCE00ACB1AC /* ImagePipeline+Configuration.swift in Sources */, + 4F0AC8142DCA2CCE00ACB1AC /* TaskLoadData.swift in Sources */, + 4F0AC8152DCA2CCE00ACB1AC /* ImageRequestKeys.swift in Sources */, + 4F0AC8162DCA2CCE00ACB1AC /* ImageEncoders+Default.swift in Sources */, + 4F0AC8172DCA2CCE00ACB1AC /* Operation.swift in Sources */, + 4F0AC8182DCA2CCE00ACB1AC /* TaskFetchWithPublisher.swift in Sources */, + 4F0AC8192DCA2CCE00ACB1AC /* ImageProcessors.swift in Sources */, + 4F0AC81A2DCA2CCE00ACB1AC /* ImageProcessors+Circle.swift in Sources */, + 4F0AC81B2DCA2CCE00ACB1AC /* ImagePublisher.swift in Sources */, + 4F0AC81C2DCA2CCE00ACB1AC /* LazyImageState.swift in Sources */, + 4F0AC81D2DCA2CCE00ACB1AC /* ImageDecoding.swift in Sources */, + 4F0AC81E2DCA2CCE00ACB1AC /* AsyncTask.swift in Sources */, + 4F0AC81F2DCA2CCE00ACB1AC /* DataLoading.swift in Sources */, + 4F0AC8202DCA2CCE00ACB1AC /* ImageEncoders.swift in Sources */, + 4F0AC8212DCA2CCE00ACB1AC /* TaskFetchOriginalData.swift in Sources */, + 4F0AC8222DCA2CCE00ACB1AC /* AVDataAsset.swift in Sources */, + 4F0AC8232DCA2CCE00ACB1AC /* ImageRequest.swift in Sources */, + 4F0AC8242DCA2CCE00ACB1AC /* ImageTask.swift in Sources */, + 4F0AC8252DCA2CCE00ACB1AC /* ImageCaching.swift in Sources */, + 4F0AC8262DCA2CCE00ACB1AC /* TaskFetchOriginalImage.swift in Sources */, + 4F0AC8272DCA2CCE00ACB1AC /* AsyncPipelineTask.swift in Sources */, + 4F0AC8282DCA2CCE00ACB1AC /* ImageEncoding.swift in Sources */, + 4F0AC8292DCA2CCE00ACB1AC /* TaskLoadImage.swift in Sources */, + 4F0AC82A2DCA2CCE00ACB1AC /* DataLoader.swift in Sources */, + 4F0AC82B2DCA2CCE00ACB1AC /* Atomic.swift in Sources */, + 4F0AC82C2DCA2CCE00ACB1AC /* ImageDecoders+Default.swift in Sources */, + 4F0AC82D2DCA2CCE00ACB1AC /* ImageCache.swift in Sources */, + 4F0AC82E2DCA2CCE00ACB1AC /* ImageDecoders+Empty.swift in Sources */, + 4F0AC82F2DCA2CCE00ACB1AC /* DataPublisher.swift in Sources */, + 4F0AC8302DCA2CCE00ACB1AC /* LazyImage.swift in Sources */, + 4F0AC8312DCA2CCE00ACB1AC /* ImagePipeline+Error.swift in Sources */, + 4F0AC8322DCA2CCE00ACB1AC /* ImageContainer.swift in Sources */, + 4F0AC8332DCA2CCE00ACB1AC /* ResumableData.swift in Sources */, + 4F0AC8342DCA2CCE00ACB1AC /* ImageResponse.swift in Sources */, + 4F0AC8352DCA2CCE00ACB1AC /* LazyImageView.swift in Sources */, + 4F0AC8362DCA2CCE00ACB1AC /* ImageProcessors+GaussianBlur.swift in Sources */, + 4F0AC8372DCA2CCE00ACB1AC /* ImageLoadingOptions.swift in Sources */, + 4F0AC8382DCA2CCE00ACB1AC /* DataCache.swift in Sources */, 8465FDB02746A95700AF091E /* DateUtils.swift in Sources */, 84DEC8E12760D24100172876 /* MessageRepliesView.swift in Sources */, AD3AB65C2CB730090014D4D7 /* Shimmer.swift in Sources */, @@ -2899,41 +2831,29 @@ 84EADEBB2B28BAE40046B50C /* RecordingWaveform.swift in Sources */, 841B64CA2775BBC10016FF3B /* Errors.swift in Sources */, 8465FD7A2746A95700AF091E /* VideoPlayerView.swift in Sources */, - 82D64BFC2AD7E5B700C5C79E /* ImageDecompression.swift in Sources */, 84D6E4F82B2CA61000D0056C /* RecordingDurationView.swift in Sources */, - 82D64BFA2AD7E5B700C5C79E /* ImageProcessors+Anonymous.swift in Sources */, - 82D64C102AD7E5B700C5C79E /* ImageDecoders+Default.swift in Sources */, 84289BE5280720E700282ABE /* PinnedMessagesView.swift in Sources */, 8465FD8B2746A95700AF091E /* FilePickerView.swift in Sources */, - 82D64C0A2AD7E5B700C5C79E /* LinkedList.swift in Sources */, 84E6EC27279B0C930017207B /* ReactionsUsersView.swift in Sources */, - 82D64BEC2AD7E5B700C5C79E /* OperationTask.swift in Sources */, - 82D64C132AD7E5B700C5C79E /* ImageDecoding.swift in Sources */, 8465FDA92746A95700AF091E /* AutoLayoutHelpers.swift in Sources */, 8465FDC32746A95700AF091E /* ChatChannelListHeader.swift in Sources */, 84289BED2807244E00282ABE /* FileAttachmentsView.swift in Sources */, - 82D64C072AD7E5B700C5C79E /* ImagePublisher.swift in Sources */, 8465FD9D2746A95700AF091E /* MessageActionsViewModel.swift in Sources */, - 82D64BE12AD7E5B700C5C79E /* LazyImageView.swift in Sources */, AD3AB6542CB54F310014D4D7 /* ChatThreadListItem.swift in Sources */, 8465FD7E2746A95700AF091E /* VideoAttachmentView.swift in Sources */, - 82D64BF52AD7E5B700C5C79E /* ImageProcessors+GaussianBlur.swift in Sources */, AD3AB6502CB41B0D0014D4D7 /* NavigationContainerView.swift in Sources */, 841BA9F32BCD6FCB000C73E4 /* CreatePollViewModel.swift in Sources */, - 82D64BDE2AD7E5B700C5C79E /* Internal.swift in Sources */, 8465FD8E2746A95700AF091E /* PhotoAssetsUtils.swift in Sources */, 8465FDB92746A95700AF091E /* NukeImageProcessor.swift in Sources */, - 82D64BCD2AD7E5B700C5C79E /* ImageLoadingOptions.swift in Sources */, 8465FD972746A95700AF091E /* ReactionsView.swift in Sources */, - 82D64BE42AD7E5B700C5C79E /* ImagePipelineConfiguration.swift in Sources */, 8465FDB62746A95700AF091E /* VideoPreviewLoader.swift in Sources */, 84289BE92807238C00282ABE /* MediaAttachmentsView.swift in Sources */, - 82D64C112AD7E5B700C5C79E /* AssetType.swift in Sources */, 8465FDAF2746A95700AF091E /* UIImage+Extensions.swift in Sources */, 8465FDCE2746A95700AF091E /* InjectedValuesExtensions.swift in Sources */, 84B738352BE2661B00EC66EC /* PollOptionAllVotesViewModel.swift in Sources */, 8465FDAC2746A95700AF091E /* UIFont+Extensions.swift in Sources */, 846D6564279FF0800094B36E /* ReactionUserView.swift in Sources */, + 4F0695102D96A58300DB7E3B /* MainActor+Extensions.swift in Sources */, 8465FDB12746A95700AF091E /* ChatChannelNamer.swift in Sources */, 8465FD9E2746A95700AF091E /* ChatChannelHelpers.swift in Sources */, 84E4F7CF294C69F300DD4CE3 /* MessageIdBuilder.swift in Sources */, @@ -3249,7 +3169,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_ENABLE_OPAQUE_TYPE_ERASURE = NO; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Profile; @@ -3308,7 +3228,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Profile; @@ -3628,7 +3548,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_ENABLE_OPAQUE_TYPE_ERASURE = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -3662,7 +3582,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_ENABLE_OPAQUE_TYPE_ERASURE = NO; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -3745,7 +3665,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -3780,7 +3700,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore io.getstream.iOS.DemoAppSwiftUI"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -3879,8 +3799,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/GetStream/stream-chat-swift.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 4.78.0; + branch = "swift-6-ui"; + kind = branch; }; }; E3A1C01A282BAC66002D1E26 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/APIClient_Mock.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/APIClient_Mock.swift index b22aa221f..a264713ee 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/APIClient_Mock.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/APIClient_Mock.swift @@ -8,7 +8,7 @@ import StreamChatTestTools import XCTest /// Mock implementation of APIClient allowing easy control and simulation of responses. -class APIClientMock: APIClient, StreamChatTestTools.Spy { +class APIClientMock: APIClient, StreamChatTestTools.Spy, @unchecked Sendable { var spyState: SpyState = .init() /// The last endpoint `request` function was called with. diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/CDNClient_Mock.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/CDNClient_Mock.swift index a1d405630..0cf89660c 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/CDNClient_Mock.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/CDNClient_Mock.swift @@ -5,7 +5,7 @@ import Foundation @testable import StreamChat -final class CDNClient_Mock: CDNClient { +final class CDNClient_Mock: CDNClient, @unchecked Sendable { static var maxAttachmentSize: Int64 = .max lazy var uploadAttachmentMockFunc = MockFunc.mock(for: uploadAttachment) diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/ChatMessageControllerSUI_Mock.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/ChatMessageControllerSUI_Mock.swift index 67abb7417..02efee9ea 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/ChatMessageControllerSUI_Mock.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/ChatMessageControllerSUI_Mock.swift @@ -6,7 +6,7 @@ import Foundation @testable import StreamChat @testable import StreamChatTestTools -public class ChatMessageControllerSUI_Mock: ChatMessageController { +public class ChatMessageControllerSUI_Mock: ChatMessageController, @unchecked Sendable { /// Creates a new mock instance of `ChatMessageController`. public static func mock( chatClient: ChatClient, diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/EventBatcherMock.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/EventBatcherMock.swift index af19e8fb3..a3b54e186 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/EventBatcherMock.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/EventBatcherMock.swift @@ -5,15 +5,15 @@ import Foundation @testable import StreamChat -final class EventBatcherMock: EventBatcher { - var currentBatch: [Event] = [] +final class EventBatcherMock: EventBatcher, @unchecked Sendable { + @Atomic var currentBatch: [Event] = [] - let handler: (_ batch: [Event], _ completion: @escaping () -> Void) -> Void + let handler: (_ batch: [Event], _ completion: @escaping @Sendable() -> Void) -> Void init( period: TimeInterval = 0, timerType: StreamChat.Timer.Type = DefaultTimer.self, - handler: @escaping (_ batch: [Event], _ completion: @escaping () -> Void) -> Void + handler: @escaping (_ batch: [Event], _ completion: @escaping @Sendable() -> Void) -> Void ) { self.handler = handler } diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/EventNotificationCenterMock.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/EventNotificationCenterMock.swift index 8e111c5d5..d312287e8 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/EventNotificationCenterMock.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/EventNotificationCenterMock.swift @@ -6,13 +6,13 @@ import Foundation @testable import StreamChat /// Mock implementation of `EventNotificationCenter` -final class EventNotificationCenterMock: EventNotificationCenter { - lazy var mock_process = MockFunc<([Event], Bool, (() -> Void)?), Void>.mock(for: process) +final class EventNotificationCenterMock: EventNotificationCenter, @unchecked Sendable { + lazy var mock_process = MockFunc<([Event], Bool, (@Sendable() -> Void)?), Void>.mock(for: process) override func process( _ events: [Event], postNotifications: Bool = true, - completion: (() -> Void)? = nil + completion: (@Sendable() -> Void)? = nil ) { super.process(events, postNotifications: postNotifications, completion: completion) diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/InternetConnectionMock.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/InternetConnectionMock.swift index 0acd5dd4a..efcadceb7 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/InternetConnectionMock.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/InternetConnectionMock.swift @@ -5,9 +5,9 @@ import Foundation @testable import StreamChat -class InternetConnectionMock: InternetConnection { - private(set) var monitorMock: InternetConnectionMonitorMock! - private(set) var init_notificationCenter: NotificationCenter! +class InternetConnectionMock: InternetConnection, @unchecked Sendable { + @Atomic private(set) var monitorMock: InternetConnectionMonitorMock! + @Atomic private(set) var init_notificationCenter: NotificationCenter! init( monitor: InternetConnectionMonitorMock = .init(), @@ -19,7 +19,7 @@ class InternetConnectionMock: InternetConnection { } } -class InternetConnectionMonitorMock: InternetConnectionMonitor { +class InternetConnectionMonitorMock: InternetConnectionMonitor, @unchecked Sendable { weak var delegate: InternetConnectionDelegate? var status: InternetConnection.Status = .unknown { @@ -28,7 +28,7 @@ class InternetConnectionMonitorMock: InternetConnectionMonitor { } } - var isStarted = false + @Atomic var isStarted = false func start() { isStarted = true diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/MockBackgroundTaskScheduler.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/MockBackgroundTaskScheduler.swift index 82ce2845c..78e8b3b0d 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/MockBackgroundTaskScheduler.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/MockBackgroundTaskScheduler.swift @@ -6,31 +6,31 @@ import Foundation @testable import StreamChat /// Mock implementation of `BackgroundTaskScheduler`. -final class MockBackgroundTaskScheduler: BackgroundTaskScheduler { - var isAppActive_called: Bool = false - var isAppActive_returns: Bool = true +final class MockBackgroundTaskScheduler: BackgroundTaskScheduler, @unchecked Sendable { + @Atomic var isAppActive_called: Bool = false + @Atomic var isAppActive_returns: Bool = true var isAppActive: Bool { isAppActive_called = true return isAppActive_returns } - var beginBackgroundTask_called: Bool = false - var beginBackgroundTask_expirationHandler: (() -> Void)? - var beginBackgroundTask_returns: Bool = true - func beginTask(expirationHandler: (() -> Void)?) -> Bool { + @Atomic var beginBackgroundTask_called: Bool = false + @Atomic var beginBackgroundTask_expirationHandler: (@MainActor @Sendable() -> Void)? + @Atomic var beginBackgroundTask_returns: Bool = true + func beginTask(expirationHandler: (@MainActor @Sendable() -> Void)?) -> Bool { beginBackgroundTask_called = true beginBackgroundTask_expirationHandler = expirationHandler return beginBackgroundTask_returns } - var endBackgroundTask_called: Bool = false + @Atomic var endBackgroundTask_called: Bool = false func endTask() { endBackgroundTask_called = true } - var startListeningForAppStateUpdates_called: Bool = false - var startListeningForAppStateUpdates_onBackground: (() -> Void)? - var startListeningForAppStateUpdates_onForeground: (() -> Void)? + @Atomic var startListeningForAppStateUpdates_called: Bool = false + @Atomic var startListeningForAppStateUpdates_onBackground: (() -> Void)? + @Atomic var startListeningForAppStateUpdates_onForeground: (() -> Void)? func startListeningForAppStateUpdates( onEnteringBackground: @escaping () -> Void, onEnteringForeground: @escaping () -> Void @@ -40,7 +40,7 @@ final class MockBackgroundTaskScheduler: BackgroundTaskScheduler { startListeningForAppStateUpdates_onForeground = onEnteringForeground } - var stopListeningForAppStateUpdates_called: Bool = false + @Atomic var stopListeningForAppStateUpdates_called: Bool = false func stopListeningForAppStateUpdates() { stopListeningForAppStateUpdates_called = true } diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/TestRequest.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/TestRequest.swift index 9a43713eb..e85c3b945 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/TestRequest.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/TestRequest.swift @@ -5,15 +5,15 @@ import Foundation @testable import StreamChat -class TestRequestEncoder: RequestEncoder { +class TestRequestEncoder: RequestEncoder, @unchecked Sendable { let init_baseURL: URL let init_apiKey: APIKey weak var connectionDetailsProviderDelegate: ConnectionDetailsProviderDelegate? - var encodeRequest: Result? = .success(URLRequest(url: .unique())) - var encodeRequest_endpoint: AnyEndpoint? - var encodeRequest_completion: ((Result) -> Void)? + @Atomic var encodeRequest: Result? = .success(URLRequest(url: .unique())) + @Atomic var encodeRequest_endpoint: AnyEndpoint? + @Atomic var encodeRequest_completion: ((Result) -> Void)? func encodeRequest( for endpoint: Endpoint, @@ -33,12 +33,12 @@ class TestRequestEncoder: RequestEncoder { } } -class TestRequestDecoder: RequestDecoder { - var decodeRequestResponse: Result? +class TestRequestDecoder: RequestDecoder, @unchecked Sendable { + @Atomic var decodeRequestResponse: Result? - var decodeRequestResponse_data: Data? - var decodeRequestResponse_response: HTTPURLResponse? - var decodeRequestResponse_error: Error? + @Atomic var decodeRequestResponse_data: Data? + @Atomic var decodeRequestResponse_response: HTTPURLResponse? + @Atomic var decodeRequestResponse_error: Error? func decodeRequestResponse(data: Data?, response: URLResponse?, error: Error?) throws -> ResponseType where ResponseType: Decodable { diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/VirtualTimer.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/VirtualTimer.swift index cdd796b60..64a80a167 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/VirtualTimer.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/VirtualTimer.swift @@ -123,12 +123,12 @@ class VirtualTime { extension VirtualTime { /// Internal representation of a timer scheduled with `VirtualTime`. Not meant to be used directly. - class TimerControl { - private(set) var isActive = true + class TimerControl: @unchecked Sendable { + @Atomic private(set) var isActive = true - var repeatingPeriod: TimeInterval - var scheduledFireTime: TimeInterval - var callback: (TimerControl) -> Void + @Atomic var repeatingPeriod: TimeInterval + @Atomic var scheduledFireTime: TimeInterval + @Atomic var callback: (TimerControl) -> Void init(scheduledFireTime: TimeInterval, repeatingPeriod: TimeInterval, callback: @escaping (TimerControl) -> Void) { self.repeatingPeriod = repeatingPeriod diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/WebSocketEngineMock.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/WebSocketEngineMock.swift index 49f8c5fdd..a08e30a4e 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/WebSocketEngineMock.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/WebSocketEngineMock.swift @@ -5,11 +5,11 @@ import Foundation @testable import StreamChat -class WebSocketEngineMock: WebSocketEngine { - var request: URLRequest - var sessionConfiguration: URLSessionConfiguration - var isConnected: Bool = false - var callbackQueue: DispatchQueue +class WebSocketEngineMock: WebSocketEngine, @unchecked Sendable { + @Atomic var request: URLRequest + @Atomic var sessionConfiguration: URLSessionConfiguration + @Atomic var isConnected: Bool = false + @Atomic var callbackQueue: DispatchQueue weak var delegate: WebSocketEngineDelegate? /// How many times was `connect()` called diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/WebSocketPingControllerMock.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/WebSocketPingControllerMock.swift index 42926bb7a..cd9761712 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/WebSocketPingControllerMock.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/WebSocketPingControllerMock.swift @@ -5,9 +5,9 @@ import Foundation @testable import StreamChat -class WebSocketPingControllerMock: WebSocketPingController { - var connectionStateDidChange_connectionStates: [WebSocketConnectionState] = [] - var pongReceivedCount = 0 +class WebSocketPingControllerMock: WebSocketPingController, @unchecked Sendable { + @Atomic var connectionStateDidChange_connectionStates: [WebSocketConnectionState] = [] + @Atomic var pongReceivedCount = 0 override func connectionStateDidChange(_ connectionState: WebSocketConnectionState) { connectionStateDidChange_connectionStates.append(connectionState) diff --git a/StreamChatSwiftUITests/Infrastructure/TestTools/ChatClient_Mock.swift b/StreamChatSwiftUITests/Infrastructure/TestTools/ChatClient_Mock.swift index 29d83fb4a..2a95a9a43 100644 --- a/StreamChatSwiftUITests/Infrastructure/TestTools/ChatClient_Mock.swift +++ b/StreamChatSwiftUITests/Infrastructure/TestTools/ChatClient_Mock.swift @@ -22,7 +22,15 @@ public extension ChatClient { config: config, workerBuilders: [], environment: .init( - apiClientBuilder: APIClient_Spy.init, + apiClientBuilder: { + APIClient_Spy( + sessionConfiguration: $0, + requestEncoder: $1, + requestDecoder: $2, + attachmentDownloader: $3, + attachmentUploader: $4 + ) + }, webSocketClientBuilder: { WebSocketClient_Mock( sessionConfiguration: $0, @@ -38,7 +46,15 @@ public extension ChatClient { chatClientConfig: $1 ) }, - authenticationRepositoryBuilder: AuthenticationRepository_Mock.init + authenticationRepositoryBuilder: { + AuthenticationRepository_Mock( + apiClient: $0, + databaseContainer: $1, + connectionRepository: $2, + tokenExpirationRetryStrategy: $3, + timerType: $4 + ) + } ) ) } diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/AddUsersViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/AddUsersViewModel_Tests.swift index 1c4cf05ac..b81307dfb 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/AddUsersViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/AddUsersViewModel_Tests.swift @@ -8,7 +8,7 @@ import Combine @testable import StreamChatTestTools import XCTest -class AddUsersViewModel_Tests: StreamChatTestCase { +@MainActor class AddUsersViewModel_Tests: StreamChatTestCase { private var cancellables = Set() diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/AddUsersView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/AddUsersView_Tests.swift index 29edd8364..367ada835 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/AddUsersView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/AddUsersView_Tests.swift @@ -10,7 +10,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class AddUsersView_Tests: StreamChatTestCase { +@MainActor class AddUsersView_Tests: StreamChatTestCase { func test_addUsersView_snapshot() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoViewModel_Tests.swift index 4e389edc1..62998f242 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoViewModel_Tests.swift @@ -6,7 +6,7 @@ @testable import StreamChatSwiftUI import XCTest -class ChatChannelInfoViewModel_Tests: StreamChatTestCase { +@MainActor class ChatChannelInfoViewModel_Tests: StreamChatTestCase { func test_chatChannelInfoVM_initialGroupParticipants() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoView_Tests.swift index 8b08fbaf2..e4659f7ab 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class ChatChannelInfoView_Tests: StreamChatTestCase { +@MainActor class ChatChannelInfoView_Tests: StreamChatTestCase { func test_chatChannelInfoView_directChannelOfflineSnapshot() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/FileAttachmentsViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/FileAttachmentsViewModel_Tests.swift index 6fc7daa07..20be9223d 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/FileAttachmentsViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/FileAttachmentsViewModel_Tests.swift @@ -8,7 +8,7 @@ import Combine @testable import StreamChatTestTools import XCTest -class FileAttachmentsViewModel_Tests: StreamChatTestCase { +@MainActor class FileAttachmentsViewModel_Tests: StreamChatTestCase { func test_fileAttachmentsViewModel_notEmpty() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/FileAttachmentsView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/FileAttachmentsView_Tests.swift index f722b95c1..ca32839c5 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/FileAttachmentsView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/FileAttachmentsView_Tests.swift @@ -10,7 +10,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class FileAttachmentsView_Tests: StreamChatTestCase { +@MainActor class FileAttachmentsView_Tests: StreamChatTestCase { func test_fileAttachmentsView_nonEmptySnapshot() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/MediaAttachmentsViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/MediaAttachmentsViewModel_Tests.swift index 4ed504494..01ed76c29 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/MediaAttachmentsViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/MediaAttachmentsViewModel_Tests.swift @@ -8,7 +8,7 @@ import Combine @testable import StreamChatTestTools import XCTest -class MediaAttachmentsViewModel_Tests: StreamChatTestCase { +@MainActor class MediaAttachmentsViewModel_Tests: StreamChatTestCase { private var cancellables = Set() diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/MediaAttachmentsView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/MediaAttachmentsView_Tests.swift index 91248e995..d596491e9 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/MediaAttachmentsView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/MediaAttachmentsView_Tests.swift @@ -10,7 +10,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class MediaAttachmentsView_Tests: StreamChatTestCase { +@MainActor class MediaAttachmentsView_Tests: StreamChatTestCase { func test_mediaAttachmentsView_notEmptySnapshot() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/PinnedMessagesViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/PinnedMessagesViewModel_Tests.swift index f0c471d0a..43336a9ba 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/PinnedMessagesViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/PinnedMessagesViewModel_Tests.swift @@ -6,7 +6,7 @@ @testable import StreamChatSwiftUI import XCTest -class PinnedMessagesViewModel_Tests: StreamChatTestCase { +@MainActor class PinnedMessagesViewModel_Tests: StreamChatTestCase { func test_pinnedMessagesVM_notEmpty() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/PinnedMessagesView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/PinnedMessagesView_Tests.swift index 08845267f..8b6582258 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/PinnedMessagesView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/PinnedMessagesView_Tests.swift @@ -123,7 +123,7 @@ class PinnedMessagesView_Tests: StreamChatTestCase { } // Temp solution for failing tests. -class EmptyDateFormatter: DateFormatter { +class EmptyDateFormatter: DateFormatter, @unchecked Sendable { override func string(from date: Date) -> String { "" diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift index 37e4da927..0da5ad862 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift @@ -9,7 +9,7 @@ import SnapshotTesting import SwiftUI import XCTest -class ChatChannelDataSource_Tests: StreamChatTestCase { +@MainActor class ChatChannelDataSource_Tests: StreamChatTestCase { private let message = ChatMessage.mock( id: .unique, diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift index 5c33f782d..7f766621a 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class ChatChannelHeader_Tests: StreamChatTestCase { +@MainActor class ChatChannelHeader_Tests: StreamChatTestCase { func test_chatChannelHeaderModifier_snapshot() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewDateOverlay_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewDateOverlay_Tests.swift index e426dfc63..cbb1b8427 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewDateOverlay_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewDateOverlay_Tests.swift @@ -10,7 +10,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class ChatChannelViewDateOverlay_Tests: StreamChatTestCase { +@MainActor class ChatChannelViewDateOverlay_Tests: StreamChatTestCase { override func setUp() { super.setUp() diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift index 64e67f5f8..6e19d02cd 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift @@ -8,7 +8,7 @@ import SwiftUI import XCTest -class ChatChannelViewModel_Tests: StreamChatTestCase { +@MainActor class ChatChannelViewModel_Tests: StreamChatTestCase { func test_chatChannelVM_channelIsUpdated() { // Given let cid = ChannelId.unique diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelView_Tests.swift index df0fcb472..77458a3c2 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelView_Tests.swift @@ -10,7 +10,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class ChatChannelView_Tests: StreamChatTestCase { +@MainActor class ChatChannelView_Tests: StreamChatTestCase { override func setUp() { super.setUp() diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatMessage_AdjustedText_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatMessage_AdjustedText_Tests.swift index 21d732116..6e058d56f 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatMessage_AdjustedText_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatMessage_AdjustedText_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatTestTools import XCTest -class ChatMessage_AdjustedText_Tests: StreamChatTestCase { +@MainActor class ChatMessage_AdjustedText_Tests: StreamChatTestCase { override func setUp() { super.setUp() diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/CreatePollViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/CreatePollViewModel_Tests.swift index fac68f9e8..8476c88a2 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/CreatePollViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/CreatePollViewModel_Tests.swift @@ -6,7 +6,7 @@ @testable import StreamChatSwiftUI import XCTest -final class CreatePollViewModel_Tests: StreamChatTestCase { +@MainActor final class CreatePollViewModel_Tests: StreamChatTestCase { // MARK: - Can Show Discard Confirmation diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/LazyImageExtensions_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/LazyImageExtensions_Tests.swift index 527d1fb93..29c2b9cae 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/LazyImageExtensions_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/LazyImageExtensions_Tests.swift @@ -14,8 +14,14 @@ final class LazyImageExtensions_Tests: StreamChatTestCase { func test_imageURL_empty() { // Given - let lazyImageView = LazyImage(imageURL: nil) - .applyDefaultSize() + let lazyImageView = LazyImage(imageURL: nil) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } + } + .applyDefaultSize() // Then assertSnapshot(matching: lazyImageView, as: .image(perceptualPrecision: precision)) @@ -25,7 +31,13 @@ final class LazyImageExtensions_Tests: StreamChatTestCase { // Given let lazyImageView = LazyImage( imageURL: URL(string: "https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg") - ) + ) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } + } .applyDefaultSize() // Then diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift index 3daff5bf7..0be6a74b9 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift @@ -6,7 +6,7 @@ @testable import StreamChatSwiftUI import XCTest -class MessageActionsViewModel_Tests: StreamChatTestCase { +@MainActor class MessageActionsViewModel_Tests: StreamChatTestCase { func test_messageActionsViewModel_confirmationAlertShown() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift index fd474938b..f52072da2 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatTestTools import XCTest -class MessageActions_Tests: StreamChatTestCase { +@MainActor class MessageActions_Tests: StreamChatTestCase { func test_messageActions_currentUserDefault() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift index eb43ea43e..ee2de8bec 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift @@ -8,7 +8,7 @@ import SwiftUI import XCTest -class MessageComposerViewModel_Tests: StreamChatTestCase { +@MainActor class MessageComposerViewModel_Tests: StreamChatTestCase { private let testImage = UIImage(systemName: "checkmark")! private var mockURL: URL! @@ -323,14 +323,15 @@ class MessageComposerViewModel_Tests: StreamChatTestCase { viewModel.addedFileURLs = [mockURL] viewModel.sendMessage( quotedMessage: nil, - editedMessage: nil - ) { - // Then - XCTAssert(viewModel.errorShown == false) - XCTAssert(viewModel.text == "") - XCTAssert(viewModel.addedAssets.isEmpty) - XCTAssert(viewModel.addedFileURLs.isEmpty) - } + editedMessage: nil, + completion: {} + ) + + // Then + XCTAssert(viewModel.errorShown == false) + XCTAssert(viewModel.text == "") + XCTAssert(viewModel.addedAssets.isEmpty) + XCTAssert(viewModel.addedFileURLs.isEmpty) } func test_messageComposerVM_notInThread() { @@ -1304,7 +1305,7 @@ class MessageComposerViewModel_Tests: StreamChatTestCase { } enum MessageComposerTestUtils { - static func makeComposerViewModel(chatClient: ChatClient) -> MessageComposerViewModel { + @MainActor static func makeComposerViewModel(chatClient: ChatClient) -> MessageComposerViewModel { let channelController = makeChannelController(chatClient: chatClient) let viewModel = MessageComposerViewModel( channelController: channelController, diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift index 3ed23f001..9ab8dbe70 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift @@ -11,7 +11,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class MessageComposerView_Tests: StreamChatTestCase { +@MainActor class MessageComposerView_Tests: StreamChatTestCase { override func setUp() { super.setUp() @@ -791,7 +791,7 @@ class SyncAttachmentsConverter: MessageAttachmentsConverter { _ attachments: [AnyChatMessageAttachment], completion: @escaping (ComposerAssets) -> Void ) { - let addedAssets = attachmentsToAssets(attachments) + let addedAssets = SyncAttachmentsConverter.attachmentsToAssets(attachments) completion(addedAssets) } } diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift index eafba0d99..cafeb59b2 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class MessageContainerView_Tests: StreamChatTestCase { +@MainActor class MessageContainerView_Tests: StreamChatTestCase { override func setUp() { super.setUp() diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewAvatars_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewAvatars_Tests.swift index 930d099d2..a44f73d8a 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewAvatars_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewAvatars_Tests.swift @@ -8,7 +8,7 @@ import StreamSwiftTestHelpers import XCTest -class MessageListViewAvatars_Tests: StreamChatTestCase { +@MainActor class MessageListViewAvatars_Tests: StreamChatTestCase { override func setUp() { super.setUp() DelayedRenderingViewModifier.isEnabled = false diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewLastGroupHeader_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewLastGroupHeader_Tests.swift index 11684a90b..da6737bd3 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewLastGroupHeader_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewLastGroupHeader_Tests.swift @@ -10,7 +10,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class MessageListViewLastGroupHeader_Tests: StreamChatTestCase { +@MainActor class MessageListViewLastGroupHeader_Tests: StreamChatTestCase { override func setUp() { super.setUp() diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewNewMessages_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewNewMessages_Tests.swift index e23398867..44ce9129a 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewNewMessages_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewNewMessages_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class MessageListViewNewMessages_Tests: StreamChatTestCase { +@MainActor final class MessageListViewNewMessages_Tests: StreamChatTestCase { override func setUp() { super.setUp() diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListView_Tests.swift index d11c2fc42..863529a06 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class MessageListView_Tests: StreamChatTestCase { +@MainActor class MessageListView_Tests: StreamChatTestCase { override func setUp() { super.setUp() DelayedRenderingViewModifier.isEnabled = false diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageViewMultiRowReactions_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageViewMultiRowReactions_Tests.swift index c6211848e..b195a6144 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageViewMultiRowReactions_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageViewMultiRowReactions_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -final class MessageViewMultiRowReactions_Tests: StreamChatTestCase { +@MainActor final class MessageViewMultiRowReactions_Tests: StreamChatTestCase { override public func setUp() { super.setUp() diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift index 148f04a13..76edfd72e 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class MessageView_Tests: StreamChatTestCase { +@MainActor class MessageView_Tests: StreamChatTestCase { func test_messageViewText_snapshot() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/PollAttachmentViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/PollAttachmentViewModel_Tests.swift index 4fb540793..4b4ae3a6d 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/PollAttachmentViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/PollAttachmentViewModel_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatTestTools import XCTest -final class PollAttachmentViewModel_Tests: StreamChatTestCase { +@MainActor final class PollAttachmentViewModel_Tests: StreamChatTestCase { func test_pollAttachmentViewModel_synchronizeCalled() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/PollAttachmentView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/PollAttachmentView_Tests.swift index eb9238bd5..29d13fcc4 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/PollAttachmentView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/PollAttachmentView_Tests.swift @@ -8,7 +8,7 @@ import SnapshotTesting import SwiftUI import XCTest -final class PollAttachmentView_Tests: StreamChatTestCase { +@MainActor final class PollAttachmentView_Tests: StreamChatTestCase { func test_pollAttachmentView_snapshotCommentsAndSuggestions() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/PollCommentsViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/PollCommentsViewModel_Tests.swift index 6981b7e56..b8eeb84e4 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/PollCommentsViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/PollCommentsViewModel_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatTestTools import XCTest -final class PollCommentsViewModel_Tests: StreamChatTestCase { +@MainActor final class PollCommentsViewModel_Tests: StreamChatTestCase { func test_pollCommentsViewModel_synchronizeCalled() { // Given let commentsController = makeCommentsController() diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift index 31a1f6082..c6cb56b78 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class QuotedMessageView_Tests: StreamChatTestCase { +@MainActor class QuotedMessageView_Tests: StreamChatTestCase { private let testMessage = ChatMessage.mock( id: "test", diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift index 7832063a2..564afe27a 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class ReactionsOverlayView_Tests: StreamChatTestCase { +@MainActor class ReactionsOverlayView_Tests: StreamChatTestCase { private static let screenSize = CGSize(width: 393, height: 852) diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsUsersView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsUsersView_Tests.swift index d48ce0aff..428142b1a 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsUsersView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsUsersView_Tests.swift @@ -9,7 +9,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class ReactionsUsersView_Tests: StreamChatTestCase { +@MainActor class ReactionsUsersView_Tests: StreamChatTestCase { func test_reactionsUsersView_snapshotOneRow() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/Suggestions/TestCommandsConfig.swift b/StreamChatSwiftUITests/Tests/ChatChannel/Suggestions/TestCommandsConfig.swift index add7ecbc7..fedfaf29b 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/Suggestions/TestCommandsConfig.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/Suggestions/TestCommandsConfig.swift @@ -114,7 +114,7 @@ class MockCommandHandler: CommandHandler { public func executeOnMessageSent( composerCommand: ComposerCommand, - completion: @escaping (Error?) -> Void + completion: @escaping @Sendable(Error?) -> Void ) { executeOnMessageSentCalled = true } diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift index 32e426e8c..f830fab1c 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift @@ -9,7 +9,7 @@ import SnapshotTesting import StreamSwiftTestHelpers import XCTest -final class ChatChannelListItemView_Tests: StreamChatTestCase { +@MainActor final class ChatChannelListItemView_Tests: StreamChatTestCase { override func setUp() { super.setUp() diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift index 2259d73d7..93979b6fc 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatTestTools import XCTest -class ChatChannelListViewModel_Tests: StreamChatTestCase { +@MainActor class ChatChannelListViewModel_Tests: StreamChatTestCase { override open func setUp() { super.setUp() diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift index a52e0f5b0..54e2bfa1e 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift @@ -10,7 +10,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class ChatChannelListView_Tests: StreamChatTestCase { +@MainActor class ChatChannelListView_Tests: StreamChatTestCase { func test_chatChannelScreen_snapshot() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/LoadingView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/LoadingView_Tests.swift index 038baadf3..48d441a1c 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/LoadingView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/LoadingView_Tests.swift @@ -8,7 +8,7 @@ import SnapshotTesting import StreamSwiftTestHelpers import XCTest -class LoadingView_Tests: StreamChatTestCase { +@MainActor class LoadingView_Tests: StreamChatTestCase { func test_redactedLoadingView_snapshot() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsViewModel_Tests.swift index e9b904617..31a9b6346 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsViewModel_Tests.swift @@ -6,7 +6,7 @@ @testable import StreamChatSwiftUI import XCTest -class MoreChannelActionsViewModel_Tests: StreamChatTestCase { +@MainActor class MoreChannelActionsViewModel_Tests: StreamChatTestCase { @Injected(\.images) var images diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsView_Tests.swift index ae706f0dd..ddeec378f 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsView_Tests.swift @@ -8,7 +8,7 @@ import SnapshotTesting import StreamSwiftTestHelpers import XCTest -class MoreChannelActionsView_Tests: StreamChatTestCase { +@MainActor class MoreChannelActionsView_Tests: StreamChatTestCase { func test_moreChannelActionsView_snapshot() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift index aaa57f81a..2af886d30 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift @@ -7,7 +7,7 @@ import SnapshotTesting @testable import StreamChatSwiftUI import XCTest -class SearchResultsView_Tests: StreamChatTestCase { +@MainActor class SearchResultsView_Tests: StreamChatTestCase { func test_searchResultsView_snapshotResults() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListViewModel_Tests.swift index ed8d33e47..fe9bdf32d 100644 --- a/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListViewModel_Tests.swift @@ -7,7 +7,7 @@ @testable import StreamChatTestTools import XCTest -class ChatThreadListViewModel_Tests: StreamChatTestCase { +@MainActor class ChatThreadListViewModel_Tests: StreamChatTestCase { func test_viewDidAppear_thenLoadsThreads() { let mockThreadListController = ChatThreadListController_Mock.mock( diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListView_Tests.swift index 027630d48..bd52ac0f3 100644 --- a/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListView_Tests.swift @@ -10,7 +10,7 @@ import StreamSwiftTestHelpers import SwiftUI import XCTest -class ChatThreadListView_Tests: StreamChatTestCase { +@MainActor class ChatThreadListView_Tests: StreamChatTestCase { func test_chatThreadListView_empty() { let view = makeView(.empty()) diff --git a/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift b/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift index 0b0fc1c38..5107dd2bf 100644 --- a/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift +++ b/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift @@ -21,7 +21,7 @@ open class StreamChatTestCase: XCTestCase { public var streamChat: StreamChat? - override open func setUp() { + @MainActor override open func setUp() { super.setUp() streamChat = StreamChat(chatClient: chatClient) } diff --git a/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift b/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift index 8387b3faa..d55d6e4c6 100644 --- a/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift +++ b/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift @@ -8,7 +8,7 @@ import Foundation import SwiftUI import XCTest -class ViewFactory_Tests: StreamChatTestCase { +@MainActor class ViewFactory_Tests: StreamChatTestCase { private let message = ChatMessage.mock( id: .unique, diff --git a/StreamChatSwiftUITestsAppTests/Pages/MessageListPage.swift b/StreamChatSwiftUITestsAppTests/Pages/MessageListPage.swift index 314d04d46..7bdea54a9 100644 --- a/StreamChatSwiftUITestsAppTests/Pages/MessageListPage.swift +++ b/StreamChatSwiftUITestsAppTests/Pages/MessageListPage.swift @@ -371,7 +371,7 @@ class MessageListPage { enum ComposerMentions { static var cells: XCUIElementQuery { - app.scrollViews["CommandsContainerView"].otherElements.matching(NSPredicate(format: "identifier LIKE 'MessageAvatarView'")) + app.scrollViews["CommandsContainerView"].images.matching(NSPredicate(format: "identifier LIKE 'MessageAvatarView'")) } }