diff --git a/.github/workflows/cron-checks.yml b/.github/workflows/cron-checks.yml index 115c5972..8fce7872 100644 --- a/.github/workflows/cron-checks.yml +++ b/.github/workflows/cron-checks.yml @@ -107,7 +107,7 @@ jobs: if: failure() run: | brew install chargepoint/xcparse/xcparse - xcparse logs fastlane/test_output/StreamChatUITestsApp.xcresult fastlane/test_output/logs/ + xcparse logs fastlane/test_output/StreamChatSwiftUITestsApp.xcresult fastlane/test_output/logs/ - uses: actions/upload-artifact@v3 if: failure() with: diff --git a/.github/workflows/smoke-checks.yml b/.github/workflows/smoke-checks.yml index d456c05c..3eb9c024 100644 --- a/.github/workflows/smoke-checks.yml +++ b/.github/workflows/smoke-checks.yml @@ -152,7 +152,7 @@ jobs: if: failure() run: | brew install chargepoint/xcparse/xcparse - xcparse logs fastlane/test_output/StreamChatUITestsApp.xcresult fastlane/test_output/logs/ + xcparse logs fastlane/test_output/StreamChatSwiftUITestsApp.xcresult fastlane/test_output/logs/ - uses: actions/upload-artifact@v3 if: failure() with: diff --git a/.gitignore b/.gitignore index 15b40561..6ba68861 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,9 @@ Pods/ Carthage/ !Sample/Carthage/ +# Ignore Products folder +Products/ + # fastlane # # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the @@ -86,3 +89,5 @@ push_payload.json sinatra_log.txt derived_data/ spm_cache/ +.buildcache +buildcache diff --git a/.slather.yml b/.slather.yml index 9bcaa32e..be3653fb 100644 --- a/.slather.yml +++ b/.slather.yml @@ -10,3 +10,5 @@ ignore: - "**/*_Mock.swift" - "**/*_Vendor.swift" - "**/Generated/*.swift" + - "Sources/StreamChatSwiftUI/StreamNuke" + - "Sources/StreamChatSwiftUI/StreamSwiftyGif" diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e28fa2..c33a13b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +### ⚠️ Important + +- Dependencies are no longer exposed (this includes Nuke and SwiftyGif). If you were using those dependencies we were exposing, you would need to import them manually. This is due to our newest addition supporting Module Stable XCFramework, see more below in the "Added" section. If you encounter any SPM-related problems, be sure to reset the package caches. + +### ✅ Added +- Add message preview with attachments in channel list +- Add support for pre-built XCFramework +- Config for composer text input paddings +- Config for left alignment of messages + ### 🔄 Changed +- Made some `ChannelList` and `MessageListView` parameters optional # [4.39.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.39.0) _October 06, 2023_ diff --git a/DemoAppSwiftUI/AppDelegate.swift b/DemoAppSwiftUI/AppDelegate.swift index 8184b7c9..68730f03 100644 --- a/DemoAppSwiftUI/AppDelegate.swift +++ b/DemoAppSwiftUI/AppDelegate.swift @@ -66,11 +66,23 @@ class AppDelegate: NSObject, UIApplicationDelegate { messageListConfig: MessageListConfig(dateIndicatorPlacement: .messageList) ) streamChat = StreamChat(chatClient: chatClient, utils: utils) + + let credentials = UnsecureRepository.shared.loadCurrentUser() + if let credentials, let token = try? Token(rawValue: credentials.token) { + chatClient.connectUser( + userInfo: .init( + id: credentials.id, + name: credentials.name, + imageURL: credentials.avatarURL + ), + token: token + ) + } - DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { withAnimation { if AppState.shared.userState == .launchAnimation { - AppState.shared.userState = .notLoggedIn + AppState.shared.userState = credentials == nil ? .notLoggedIn : .loggedIn } } } diff --git a/DemoAppSwiftUI/AppleMessageComposerView.swift b/DemoAppSwiftUI/AppleMessageComposerView.swift new file mode 100644 index 00000000..3f859952 --- /dev/null +++ b/DemoAppSwiftUI/AppleMessageComposerView.swift @@ -0,0 +1,366 @@ +// +// Copyright © 2023 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatSwiftUI +import SwiftUI + +@available(iOS 15.0, *) +struct AppleMessageComposerView: View, KeyboardReadable { + + @State var text = "" + @State var shouldShow = false + + @Injected(\.colors) private var colors + @Injected(\.fonts) private var fonts + + // Initial popup size, before the keyboard is shown. + @State private var popupSize: CGFloat = 350 + @State private var composerHeight: CGFloat = 0 + @State private var keyboardShown = false + @State private var editedMessageWillShow = false + + private var factory: Factory + private var channelConfig: ChannelConfig? + @Binding var quotedMessage: ChatMessage? + @Binding var editedMessage: ChatMessage? + + @State private var state: AnimationState = .initial + @State private var listScale: CGFloat = 0 + + public init( + viewFactory: Factory, + viewModel: MessageComposerViewModel? = nil, + channelController: ChatChannelController, + messageController: ChatMessageController? = nil, + quotedMessage: Binding, + editedMessage: Binding, + onMessageSent: @escaping () -> Void + ) { + factory = viewFactory + channelConfig = channelController.channel?.config + let vm = viewModel ?? ViewModelsFactory.makeMessageComposerViewModel( + with: channelController, + messageController: messageController + ) + _viewModel = StateObject( + wrappedValue: vm + ) + _quotedMessage = quotedMessage + _editedMessage = editedMessage + self.onMessageSent = onMessageSent + } + + @StateObject var viewModel: MessageComposerViewModel + + var onMessageSent: () -> Void + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .bottom) { + Button { + withAnimation(.bouncy) { + switch state { + case .initial: + listScale = 1 + state = .expanded + case .expanded: + listScale = 0 + state = .initial + } + } + } label: { + Image(systemName: "plus") + .padding(.all, 8) + .foregroundColor(Color.gray) + .background(Color(colors.background1)) + .clipShape(Circle()) + } + .padding(.bottom, 4) + + ComposerInputView( + factory: DefaultViewFactory.shared, + text: $viewModel.text, + selectedRangeLocation: $viewModel.selectedRangeLocation, + command: $viewModel.composerCommand, + addedAssets: viewModel.addedAssets, + addedFileURLs: viewModel.addedFileURLs, + addedCustomAttachments: viewModel.addedCustomAttachments, + quotedMessage: $quotedMessage, + maxMessageLength: channelConfig?.maxMessageLength, + cooldownDuration: viewModel.cooldownDuration, + onCustomAttachmentTap: viewModel.customAttachmentTapped(_:), + removeAttachmentWithId: viewModel.removeAttachment(with:) + ) + .overlay( + viewModel.sendButtonEnabled ? sendButton : nil + ) + } + .padding(.all, 8) + + factory.makeAttachmentPickerView( + attachmentPickerState: $viewModel.pickerState, + filePickerShown: $viewModel.filePickerShown, + cameraPickerShown: $viewModel.cameraPickerShown, + addedFileURLs: $viewModel.addedFileURLs, + onPickerStateChange: viewModel.change(pickerState:), + photoLibraryAssets: viewModel.imageAssets, + onAssetTap: viewModel.imageTapped(_:), + onCustomAttachmentTap: viewModel.customAttachmentTapped(_:), + isAssetSelected: viewModel.isImageSelected(with:), + addedCustomAttachments: viewModel.addedCustomAttachments, + cameraImageAdded: viewModel.cameraImageAdded(_:), + askForAssetsAccessPermissions: viewModel.askForPhotosPermission, + isDisplayed: viewModel.overlayShown, + height: viewModel.overlayShown ? popupSize : 0, + popupHeight: popupSize + ) + } + .background( + GeometryReader { proxy in + let frame = proxy.frame(in: .local) + let height = frame.height + Color.clear.preference(key: HeightPreferenceKey.self, value: height) + } + ) + .onPreferenceChange(HeightPreferenceKey.self) { value in + if let value = value, value != composerHeight { + self.composerHeight = value + } + } + .onReceive(keyboardWillChangePublisher) { visible in + if visible && !keyboardShown { + if viewModel.composerCommand == nil && !editedMessageWillShow { + withAnimation(.easeInOut(duration: 0.02)) { + viewModel.pickerTypeState = .expanded(.none) + } + } + } + keyboardShown = visible + editedMessageWillShow = false + } + .onReceive(keyboardHeight) { height in + if height > 0 && height != popupSize { + self.popupSize = height - bottomSafeArea + } + } + .overlay( + viewModel.showCommandsOverlay ? + factory.makeCommandsContainerView( + suggestions: viewModel.suggestions, + handleCommand: { commandInfo in + viewModel.handleCommand( + for: $viewModel.text, + selectedRangeLocation: $viewModel.selectedRangeLocation, + command: $viewModel.composerCommand, + extraData: commandInfo + ) + } + ) + .offset(y: -composerHeight) + .animation(nil) : nil, + alignment: .bottom + ) + .modifier(factory.makeComposerViewModifier()) + .onChange(of: editedMessage) { _ in + viewModel.text = editedMessage?.text ?? "" + if editedMessage != nil { + editedMessageWillShow = true + viewModel.selectedRangeLocation = editedMessage?.text.count ?? 0 + } + } + .accessibilityElement(children: .contain) + .overlay( + ComposerActionsView(viewModel: viewModel, state: $state, listScale: $listScale) + .offset(y: -(UIScreen.main.bounds.height - composerHeight) / 2 + 80) + .allowsHitTesting(state == .expanded) + ) + } + + private var sendButton: some View { + BottomRightView { + Button { + viewModel.sendMessage(quotedMessage: nil, editedMessage: nil) { + onMessageSent() + } + } label: { + Image(systemName: "arrow.up.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24) + .foregroundColor(.blue) + } + .padding(.trailing, 4) + .padding(.bottom, !viewModel.addedAssets.isEmpty ? 16 : 8) + } + } +} + +@available(iOS 15.0, *) +struct BlurredBackground: View { + var body: some View { + Color.clear + .frame( + width: UIScreen.main.bounds.width, + height: UIScreen.main.bounds.height + ) + .background( + .ultraThinMaterial, + in: RoundedRectangle(cornerRadius: 16.0) + ) + } +} + +struct HeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat? = nil + + static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { + value = value ?? nextValue() + } +} + +enum AnimationState { + case initial, expanded +} + +struct ComposerAction: Equatable, Identifiable { + static func == (lhs: ComposerAction, rhs: ComposerAction) -> Bool { + lhs.id == rhs.id + } + + var imageName: String + var text: String + var color: Color + var action: () -> Void + var id: String { + "\(imageName)-\(text)" + } +} + +@available(iOS 15.0, *) +struct ComposerActionsView: View { + + @ObservedObject var viewModel: MessageComposerViewModel + + @State var composerActions: [ComposerAction] = [] + + @Binding var state: AnimationState + @Binding var listScale: CGFloat + + var body: some View { + ZStack(alignment: .bottomLeading) { + Color.white.opacity(state == .initial ? 0.2 : 0.5) + + BlurredBackground() + .opacity(state == .initial ? 0.0 : 1) + + VStack(alignment: .leading, spacing: 30) { + ForEach(composerActions) { composerAction in + Button { + withAnimation { + state = .initial + composerAction.action() + } + } label: { + ComposerActionView(composerAction: composerAction) + } + } + } + .padding(.leading, 40) + .padding(.bottom, 84) + .scaleEffect( + CGSize( + width: state == .initial ? 0 : 1, + height: state == .initial ? 0 : 1 + ) + ) + .offset( + x: state == .initial ? -75 : 0, + y: state == .initial ? 90 : 0 + ) + } + .onAppear { + setupComposerActions() + } + .edgesIgnoringSafeArea(.all) + .onTapGesture { + withAnimation(.bouncy) { + switch state { + case .initial: + listScale = 1 + state = .expanded + case .expanded: + listScale = 0 + state = .initial + } + } + } + } + + private func setupComposerActions() { + let imageAction: () -> Void = { + viewModel.pickerTypeState = .expanded(.media) + viewModel.pickerState = .photos + } + let commandsAction: () -> Void = { + viewModel.pickerTypeState = .expanded(.instantCommands) + } + let filesAction: () -> Void = { + viewModel.pickerTypeState = .expanded(.media) + viewModel.pickerState = .files + } + let cameraAction: () -> Void = { + viewModel.pickerTypeState = .expanded(.media) + viewModel.pickerState = .camera + } + + composerActions = [ + ComposerAction( + imageName: "photo.on.rectangle", + text: "Photos", + color: .purple, + action: imageAction + ), + ComposerAction( + imageName: "camera.circle.fill", + text: "Camera", + color: .gray, + action: cameraAction + ), + ComposerAction( + imageName: "folder.circle", + text: "Files", + color: .indigo, + action: filesAction + ), + ComposerAction( + imageName: "command.circle.fill", + text: "Commands", + color: .orange, + action: commandsAction + ) + ] + } +} + +struct ComposerActionView: View { + + private let imageSize: CGFloat = 34 + + var composerAction: ComposerAction + + var body: some View { + HStack(spacing: 20) { + Image(systemName: composerAction.imageName) + .resizable() + .scaledToFit() + .foregroundColor(composerAction.color) + .frame(width: imageSize, height: imageSize) + + Text(composerAction.text) + .foregroundColor(.primary) + .font(.title2) + } + } +} diff --git a/DemoAppSwiftUI/CustomChannelHeader.swift b/DemoAppSwiftUI/CustomChannelHeader.swift index e7ef31ed..a3475303 100644 --- a/DemoAppSwiftUI/CustomChannelHeader.swift +++ b/DemoAppSwiftUI/CustomChannelHeader.swift @@ -86,8 +86,12 @@ struct CustomChannelModifier: ChannelListHeaderViewModifier { message: Text("Are you sure you want to sign out?"), primaryButton: .destructive(Text("Sign out")) { withAnimation { - chatClient.disconnect {} - AppState.shared.userState = .notLoggedIn + chatClient.disconnect { + UnsecureRepository.shared.removeCurrentUser() + DispatchQueue.main.async { + AppState.shared.userState = .notLoggedIn + } + } } }, secondaryButton: .cancel() diff --git a/DemoAppSwiftUI/DemoUser.swift b/DemoAppSwiftUI/DemoUser.swift index fc0e4fbb..66ae9363 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 { +public struct UserCredentials: Codable { public let id: String public let name: String public let avatarURL: URL diff --git a/DemoAppSwiftUI/LoginViewModel.swift b/DemoAppSwiftUI/LoginViewModel.swift index b128b69f..56432365 100644 --- a/DemoAppSwiftUI/LoginViewModel.swift +++ b/DemoAppSwiftUI/LoginViewModel.swift @@ -34,6 +34,7 @@ class LoginViewModel: ObservableObject { DispatchQueue.main.async { [weak self] in withAnimation { self?.loading = false + UnsecureRepository.shared.save(user: credentials) AppState.shared.userState = .loggedIn } } diff --git a/DemoAppSwiftUI/UserRepository.swift b/DemoAppSwiftUI/UserRepository.swift new file mode 100644 index 00000000..bcf33f3d --- /dev/null +++ b/DemoAppSwiftUI/UserRepository.swift @@ -0,0 +1,62 @@ +// +// Copyright © 2023 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamChat + +protocol UserRepository { + + func save(user: UserCredentials) + + func loadCurrentUser() -> UserCredentials? + + func removeCurrentUser() +} + +// NOTE: This is just for simplicity. User data shouldn't be kept in `UserDefaults`. +final class UnsecureRepository: UserRepository { + enum Key: String, CaseIterable { + case user = "stream.chat.user" + } + + private let defaults: UserDefaults + + private init(defaults: UserDefaults = UserDefaults.standard) { + self.defaults = defaults + } + + private func set(_ value: Any?, for key: Key) { + defaults.set(value, forKey: key.rawValue) + } + + private func get(for key: Key) -> T? { + defaults.object(forKey: key.rawValue) as? T + } + + static let shared = UnsecureRepository() + + func save(user: UserCredentials) { + let encoder = JSONEncoder() + if let encoded = try? encoder.encode(user) { + set(encoded, for: .user) + } + } + + func loadCurrentUser() -> UserCredentials? { + if let savedUser: Data = get(for: .user) { + let decoder = JSONDecoder() + do { + let loadedUser = try decoder.decode(UserCredentials.self, from: savedUser) + return loadedUser + } catch { + log.error("Error while decoding user") + } + } + return nil + } + + func removeCurrentUser() { + defaults.set(nil, forKey: Key.user.rawValue) + } +} diff --git a/DemoAppSwiftUI/WhatsAppChannelHeader.swift b/DemoAppSwiftUI/WhatsAppChannelHeader.swift new file mode 100644 index 00000000..314b2a08 --- /dev/null +++ b/DemoAppSwiftUI/WhatsAppChannelHeader.swift @@ -0,0 +1,84 @@ +// +// Copyright © 2023 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatSwiftUI +import SwiftUI + +import SwiftUI + +struct WhatsAppChannelHeaderModifier: ChatChannelHeaderViewModifier { + + let channel: ChatChannel + + func body(content: Content) -> some View { + content.toolbar { + WhatsAppChannelHeader(channel: channel) + } + } +} + +struct WhatsAppChannelHeader: ToolbarContent { + + @ObservedObject private var channelHeaderLoader = InjectedValues[\.utils].channelHeaderLoader + + @Injected(\.chatClient) var chatClient + @Injected(\.utils) var utils + @Injected(\.fonts) var fonts + @Injected(\.colors) var colors + + var channel: ChatChannel + + private var currentUserId: String { + chatClient.currentUserId ?? "" + } + + private var channelNamer: ChatChannelNamer { + utils.channelNamer + } + + private var channelSubtitle: String { + if channel.memberCount <= 2 { + return channel.onlineInfoText(currentUserId: currentUserId) + } else { + return channel + .lastActiveMembers + .map { $0.name ?? $0.id } + .joined(separator: ", ") + } + } + + var body: some ToolbarContent { + ToolbarItem(placement: .principal) { + HStack { + ChannelAvatarView( + avatar: channelHeaderLoader.image(for: channel), + showOnlineIndicator: false, + size: CGSize(width: 36, height: 36) + ) + VStack(alignment: .leading) { + Text(channelNamer(channel, currentUserId) ?? "") + .font(fonts.bodyBold) + Text(channelSubtitle) + .font(fonts.caption1) + .foregroundColor(Color(colors.textLowEmphasis)) + } + } + } + ToolbarItem(placement: .topBarTrailing) { + HStack { + Button(action: { + print("tapped on video") + }, label: { + Image(systemName: "video") + }) + Button(action: { + print("tapped on audio") + }, label: { + Image(systemName: "phone") + }) + } + } + } +} diff --git a/Gemfile.lock b/Gemfile.lock index 9fd88a40..f7815be9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -183,6 +183,7 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) + fastlane-plugin-create_xcframework (1.1.2) fastlane-plugin-emerge (0.6.0) faraday (~> 1.1) fastlane-plugin-lizard (1.3.3) @@ -405,6 +406,7 @@ DEPENDENCIES danger danger-commit_lint fastlane + fastlane-plugin-create_xcframework fastlane-plugin-emerge fastlane-plugin-lizard fastlane-plugin-sonarcloud_metric_kit diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..461beee4 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +MAKEFLAGS += --silent + +update_dependencies: + echo "👉 Updating Nuke" + make update_nuke version=11.3.1 + echo "👉 Updating SwiftyGif" + make update_swiftygif version=5.4.2 + +update_nuke: check_version_parameter + ./Scripts/updateDependency.sh $(version) Dependencies/Nuke Sources/StreamChatSwiftUI/StreamNuke Sources + ./Scripts/removePublicDeclarations.sh Sources/StreamChatSwiftUI/StreamNuke + +update_swiftygif: check_version_parameter + ./Scripts/updateDependency.sh $(version) Dependencies/SwiftyGif Sources/StreamChatSwiftUI/StreamSwiftyGif SwiftyGif + ./Scripts/removePublicDeclarations.sh Sources/StreamChatSwiftUI/StreamSwiftyGif + +check_version_parameter: + @if [ "$(version)" = "" ]; then\ + echo "❌ Missing version parameter"; \ + exit 1;\ + fi diff --git a/Package.swift b/Package.swift index bb1178f1..531eebc8 100644 --- a/Package.swift +++ b/Package.swift @@ -17,13 +17,12 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "4.39.0"), - .package(url: "https://github.com/kean/Nuke.git", .exact("11.3.1")) + .package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "4.40.0"), ], targets: [ .target( name: "StreamChatSwiftUI", - dependencies: [.product(name: "StreamChat", package: "stream-chat-swift"), "Nuke", .product(name: "NukeUI", package: "Nuke")], + dependencies: [.product(name: "StreamChat", package: "stream-chat-swift")], exclude: ["README.md", "Info.plist", "Generated/L10n_template.stencil"], resources: [.process("Resources")] ) diff --git a/Scripts/removePublicDeclarations.sh b/Scripts/removePublicDeclarations.sh new file mode 100755 index 00000000..ac3fd298 --- /dev/null +++ b/Scripts/removePublicDeclarations.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Usage: ./removePublicDeclarations.sh Sources/StreamNuke +# +# This script would iterate over the files on a particular directory, and perform basic replacement operations. +# It heavily relies on 'sed': +# sed -i '' -e 's///g' +# ^ +# Passing empty string prevents the creation of backup files + +args=("$@") +directory=$1 + +replaceDeclaration() { + original=$1 + replacement=$2 + file=$3 + `sed -i '' -e "s/$original/$replacement/g" $file` +} + +files=`find $directory -name "*.swift"` +for f in $files +do + replaceDeclaration 'public internal(set) ' '' $f + replaceDeclaration 'open ' '' $f + replaceDeclaration 'public ' '' $f + + # Nuke + if [[ $directory == *"Nuke"* ]]; then + replaceDeclaration 'var log' 'var nukeLog' $f + replaceDeclaration 'log =' 'nukeLog =' $f + replaceDeclaration 'log: log' 'log: nukeLog' $f + 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 + replaceDeclaration 'AssetType' 'NukeAssetType' $f + replaceDeclaration 'typealias ImageRequest = Nuke.ImageRequest' '' $f + replaceDeclaration 'typealias ImageResponse = Nuke.ImageResponse' '' $f + replaceDeclaration 'typealias ImagePipeline = Nuke.ImagePipeline' '' $f + replaceDeclaration 'typealias ImageContainer = Nuke.ImageContainer' '' $f + replaceDeclaration 'open class ' '' $f + replaceDeclaration 'import Nuke' '' $f + + # Remove Cancellable interface duplicate + if [[ $f == *"DataLoader"* && `head -10 $f` == *"protocol Cancellable"* ]]; then + `sed -i '' -e '7,11d' $f` + fi + + # Rename files + if [[ $f == *"Caching/Cache.swift" ]]; then + new_f="${f/Cache.swift/NukeCache.swift}" + mv "$f" "$new_f" + elif [[ $f == *"NukeUI/VideoPlayerView.swift" ]]; then + new_f="${f/VideoPlayerView.swift/NukeVideoPlayerView.swift}" + mv "$f" "$new_f" + fi + fi +done diff --git a/Scripts/removeUnneededSymbols.sh b/Scripts/removeUnneededSymbols.sh new file mode 100755 index 00000000..a2279aca --- /dev/null +++ b/Scripts/removeUnneededSymbols.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Usage: ./removeUnneededSymbols.sh StreamChatSwiftUI ./Products +# +# Creating an xcframework for StreamChatSwiftUI generates a .bcsymbolmap file for itself, and one for +# each of its dependencies too (eg. StreamChat). That means that we will end up having something like: +# +# -> StreamChatSwiftUI/BCSymbolMaps/ +# .bcsymbolmap +# .bcsymbolmap +# +# When adding both StreamChat and StreamChatSwiftUI to an app, it will throw an error when trying to compile +# saying that there are multiple executions producing the same file (.bcsymbolmap). +# +# This script will remove duplicated .bcsymbolmap in the generated xcframeworks. +# If we countinue with the same example, it will leave it as follows: +# +# -> StreamChatSwiftUI/BCSymbolMaps/ +# .bcsymbolmap +# +# Each xcframework only contains its symbols now. + +args=("$@") +library=$1 +output_directory=$2 + +function removeUnneededSymbols() { + arch=$1 + path="$output_directory/$library.xcframework/$arch/BCSymbolMaps" + cd $path + + # Looking for [...]/DerivedSources/[LIBRARY-NAME]_vers.c + regex="(\/DerivedSources\/)([a-zA-Z_]*)(_vers.c)" + files="*.bcsymbolmap" + for f in $files + do + text=`head -10 $f` + [[ $text =~ $regex ]] + library_match="${BASH_REMATCH[2]}" + if [[ $library_match != $library ]] + then + echo "→ Removing uneeded 'bcsymbolmap' from $library-$arch: $library_match - $f" + rm $f + fi + done + + cd - >/dev/null +} + +removeUnneededSymbols "ios-arm64" +removeUnneededSymbols "ios-arm64_x86_64-simulator" diff --git a/Scripts/run-linter.sh b/Scripts/run-linter.sh index 2e2963a1..3c193471 100755 --- a/Scripts/run-linter.sh +++ b/Scripts/run-linter.sh @@ -4,7 +4,7 @@ set -euo pipefail echo -e "👉 Running SwiftFormat Linting" echo -e "👉 Linting Sources..." -mint run swiftformat --lint --config .swiftformat Sources --exclude **/Generated +mint run swiftformat --lint --config .swiftformat Sources --exclude **/Generated,Sources/StreamChatSwiftUI/StreamNuke,Sources/StreamChatSwiftUI/StreamSwiftyGif echo -e "👉 Linting Tests..." mint run swiftformat --lint --config .swiftformat StreamChatSwiftUITests echo -e "👉 Linting DemoApp..." diff --git a/Scripts/updateDependency.sh b/Scripts/updateDependency.sh new file mode 100755 index 00000000..9feb9dfd --- /dev/null +++ b/Scripts/updateDependency.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Usage: ./updateDependency.sh 10.3.3 Dependencies/Nuke Sources/StreamNuke Sources +# +# This script gets the source code of a dependency of a given library, and copies it to our codebase + +ensure_clean_git () { + if !(git diff-index --quiet HEAD) + then + echo "→ Seems like git is not clean in $dependency_directory. Please make sure it is clean, and run it again" + exit 1 + fi +} + +args=("$@") +version=$1 +dependency_directory=$2 +output_directory=$3 +sources_directory=$4 + +dependency_url="" + +if [[ $dependency_directory == *"Nuke"* ]]; then + dependency_url="git@github.com:kean/Nuke.git" +elif [[ $dependency_directory == *"SwiftyGif"* ]]; then + dependency_url="git@github.com:kirualex/SwiftyGif.git" +else + echo "→ Unknown dependency at $dependency_directory" + exit 1 +fi + +if ! [[ -d "$dependency_directory" ]]; then + echo "→ $dependency_directory does not exist in your filesystem. Cloning the repo" + git clone $dependency_url $dependency_directory +fi + +cd $dependency_directory + +ensure_clean_git + +git fetch --tags +git checkout $version + +ensure_clean_git + +cd - + +echo "→ Copying source files" +rm -rf $output_directory +mkdir $output_directory +cp -r "$dependency_directory/$sources_directory/." $output_directory + + +for f in `find $output_directory -type f \( -iname \*.h -o -iname \*.plist \)` +do + echo "→ Removing $f" + rm $f +done + +rm -rf $dependency_directory diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift index 08a0c184..741ca9f5 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift @@ -93,10 +93,14 @@ public struct DefaultChatChannelHeader: ToolbarContent { /// The default header modifier. public struct DefaultChannelHeaderModifier: ChatChannelHeaderViewModifier { - @StateObject private var channelHeaderLoader = ChannelHeaderLoader() + @ObservedObject private var channelHeaderLoader = InjectedValues[\.utils].channelHeaderLoader @State private var isActive: Bool = false public var channel: ChatChannel + + public init(channel: ChatChannel) { + self.channel = channel + } public func body(content: Content) -> some View { content.toolbar { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift index 02896dbd..17e4e0e7 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift @@ -22,7 +22,7 @@ public struct ChatChannelView: View, KeyboardReadable { private var factory: Factory public init( - viewFactory: Factory, + viewFactory: Factory = DefaultViewFactory.shared, viewModel: ChatChannelViewModel? = nil, channelController: ChatChannelController, messageController: ChatMessageController? = nil, diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift index fc468225..dbef511f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift @@ -3,7 +3,6 @@ // import Combine -import Nuke import StreamChat import SwiftUI @@ -163,7 +162,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { @objc private func didReceiveMemoryWarning() { - Nuke.ImageCache.shared.removeAll() + ImageCache.shared.removeAll() messageCachingUtils.clearCache() } @@ -511,7 +510,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { messageCachingUtils.clearCache() if messageController == nil { utils.channelControllerFactory.clearCurrentController() - Nuke.ImageCache.shared.trim(toCost: utils.messageListConfig.cacheSizeOnChatDismiss) + ImageCache.shared.trim(toCost: utils.messageListConfig.cacheSizeOnChatDismiss) } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift index 58834f74..41f24598 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift @@ -13,6 +13,7 @@ public struct ComposerConfig { public var inputViewCornerRadius: CGFloat public var inputFont: UIFont public var gallerySupportedTypes: GallerySupportedTypes + public var inputPaddingsConfig: PaddingsConfig public var adjustMessageOnSend: (String) -> (String) public var adjustMessageOnRead: (String) -> (String) public var attachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload] @@ -23,6 +24,7 @@ public struct ComposerConfig { inputViewCornerRadius: CGFloat = 20, inputFont: UIFont = UIFont.preferredFont(forTextStyle: .body), gallerySupportedTypes: GallerySupportedTypes = .imagesAndVideo, + inputPaddingsConfig: PaddingsConfig = .composerInput, adjustMessageOnSend: @escaping (String) -> (String) = { $0 }, adjustMessageOnRead: @escaping (String) -> (String) = { $0 }, attachmentPayloadConverter: @escaping (ChatMessage) -> [AnyAttachmentPayload] @@ -36,6 +38,7 @@ public struct ComposerConfig { self.adjustMessageOnRead = adjustMessageOnRead self.attachmentPayloadConverter = attachmentPayloadConverter self.gallerySupportedTypes = gallerySupportedTypes + self.inputPaddingsConfig = inputPaddingsConfig } public static var defaultAttachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload] = { message in @@ -48,3 +51,34 @@ public enum GallerySupportedTypes { case images case videos } + +public struct PaddingsConfig { + public let top: CGFloat + public let bottom: CGFloat + public let leading: CGFloat + public let trailing: CGFloat + + public var horizontal: CGFloat { + leading + trailing + } + + public var vertical: CGFloat { + top + bottom + } + + public init(top: CGFloat, bottom: CGFloat, leading: CGFloat, trailing: CGFloat) { + self.top = top + self.bottom = bottom + self.leading = leading + self.trailing = trailing + } +} + +extension PaddingsConfig { + public static let composerInput = PaddingsConfig( + top: 4, + bottom: 4, + leading: 8, + trailing: 0 + ) +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift index 7aa27a78..6780e4ef 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift @@ -25,7 +25,7 @@ public struct MessageComposerView: View, KeyboardReadable viewFactory: Factory, viewModel: MessageComposerViewModel? = nil, channelController: ChatChannelController, - messageController: ChatMessageController?, + messageController: ChatMessageController? = nil, quotedMessage: Binding, editedMessage: Binding, onMessageSent: @escaping () -> Void @@ -197,6 +197,7 @@ public struct ComposerInputView: View { @Injected(\.colors) private var colors @Injected(\.fonts) private var fonts @Injected(\.images) private var images + @Injected(\.utils) private var utils var factory: Factory @Binding var text: String @@ -255,6 +256,10 @@ public struct ComposerInputView: View { return textHeight } + + var inputPaddingsConfig: PaddingsConfig { + utils.composerConfig.inputPaddingsConfig + } public var body: some View { VStack { @@ -340,8 +345,9 @@ public struct ComposerInputView: View { } .frame(height: textFieldHeight) } - .padding(.vertical, shouldAddVerticalPadding ? 8 : 0) - .padding(.leading, 8) + .padding(.vertical, shouldAddVerticalPadding ? inputPaddingsConfig.vertical : 0) + .padding(.leading, inputPaddingsConfig.leading) + .padding(.trailing, inputPaddingsConfig.trailing) .background(composerInputBackground) .overlay( RoundedRectangle(cornerRadius: TextSizeConstants.cornerRadius) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Gallery/ZoomableScrollView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/ZoomableScrollView.swift index e7c64739..6a78d14f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Gallery/ZoomableScrollView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/ZoomableScrollView.swift @@ -50,8 +50,8 @@ private struct ZoomableScrollViewImpl: UIViewControllerRepresenta let coordinator: Coordinator let scrollView = UIScrollView() - var doubleTapCancellable: Cancellable? - var updateConstraintsCancellable: Cancellable? + var doubleTapCancellable: Combine.Cancellable? + var updateConstraintsCancellable: Combine.Cancellable? private var hostedView: UIView { coordinator.hostingController.view! } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/DeletedMessageView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/DeletedMessageView.swift index 17b73926..b43fdc7f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/DeletedMessageView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/DeletedMessageView.swift @@ -26,7 +26,7 @@ public struct DeletedMessageView: View { public var body: some View { VStack( - alignment: message.isSentByCurrentUser ? .trailing : .leading, + alignment: message.isRightAligned ? .trailing : .leading, spacing: 4 ) { Text(L10n.Message.deletedMessagePlaceholder) @@ -38,7 +38,9 @@ public struct DeletedMessageView: View { if message.isSentByCurrentUser { HStack { - Spacer() + if message.isRightAligned { + Spacer() + } if deletedMessageVisibility == .visibleForCurrentUser { Image(uiImage: images.eye) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift index e2541f4a..01fb1c6b 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift @@ -2,8 +2,6 @@ // Copyright © 2023 Stream.io Inc. All rights reserved. // -import Nuke -import NukeUI import StreamChat import SwiftUI @@ -106,7 +104,7 @@ struct LazyGiphyView: View { var body: some View { LazyImage(imageURL: source) { state in if let imageContainer = state.imageContainer { - Image(imageContainer) + NukeImage(imageContainer) } else if state.error != nil { Color(.secondarySystemBackground) } else { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift index da4d40a6..7e200650 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift @@ -2,8 +2,6 @@ // Copyright © 2023 Stream.io Inc. All rights reserved. // -import Nuke -import NukeUI import StreamChat import SwiftUI diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift index 33200af9..6cd13498 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift @@ -2,8 +2,6 @@ // Copyright © 2023 Stream.io Inc. All rights reserved. // -import Nuke -import NukeUI import StreamChat import SwiftUI diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageAvatarView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageAvatarView.swift index 1795f32e..5602a621 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageAvatarView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageAvatarView.swift @@ -2,7 +2,6 @@ // Copyright © 2023 Stream.io Inc. All rights reserved. // -import NukeUI import StreamChat import SwiftUI diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageBubble.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageBubble.swift index 9ca5e3aa..42a4386a 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageBubble.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageBubble.swift @@ -32,6 +32,7 @@ public struct MessageModifierInfo { /// Modifier that enables message bubble container. public struct MessageBubbleModifier: ViewModifier { @Injected(\.colors) private var colors + @Injected(\.utils) private var utils public var message: ChatMessage public var isFirst: Bool @@ -54,7 +55,11 @@ public struct MessageBubbleModifier: ViewModifier { self.isFirst = isFirst self.injectedBackgroundColor = injectedBackgroundColor self.cornerRadius = cornerRadius - self.forceLeftToRight = forceLeftToRight + if utils.messageListConfig.messageListAlignment == .leftAligned { + self.forceLeftToRight = true + } else { + self.forceLeftToRight = forceLeftToRight + } self.topPadding = topPadding self.bottomPadding = bottomPadding } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift index e6a4e413..e9da6068 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift @@ -3,8 +3,6 @@ // import AVKit -import Nuke -import NukeUI import StreamChat import SwiftUI @@ -64,7 +62,7 @@ public struct MessageContainerView: View { if message.type == .system || message.type == .error { factory.makeSystemMessageView(message: message) } else { - if message.isSentByCurrentUser { + if message.isRightAligned { MessageSpacer(spacerWidth: spacerWidth) } else { if messageListConfig.messageDisplayOptions.showAvatars(for: channel) { @@ -75,7 +73,7 @@ public struct MessageContainerView: View { } } - VStack(alignment: message.isSentByCurrentUser ? .trailing : .leading) { + VStack(alignment: message.isRightAligned ? .trailing : .leading) { if isMessagePinned { MessagePinDetailsView( message: message, @@ -187,7 +185,7 @@ public struct MessageContainerView: View { factory.makeMessageDateView(for: message) } } - } else if !message.isSentByCurrentUser + } else if !message.isRightAligned && !channel.isDirectMessageChannel && messageListConfig.messageDisplayOptions.showAuthorName { factory.makeMessageAuthorAndDateView(for: message) @@ -205,7 +203,7 @@ public struct MessageContainerView: View { : nil ) - if !message.isSentByCurrentUser { + if !message.isRightAligned { MessageSpacer(spacerWidth: spacerWidth) } } @@ -241,7 +239,7 @@ public struct MessageContainerView: View { let minimumWidth: CGFloat = 240 let available = max(minimumWidth, (width ?? 0) - spacerWidth) - 2 * padding let avatarSize: CGFloat = CGSize.messageAvatarSize.width + padding - let totalWidth = message.isSentByCurrentUser ? available : available - avatarSize + let totalWidth = message.isRightAligned ? available : available - avatarSize return totalWidth } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift index 14254ffa..9085548f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift @@ -25,7 +25,8 @@ public struct MessageListConfig { iPadSplitViewEnabled: Bool = true, scrollingAnchor: UnitPoint = .bottom, showNewMessagesSeparator: Bool = false, - handleTabBarVisibility: Bool = true + handleTabBarVisibility: Bool = true, + messageListAlignment: MessageListAlignment = .standard ) { self.messageListType = messageListType self.typingIndicatorPlacement = typingIndicatorPlacement @@ -44,6 +45,7 @@ public struct MessageListConfig { self.scrollingAnchor = scrollingAnchor self.showNewMessagesSeparator = showNewMessagesSeparator self.handleTabBarVisibility = handleTabBarVisibility + self.messageListAlignment = messageListAlignment } public let messageListType: MessageListType @@ -63,6 +65,7 @@ public struct MessageListConfig { public let scrollingAnchor: UnitPoint public let showNewMessagesSeparator: Bool public let handleTabBarVisibility: Bool + public let messageListAlignment: MessageListAlignment } /// Contains information about the message paddings. @@ -187,3 +190,13 @@ public enum MessageListType { case livestream case commerce } + +/// The alignment of the messages in the message list. +public enum MessageListAlignment { + /// Standard message alignment. + /// The current user's messages are on the right. + /// The other users' messages are on the left. + case standard + /// Everything is left aligned. + case leftAligned +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index d5357c8d..0d1dc80f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -67,8 +67,8 @@ public struct MessageListView: View, KeyboardReadable { quotedMessage: Binding, currentDateString: String? = nil, listId: String, - isMessageThread: Bool, - shouldShowTypingIndicator: Bool, + isMessageThread: Bool = false, + shouldShowTypingIndicator: Bool = false, onMessageAppear: @escaping (Int) -> Void, onScrollToBottom: @escaping () -> Void, onLongPress: @escaping (MessageDisplayInfo) -> Void diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageRepliesView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageRepliesView.swift index 27716b1a..cedc4f0c 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageRepliesView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageRepliesView.swift @@ -41,7 +41,7 @@ public struct MessageRepliesView: View { ) } label: { HStack { - if !message.isSentByCurrentUser { + if !message.isRightAligned { MessageAvatarView( avatarURL: message.threadParticipants.first?.imageURL, size: .init(width: 16, height: 16) @@ -49,7 +49,7 @@ public struct MessageRepliesView: View { } Text("\(replyCount) \(repliesText)") .font(fonts.footnoteBold) - if message.isSentByCurrentUser { + if message.isRightAligned { MessageAvatarView( avatarURL: message.threadParticipants.first?.imageURL, size: .init(width: 16, height: 16) @@ -81,7 +81,7 @@ public struct MessageRepliesView: View { ) .offset(y: -24) .rotation3DEffect( - .degrees(message.isSentByCurrentUser ? 180 : 0), + .degrees(message.isRightAligned ? 180 : 0), axis: (x: 0, y: 1, z: 0) ) ) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift index 679290bc..57baab42 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift @@ -2,8 +2,6 @@ // Copyright © 2023 Stream.io Inc. All rights reserved. // -import Nuke -import NukeUI import StreamChat import SwiftUI diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsHelperViews.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsHelperViews.swift index 89aaf1cb..2eb7fc6c 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsHelperViews.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsHelperViews.swift @@ -16,13 +16,13 @@ public struct ReactionsHStack: View { public var body: some View { HStack { - if !message.isSentByCurrentUser { + if !message.isRightAligned { Spacer() } content() - if message.isSentByCurrentUser { + if message.isRightAligned { Spacer() } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift index c9e7987c..9e4c7407 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift @@ -65,7 +65,7 @@ public extension ChatMessage { availableWidth: CGFloat = UIScreen.main.bounds.width, reactionsSize: CGFloat ) -> CGFloat { - if isSentByCurrentUser { + if isRightAligned { var originX = contentRect.origin.x - reactionsSize / 2 let total = originX + reactionsSize if total > availableWidth { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift index 2449ced5..1cb46e99 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift @@ -85,7 +85,7 @@ public struct ReactionsOverlayView: View { Alert.defaultErrorAlert } - if !messageDisplayInfo.message.isSentByCurrentUser && + if !messageDisplayInfo.message.isRightAligned && utils.messageListConfig.messageDisplayOptions.showAvatars(for: channel) { factory.makeMessageAvatarView( for: utils.messageCachingUtils.authorInfo(from: messageDisplayInfo.message) @@ -238,13 +238,12 @@ public struct ReactionsOverlayView: View { private func messageActionsOffsetX(reader: GeometryProxy) -> CGFloat { let originX = messageActionsOriginX(availableWidth: reader.size.width) - let sentByCurrentUser = messageDisplayInfo.message.isSentByCurrentUser if popIn { return originX } else if willPopOut { return messageOriginX(proxy: reader) } else { - return sentByCurrentUser ? messageActionsWidth : 0 + return messageDisplayInfo.message.isRightAligned ? messageActionsWidth : 0 } } @@ -323,7 +322,7 @@ public struct ReactionsOverlayView: View { } private func messageActionsOriginX(availableWidth: CGFloat) -> CGFloat { - if messageDisplayInfo.message.isSentByCurrentUser { + if messageDisplayInfo.message.isRightAligned { return availableWidth - messageActionsWidth - paddingValue / 2 } else { return CGSize.messageAvatarSize.width + paddingValue @@ -331,7 +330,7 @@ public struct ReactionsOverlayView: View { } private func userReactionsOriginX(availableWidth: CGFloat) -> CGFloat { - if messageDisplayInfo.message.isSentByCurrentUser { + if messageDisplayInfo.message.isRightAligned { return availableWidth - maxUserReactionsWidth(availableWidth: availableWidth) - paddingValue / 2 } else { return paddingValue @@ -340,7 +339,7 @@ public struct ReactionsOverlayView: View { private var messageActionsWidth: CGFloat { var width = messageDisplayInfo.contentWidth + 2 * paddingValue - if messageDisplayInfo.message.isSentByCurrentUser { + if messageDisplayInfo.message.isRightAligned { width -= 2 * paddingValue } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsUsersView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsUsersView.swift index 1c117799..891fecb4 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsUsersView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsUsersView.swift @@ -32,7 +32,7 @@ struct ReactionsUsersView: View { var body: some View { HStack { - if message.isSentByCurrentUser { + if message.isRightAligned { Spacer() } @@ -70,7 +70,7 @@ struct ReactionsUsersView: View { .background(Color(colors.background)) .cornerRadius(16) - if !message.isSentByCurrentUser { + if !message.isRightAligned { Spacer() } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsView.swift index 2224e717..d972e0ac 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsView.swift @@ -57,7 +57,7 @@ struct ReactionsContainer: View { if message.reactionScores.count == 1 { offset = 16 } - return message.isSentByCurrentUser ? -offset : offset + return message.isRightAligned ? -offset : offset } } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChannelHeaderLoader.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChannelHeaderLoader.swift index 050de2a3..7f1fbb4c 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChannelHeaderLoader.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChannelHeaderLoader.swift @@ -36,7 +36,7 @@ open class ChannelHeaderLoader: ObservableObject { willSet { if !scheduledUpdate { scheduledUpdate = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.objectWillChange.send() self?.scheduledUpdate = false } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift index 3ab87463..f65913f9 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift @@ -10,7 +10,7 @@ import SwiftUI public struct ChannelList: View { @Injected(\.colors) private var colors - + private var factory: Factory var channels: LazyCachedMapCollection @Binding var selectedChannel: ChannelSelectionInfo? @@ -32,24 +32,41 @@ public struct ChannelList: View { selectedChannel: Binding, swipedChannelId: Binding, scrollable: Bool = true, - onlineIndicatorShown: @escaping (ChatChannel) -> Bool, - imageLoader: @escaping (ChatChannel) -> UIImage, + onlineIndicatorShown: ((ChatChannel) -> Bool)? = nil, + imageLoader: ((ChatChannel) -> UIImage)? = nil, onItemTap: @escaping (ChatChannel) -> Void, onItemAppear: @escaping (Int) -> Void, - channelNaming: @escaping (ChatChannel) -> String, + channelNaming: ((ChatChannel) -> String)? = nil, channelDestination: @escaping (ChannelSelectionInfo) -> Factory.ChannelDestination, - trailingSwipeRightButtonTapped: @escaping (ChatChannel) -> Void, - trailingSwipeLeftButtonTapped: @escaping (ChatChannel) -> Void, - leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void + trailingSwipeRightButtonTapped: @escaping (ChatChannel) -> Void = { _ in }, + trailingSwipeLeftButtonTapped: @escaping (ChatChannel) -> Void = { _ in }, + leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void = { _ in } ) { self.factory = factory self.channels = channels self.onItemTap = onItemTap self.onItemAppear = onItemAppear - self.channelNaming = channelNaming + if let channelNaming = channelNaming { + self.channelNaming = channelNaming + } else { + let channelNamer = InjectedValues[\.utils].channelNamer + self.channelNaming = { channel in + channelNamer(channel, InjectedValues[\.chatClient].currentUserId) ?? "" + } + } self.channelDestination = channelDestination - self.imageLoader = imageLoader - self.onlineIndicatorShown = onlineIndicatorShown + if let imageLoader = imageLoader { + self.imageLoader = imageLoader + } else { + self.imageLoader = InjectedValues[\.utils].channelHeaderLoader.image(for:) + } + if let onlineIndicatorShown = onlineIndicatorShown { + self.onlineIndicatorShown = onlineIndicatorShown + } else { + self.onlineIndicatorShown = { channel in + channel.shouldShowOnlineIndicator + } + } self.trailingSwipeRightButtonTapped = trailingSwipeRightButtonTapped self.trailingSwipeLeftButtonTapped = trailingSwipeLeftButtonTapped self.leadingSwipeButtonTapped = leadingSwipeButtonTapped diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift index 75ce0a22..38bbaaab 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift @@ -219,7 +219,7 @@ extension ChatChannel { public var lastMessageText: String? { if let latestMessage = latestMessages.first { - return "\(latestMessage.author.name ?? latestMessage.author.id): \(latestMessage.textContent ?? latestMessage.adjustedText)" + return "\(latestMessage.author.name ?? latestMessage.author.id): \(textContent(for: latestMessage))" } else { return nil } @@ -230,6 +230,13 @@ extension ChatChannel { currentUserId: InjectedValues[\.chatClient].currentUserId ).isEmpty && config.typingEventsEnabled } + + public var shouldShowOnlineIndicator: Bool { + !lastActiveMembers.filter { member in + member.isOnline && member.id != InjectedValues[\.chatClient].currentUserId + } + .isEmpty + } public var subtitleText: String { if isMuted { @@ -250,4 +257,45 @@ extension ChatChannel { return "" } } + + private func textContent(for previewMessage: ChatMessage) -> String { + if let attachmentPreviewText = attachmentPreviewText(for: previewMessage) { + return attachmentPreviewText + } + if let textContent = previewMessage.textContent, !textContent.isEmpty { + return textContent + } + return previewMessage.adjustedText + } + + /// The message preview text in case it contains attachments. + /// - Parameter previewMessage: The preview message of the channel. + /// - Returns: A string representing the message preview text. + private func attachmentPreviewText(for previewMessage: ChatMessage) -> String? { + guard let attachment = previewMessage.allAttachments.first else { + return nil + } + let text = previewMessage.textContent ?? previewMessage.text + switch attachment.type { + case .audio: + let defaultAudioText = L10n.Channel.Item.audio + return "🎧 \(text.isEmpty ? defaultAudioText : text)" + case .file: + guard let fileAttachment = previewMessage.fileAttachments.first else { + return nil + } + let title = fileAttachment.payload.title + return "📄 \(title ?? text)" + case .image: + let defaultPhotoText = L10n.Channel.Item.photo + return "📷 \(text.isEmpty ? defaultPhotoText : text)" + case .video: + let defaultVideoText = L10n.Channel.Item.video + return "📹 \(text.isEmpty ? defaultVideoText : text)" + case .giphy: + return "/giphy" + default: + return nil + } + } } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift index 0050bcdd..874e7f77 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift @@ -21,7 +21,7 @@ public struct ChatChannelListView: View { private var handleTabBarVisibility: Bool public init( - viewFactory: Factory, + viewFactory: Factory = DefaultViewFactory.shared, viewModel: ChatChannelListViewModel? = nil, channelListController: ChatChannelListController? = nil, title: String = "Stream Chat", @@ -176,7 +176,7 @@ public struct ChatChannelListContentView: View { private var viewFactory: Factory @ObservedObject private var viewModel: ChatChannelListViewModel - @StateObject private var channelHeaderLoader = ChannelHeaderLoader() + @ObservedObject private var channelHeaderLoader = InjectedValues[\.utils].channelHeaderLoader private var onItemTap: (ChatChannel) -> Void public init( diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift index 65aa4037..6614994f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift @@ -181,10 +181,7 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController /// - Parameter channel: the provided channel. /// - Returns: Boolean whether the indicator is shown. public func onlineIndicatorShown(for channel: ChatChannel) -> Bool { - !channel.lastActiveMembers.filter { member in - member.isOnline && member.id != chatClient.currentUserId - } - .isEmpty + channel.shouldShowOnlineIndicator } public func onDeleteTapped(channel: ChatChannel) { diff --git a/Sources/StreamChatSwiftUI/Generated/L10n.swift b/Sources/StreamChatSwiftUI/Generated/L10n.swift index e478689b..6993c377 100644 --- a/Sources/StreamChatSwiftUI/Generated/L10n.swift +++ b/Sources/StreamChatSwiftUI/Generated/L10n.swift @@ -57,18 +57,24 @@ internal enum L10n { internal enum Channel { internal enum Item { + /// Audio + internal static var audio: String { L10n.tr("Localizable", "channel.item.audio") } /// No messages internal static var emptyMessages: String { L10n.tr("Localizable", "channel.item.empty-messages") } /// Mute internal static var mute: String { L10n.tr("Localizable", "channel.item.mute") } /// Channel is muted internal static var muted: String { L10n.tr("Localizable", "channel.item.muted") } + /// Photo + internal static var photo: String { L10n.tr("Localizable", "channel.item.photo") } /// are typing ... internal static var typingPlural: String { L10n.tr("Localizable", "channel.item.typing-plural") } /// is typing ... internal static var typingSingular: String { L10n.tr("Localizable", "channel.item.typing-singular") } /// Unmute internal static var unmute: String { L10n.tr("Localizable", "channel.item.unmute") } + /// Video + internal static var video: String { L10n.tr("Localizable", "channel.item.video") } } internal enum Name { /// and diff --git a/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift b/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift new file mode 100644 index 00000000..ce4f253a --- /dev/null +++ b/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift @@ -0,0 +1,11 @@ +// +// Copyright © 2023 Stream.io Inc. All rights reserved. +// +// ⚠️ Generated file, please use `fastlane :bump_SDK_version or fastlane release major|minor|patch` lanes + +import Foundation + +enum SystemEnvironment { + /// A Stream Chat version. + public static let version: String = "4.39.0" +} diff --git a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings index 865a74d4..5c9fb4a0 100644 --- a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings +++ b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings @@ -140,3 +140,7 @@ "chat-info.rename.name" = "NAME"; "chat-info.rename.placeholder" = "Add a group name"; + +"channel.item.audio" = "Audio"; +"channel.item.photo" = "Photo"; +"channel.item.video" = "Video"; diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCache.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCache.swift new file mode 100644 index 00000000..f63441f9 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCache.swift @@ -0,0 +1,510 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// An LRU disk cache that stores data in separate files. +/// +/// ``DataCache`` uses LRU cleanup policy (least recently used items are removed +/// first). The elements stored in the cache are automatically discarded if +/// either *cost* or *count* limit is reached. The sweeps are performed periodically. +/// +/// DataCache always writes and removes data asynchronously. It also allows for +/// reading and writing data in parallel. This is implemented using a "staging" +/// area which stores changes until they are flushed to disk: +/// +/// ```swift +/// // Schedules data to be written asynchronously and returns immediately +/// cache[key] = data +/// +/// // The data is returned from the staging area +/// let data = cache[key] +/// +/// // Schedules data to be removed asynchronously and returns immediately +/// cache[key] = nil +/// +/// // Data is nil +/// let data = cache[key] +/// ``` +/// +/// - important: It's possible to have more than one instance of ``DataCache`` with +/// the same path but it is not recommended. +final class DataCache: DataCaching, @unchecked Sendable { + /// Size limit in bytes. `150 Mb` by default. + /// + /// Changes to the size limit will take effect when the next LRU sweep is run. + var sizeLimit: Int = 1024 * 1024 * 150 + + /// When performing a sweep, the cache will remote entries until the size of + /// the remaining items is lower than or equal to `sizeLimit * trimRatio` and + /// the total count is lower than or equal to `countLimit * trimRatio`. `0.7` + /// by default. + var trimRatio = 0.7 + + /// 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 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 + + // Staging + + private let lock = NSLock() + private var staging = Staging() + private var isFlushNeeded = false + private var isFlushScheduled = false + + var flushInterval: DispatchTimeInterval = .seconds(1) + + /// A queue which is used for disk I/O. + let queue = DispatchQueue(label: "com.github.kean.Nuke.DataCache.WriteQueue", qos: .utility) + + /// A function which generates a filename for the given key. A good candidate + /// for a filename generator is a _cryptographic_ hash function like SHA1. + /// + /// The reason why filename needs to be generated in the first place is + /// that filesystems have a size limit for filenames (e.g. 255 UTF-8 characters + /// in AFPS) and do not allow certain characters to be used in filenames. + typealias FilenameGenerator = (_ key: String) -> String? + + private let filenameGenerator: FilenameGenerator + + /// Creates a cache instance with a given `name`. The cache creates a directory + /// with the given `name` in a `.cachesDirectory` in `.userDomainMask`. + /// - 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 { + guard let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil) + } + try self.init(path: root.appendingPathComponent(name, isDirectory: true), filenameGenerator: filenameGenerator) + } + + /// Creates a cache instance with a given path. + /// - parameter filenameGenerator: Generates a filename for the given URL. + /// The default implementation generates a filename using SHA1 hash function. + init(path: URL, filenameGenerator: @escaping (String) -> String? = DataCache.filename(for:)) throws { + 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 + } + + private func didInit() throws { + try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true, attributes: nil) + queue.asyncAfter(deadline: .now() + initialSweepDelay) { [weak self] in + self?.performAndScheduleSweep() + } + } + + // MARK: DataCaching + + /// Retrieves data for the given key. + func cachedData(for key: String) -> Data? { + if let change = change(for: key) { + switch change { // Change wasn't flushed to disk yet + case let .add(data): + return data + case .remove: + return nil + } + } + guard let url = url(for: key) else { + return nil + } + return try? Data(contentsOf: url) + } + + /// Returns `true` if the cache contains the data for the given key. + func containsData(for key: String) -> Bool { + if let change = change(for: key) { + switch change { // Change wasn't flushed to disk yet + case .add: + return true + case .remove: + return false + } + } + guard let url = url(for: key) else { + return false + } + return FileManager.default.fileExists(atPath: url.path) + } + + private func change(for key: String) -> Staging.ChangeType? { + lock.lock() + defer { lock.unlock() } + return staging.change(for: key) + } + + /// Stores data for the given key. The method returns instantly and the data + /// is written asynchronously. + func storeData(_ data: Data, for key: String) { + stage { staging.add(data: data, for: key) } + } + + /// Removes data for the given key. The method returns instantly, the data + /// is removed asynchronously. + func removeData(for key: String) { + stage { staging.removeData(for: key) } + } + + /// Removes all items. The method returns instantly, the data is removed + /// asynchronously. + func removeAll() { + stage { staging.removeAll() } + } + + private func stage(_ change: () -> Void) { + lock.lock() + change() + setNeedsFlushChanges() + lock.unlock() + } + + /// Accesses the data associated with the given key for reading and writing. + /// + /// When you assign a new data for a key and the key already exists, the cache + /// overwrites the existing data. + /// + /// When assigning or removing data, the subscript adds a requested operation + /// in a staging area and returns immediately. The staging area allows for + /// reading and writing data in parallel. + /// + /// ```swift + /// // Schedules data to be written asynchronously and returns immediately + /// cache[key] = data + /// + /// // The data is returned from the staging area + /// let data = cache[key] + /// + /// // Schedules data to be removed asynchronously and returns immediately + /// cache[key] = nil + /// + /// // Data is nil + /// let data = cache[key] + /// ``` + subscript(key: String) -> Data? { + get { + cachedData(for: key) + } + set { + if let data = newValue { + storeData(data, for: key) + } else { + removeData(for: key) + } + } + } + + // MARK: Managing URLs + + /// Uses the the filename generator that the cache was initialized with to + /// generate and return a filename for the given key. + func filename(for key: String) -> String? { + filenameGenerator(key) + } + + /// Returns `url` for the given cache key. + func url(for key: String) -> URL? { + guard let filename = self.filename(for: key) else { + return nil + } + return self.path.appendingPathComponent(filename, isDirectory: false) + } + + // MARK: Flush Changes + + /// Synchronously waits on the caller's thread until all outstanding disk I/O + /// operations are finished. + func flush() { + queue.sync { self.flushChangesIfNeeded() } + } + + /// Synchronously waits on the caller's thread until all outstanding disk I/O + /// operations for the given key are finished. + func flush(for key: String) { + queue.sync { + guard let change = lock.sync({ staging.changes[key] }) else { return } + perform(change) + lock.sync { staging.flushed(change) } + } + } + + private func setNeedsFlushChanges() { + guard !isFlushNeeded else { return } + isFlushNeeded = true + scheduleNextFlush() + } + + private func scheduleNextFlush() { + guard !isFlushScheduled else { return } + isFlushScheduled = true + queue.asyncAfter(deadline: .now() + flushInterval) { self.flushChangesIfNeeded() } + } + + private func flushChangesIfNeeded() { + // Create a snapshot of the recently made changes + let staging: Staging + lock.lock() + guard isFlushNeeded else { + return lock.unlock() + } + staging = self.staging + isFlushNeeded = false + lock.unlock() + + // Apply the snapshot to disk + performChanges(for: staging) + + // Update the staging area and schedule the next flush if needed + lock.lock() + self.staging.flushed(staging) + isFlushScheduled = false + if isFlushNeeded { + scheduleNextFlush() + } + lock.unlock() + } + + // MARK: - I/O + + private func performChanges(for staging: Staging) { + autoreleasepool { + if let change = staging.changeRemoveAll { + perform(change) + } + for change in staging.changes.values { + perform(change) + } + } + } + + private func perform(_ change: Staging.ChangeRemoveAll) { + try? FileManager.default.removeItem(at: self.path) + try? FileManager.default.createDirectory(at: self.path, withIntermediateDirectories: true, attributes: nil) + } + + /// Performs the IO for the given change. + private func perform(_ change: Staging.Change) { + guard let url = url(for: change.key) else { + return + } + switch change.type { + case let .add(data): + do { + try 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 + } + case .remove: + try? FileManager.default.removeItem(at: url) + } + } + + // MARK: Sweep + + private func performAndScheduleSweep() { + performSweep() + queue.asyncAfter(deadline: .now() + sweepInterval) { [weak self] in + self?.performAndScheduleSweep() + } + } + + /// Synchronously performs a cache sweep and removes the least recently items + /// which no longer fit in cache. + func sweep() { + queue.sync { self.performSweep() } + } + + /// Discards the least recently used items first. + private func performSweep() { + var items = contents(keys: [.contentAccessDateKey, .totalFileAllocatedSizeKey]) + guard !items.isEmpty else { + return + } + var size = items.reduce(0) { $0 + ($1.meta.totalFileAllocatedSize ?? 0) } + + guard size > sizeLimit else { + return // All good, no need to perform any work. + } + + let targetSizeLimit = Int(Double(sizeLimit) * trimRatio) + + // Most recently accessed items first + let past = Date.distantPast + items.sort { // Sort in place + ($0.meta.contentAccessDate ?? past) > ($1.meta.contentAccessDate ?? past) + } + + // Remove the items until it satisfies both size and count limits. + while size > targetSizeLimit, let item = items.popLast() { + size -= (item.meta.totalFileAllocatedSize ?? 0) + try? FileManager.default.removeItem(at: item.url) + } + } + + // MARK: Contents + + struct Entry { + let url: URL + let meta: URLResourceValues + } + + func contents(keys: [URLResourceKey] = []) -> [Entry] { + guard let urls = try? FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: keys, options: .skipsHiddenFiles) else { + return [] + } + let keys = Set(keys) + return urls.compactMap { + guard let meta = try? $0.resourceValues(forKeys: keys) else { + return nil + } + return Entry(url: $0, meta: meta) + } + } + + // MARK: Inspection + + /// The total number of items in the cache. + /// + /// - important: Requires disk IO, avoid using from the main thread. + var totalCount: Int { + contents().count + } + + /// The total file size of items written on disk. + /// + /// Uses `URLResourceKey.fileSizeKey` to calculate the size of each entry. + /// The total allocated size (see `totalAllocatedSize`. on disk might + /// actually be bigger. + /// + /// - important: Requires disk IO, avoid using from the main thread. + var totalSize: Int { + contents(keys: [.fileSizeKey]).reduce(0) { + $0 + ($1.meta.fileSize ?? 0) + } + } + + /// The total file allocated size of all the items written on disk. + /// + /// Uses `URLResourceKey.totalFileAllocatedSizeKey`. + /// + /// - important: Requires disk IO, avoid using from the main thread. + var totalAllocatedSize: Int { + contents(keys: [.totalFileAllocatedSizeKey]).reduce(0) { + $0 + ($1.meta.totalFileAllocatedSize ?? 0) + } + } +} + +// MARK: - Staging + +/// DataCache allows for parallel reads and writes. This is made possible by +/// DataCacheStaging. +/// +/// For example, when the data is added in cache, it is first added to staging +/// and is removed from staging only after data is written to disk. Removal works +/// the same way. +private struct Staging { + private(set) var changes = [String: Change]() + private(set) var changeRemoveAll: ChangeRemoveAll? + + struct ChangeRemoveAll { + let id: Int + } + + struct Change { + let key: String + let id: Int + let type: ChangeType + } + + enum ChangeType { + case add(Data) + case remove + } + + private var nextChangeId = 0 + + // MARK: Changes + + func change(for key: String) -> ChangeType? { + if let change = changes[key] { + return change.type + } + if changeRemoveAll != nil { + return .remove + } + return nil + } + + // MARK: Register Changes + + mutating func add(data: Data, for key: String) { + nextChangeId += 1 + changes[key] = Change(key: key, id: nextChangeId, type: .add(data)) + } + + mutating func removeData(for key: String) { + nextChangeId += 1 + changes[key] = Change(key: key, id: nextChangeId, type: .remove) + } + + mutating func removeAll() { + nextChangeId += 1 + changeRemoveAll = ChangeRemoveAll(id: nextChangeId) + changes.removeAll() + } + + // MARK: Flush Changes + + mutating func flushed(_ staging: Staging) { + for change in staging.changes.values { + flushed(change) + } + if let change = staging.changeRemoveAll { + flushed(change) + } + } + + mutating func flushed(_ change: Change) { + if let index = changes.index(forKey: change.key), + changes[index].value.id == change.id { + changes.remove(at: index) + } + } + + mutating func flushed(_ change: ChangeRemoveAll) { + if changeRemoveAll?.id == change.id { + changeRemoveAll = nil + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCaching.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCaching.swift new file mode 100644 index 00000000..4f8dcc9e --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCaching.swift @@ -0,0 +1,27 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Data cache. +/// +/// - important: The implementation must be thread safe. +protocol DataCaching: Sendable { + /// Retrieves data from cache for the given key. + func cachedData(for key: String) -> Data? + + /// Returns `true` if the cache contains data for the given key. + func containsData(for key: String) -> Bool + + /// Stores data for the given key. + /// - note: The implementation must return immediately and store data + /// asynchronously. + func storeData(_ data: Data, for key: String) + + /// Removes data for the given key. + func removeData(for key: String) + + /// Removes all items. + func removeAll() +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCache.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCache.swift new file mode 100644 index 00000000..f6781826 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCache.swift @@ -0,0 +1,126 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation +#if !os(macOS) +import UIKit +#else +import Cocoa +#endif + +/// An LRU memory cache. +/// +/// The elements stored in cache are automatically discarded if either *cost* or +/// *count* limit is reached. The default cost limit represents a number of bytes +/// and is calculated based on the amount of physical memory available on the +/// device. The default count limit is set to `Int.max`. +/// +/// ``ImageCache`` automatically removes all stored elements when it receives a +/// memory warning. It also automatically removes *most* stored elements +/// when the app enters the background. +final class ImageCache: ImageCaching { + private let impl: NukeCache + + /// The maximum total cost that the cache can hold. + var costLimit: Int { + get { impl.conf.costLimit } + set { impl.conf.costLimit = newValue } + } + + /// The maximum number of items that the cache can hold. + var countLimit: Int { + get { impl.conf.countLimit } + set { impl.conf.countLimit = newValue } + } + + /// Default TTL (time to live) for each entry. Can be used to make sure that + /// the entries get validated at some point. `nil` (never expire) by default. + var ttl: TimeInterval? { + get { impl.conf.ttl } + set { impl.conf.ttl = newValue } + } + + /// The maximum cost of an entry in proportion to the ``costLimit``. + /// By default, `0.1`. + var entryCostLimit: Double { + get { impl.conf.entryCostLimit } + set { impl.conf.entryCostLimit = newValue } + } + + /// The total number of items in the cache. + var totalCount: Int { impl.totalCount } + + /// The total cost of items in the cache. + var totalCost: Int { impl.totalCost } + + /// Shared `Cache` instance. + static let shared = ImageCache() + + 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. + static func defaultCostLimit() -> Int { + let physicalMemory = ProcessInfo.processInfo.physicalMemory + let ratio = physicalMemory <= (536_870_912 /* 512 Mb */) ? 0.1 : 0.2 + let limit = physicalMemory / UInt64(1 / ratio) + return limit > UInt64(Int.max) ? Int.max : Int(limit) + } + + subscript(key: ImageCacheKey) -> ImageContainer? { + get { impl.value(forKey: key) } + set { + if let image = newValue { + impl.set(image, forKey: key, cost: cost(for: image)) + } else { + impl.removeValue(forKey: key) + } + } + } + + /// Removes all cached images. + func removeAll() { + impl.removeAll() + } + /// Removes least recently used items from the cache until the total cost + /// of the remaining items is less than the given cost limit. + func trim(toCost limit: Int) { + impl.trim(toCost: limit) + } + + /// Removes least recently used items from the cache until the total count + /// of the remaining items is less than the given count limit. + func trim(toCount limit: Int) { + impl.trim(toCount: limit) + } + + /// Returns cost for the given image by approximating its bitmap size in bytes in memory. + func cost(for container: ImageContainer) -> Int { + let dataCost = container.data?.count ?? 0 + + // bytesPerRow * height gives a rough estimation of how much memory + // image uses in bytes. In practice this algorithm combined with a + // conservative default cost limit works OK. + guard let cgImage = container.image.cgImage else { + return 1 + dataCost + } + return cgImage.bytesPerRow * cgImage.height + dataCost + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCaching.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCaching.swift new file mode 100644 index 00000000..74152bd8 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCaching.swift @@ -0,0 +1,37 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// In-memory image cache. +/// +/// The implementation must be thread safe. +protocol ImageCaching: AnyObject, Sendable { + /// Access the image cached for the given request. + subscript(key: ImageCacheKey) -> ImageContainer? { get set } + + /// Removes all caches items. + func removeAll() +} + +/// An opaque container that acts as a cache key. +/// +/// In general, you don't construct it directly, and use ``ImagePipeline`` or ``ImagePipeline/Cache-swift.struct`` APIs. +struct ImageCacheKey: Hashable, Sendable { + let key: Inner + + // This is faster than using AnyHashable (and it shows in performance tests). + enum Inner: Hashable, Sendable { + case custom(String) + case `default`(CacheKey) + } + + init(key: String) { + self.key = .custom(key) + } + + init(request: ImageRequest) { + self.key = .default(request.makeImageCacheKey()) + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/NukeCache.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/NukeCache.swift new file mode 100644 index 00000000..e0b45ccd --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/NukeCache.swift @@ -0,0 +1,204 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if os(iOS) || os(tvOS) +import UIKit.UIApplication +#endif + +// Internal memory-cache implementation. +final class NukeCache: @unchecked Sendable { + // Can't use `NSCache` because it is not LRU + + struct Configuration { + var costLimit: Int + var countLimit: Int + var ttl: TimeInterval? + var entryCostLimit: Double + } + + var conf: Configuration { + get { lock.sync { _conf } } + set { lock.sync { _conf = newValue } } + } + + private var _conf: Configuration { + didSet { _trim() } + } + + var totalCost: Int { + lock.sync { _totalCost } + } + + var totalCount: Int { + lock.sync { map.count } + } + + private var _totalCost = 0 + private var map = [Key: LinkedList.Node]() + private let list = LinkedList() + private let lock = NSLock() + 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.memoryPressure = DispatchSource.makeMemoryPressureSource(eventMask: [.warning, .critical], queue: .main) + self.memoryPressure.setEventHandler { [weak self] in + self?.removeAll() + } + self.memoryPressure.resume() + +#if os(iOS) || os(tvOS) + self.registerForEnterBackground() +#endif + +#if TRACK_ALLOCATIONS + Allocations.increment("Cache") +#endif + } + + deinit { + memoryPressure.cancel() + +#if TRACK_ALLOCATIONS + Allocations.decrement("Cache") +#endif + } + +#if os(iOS) || os(tvOS) + private func registerForEnterBackground() { + notificationObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in + self?.clearCacheOnEnterBackground() + } + } +#endif + + func value(forKey key: Key) -> Value? { + lock.lock() + defer { lock.unlock() } + + guard let node = map[key] else { + return nil + } + + guard !node.value.isExpired else { + _remove(node: node) + return nil + } + + // bubble node up to make it last added (most recently used) + list.remove(node) + list.append(node) + + return node.value.value + } + + func set(_ value: Value, forKey key: Key, cost: Int = 0, ttl: TimeInterval? = nil) { + lock.lock() + defer { lock.unlock() } + + // 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 { + return + } + + let ttl = ttl ?? _conf.ttl + let expiration = ttl.map { Date() + $0 } + let entry = Entry(value: value, key: key, cost: cost, expiration: expiration) + _add(entry) + _trim() // _trim is extremely fast, it's OK to call it each time + } + + @discardableResult + func removeValue(forKey key: Key) -> Value? { + lock.lock() + defer { lock.unlock() } + + guard let node = map[key] else { + return nil + } + _remove(node: node) + return node.value.value + } + + private func _add(_ element: Entry) { + if let existingNode = map[element.key] { + _remove(node: existingNode) + } + map[element.key] = list.append(element) + _totalCost += element.cost + } + + private func _remove(node: LinkedList.Node) { + list.remove(node) + map[node.value.key] = nil + _totalCost -= node.value.cost + } + + func removeAll() { + lock.lock() + defer { lock.unlock() } + + map.removeAll() + list.removeAll() + _totalCost = 0 + } + + private dynamic func clearCacheOnEnterBackground() { + // Remove most of the stored items when entering background. + // This behavior is similar to `NSCache` (which removes all + // items). This feature is not documented and may be subject + // to change in future Nuke versions. + lock.lock() + defer { lock.unlock() } + + _trim(toCost: Int(Double(_conf.costLimit) * 0.1)) + _trim(toCount: Int(Double(_conf.countLimit) * 0.1)) + } + + private func _trim() { + _trim(toCost: _conf.costLimit) + _trim(toCount: _conf.countLimit) + } + + func trim(toCost limit: Int) { + lock.sync { _trim(toCost: limit) } + } + + private func _trim(toCost limit: Int) { + _trim(while: { _totalCost > limit }) + } + + func trim(toCount limit: Int) { + lock.sync { _trim(toCount: limit) } + } + + private func _trim(toCount limit: Int) { + _trim(while: { map.count > limit }) + } + + private func _trim(while condition: () -> Bool) { + while condition(), let node = list.first { // least recently used + _remove(node: node) + } + } + + private struct Entry { + let value: Value + let key: Key + let cost: Int + let expiration: Date? + var isExpired: Bool { + guard let expiration = expiration else { + return false + } + return expiration.timeIntervalSinceNow < 0 + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/AssetType.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/AssetType.swift new file mode 100644 index 00000000..79800ae2 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/AssetType.swift @@ -0,0 +1,90 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A uniform type identifier (UTI). +struct NukeAssetType: ExpressibleByStringLiteral, Hashable, Sendable { + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } + + init(stringLiteral value: String) { + self.rawValue = value + } + + static let png: NukeAssetType = "public.png" + static let jpeg: NukeAssetType = "public.jpeg" + static let gif: NukeAssetType = "com.compuserve.gif" + /// HEIF (High Efficiency Image Format) by Apple. + static let heic: NukeAssetType = "public.heic" + + /// WebP + /// + /// Native decoding support only available on the following platforms: macOS 11, + /// iOS 14, watchOS 7, tvOS 14. + static let webp: NukeAssetType = "public.webp" + + static let mp4: NukeAssetType = "public.mpeg4" + + /// The M4V file format is a video container format developed by Apple and + /// is very similar to the MP4 format. The primary difference is that M4V + /// files may optionally be protected by DRM copy protection. + static let m4v: NukeAssetType = "public.m4v" + + static let mov: NukeAssetType = "public.mov" + + var isVideo: Bool { + self == .mp4 || self == .m4v || self == .mov + } +} + +extension NukeAssetType { + /// Determines a type of the image based on the given data. + init?(_ data: Data) { + guard let type = NukeAssetType.make(data) else { + return nil + } + self = type + } + + private static func make(_ data: Data) -> NukeAssetType? { + func _match(_ numbers: [UInt8?], offset: Int = 0) -> Bool { + guard data.count >= numbers.count else { + return false + } + return zip(numbers.indices, numbers).allSatisfy { index, number in + guard let number = number else { return true } + guard (index + offset) < data.count else { return false } + return data[index + offset] == number + } + } + + // JPEG magic numbers https://en.wikipedia.org/wiki/JPEG + if _match([0xFF, 0xD8, 0xFF]) { return .jpeg } + + // PNG Magic numbers https://en.wikipedia.org/wiki/Portable_Network_Graphics + if _match([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { return .png } + + // GIF magic numbers https://en.wikipedia.org/wiki/GIF + if _match([0x47, 0x49, 0x46]) { return .gif } + + // WebP magic numbers https://en.wikipedia.org/wiki/List_of_file_signatures + if _match([0x52, 0x49, 0x46, 0x46, nil, nil, nil, nil, 0x57, 0x45, 0x42, 0x50]) { return .webp } + + // see https://stackoverflow.com/questions/21879981/avfoundation-avplayer-supported-formats-no-vob-or-mpg-containers + // https://en.wikipedia.org/wiki/List_of_file_signatures + if _match([0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D], offset: 4) { return .mp4 } + + if _match([0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32], offset: 4) { return .m4v } + + // MOV magic numbers https://www.garykessler.net/library/file_sigs.html + if _match([0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20], offset: 4) { return .mov } + + // Either not enough data, or we just don't support this format. + return nil + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoderRegistry.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoderRegistry.swift new file mode 100644 index 00000000..8eddb0ed --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoderRegistry.swift @@ -0,0 +1,75 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A registry of image codecs. +final class ImageDecoderRegistry: @unchecked Sendable { + /// A shared registry. + static let shared = ImageDecoderRegistry() + + private var matches = [(ImageDecodingContext) -> (any ImageDecoding)?]() + private let lock = NSLock() + + /// Initializes a custom registry. + init() { + register(ImageDecoders.Default.init) + #if !os(watchOS) + register(ImageDecoders.Video.init) + #endif + } + + /// Returns a decoder that matches the given context. + func decoder(for context: ImageDecodingContext) -> (any ImageDecoding)? { + lock.lock() + defer { lock.unlock() } + + for match in matches.reversed() { + if let decoder = match(context) { + return decoder + } + } + return nil + } + + /// Registers a decoder to be used in a given decoding context. + /// + /// **Progressive Decoding** + /// + /// The decoder is created once and is used for the entire decoding session, + /// including progressively decoded images. If the decoder doesn't support + /// progressive decoding, return `nil` when `isCompleted` is `false`. + func register(_ match: @escaping (ImageDecodingContext) -> (any ImageDecoding)?) { + lock.lock() + defer { lock.unlock() } + + matches.append(match) + } + + /// Removes all registered decoders. + func clear() { + lock.lock() + defer { lock.unlock() } + + matches = [] + } +} + +/// Image decoding context used when selecting which decoder to use. +struct ImageDecodingContext: @unchecked Sendable { + var request: ImageRequest + var data: Data + /// Returns `true` if the download was completed. + var isCompleted: Bool + var urlResponse: URLResponse? + var cacheType: ImageResponse.CacheType? + + init(request: ImageRequest, data: Data, isCompleted: Bool, urlResponse: URLResponse?, cacheType: ImageResponse.CacheType?) { + self.request = request + self.data = data + self.isCompleted = isCompleted + self.urlResponse = urlResponse + self.cacheType = cacheType + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Default.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Default.swift new file mode 100644 index 00000000..cf19bebd --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Default.swift @@ -0,0 +1,215 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +#if !os(macOS) +import UIKit +#else +import Cocoa +#endif + +/// A namespace with all available decoders. +enum ImageDecoders {} + +extension ImageDecoders { + + /// A decoder that supports all of the formats natively supported by the system. + /// + /// - note: The decoder automatically sets the scale of the decoded images to + /// match the scale of the screen. + /// + /// - note: The default decoder supports progressive JPEG. It produces a new + /// preview every time it encounters a new full frame. + final class Default: ImageDecoding, @unchecked Sendable { + // Number of scans that the decoder has found so far. The last scan might be + // incomplete at this point. + var numberOfScans: Int { scanner.numberOfScans } + private var scanner = ProgressiveJPEGScanner() + + private var isPreviewForGIFGenerated = false + private var scale: CGFloat? + private var thumbnail: ImageRequest.ThumbnailOptions? + private let lock = NSLock() + + var isAsynchronous: Bool { thumbnail != nil } + + init() { } + + /// Returns `nil` if progressive decoding is not allowed for the given + /// content. + init?(context: ImageDecodingContext) { + self.scale = context.request.scale.map { CGFloat($0) } + self.thumbnail = context.request.thubmnail + + if !context.isCompleted && !isProgressiveDecodingAllowed(for: context.data) { + return nil // Progressive decoding not allowed for this image + } + } + + func decode(_ data: Data) throws -> ImageContainer { + lock.lock() + defer { lock.unlock() } + + func makeImage() -> PlatformImage? { + if let thumbnail = self.thumbnail { + return makeThumbnail(data: data, options: thumbnail) + } + return ImageDecoders.Default._decode(data, scale: scale) + } + guard let image = makeImage() else { + throw ImageDecodingError.unknown + } + let type = NukeAssetType(data) + var container = ImageContainer(image: image) + container.type = type + if type == .gif { + container.data = data + } + if numberOfScans > 0 { + container.userInfo[.scanNumberKey] = numberOfScans + } + if thumbnail != nil { + container.userInfo[.isThumbnailKey] = true + } + return container + } + + func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { + lock.lock() + defer { lock.unlock() } + + let assetType = NukeAssetType(data) + if assetType == .gif { // Special handling for GIF + if !isPreviewForGIFGenerated, let image = ImageDecoders.Default._decode(data, scale: scale) { + isPreviewForGIFGenerated = true + return ImageContainer(image: image, type: .gif, isPreview: true, userInfo: [:]) + } + return nil + } + + guard let endOfScan = scanner.scan(data), endOfScan > 0 else { + return nil + } + guard let image = ImageDecoders.Default._decode(data[0...endOfScan], scale: scale) else { + return nil + } + return ImageContainer(image: image, type: assetType, isPreview: true, userInfo: [.scanNumberKey: numberOfScans]) + } + } +} + +private func isProgressiveDecodingAllowed(for data: Data) -> Bool { + let assetType = NukeAssetType(data) + + // Determined whether the image supports progressive decoding or not + // (only proressive JPEG is allowed for now, but you can add support + // for other formats by implementing your own decoder). + if assetType == .jpeg, ImageProperties.JPEG(data)?.isProgressive == true { + return true + } + + // Generate one preview for GIF. + if assetType == .gif { + return true + } + + return false +} + +private struct ProgressiveJPEGScanner: Sendable { + // Number of scans that the decoder has found so far. The last scan might be + // incomplete at this point. + private(set) var numberOfScans = 0 + private var lastStartOfScan: Int = 0 // Index of the last found Start of Scan + private var scannedIndex: Int = -1 // Index at which previous scan was finished + + /// Scans the given data. If finds new scans, returns the last index of the + /// last available scan. + mutating func scan(_ data: Data) -> Int? { + // Check if there is more data to scan. + guard (scannedIndex + 1) < data.count else { + return nil + } + + // Start scanning from the where it left off previous time. + var index = (scannedIndex + 1) + var numberOfScans = self.numberOfScans + while index < (data.count - 1) { + scannedIndex = index + // 0xFF, 0xDA - Start Of Scan + if data[index] == 0xFF, data[index + 1] == 0xDA { + lastStartOfScan = index + numberOfScans += 1 + } + index += 1 + } + + // Found more scans this the previous time + guard numberOfScans > self.numberOfScans else { + return nil + } + self.numberOfScans = numberOfScans + + // `> 1` checks that we've received a first scan (SOS) and then received + // and also received a second scan (SOS). This way we know that we have + // at least one full scan available. + guard numberOfScans > 1 && lastStartOfScan > 0 else { + return nil + } + + return lastStartOfScan - 1 + } +} + +extension ImageDecoders.Default { + private static func _decode(_ data: Data, scale: CGFloat?) -> PlatformImage? { + #if os(macOS) + return NSImage(data: data) + #else + return UIImage(data: data, scale: scale ?? Screen.scale) + #endif + } +} + +enum ImageProperties {} + +// Keeping this private for now, not sure neither about the API, not the implementation. +extension ImageProperties { + struct JPEG { + var isProgressive: Bool + + init?(_ data: Data) { + guard let isProgressive = ImageProperties.JPEG.isProgressive(data) else { + return nil + } + self.isProgressive = isProgressive + } + + private static func isProgressive(_ data: Data) -> Bool? { + var index = 3 // start scanning right after magic numbers + while index < (data.count - 1) { + // A example of first few bytes of progressive jpeg image: + // FF D8 FF E0 00 10 4A 46 49 46 00 01 01 00 00 48 00 ... + // + // 0xFF, 0xC0 - Start Of Frame (baseline DCT) + // 0xFF, 0xC2 - Start Of Frame (progressive DCT) + // https://en.wikipedia.org/wiki/JPEG + // + // As an alternative, Image I/O provides facilities to parse + // JPEG metadata via CGImageSourceCopyPropertiesAtIndex. It is a + // bit too convoluted to use and most likely slightly less + // efficient that checking this one special bit directly. + if data[index] == 0xFF { + if data[index + 1] == 0xC2 { + return true + } + if data[index + 1] == 0xC0 { + return false // baseline + } + } + index += 1 + } + return nil + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Empty.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Empty.swift new file mode 100644 index 00000000..b35e3b59 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Empty.swift @@ -0,0 +1,36 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImageDecoders { + /// A decoder that returns an empty placeholder image and attaches image + /// data to the image container. + struct Empty: ImageDecoding, Sendable { + let isProgressive: Bool + private let assetType: NukeAssetType? + + var isAsynchronous: Bool { false } + + /// Initializes the decoder. + /// + /// - Parameters: + /// - type: Image type to be associated with an image container. + /// `nil` by default. + /// - isProgressive: If `false`, returns nil for every progressive + /// scan. `false` by default. + init(assetType: NukeAssetType? = nil, isProgressive: Bool = false) { + self.assetType = assetType + self.isProgressive = isProgressive + } + + func decode(_ data: Data) throws -> ImageContainer { + ImageContainer(image: PlatformImage(), type: assetType, data: data, userInfo: [:]) + } + + func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { + isProgressive ? ImageContainer(image: PlatformImage(), type: assetType, data: data, userInfo: [:]) : nil + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Video.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Video.swift new file mode 100644 index 00000000..6529e1fc --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Video.swift @@ -0,0 +1,53 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +#if !os(watchOS) + +import Foundation +import AVKit + +extension ImageDecoders { + final class Video: ImageDecoding, @unchecked Sendable { + private var didProducePreview = false + private let type: NukeAssetType + var isAsynchronous: Bool { true } + + private let lock = NSLock() + + init?(context: ImageDecodingContext) { + guard let type = NukeAssetType(context.data), type.isVideo else { return nil } + self.type = type + } + + func decode(_ data: Data) throws -> ImageContainer { + ImageContainer(image: PlatformImage(), type: type, data: data) + } + + func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { + lock.lock() + defer { lock.unlock() } + + guard let type = NukeAssetType(data), type.isVideo else { return nil } + guard !didProducePreview else { + return nil // We only need one preview + } + guard let preview = makePreview(for: data, type: type) else { + return nil + } + didProducePreview = true + return ImageContainer(image: preview, type: type, isPreview: true, data: data) + } + } +} + +private func makePreview(for data: Data, type: NukeAssetType) -> PlatformImage? { + let asset = AVDataAsset(data: data, type: type) + let generator = AVAssetImageGenerator(asset: asset) + guard let cgImage = try? generator.copyCGImage(at: CMTime(value: 0, timescale: 1), actualTime: nil) else { + return nil + } + return PlatformImage(cgImage: cgImage) +} + +#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoding.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoding.swift new file mode 100644 index 00000000..da68348a --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoding.swift @@ -0,0 +1,64 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// An image decoder. +/// +/// A decoder is a one-shot object created for a single image decoding session. +/// +/// - note: If you need additional information in the decoder, you can pass +/// anything that you might need from the ``ImageDecodingContext``. +protocol ImageDecoding: Sendable { + /// Return `true` if you want the decoding to be performed on the decoding + /// queue (see ``ImagePipeline/Configuration-swift.struct/imageDecodingQueue``). If `false`, the decoding will be + /// performed synchronously on the pipeline operation queue. By default, `true`. + var isAsynchronous: Bool { get } + + /// Produces an image from the given image data. + func decode(_ data: Data) throws -> ImageContainer + + /// Produces an image from the given partially downloaded image data. + /// This method might be called multiple times during a single decoding + /// session. When the image download is complete, ``decode(_:)`` method is called. + /// + /// - returns: nil by default. + func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? +} + +extension ImageDecoding { + /// Returns `true` by default. + var isAsynchronous: Bool { true } + + /// The default implementation which simply returns `nil` (no progressive + /// decoding available). + func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { nil } +} + +enum ImageDecodingError: Error, CustomStringConvertible, Sendable { + case unknown + + var description: String { "Unknown" } +} + +extension ImageDecoding { + func decode(_ context: ImageDecodingContext) throws -> ImageResponse { + let container: ImageContainer = try autoreleasepool { + if context.isCompleted { + return try decode(context.data) + } else { + if let preview = decodePartiallyDownloadedData(context.data) { + return preview + } + throw ImageDecodingError.unknown + } + } + #if !os(macOS) + if container.userInfo[.isThumbnailKey] == nil { + ImageDecompression.setDecompressionNeeded(true, for: container.image) + } + #endif + return ImageResponse(container: container, request: context.request, urlResponse: context.urlResponse, cacheType: context.cacheType) + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+Default.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+Default.swift new file mode 100644 index 00000000..520fe982 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+Default.swift @@ -0,0 +1,39 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImageEncoders { + /// A default adaptive encoder which uses best encoder available depending + /// on the input image and its configuration. + struct Default: ImageEncoding { + var compressionQuality: Float + + /// Set to `true` to switch to HEIF when it is available on the current hardware. + /// `false` by default. + var isHEIFPreferred = false + + init(compressionQuality: Float = 0.8) { + self.compressionQuality = compressionQuality + } + + func encode(_ image: PlatformImage) -> Data? { + guard let cgImage = image.cgImage else { + return nil + } + let type: NukeAssetType + if cgImage.isOpaque { + if isHEIFPreferred && ImageEncoders.ImageIO.isSupported(type: .heic) { + type = .heic + } else { + type = .jpeg + } + } else { + type = .png + } + let encoder = ImageEncoders.ImageIO(type: type, compressionRatio: compressionQuality) + return encoder.encode(image) + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+ImageIO.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+ImageIO.swift new file mode 100644 index 00000000..0e944926 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+ImageIO.swift @@ -0,0 +1,62 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation +import CoreGraphics +import ImageIO + +extension ImageEncoders { + /// An Image I/O based encoder. + /// + /// Image I/O is a system framework that allows applications to read and + /// write most image file formats. This framework offers high efficiency, + /// color management, and access to image metadata. + struct ImageIO: ImageEncoding { + let type: NukeAssetType + let compressionRatio: Float + + /// - parameter format: The output format. Make sure that the format is + /// supported on the current hardware.s + /// - parameter compressionRatio: 0.8 by default. + init(type: NukeAssetType, compressionRatio: Float = 0.8) { + self.type = type + self.compressionRatio = compressionRatio + } + + private static let lock = NSLock() + private static var availability = [NukeAssetType: Bool]() + + /// 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] { + return isAvailable + } + let isAvailable = CGImageDestinationCreateWithData( + NSMutableData() as CFMutableData, type.rawValue as CFString, 1, nil + ) != nil + availability[type] = isAvailable + return isAvailable + } + + func encode(_ image: PlatformImage) -> Data? { + let data = NSMutableData() + let options: NSDictionary = [ + kCGImageDestinationLossyCompressionQuality: compressionRatio + ] + guard let source = image.cgImage, + let destination = CGImageDestinationCreateWithData( + data as CFMutableData, type.rawValue as CFString, 1, nil + ) else { + return nil + } + CGImageDestinationAddImage(destination, source, options) + CGImageDestinationFinalize(destination) + return data as Data + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders.swift new file mode 100644 index 00000000..c65837ae --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders.swift @@ -0,0 +1,20 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A namespace with all available encoders. +enum ImageEncoders {} + +extension ImageEncoding where Self == ImageEncoders.Default { + static func `default`(compressionQuality: Float = 0.8) -> ImageEncoders.Default { + ImageEncoders.Default(compressionQuality: compressionQuality) + } +} + +extension ImageEncoding where Self == ImageEncoders.ImageIO { + static func imageIO(type: NukeAssetType, compressionRatio: Float = 0.8) -> ImageEncoders.ImageIO { + ImageEncoders.ImageIO(type: type, compressionRatio: compressionRatio) + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoding.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoding.swift new file mode 100644 index 00000000..5c11da6e --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoding.swift @@ -0,0 +1,35 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +#if !os(macOS) +import UIKit +#else +import Cocoa +#endif + +import ImageIO + +// MARK: - ImageEncoding + +/// An image encoder. +protocol ImageEncoding: Sendable { + /// Encodes the given image. + func encode(_ image: PlatformImage) -> Data? + + /// An optional method which encodes the given image container. + func encode(_ container: ImageContainer, context: ImageEncodingContext) -> Data? +} + +extension ImageEncoding { + func encode(_ container: ImageContainer, context: ImageEncodingContext) -> Data? { + self.encode(container.image) + } +} + +/// Image encoding context used when selecting which encoder to use. +struct ImageEncodingContext: @unchecked Sendable { + let request: ImageRequest + let image: PlatformImage + let urlResponse: URLResponse? +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageContainer.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageContainer.swift new file mode 100644 index 00000000..dabf7632 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageContainer.swift @@ -0,0 +1,94 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +#if !os(watchOS) +import AVKit +#endif + +import Foundation + +#if !os(macOS) +import UIKit.UIImage +/// Alias for `UIImage`. +typealias PlatformImage = UIImage +#else +import AppKit.NSImage +/// Alias for `NSImage`. +typealias PlatformImage = NSImage +#endif + +/// An image container with an image and associated metadata. +struct ImageContainer: @unchecked Sendable { + #if os(macOS) + /// A fetched image. + var image: NSImage + #else + /// A fetched image. + var image: UIImage + #endif + + /// An image type. + var type: NukeAssetType? + + /// Returns `true` if the image in the container is a preview of the image. + var isPreview: Bool + + /// Contains the original image `data`, but only if the decoder decides to + /// attach it to the image. + /// + /// The default decoder (``ImageDecoders/Default``) attaches data to GIFs to + /// allow to display them using a rendering engine of your choice. + /// + /// - note: The `data`, along with the image container itself gets stored + /// in the memory cache. + var data: Data? + + #if !os(watchOS) + /// Represents in-memory video asset. + var asset: AVAsset? + #endif + + /// An metadata provided by the user. + var userInfo: [UserInfoKey: Any] + + /// 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 + } + + func map(_ closure: (PlatformImage) throws -> PlatformImage) rethrows -> ImageContainer { + var copy = self + copy.image = try closure(image) + return copy + } + + /// A key use in ``userInfo``. + struct UserInfoKey: Hashable, ExpressibleByStringLiteral, Sendable { + let rawValue: String + + init(_ rawValue: String) { + self.rawValue = rawValue + } + + init(stringLiteral value: String) { + self.rawValue = value + } + + // For internal purposes. + static let isThumbnailKey: UserInfoKey = "com.github/kean/nuke/skip-decompression" + + /// A user info key to get the scan number (Int). + static let scanNumberKey: UserInfoKey = "com.github/kean/nuke/scan-number" + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageRequest.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageRequest.swift new file mode 100644 index 00000000..a7e79e76 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageRequest.swift @@ -0,0 +1,516 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Combine + +/// Represents an image request that specifies what images to download, how to +/// process them, set the request priority, and more. +/// +/// Creating a request: +/// +/// ```swift +/// let request = ImageRequest( +/// url: URL(string: "http://example.com/image.jpeg"), +/// processors: [.resize(width: 320)], +/// priority: .high, +/// options: [.reloadIgnoringCachedData] +/// ) +/// let response = try await pipeline.image(for: request) +/// ``` +struct ImageRequest: CustomStringConvertible, Sendable { + + // MARK: Options + + /// The relative priority of the request. The priority affects the order in + /// which the requests are performed. ``Priority-swift.enum/normal`` by default. + /// + /// - note: You can change the priority of a running task using ``ImageTask/priority``. + var priority: Priority { + get { ref.priority } + set { mutate { $0.priority = newValue } } + } + + /// Processor to be applied to the image. Empty by default. + /// + /// See to learn more. + var processors: [any ImageProcessing] { + get { ref.processors } + set { mutate { $0.processors = newValue } } + } + + /// The request options. For a complete list of options, see ``ImageRequest/Options-swift.struct``. + var options: Options { + get { ref.options } + set { mutate { $0.options = newValue } } + } + + /// Custom info passed alongside the request. + var userInfo: [UserInfoKey: Any] { + get { ref.userInfo ?? [:] } + set { mutate { $0.userInfo = newValue } } + } + + // MARK: Instance Properties + + /// Returns the request `URLRequest`. + /// + /// Returns `nil` for publisher-based requests. + var urlRequest: URLRequest? { + switch ref.resource { + case .url(let url): return url.map { URLRequest(url: $0) } // create lazily + case .urlRequest(let urlRequest): return urlRequest + case .publisher: return nil + } + } + + /// Returns the request `URL`. + /// + /// Returns `nil` for publisher-based requests. + var url: URL? { + switch ref.resource { + case .url(let url): return url + case .urlRequest(let request): return request.url + case .publisher: return nil + } + } + + /// Returns the ID of the underlying image. For URL-based requests, it's an + /// image URL. For an async function – a custom ID provided in initializer. + var imageId: String? { + 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 + } + } + + /// Returns a debug request description. + var description: String { + "ImageRequest(resource: \(ref.resource), priority: \(priority), processors: \(processors), options: \(options), userInfo: \(userInfo))" + } + + // MARK: Initializers + + /// Initializes a request with the given `URL`. + /// + /// - parameters: + /// - url: The request URL. + /// - processors: Processors to be apply to the image. See to learn more. + /// - priority: The priority of the request. + /// - options: Image loading options. + /// - userInfo: Custom info passed alongside the request. + /// + /// ```swift + /// let request = ImageRequest( + /// url: URL(string: "http://..."), + /// processors: [ImageProcessors.Resize(size: imageView.bounds.size)], + /// priority: .high + /// ) + /// ``` + init( + url: URL?, + processors: [any ImageProcessing] = [], + priority: Priority = .normal, + options: Options = [], + userInfo: [UserInfoKey: Any]? = nil + ) { + self.ref = Container( + resource: Resource.url(url), + processors: processors, + priority: priority, + options: options, + userInfo: userInfo + ) + } + + /// Initializes a request with the given `URLRequest`. + /// + /// - parameters: + /// - urlRequest: The URLRequest describing the image request. + /// - processors: Processors to be apply to the image. See to learn more. + /// - priority: The priority of the request. + /// - options: Image loading options. + /// - userInfo: Custom info passed alongside the request. + /// + /// ```swift + /// let request = ImageRequest( + /// url: URLRequest(url: URL(string: "http://...")), + /// processors: [ImageProcessors.Resize(size: imageView.bounds.size)], + /// priority: .high + /// ) + /// ``` + init( + urlRequest: URLRequest, + processors: [any ImageProcessing] = [], + priority: Priority = .normal, + options: Options = [], + userInfo: [UserInfoKey: Any]? = nil + ) { + self.ref = Container( + resource: Resource.urlRequest(urlRequest), + processors: processors, + priority: priority, + options: options, + userInfo: userInfo + ) + } + + /// Initializes a request with the given async function. + /// + /// For example, you can use it with the Photos framework after wrapping its + /// API in an async function. + /// + /// ```swift + /// ImageRequest( + /// id: asset.localIdentifier, + /// data: { try await PHAssetManager.default.imageData(for: asset) } + /// ) + /// ``` + /// + /// - important: If you are using a pipeline with a custom configuration that + /// enables aggressive disk cache, fetched data will be stored in this cache. + /// You can use ``Options-swift.struct/disableDiskCache`` to disable it. + /// + /// - note: If the resource is identifiable with a `URL`, consider + /// implementing a custom data loader instead. See . + /// + /// - parameters: + /// - id: Uniquely identifies the fetched image. + /// - data: An async function to be used to fetch image data. + /// - processors: Processors to be apply to the image. See to learn more. + /// - priority: The priority of the request. + /// - options: Image loading options. + /// - userInfo: Custom info passed alongside the request. + init( + id: String, + data: @Sendable @escaping () async throws -> Data, + processors: [any ImageProcessing] = [], + priority: Priority = .normal, + options: Options = [], + userInfo: [UserInfoKey: Any]? = nil + ) { + // It could technically be implemented without any special change to the + // pipeline by using a custom DataLoader and passing an async function in + // the request userInfo. g + self.ref = Container( + resource: .publisher(DataPublisher(id: id, data)), + processors: processors, + priority: priority, + options: options, + userInfo: userInfo + ) + } + + /// Initializes a request with the given data publisher. + /// + /// For example, here is how you can use it with the Photos framework (the + /// `imageDataPublisher` API is a custom convenience extension not included + /// in the framework). + /// + /// ```swift + /// let request = ImageRequest( + /// id: asset.localIdentifier, + /// dataPublisher: PHAssetManager.imageDataPublisher(for: asset) + /// ) + /// ``` + /// + /// - important: If you are using a pipeline with a custom configuration that + /// enables aggressive disk cache, fetched data will be stored in this cache. + /// You can use ``Options-swift.struct/disableDiskCache`` to disable it. + /// + /// - parameters: + /// - id: Uniquely identifies the fetched image. + /// - data: A data publisher to be used for fetching image data. + /// - processors: Processors to be apply to the image. See to learn more. + /// - priority: The priority of the request, ``Priority-swift.enum/normal`` by default. + /// - options: Image loading options. + /// - userInfo: Custom info passed alongside the request. + init

( + id: String, + dataPublisher: P, + processors: [any ImageProcessing] = [], + priority: Priority = .normal, + options: Options = [], + userInfo: [UserInfoKey: Any]? = nil + ) where P: Publisher, P.Output == Data { + // It could technically be implemented without any special change to the + // pipeline by using a custom DataLoader and passing a publisher in the + // request userInfo. + self.ref = Container( + resource: .publisher(DataPublisher(id: id, dataPublisher)), + processors: processors, + priority: priority, + options: options, + userInfo: userInfo + ) + } + + // MARK: Nested Types + + /// The priority affecting the order in which the requests are performed. + enum Priority: Int, Comparable, Sendable { + case veryLow = 0, low, normal, high, veryHigh + + static func < (lhs: Priority, rhs: Priority) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + /// Image request options. + /// + /// By default, the pipeline makes full use of all of its caching layers. You can change this behavior using options. For example, you can ignore local caches using ``ImageRequest/Options-swift.struct/reloadIgnoringCachedData`` option. + /// + /// ```swift + /// request.options = [.reloadIgnoringCachedData] + /// ``` + /// + /// Another useful cache policy is ``ImageRequest/Options-swift.struct/returnCacheDataDontLoad`` + /// that terminates the request if no cached data is available. + struct Options: OptionSet, Hashable, Sendable { + /// Returns a raw value. + let rawValue: UInt16 + + /// Initialializes options with a given raw values. + init(rawValue: UInt16) { + self.rawValue = rawValue + } + + /// Disables memory cache reads (see ``ImageCaching``). + static let disableMemoryCacheReads = Options(rawValue: 1 << 0) + + /// Disables memory cache writes (see ``ImageCaching``). + static let disableMemoryCacheWrites = Options(rawValue: 1 << 1) + + /// Disables both memory cache reads and writes (see ``ImageCaching``). + static let disableMemoryCache: Options = [.disableMemoryCacheReads, .disableMemoryCacheWrites] + + /// Disables disk cache reads (see ``DataCaching``). + static let disableDiskCacheReads = Options(rawValue: 1 << 2) + + /// Disables disk cache writes (see ``DataCaching``). + static let disableDiskCacheWrites = Options(rawValue: 1 << 3) + + /// Disables both disk cache reads and writes (see ``DataCaching``). + static let disableDiskCache: Options = [.disableDiskCacheReads, .disableDiskCacheWrites] + + /// The image should be loaded only from the originating source. + /// + /// This option only works ``ImageCaching`` and ``DataCaching``, but not + /// `URLCache`. If you want to ignore `URLCache`, initialize the request + /// with `URLRequest` with the respective policy + static let reloadIgnoringCachedData: Options = [.disableMemoryCacheReads, .disableDiskCacheReads] + + /// Use existing cache data and fail if no cached data is available. + static let returnCacheDataDontLoad = Options(rawValue: 1 << 4) + + /// Skip decompression ("bitmapping") for the given image. Decompression + /// will happen lazily when you display the image. + static let skipDecompression = Options(rawValue: 1 << 5) + + /// Perform data loading immediately, ignoring ``ImagePipeline/Configuration-swift.struct/dataLoadingQueue``. It + /// can be used to elevate priority of certain tasks. + /// + /// - importajt: If there is an outstanding task for loading the same + /// resource but without this option, a new task will be created. + static let skipDataLoadingQueue = Options(rawValue: 1 << 6) + } + + /// A key used in `userInfo` for providing custom request options. + /// + /// There are a couple of built-in options that are passed using user info + /// as well, including ``imageIdKey``, ``scaleKey``, and ``thumbnailKey``. + struct UserInfoKey: Hashable, ExpressibleByStringLiteral, Sendable { + /// Returns a key raw value. + let rawValue: String + + /// Initializes the key with a raw value. + init(_ rawValue: String) { + self.rawValue = rawValue + } + + /// Initializes the key with a raw value. + init(stringLiteral value: String) { + self.rawValue = value + } + + /// Overrides the image identifier used for caching and task coalescing. + /// + /// By default, ``ImagePipeline`` uses an image URL as a unique identifier + /// for caching and task coalescing. You can override this behavior by + /// providing a custom identifier. For example, you can use it to remove + /// transient query parameters from the URL, like access token. + /// + /// ```swift + /// let request = ImageRequest( + /// url: URL(string: "http://example.com/image.jpeg?token=123"), + /// userInfo: [.imageIdKey: "http://example.com/image.jpeg"] + /// ) + /// ``` + static let imageIdKey: ImageRequest.UserInfoKey = "github.com/kean/nuke/imageId" + + /// The image scale to be used. By default, the scale matches the scale + /// of the current display. + static let scaleKey: ImageRequest.UserInfoKey = "github.com/kean/nuke/scale" + + /// Specifies whether the pipeline should retrieve or generate a thumbnail + /// instead of a full image. The thumbnail creation is generally significantly + /// more efficient, especially in terms of memory usage, than image resizing + /// (``ImageProcessors/Resize``). + /// + /// - note: You must be using the default image decoder to make it work. + static let thumbnailKey: ImageRequest.UserInfoKey = "github.com/kean/nuke/thumbmnailKey" + } + + /// 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 + + /// 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``. + 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``. + var createThumbnailFromImageAlways = true + + /// Whether the thumbnail should be rotated and scaled according to the + /// orientation and pixel aspect ratio of the full image. + var createThumbnailWithTransform = true + + /// Specifies whether image decoding and caching should happen at image + /// creation time. + var shouldCacheImmediately = true + + 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 + } + + var identifier: String { + "com.github/kean/nuke/thumbnail?mxs=\(maxPixelSize),options=\(createThumbnailFromImageIfAbsent)\(createThumbnailFromImageAlways)\(createThumbnailWithTransform)\(shouldCacheImmediately)" + } + } + + // MARK: Internal + + private var ref: Container + + private mutating func mutate(_ closure: (Container) -> Void) { + if !isKnownUniquelyReferenced(&ref) { + ref = Container(ref) + } + closure(ref) + } + + var resource: Resource { ref.resource } + + func withProcessors(_ processors: [any ImageProcessing]) -> ImageRequest { + var request = self + request.processors = processors + return request + } + + var preferredImageId: String { + if let imageId = ref.userInfo?[.imageIdKey] as? String { + return imageId + } + return imageId ?? "" + } + + var thubmnail: ThumbnailOptions? { + ref.userInfo?[.thumbnailKey] as? ThumbnailOptions + } + + var scale: Float? { + (ref.userInfo?[.scaleKey] as? NSNumber)?.floatValue + } + + var publisher: DataPublisher? { + if case .publisher(let publisher) = ref.resource { return publisher } + return nil + } +} + +// MARK: - ImageRequest (Private) + +extension ImageRequest { + /// Just like many Swift built-in types, ``ImageRequest`` uses CoW approach to + /// avoid memberwise retain/releases when ``ImageRequest`` is passed around. + private final class Container: @unchecked Sendable { + // It's beneficial to put resource before priority and options because + // of the resource size/stride of 9/16. Priority (1 byte) and Options + // (2 bytes) slot just right in the remaining space. + let resource: Resource + var priority: Priority + var options: Options + var 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.userInfo = userInfo + + #if TRACK_ALLOCATIONS + Allocations.increment("ImageRequest.Container") + #endif + } + + /// Creates a copy. + init(_ ref: Container) { + self.resource = ref.resource + self.processors = ref.processors + self.priority = ref.priority + self.options = ref.options + self.userInfo = ref.userInfo + + #if TRACK_ALLOCATIONS + Allocations.increment("ImageRequest.Container") + #endif + } + } + + // Every case takes 8 bytes and the enum 9 bytes overall (use stride!) + enum Resource: CustomStringConvertible { + case url(URL?) + case urlRequest(URLRequest) + case publisher(DataPublisher) + + var description: String { + switch self { + case .url(let url): return "\(url?.absoluteString ?? "nil")" + case .urlRequest(let urlRequest): return "\(urlRequest)" + case .publisher(let data): return "\(data)" + } + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageResponse.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageResponse.swift new file mode 100644 index 00000000..e1adbbd4 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageResponse.swift @@ -0,0 +1,61 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if !os(macOS) +import UIKit.UIImage +#else +import AppKit.NSImage +#endif + +/// An image response that contains a fetched image and some metadata. +struct ImageResponse: @unchecked Sendable { + /// An image container with an image and associated metadata. + var container: ImageContainer + + #if os(macOS) + /// A convenience computed property that returns an image from the container. + var image: NSImage { container.image } + #else + /// A convenience computed property that returns an image from the container. + var image: UIImage { container.image } + #endif + + /// Returns `true` if the image in the container is a preview of the image. + var isPreview: Bool { container.isPreview } + + /// The request for which the response was created. + var request: ImageRequest + + /// A response. `nil` unless the resource was fetched from the network or an + /// HTTP cache. + var urlResponse: URLResponse? + + /// Contains a cache type in case the image was returned from one of the + /// pipeline caches (not including any of the HTTP caches if enabled). + var cacheType: CacheType? + + /// Initializes the response with the given image. + init(container: ImageContainer, request: ImageRequest, urlResponse: URLResponse? = nil, cacheType: CacheType? = nil) { + self.container = container + self.request = request + self.urlResponse = urlResponse + self.cacheType = cacheType + } + + /// A cache type. + enum CacheType: Sendable { + /// Memory cache (see ``ImageCaching``) + case memory + /// Disk cache (see ``DataCaching``) + case disk + } + + 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 new file mode 100644 index 00000000..a82872fb --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageTask.swift @@ -0,0 +1,196 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A task performed by the ``ImagePipeline``. +/// +/// The pipeline maintains a strong reference to the task until the request +/// finishes or fails; you do not need to maintain a reference to the task unless +/// it is useful for your app. +final class ImageTask: Hashable, CustomStringConvertible, @unchecked Sendable { + /// An identifier that uniquely identifies the task within a given pipeline. + /// Unique only within that pipeline. + let taskId: Int64 + + /// The original request. + let request: ImageRequest + + /// Updates the priority of the task, even if it 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) + } + } + 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 } } + } + private var _progress = Progress(completed: 0, total: 0) + + /// The download progress. + struct Progress: Hashable, Sendable { + /// The number of bytes that the task has received. + let completed: Int64 + /// A best-guess upper bound on the number of bytes of the resource. + let total: Int64 + + /// Returns the fraction of the completion. + var fraction: Float { + guard total > 0 else { return 0 } + return min(1, Float(completed) / Float(total)) + } + + /// Initializes progress with the given status. + init(completed: Int64, total: Int64) { + self.completed = completed + self.total = total + } + } + + /// The current state of the task. + var state: State { sync { _state } } + private var _state: State = .running + + /// The state of the image task. + enum State { + /// The task is currently running. + case running + /// The task has received a cancel message. + case cancelled + /// The task has completed (without being canceled). + case completed + } + + var onCancel: (() -> Void)? + + weak var pipeline: ImagePipeline? + weak var delegate: ImageTaskDelegate? + var callbackQueue: DispatchQueue? + var isDataTask = false + + private let lock: os_unfair_lock_t + + deinit { + lock.deinitialize(count: 1) + lock.deallocate() + + #if TRACK_ALLOCATIONS + Allocations.decrement("ImageTask") + #endif + } + + init(taskId: Int64, request: ImageRequest) { + self.taskId = taskId + self.request = request + self._priority = request.priority + + lock = .allocate(capacity: 1) + lock.initialize(to: os_unfair_lock()) + + #if TRACK_ALLOCATIONS + Allocations.increment("ImageTask") + #endif + } + + /// Marks task as being cancelled. + /// + /// The pipeline will immediately cancel any work associated with a task + /// unless there is an equivalent outstanding task running. + func cancel() { + os_unfair_lock_lock(lock) + guard _state == .running else { + return os_unfair_lock_unlock(lock) + } + _state = .cancelled + os_unfair_lock_unlock(lock) + + pipeline?.imageTaskCancelCalled(self) + } + + func didComplete() { + os_unfair_lock_lock(lock) + guard _state == .running else { + return os_unfair_lock_unlock(lock) + } + _state = .completed + os_unfair_lock_unlock(lock) + } + + private func sync(_ closure: () -> T) -> T { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return closure() + } + + // MARK: Hashable + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self).hashValue) + } + + static func == (lhs: ImageTask, rhs: ImageTask) -> Bool { + ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + } + + // MARK: CustomStringConvertible + + var description: String { + "ImageTask(id: \(taskId), priority: \(_priority), progress: \(progress.completed) / \(progress.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) + + /// Gets called when the progress is updated. + func imageTask(_ task: ImageTask, didUpdateProgress progress: ImageTask.Progress) + + /// Gets called when a new progressive image is produced. + func imageTask(_ task: ImageTask, didReceivePreview response: ImageResponse) + + /// Gets called when the task is cancelled. + /// + /// - 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) {} +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/AVDataAsset.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/AVDataAsset.swift new file mode 100644 index 00000000..e575498c --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/AVDataAsset.swift @@ -0,0 +1,76 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import AVKit +import Foundation + +#if !os(watchOS) + +private extension NukeAssetType { + var avFileType: AVFileType? { + switch self { + case .mp4: return .mp4 + case .m4v: return .m4v + case .mov: return .mov + default: return nil + } + } +} + +// This class keeps strong pointer to DataAssetResourceLoader +final class AVDataAsset: AVURLAsset { + private let resourceLoaderDelegate: DataAssetResourceLoader + + init(data: Data, type: NukeAssetType?) { + self.resourceLoaderDelegate = DataAssetResourceLoader( + data: data, + contentType: type?.avFileType?.rawValue ?? AVFileType.mp4.rawValue + ) + + // The URL is irrelevant + let url = URL(string: "in-memory-data://\(UUID().uuidString)") ?? URL(fileURLWithPath: "/dev/null") + super.init(url: url, options: nil) + + resourceLoader.setDelegate(resourceLoaderDelegate, queue: .global()) + } +} + +// This allows LazyImage to play video from memory. +private final class DataAssetResourceLoader: NSObject, AVAssetResourceLoaderDelegate { + private let data: Data + private let contentType: String + + init(data: Data, contentType: String) { + self.data = data + self.contentType = contentType + } + + // MARK: - DataAssetResourceLoader + + func resourceLoader( + _ resourceLoader: AVAssetResourceLoader, + shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest + ) -> Bool { + if let contentRequest = loadingRequest.contentInformationRequest { + contentRequest.contentType = contentType + contentRequest.contentLength = Int64(data.count) + contentRequest.isByteRangeAccessSupported = true + } + + if let dataRequest = loadingRequest.dataRequest { + if dataRequest.requestsAllDataToEndOfResource { + dataRequest.respond(with: data[dataRequest.requestedOffset...]) + } else { + let range = dataRequest.requestedOffset..<(dataRequest.requestedOffset + Int64(dataRequest.requestedLength)) + dataRequest.respond(with: data[range]) + } + } + + loadingRequest.finishLoading() + + return true + } +} + +#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Allocations.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Allocations.swift new file mode 100644 index 00000000..264bf9d2 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Allocations.swift @@ -0,0 +1,81 @@ +// 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/DataPublisher.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/DataPublisher.swift new file mode 100644 index 00000000..4342c568 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/DataPublisher.swift @@ -0,0 +1,53 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Combine + +final class DataPublisher { + let id: String + private let _sink: (@escaping ((PublisherCompletion) -> Void), @escaping ((Data) -> Void)) -> any Cancellable + + init(id: String, _ publisher: P) where P.Output == Data { + self.id = id + self._sink = { onCompletion, onValue in + let cancellable = publisher.sink(receiveCompletion: { + switch $0 { + case .finished: onCompletion(.finished) + case .failure(let error): onCompletion(.failure(error)) + } + }, receiveValue: { + onValue($0) + }) + return AnonymousCancellable { cancellable.cancel() } + } + } + + convenience init(id: String, _ data: @Sendable @escaping () async throws -> Data) { + self.init(id: id, publisher(from: data)) + } + + func sink(receiveCompletion: @escaping ((PublisherCompletion) -> Void), receiveValue: @escaping ((Data) -> Void)) -> any Cancellable { + _sink(receiveCompletion, receiveValue) + } +} + +private func publisher(from closure: @Sendable @escaping () async throws -> Data) -> AnyPublisher { + let subject = PassthroughSubject() + Task { + do { + let data = try await closure() + subject.send(data) + subject.send(completion: .finished) + } catch { + subject.send(completion: .failure(error)) + } + } + return subject.eraseToAnyPublisher() +} + +enum PublisherCompletion { + case finished + case failure(Error) +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Deprecated.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Deprecated.swift new file mode 100644 index 00000000..f103a2c9 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Deprecated.swift @@ -0,0 +1,158 @@ +// 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 new file mode 100644 index 00000000..960c1ca5 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Extensions.swift @@ -0,0 +1,74 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation +import CommonCrypto + +extension String { + /// Calculates SHA1 from the given string and returns its hex representation. + /// + /// ```swift + /// print("http://test.com".sha1) + /// // prints "50334ee0b51600df6397ce93ceed4728c37fee4e" + /// ``` + var sha1: String? { + guard !isEmpty, let input = self.data(using: .utf8) else { + return nil + } + + 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 + } + + return hash.map({ String(format: "%02x", $0) }).joined() + } +} + +extension NSLock { + func sync(_ closure: () -> T) -> T { + lock() + defer { unlock() } + return closure() + } +} + +extension URL { + var isCacheable: Bool { + let scheme = self.scheme + return scheme != "file" && scheme != "data" + } +} + +extension OperationQueue { + convenience init(maxConcurrentCount: Int) { + self.init() + self.maxConcurrentOperationCount = maxConcurrentCount + } +} + +extension ImageRequest.Priority { + var taskPriority: TaskPriority { + switch self { + case .veryLow: return .veryLow + case .low: return .low + case .normal: return .normal + case .high: return .high + case .veryHigh: return .veryHigh + } + } +} + +final class AnonymousCancellable: Cancellable { + let onCancel: @Sendable () -> Void + + init(_ onCancel: @Sendable @escaping () -> Void) { + self.onCancel = onCancel + } + + func cancel() { + onCancel() + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Graphics.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Graphics.swift new file mode 100644 index 00000000..6133dff9 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Graphics.swift @@ -0,0 +1,338 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 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 + +extension PlatformImage { + var processed: ImageProcessingExtensions { + ImageProcessingExtensions(image: self) + } +} + +struct ImageProcessingExtensions { + let image: PlatformImage + + func byResizing(to targetSize: CGSize, + contentMode: ImageProcessors.Resize.ContentMode, + upscale: Bool) -> PlatformImage? { + guard let cgImage = image.cgImage else { + return nil + } + #if os(iOS) || os(tvOS) || os(watchOS) + let targetSize = targetSize.rotatedForOrientation(image.imageOrientation) + #endif + let scale = cgImage.size.getScale(targetSize: targetSize, contentMode: contentMode) + guard scale < 1 || upscale else { + return image // The image doesn't require scaling + } + let size = cgImage.size.scaled(by: scale).rounded() + return image.draw(inCanvasWithSize: size) + } + + /// Crops the input image to the given size and resizes it if needed. + /// - note: this method will always upscale. + func byResizingAndCropping(to targetSize: CGSize) -> PlatformImage? { + guard let cgImage = image.cgImage else { + return nil + } + #if os(iOS) || os(tvOS) || os(watchOS) + let targetSize = targetSize.rotatedForOrientation(image.imageOrientation) + #endif + let scale = cgImage.size.getScale(targetSize: targetSize, contentMode: .aspectFill) + let scaledSize = cgImage.size.scaled(by: scale) + let drawRect = scaledSize.centeredInRectWithSize(targetSize) + return image.draw(inCanvasWithSize: targetSize, drawRect: drawRect) + } + + func byDrawingInCircle(border: ImageProcessingOptions.Border?) -> PlatformImage? { + guard let squared = byCroppingToSquare(), let cgImage = squared.cgImage else { + return nil + } + let radius = CGFloat(cgImage.width) // Can use any dimension since image is a square + return squared.processed.byAddingRoundedCorners(radius: radius / 2.0, border: border) + } + + /// Draws an image in square by preserving an aspect ratio and filling the + /// square if needed. If the image is already a square, returns an original image. + func byCroppingToSquare() -> PlatformImage? { + guard let cgImage = image.cgImage else { + return nil + } + + guard cgImage.width != cgImage.height else { + return image // Already a square + } + + let imageSize = cgImage.size + let side = min(cgImage.width, cgImage.height) + let targetSize = CGSize(width: side, height: side) + let cropRect = CGRect(origin: .zero, size: targetSize).offsetBy( + dx: max(0, (imageSize.width - targetSize.width) / 2), + dy: max(0, (imageSize.height - targetSize.height) / 2) + ) + guard let cropped = cgImage.cropping(to: cropRect) else { + return nil + } + return PlatformImage.make(cgImage: cropped, source: image) + } + + /// Adds rounded corners with the given radius to the image. + /// - parameter radius: Radius in pixels. + /// - parameter border: Optional stroke border. + func byAddingRoundedCorners(radius: CGFloat, border: ImageProcessingOptions.Border? = nil) -> PlatformImage? { + guard let cgImage = image.cgImage else { + return nil + } + guard let ctx = CGContext.make(cgImage, size: cgImage.size, alphaInfo: .premultipliedLast) else { + return nil + } + let rect = CGRect(origin: CGPoint.zero, size: cgImage.size) + let path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) + ctx.addPath(path) + ctx.clip() + ctx.draw(cgImage, in: CGRect(origin: CGPoint.zero, size: cgImage.size)) + + if let border = border { + ctx.setStrokeColor(border.color.cgColor) + ctx.addPath(path) + ctx.setLineWidth(border.width) + ctx.strokePath() + } + guard let outputCGImage = ctx.makeImage() else { + return nil + } + return PlatformImage.make(cgImage: outputCGImage, source: image) + } +} + +extension PlatformImage { + /// Draws the image in a `CGContext` in a canvas with the given size using + /// the specified draw rect. + /// + /// For example, if the canvas size is `CGSize(width: 10, height: 10)` and + /// the draw rect is `CGRect(x: -5, y: 0, width: 20, height: 10)` it would + /// draw the input image (which is horizontal based on the known draw rect) + /// in a square by centering it in the canvas. + /// + /// - parameter drawRect: `nil` by default. If `nil` will use the canvas rect. + func draw(inCanvasWithSize canvasSize: CGSize, drawRect: CGRect? = nil) -> PlatformImage? { + guard let cgImage = cgImage else { + return nil + } + guard let ctx = CGContext.make(cgImage, size: canvasSize) else { + return nil + } + ctx.draw(cgImage, in: drawRect ?? CGRect(origin: .zero, size: canvasSize)) + guard let outputCGImage = ctx.makeImage() else { + return nil + } + return PlatformImage.make(cgImage: outputCGImage, source: self) + } + + /// Decompresses the input image by drawing in the the `CGContext`. + func decompressed(isUsingPrepareForDisplay: Bool) -> PlatformImage? { +#if os(iOS) || os(tvOS) + if isUsingPrepareForDisplay, #available(iOS 15.0, tvOS 15.0, *) { + return preparingForDisplay() + } +#endif + guard let cgImage = cgImage else { + return nil + } + return draw(inCanvasWithSize: cgImage.size, drawRect: CGRect(origin: .zero, size: cgImage.size)) + } +} + +private extension CGContext { + static func make(_ image: CGImage, size: CGSize, alphaInfo: CGImageAlphaInfo? = nil) -> CGContext? { + let alphaInfo: CGImageAlphaInfo = alphaInfo ?? (image.isOpaque ? .noneSkipLast : .premultipliedLast) + + // Create the context which matches the input image. + if let ctx = CGContext( + data: nil, + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: alphaInfo.rawValue + ) { + return ctx + } + + // In case the combination of parameters (color space, bits per component, etc) + // is nit supported by Core Graphics, switch to default context. + // - Quartz 2D Programming Guide + // - https://github.com/kean/Nuke/issues/35 + // - https://github.com/kean/Nuke/issues/57 + return CGContext( + data: nil, + width: Int(size.width), height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: alphaInfo.rawValue + ) + } +} + +extension CGFloat { + func converted(to unit: ImageProcessingOptions.Unit) -> CGFloat { + switch unit { + case .pixels: return self + case .points: return self * Screen.scale + } + } +} + +extension CGSize { + func getScale(targetSize: CGSize, contentMode: ImageProcessors.Resize.ContentMode) -> CGFloat { + let scaleHor = targetSize.width / width + let scaleVert = targetSize.height / height + + switch contentMode { + case .aspectFill: + return max(scaleHor, scaleVert) + case .aspectFit: + return min(scaleHor, scaleVert) + } + } + + /// Calculates a rect such that the output rect will be in the center of + /// the rect of the input size (assuming origin: .zero) + func centeredInRectWithSize(_ targetSize: CGSize) -> CGRect { + // First, resize the original size to fill the target size. + CGRect(origin: .zero, size: self).offsetBy( + dx: -(width - targetSize.width) / 2, + dy: -(height - targetSize.height) / 2 + ) + } +} + +#if os(iOS) || os(tvOS) || os(watchOS) +private extension CGSize { + func rotatedForOrientation(_ imageOrientation: UIImage.Orientation) -> CGSize { + switch imageOrientation { + case .left, .leftMirrored, .right, .rightMirrored: + return CGSize(width: height, height: width) // Rotate 90 degrees + case .up, .upMirrored, .down, .downMirrored: + return self + @unknown default: + return self + } + } +} +#endif + +#if os(macOS) +extension NSImage { + var cgImage: CGImage? { + cgImage(forProposedRect: nil, context: nil, hints: nil) + } + + var ciImage: CIImage? { + cgImage.map { CIImage(cgImage: $0) } + } + + static func make(cgImage: CGImage, source: NSImage) -> NSImage { + NSImage(cgImage: cgImage, size: .zero) + } + + convenience init(cgImage: CGImage) { + self.init(cgImage: cgImage, size: .zero) + } +} +#else +extension UIImage { + static func make(cgImage: CGImage, source: UIImage) -> UIImage { + UIImage(cgImage: cgImage, scale: source.scale, orientation: source.imageOrientation) + } +} +#endif + +extension CGImage { + /// Returns `true` if the image doesn't contain alpha channel. + var isOpaque: Bool { + let alpha = alphaInfo + return alpha == .none || alpha == .noneSkipFirst || alpha == .noneSkipLast + } + + var size: CGSize { + CGSize(width: width, height: height) + } +} + +extension CGSize { + func scaled(by scale: CGFloat) -> CGSize { + CGSize(width: width * scale, height: height * scale) + } + + func rounded() -> CGSize { + CGSize(width: CGFloat(round(width)), height: CGFloat(round(height))) + } +} + +@MainActor +enum Screen { +#if os(iOS) || os(tvOS) + /// Returns the current screen scale. + static let scale: CGFloat = UIScreen.main.scale +#elseif os(watchOS) + /// Returns the current screen scale. + static let scale: CGFloat = WKInterfaceDevice.current().screenScale +#elseif os(macOS) + /// Always returns 1. + static let scale: CGFloat = 1 +#endif +} + +#if os(macOS) +typealias NukeColor = NSColor +#else +typealias NukeColor = UIColor +#endif + +extension NukeColor { + /// Returns a hex representation of the color, e.g. "#FFFFAA". + var hex: String { + var (r, g, b, a) = (CGFloat(0), CGFloat(0), CGFloat(0), CGFloat(0)) + getRed(&r, green: &g, blue: &b, alpha: &a) + let components = [r, g, b, a < 1 ? a : nil] + return "#" + components + .compactMap { $0 } + .map { String(format: "%02lX", lroundf(Float($0) * 255)) } + .joined() + } +} + +/// Creates an image thumbnail. Uses significantly less memory than other options. +func makeThumbnail(data: Data, options: ImageRequest.ThumbnailOptions) -> PlatformImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary) else { + return nil + } + 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 { + return nil + } + return PlatformImage(cgImage: image) +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImagePublisher.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImagePublisher.swift new file mode 100644 index 00000000..c7df3e95 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImagePublisher.swift @@ -0,0 +1,86 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Combine + +/// A publisher that starts a new `ImageTask` when a subscriber is added. +/// +/// If the requested image is available in the memory cache, the value is +/// delivered immediately. When the subscription is cancelled, the task also +/// gets cancelled. +/// +/// - note: In case the pipeline has `isProgressiveDecodingEnabled` option enabled +/// and the image being downloaded supports progressive decoding, the publisher +/// might emit more than a single value. +struct ImagePublisher: Publisher, Sendable { + typealias Output = ImageResponse + typealias Failure = ImagePipeline.Error + + let request: ImageRequest + let pipeline: ImagePipeline + + func receive(subscriber: S) where S: Subscriber, S: Sendable, Failure == S.Failure, Output == S.Input { + let subscription = ImageSubscription( + request: self.request, + pipeline: self.pipeline, + subscriber: subscriber + ) + subscriber.receive(subscription: subscription) + } +} + +private final class ImageSubscription: Subscription where S: Subscriber, S: Sendable, S.Input == ImageResponse, S.Failure == ImagePipeline.Error { + private var task: ImageTask? + private let subscriber: S? + private let request: ImageRequest + private let pipeline: ImagePipeline + private var isStarted = false + + init(request: ImageRequest, pipeline: ImagePipeline, subscriber: S) { + self.pipeline = pipeline + self.request = request + self.subscriber = subscriber + + } + + func request(_ demand: Subscribers.Demand) { + guard demand > 0 else { return } + guard let subscriber = subscriber else { return } + + if let image = pipeline.cache[request] { + _ = subscriber.receive(ImageResponse(container: image, request: request, cacheType: .memory)) + + if !image.isPreview { + subscriber.receive(completion: .finished) + return + } + } + + task = pipeline.loadImage( + with: request, + queue: nil, + progress: { response, _, _ in + if let response = response { + // Send progressively decoded image (if enabled and if any) + _ = subscriber.receive(response) + } + }, + completion: { result in + switch result { + case let .success(response): + _ = subscriber.receive(response) + subscriber.receive(completion: .finished) + case let .failure(error): + subscriber.receive(completion: .failure(error)) + } + } + ) + } + + func cancel() { + task?.cancel() + task = nil + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImageRequestKeys.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImageRequestKeys.swift new file mode 100644 index 00000000..2f4a03c4 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImageRequestKeys.swift @@ -0,0 +1,115 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 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 { + private let imageId: String? + private let thumbnail: ImageRequest.ThumbnailOptions? + private let processors: [any ImageProcessing] + + init(_ request: ImageRequest) { + self.imageId = request.preferredImageId + self.thumbnail = request.thubmnail + self.processors = request.processors + } + + func hash(into hasher: inout Hasher) { + hasher.combine(imageId) + 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 + } +} + +/// 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 + + init(_ request: ImageRequest) { + self.cacheKey = CacheKey(request) + self.options = request.options + self.thumbnail = request.thubmnail + self.loadKey = DataLoadKey(request) + } +} + +/// Uniquely identifies a task of retrieving the decoded image. +struct DecodedImageLoadKey: Hashable { + let dataLoadKey: DataLoadKey + let thumbnail: ImageRequest.ThumbnailOptions? + + init(_ request: ImageRequest) { + self.dataLoadKey = DataLoadKey(request) + self.thumbnail = request.thubmnail + } +} + +/// Uniquely identifies a task of retrieving the original image dataa. +struct DataLoadKey: Hashable { + private let imageId: String? + private let cachePolicy: URLRequest.CachePolicy + private let allowsCellularAccess: Bool + + init(_ request: ImageRequest) { + self.imageId = request.imageId + switch request.resource { + case .url, .publisher: + self.cachePolicy = .useProtocolCachePolicy + self.allowsCellularAccess = true + case let .urlRequest(urlRequest): + self.cachePolicy = urlRequest.cachePolicy + self.allowsCellularAccess = urlRequest.allowsCellularAccess + } + } +} + +struct ImageProcessingKey: Equatable, Hashable { + let imageId: ObjectIdentifier + let processorId: AnyHashable + + init(image: ImageResponse, processor: 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 new file mode 100644 index 00000000..afd492d0 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/LinkedList.swift @@ -0,0 +1,85 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A doubly linked list. +final class LinkedList { + // first <-> node <-> ... <-> last + private(set) var first: Node? + private(set) var last: Node? + + deinit { + removeAll() + + #if TRACK_ALLOCATIONS + Allocations.decrement("LinkedList") + #endif + } + + init() { + #if TRACK_ALLOCATIONS + Allocations.increment("LinkedList") + #endif + } + + var isEmpty: Bool { + last == nil + } + + /// Adds an element to the end of the list. + @discardableResult + func append(_ element: Element) -> Node { + let node = Node(value: element) + append(node) + return node + } + + /// Adds a node to the end of the list. + func append(_ node: Node) { + if let last = last { + last.next = node + node.previous = last + self.last = node + } else { + last = node + first = node + } + } + + func remove(_ node: Node) { + node.next?.previous = node.previous // node.previous is nil if node=first + node.previous?.next = node.next // node.next is nil if node=last + if node === last { + last = node.previous + } + if node === first { + first = node.next + } + node.next = nil + node.previous = nil + } + + func removeAll() { + // avoid recursive Nodes deallocation + var node = first + while let next = node?.next { + node?.next = nil + next.previous = nil + node = next + } + last = nil + first = nil + } + + final class Node { + let value: Element + fileprivate var next: Node? + fileprivate var previous: Node? + + init(value: Element) { + self.value = value + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Log.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Log.swift new file mode 100644 index 00000000..39d86899 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Log.swift @@ -0,0 +1,53 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 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 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 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) + } + 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() + +enum Formatter { + static func bytes(_ count: Int) -> String { + bytes(Int64(count)) + } + + static func bytes(_ count: Int64) -> String { + byteFormatter.string(fromByteCount: count) + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Operation.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Operation.swift new file mode 100644 index 00000000..13b50bce --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Operation.swift @@ -0,0 +1,106 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +final class Operation: Foundation.Operation { + override var isExecuting: Bool { + get { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return _isExecuting + } + set { + os_unfair_lock_lock(lock) + _isExecuting = newValue + os_unfair_lock_unlock(lock) + + willChangeValue(forKey: "isExecuting") + didChangeValue(forKey: "isExecuting") + } + } + + override var isFinished: Bool { + get { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return _isFinished + } + set { + os_unfair_lock_lock(lock) + _isFinished = newValue + os_unfair_lock_unlock(lock) + + willChangeValue(forKey: "isFinished") + didChangeValue(forKey: "isFinished") + } + } + + typealias Starter = @Sendable (_ finish: @Sendable @escaping () -> Void) -> Void + private let starter: Starter + + private var _isExecuting = false + private var _isFinished = false + private var isFinishCalled = false + private let lock: os_unfair_lock_t + + deinit { + self.lock.deinitialize(count: 1) + self.lock.deallocate() + + #if TRACK_ALLOCATIONS + Allocations.decrement("Operation") + #endif + } + + init(starter: @escaping Starter) { + self.starter = starter + + self.lock = .allocate(capacity: 1) + self.lock.initialize(to: os_unfair_lock()) + + #if TRACK_ALLOCATIONS + Allocations.increment("Operation") + #endif + } + + override func start() { + guard !isCancelled else { + isFinished = true + return + } + isExecuting = true + starter { [weak self] in + self?._finish() + } + } + + private func _finish() { + os_unfair_lock_lock(lock) + guard !isFinishCalled else { + return os_unfair_lock_unlock(lock) + } + isFinishCalled = true + os_unfair_lock_unlock(lock) + + isExecuting = false + isFinished = true + } +} + +extension OperationQueue { + /// Adds simple `BlockOperation`. + func add(_ closure: @Sendable @escaping () -> Void) -> BlockOperation { + let operation = BlockOperation(block: closure) + addOperation(operation) + return operation + } + + /// Adds asynchronous operation (`Nuke.Operation`) with the given starter. + func add(_ starter: @escaping Operation.Starter) -> Operation { + let operation = Operation(starter: starter) + addOperation(operation) + return operation + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/RateLimiter.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/RateLimiter.swift new file mode 100644 index 00000000..1d6caee3 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/RateLimiter.swift @@ -0,0 +1,118 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Controls the rate at which the work is executed. Uses the classic [token +/// bucket](https://en.wikipedia.org/wiki/Token_bucket) algorithm. +/// +/// The main use case for rate limiter is to support large (infinite) collections +/// of images by preventing trashing of underlying systems, primary URLSession. +/// +/// The implementation supports quick bursts of requests which can be executed +/// without any delays when "the bucket is full". This is important to prevent +/// rate limiter from affecting "normal" requests flow. +final class RateLimiter: @unchecked Sendable { + // This type isn't really Sendable and requires the caller to use the same + // queue as it does for synchronization. + + private let bucket: TokenBucket + private let queue: DispatchQueue + private var pending = LinkedList() // fast append, fast remove first + private var isExecutingPendingTasks = false + + typealias Work = () -> Bool + + /// Initializes the `RateLimiter` with the given configuration. + /// - parameters: + /// - queue: Queue on which to execute pending tasks. + /// - rate: Maximum number of requests per second. 80 by default. + /// - burst: Maximum number of requests which can be executed without any + /// delays when "bucket is full". 25 by default. + init(queue: DispatchQueue, rate: Int = 80, burst: Int = 25) { + self.queue = queue + self.bucket = TokenBucket(rate: Double(rate), burst: Double(burst)) + + #if TRACK_ALLOCATIONS + Allocations.increment("RateLimiter") + #endif + } + + deinit { + #if TRACK_ALLOCATIONS + Allocations.decrement("RateLimiter") + #endif + } + + /// - parameter closure: Returns `true` if the close was executed, `false` + /// if the work was cancelled. + func execute( _ work: @escaping Work) { + if !pending.isEmpty || !bucket.execute(work) { + pending.append(work) + setNeedsExecutePendingTasks() + } + } + + private func setNeedsExecutePendingTasks() { + guard !isExecutingPendingTasks else { + return + } + isExecutingPendingTasks = true + // Compute a delay such that by the time the closure is executed the + // bucket is refilled to a point that is able to execute at least one + // pending task. With a rate of 80 tasks we expect a refill every ~26 ms + // or as soon as the new tasks are added. + let bucketRate = 1000.0 / bucket.rate + let delay = Int(2.1 * bucketRate) // 14 ms for rate 80 (default) + let bounds = min(100, max(15, delay)) + queue.asyncAfter(deadline: .now() + .milliseconds(bounds)) { self.executePendingTasks() } + } + + private func executePendingTasks() { + while let node = pending.first, bucket.execute(node.value) { + pending.remove(node) + } + isExecutingPendingTasks = false + if !pending.isEmpty { // Not all pending items were executed + setNeedsExecutePendingTasks() + } + } +} + +private final class TokenBucket { + let rate: Double + private let burst: Double // maximum bucket size + private var bucket: Double + private var timestamp: TimeInterval // last refill timestamp + + /// - parameter rate: Rate (tokens/second) at which bucket is refilled. + /// - parameter burst: Bucket size (maximum number of tokens). + init(rate: Double, burst: Double) { + self.rate = rate + self.burst = burst + self.bucket = burst + self.timestamp = CFAbsoluteTimeGetCurrent() + } + + /// Returns `true` if the closure was executed, `false` if dropped. + func execute(_ work: () -> Bool) -> Bool { + refill() + guard bucket >= 1.0 else { + return false // bucket is empty + } + if work() { + bucket -= 1.0 // work was cancelled, no need to reduce the bucket + } + return true + } + + private func refill() { + let now = CFAbsoluteTimeGetCurrent() + bucket += rate * max(0, now - timestamp) // rate * (time delta) + timestamp = now + if bucket > burst { // prevent bucket overflow + bucket = burst + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ResumableData.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ResumableData.swift new file mode 100644 index 00000000..7752e7fc --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ResumableData.swift @@ -0,0 +1,134 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Resumable data support. For more info see: +/// - https://developer.apple.com/library/content/qa/qa1761/_index.html +struct ResumableData: @unchecked Sendable { + let data: Data + let validator: String // Either Last-Modified or ETag + + init?(response: URLResponse, data: Data) { + // Check if "Accept-Ranges" is present and the response is valid. + guard !data.isEmpty, + let response = response as? HTTPURLResponse, + data.count < response.expectedContentLength, + response.statusCode == 200 /* OK */ || response.statusCode == 206, /* Partial Content */ + let acceptRanges = response.allHeaderFields["Accept-Ranges"] as? String, + acceptRanges.lowercased() == "bytes", + let validator = ResumableData._validator(from: response) else { + return nil + } + + // NOTE: https://developer.apple.com/documentation/foundation/httpurlresponse/1417930-allheaderfields + // HTTP headers are case insensitive. To simplify your code, certain + // header field names are canonicalized into their standard form. + // For example, if the server sends a content-length header, + // it is automatically adjusted to be Content-Length. + + self.data = data; self.validator = validator + } + + private static func _validator(from response: HTTPURLResponse) -> String? { + if let entityTag = response.allHeaderFields["ETag"] as? String { + return entityTag // Prefer ETag + } + // There seems to be a bug with ETag where HTTPURLResponse would canonicalize + // it to Etag instead of ETag + // https://bugs.swift.org/browse/SR-2429 + if let entityTag = response.allHeaderFields["Etag"] as? String { + return entityTag // Prefer ETag + } + if let lastModified = response.allHeaderFields["Last-Modified"] as? String { + return lastModified + } + return nil + } + + func resume(request: inout URLRequest) { + var headers = request.allHTTPHeaderFields ?? [:] + // "bytes=1000-" means bytes from 1000 up to the end (inclusive) + headers["Range"] = "bytes=\(data.count)-" + headers["If-Range"] = validator + request.allHTTPHeaderFields = headers + } + + // Check if the server decided to resume the response. + static func isResumedResponse(_ response: URLResponse) -> Bool { + // "206 Partial Content" (server accepted "If-Range") + (response as? HTTPURLResponse)?.statusCode == 206 + } +} + +/// Shared cache, uses the same memory pool across multiple pipelines. +final class ResumableDataStorage: @unchecked Sendable { + static let shared = ResumableDataStorage() + + private let lock = NSLock() + private var registeredPipelines = Set() + + private var cache: NukeCache? + + // MARK: Registration + + func register(_ pipeline: ImagePipeline) { + lock.lock() + defer { lock.unlock() } + + if registeredPipelines.isEmpty { + // 32 MB + cache = NukeCache(costLimit: 32000000, countLimit: 100) + } + registeredPipelines.insert(pipeline.id) + } + + func unregister(_ pipeline: ImagePipeline) { + lock.lock() + defer { lock.unlock() } + + registeredPipelines.remove(pipeline.id) + if registeredPipelines.isEmpty { + cache = nil // Deallocate storage + } + } + + func removeAll() { + lock.lock() + defer { lock.unlock() } + + cache?.removeAll() + } + + // MARK: Storage + + func removeResumableData(for request: ImageRequest, pipeline: ImagePipeline) -> ResumableData? { + lock.lock() + defer { lock.unlock() } + + guard let key = Key(request: request, pipeline: pipeline) else { return nil } + return cache?.removeValue(forKey: key) + } + + func storeResumableData(_ data: ResumableData, for request: ImageRequest, pipeline: ImagePipeline) { + lock.lock() + defer { lock.unlock() } + + guard let key = Key(request: request, pipeline: pipeline) else { return } + cache?.set(data, forKey: key, cost: data.data.count) + } + + private struct Key: Hashable { + let pipelineId: UUID + let imageId: String + + init?(request: ImageRequest, pipeline: ImagePipeline) { + guard let imageId = request.imageId else { + return nil + } + self.pipelineId = pipeline.id + self.imageId = imageId + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoader.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoader.swift new file mode 100644 index 00000000..c31a4df9 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoader.swift @@ -0,0 +1,243 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Provides basic networking using `URLSession`. +final class DataLoader: DataLoading, _DataLoaderObserving, @unchecked Sendable { + let session: URLSession + private let impl = _DataLoader() + + @available(*, deprecated, message: "Please use `DataLoader/delegate` instead") + var observer: (any DataLoaderObserving)? + + /// 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. + /// + /// For example, you can use it to log network requests using [Pulse](https://github.com/kean/Pulse) + /// which is optimized to work with images. + /// + /// ```swift + /// (ImagePipeline.shared.configuration.dataLoader as? DataLoader)?.delegate = URLSessionProxyDelegate() + /// ``` + /// + /// - note: The delegate is retained. + var delegate: URLSessionDelegate? { + didSet { impl.delegate = delegate } + } + + deinit { + session.invalidateAndCancel() + + #if TRACK_ALLOCATIONS + Allocations.decrement("DataLoader") + #endif + } + + /// Initializes ``DataLoader`` with the given configuration. + /// + /// - parameters: + /// - configuration: `URLSessionConfiguration.default` with `URLCache` with + /// 0 MB memory capacity and 150 MB disk capacity by default. + /// - validate: Validates the response. By default, check if the status + /// code is in the acceptable range (`200..<300`). + init(configuration: URLSessionConfiguration = DataLoader.defaultConfiguration, + validate: @escaping (URLResponse) -> Swift.Error? = DataLoader.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 + /// as a `urlCache`. + static var defaultConfiguration: URLSessionConfiguration { + let conf = URLSessionConfiguration.default + conf.urlCache = DataLoader.sharedUrlCache + return conf + } + + /// Validates `HTTP` responses by checking that the status code is 2xx. If + /// it's not returns ``DataLoader/Error/statusCodeUnacceptable(_:)``. + static func validate(response: URLResponse) -> Swift.Error? { + guard let response = response as? HTTPURLResponse else { + return nil + } + return (200..<300).contains(response.statusCode) ? nil : Error.statusCodeUnacceptable(response.statusCode) + } + + #if !os(macOS) && !targetEnvironment(macCatalyst) + private static let cachePath = "com.github.kean.Nuke.Cache" + #else + private static let cachePath: String = { + let cachePaths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true) + if let cachePath = cachePaths.first, let identifier = Bundle.main.bundleIdentifier { + return cachePath.appending("/" + identifier) + } + + return "" + }() + #endif + + /// Shared url cached used by a default ``DataLoader``. The cache is + /// initialized with 0 MB memory capacity and 150 MB disk capacity. + static let sharedUrlCache: URLCache = { + let diskCapacity = 150 * 1048576 // 150 MB + #if targetEnvironment(macCatalyst) + return URLCache(memoryCapacity: 0, diskCapacity: diskCapacity, directory: URL(fileURLWithPath: cachePath)) + #else + return URLCache(memoryCapacity: 0, diskCapacity: diskCapacity, diskPath: cachePath) + #endif + }() + + func loadData(with request: URLRequest, + didReceiveData: @escaping (Data, URLResponse) -> Void, + completion: @escaping (Swift.Error?) -> Void) -> any Cancellable { + let task = session.dataTask(with: request) + if #available(iOS 14.5, tvOS 14.5, watchOS 7.4, macOS 11.3, *) { + task.prefersIncrementalDelivery = prefersIncrementalDelivery + } + return impl.loadData(with: task, session: session, didReceiveData: didReceiveData, completion: completion) + } + + /// Errors produced by ``DataLoader``. + enum Error: Swift.Error, CustomStringConvertible { + /// Validation failed. + case statusCodeUnacceptable(Int) + + var description: String { + switch self { + case let .statusCodeUnacceptable(code): + return "Response status code was unacceptable: \(code.description)" + } + } + } + + // 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 var handlers = [URLSessionTask: _Handler]() + var delegate: URLSessionDelegate? + weak var observer: (any _DataLoaderObserving)? + + /// Loads data with the given request. + func loadData(with task: URLSessionDataTask, + session: URLSession, + didReceiveData: @escaping (Data, URLResponse) -> Void, + completion: @escaping (Error?) -> Void) -> any Cancellable { + let handler = _Handler(didReceiveData: didReceiveData, completion: completion) + session.delegateQueue.addOperation { // `URLSession` is configured to use this same queue + self.handlers[task] = handler + } + task.taskDescription = "Nuke Load Data" + task.resume() + send(task, .resumed) + return AnonymousCancellable { task.cancel() } + } + + // MARK: URLSessionDelegate + +#if !os(macOS) && !targetEnvironment(macCatalyst) && swift(>=5.7) + func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + (delegate as? URLSessionTaskDelegate)?.urlSession?(session, didCreateTask: task) + } else { + // Doesn't exist on earlier versions + } + } +#endif + + func urlSession(_ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + (delegate as? URLSessionDataDelegate)?.urlSession?(session, dataTask: dataTask, didReceive: response, completionHandler: { _ in }) + send(dataTask, .receivedResponse(response: response)) + + guard let handler = handlers[dataTask] else { + completionHandler(.cancel) + return + } + if let error = validate(response) { + handler.completion(error) + completionHandler(.cancel) + return + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + (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 + } + handlers[task] = nil + handler.completion(error) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + (delegate as? URLSessionTaskDelegate)?.urlSession?(session, task: task, didFinishCollecting: metrics) + observer?.task(task, didFinishCollecting: metrics) + } + + // 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 + } + // Don't store data anywhere, just send it to the pipeline. + handler.didReceiveData(data, response) + } + + // MARK: Internal + + private func send(_ dataTask: URLSessionDataTask, _ event: DataTaskEvent) { + observer?.dataTask(dataTask, didReceiveEvent: event) + } + + private final class _Handler { + let didReceiveData: (Data, URLResponse) -> Void + let completion: (Error?) -> Void + + init(didReceiveData: @escaping (Data, URLResponse) -> Void, completion: @escaping (Error?) -> Void) { + self.didReceiveData = didReceiveData + self.completion = completion + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoading.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoading.swift new file mode 100644 index 00000000..06e5f546 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoading.swift @@ -0,0 +1,21 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Fetches original image data. +protocol DataLoading: Sendable { + /// - parameter didReceiveData: Can be called multiple times if streaming + /// is supported. + /// - parameter completion: Must be called once after all (or none in case + /// of an error) `didReceiveData` closures have been called. + func loadData(with request: URLRequest, + didReceiveData: @escaping (Data, URLResponse) -> Void, + completion: @escaping (Error?) -> Void) -> any Cancellable +} + +/// A unit of work that can be cancelled. +protocol Cancellable: AnyObject, Sendable { + func cancel() +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline.swift new file mode 100644 index 00000000..b50f277f --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline.swift @@ -0,0 +1,511 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Combine + +/// 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) + + /// The pipeline configuration. + let configuration: Configuration + + /// Provides access to the underlying caching subsystems. + var cache: ImagePipeline.Cache { ImagePipeline.Cache(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 + + // The queue on which the entire subsystem is synchronized. + let queue = DispatchQueue(label: "com.github.kean.Nuke.ImagePipeline", qos: .userInitiated) + private var isInvalidated = false + + private var nextTaskId: Int64 { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + _nextTaskId += 1 + return _nextTaskId + } + private var _nextTaskId: Int64 = 0 + private let lock: os_unfair_lock_t + + let rateLimiter: RateLimiter? + let id = UUID() + + 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. + /// + /// - parameters: + /// - configuration: The pipeline configuration. + /// - delegate: Provides more ways to customize the pipeline behavior on per-request basis. + init(configuration: Configuration = Configuration(), delegate: (any ImagePipelineDelegate)? = nil) { + self.configuration = configuration + self.rateLimiter = configuration.isRateLimiterEnabled ? RateLimiter(queue: queue) : nil + self.delegate = delegate ?? ImagePipelineDefaultDelegate() + (configuration.dataLoader as? DataLoader)?.prefersIncrementalDelivery = configuration.isProgressiveDecodingEnabled + + let isCoalescingEnabled = configuration.isTaskCoalescingEnabled + self.tasksLoadData = TaskPool(isCoalescingEnabled) + self.tasksLoadImage = TaskPool(isCoalescingEnabled) + self.tasksFetchDecodedImage = TaskPool(isCoalescingEnabled) + self.tasksFetchOriginalImageData = TaskPool(isCoalescingEnabled) + self.tasksProcessImage = 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. + /// + /// Example usage: + /// + /// ```swift + /// ImagePipeline { + /// $0.dataCache = try? DataCache(name: "com.myapp.datacache") + /// $0.dataCachePolicy = .automatic + /// } + /// ``` + /// + /// - parameters: + /// - configuration: The pipeline configuration. + /// - delegate: Provides more ways to customize the pipeline behavior on per-request basis. + convenience init(delegate: (any ImagePipelineDelegate)? = nil, _ configure: (inout ImagePipeline.Configuration) -> Void) { + var configuration = ImagePipeline.Configuration() + configure(&configuration) + self.init(configuration: configuration, delegate: delegate) + } + + /// Invalidates the pipeline and cancels all outstanding tasks. Any new + /// requests will immediately fail with ``ImagePipeline/Error/pipelineInvalidated`` error. + func invalidate() { + queue.async { + guard !self.isInvalidated else { return } + self.isInvalidated = true + self.tasks.keys.forEach { self.cancel($0) } + } + } + + // MARK: - Loading Images (Async/Await) + + /// Returns an image for 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) + } + + /// Returns an image for 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() + } + ) + } + + // MARK: - Loading Data (Async/Await) + + /// 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 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() + } + ) + } + + // MARK: - Loading Images (Closures) + + /// Loads an image for the given request. + /// + /// - parameters: + /// - request: An image request. + /// - completion: A closure to be called on the main thread when the request + /// is finished. + @discardableResult func loadImage( + with request: any ImageRequestConvertible, + completion: @escaping (_ result: Result) -> Void + ) -> ImageTask { + loadImage(with: request, queue: nil, 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, + 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: { + progress?($0, $1.completed, $1.total) + }, completion: completion) + } + + func loadImage( + with request: any ImageRequestConvertible, + isConfined: Bool, + queue callbackQueue: DispatchQueue?, + 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() } + } + 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)) + } + 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, + completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void + ) -> ImageTask { + loadData(with: request, queue: nil, progress: nil, completion: completion) + } + + /// Loads the image data for the given request. The data doesn't get decoded + /// or processed in any other way. + /// + /// You can call ``loadImage(with:completion:)`` for the request at any point after calling + /// ``loadData(with:completion:)``, the pipeline will use the same operation to load the data, + /// no duplicated work will be performed. + /// + /// - parameters: + /// - request: An image request. + /// - queue: A queue on which to execute `progress` and `completion` + /// callbacks. By default, the pipeline uses `.main` queue. + /// - progress: A closure to be called periodically on the main thread when the progress is updated. + /// - completion: A closure to be called on the main thread when the request is finished. + @discardableResult func loadData( + with request: any ImageRequestConvertible, + 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)) + } + return + } + + tasks[task] = makeTaskLoadData(for: task.request) + .subscribe(priority: task.priority.taskPriority, subscriber: task) { [weak self, weak task] event in + guard let self = self, let task = task else { return } + + if event.isCompleted { + 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) + + /// Returns a publisher which starts a new ``ImageTask`` when a subscriber is added. + func imagePublisher(with url: URL) -> AnyPublisher { + imagePublisher(with: ImageRequest(url: url)) + } + + /// Returns a publisher which starts a new ``ImageTask`` when a subscriber is added. + func imagePublisher(with request: ImageRequest) -> AnyPublisher { + ImagePublisher(request: request, pipeline: self).eraseToAnyPublisher() + } + + // MARK: - Image Task Events + + func imageTaskCancelCalled(_ task: ImageTask) { + queue.async { + self.cancel(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 + } + subscription.unsubscribe() + } + + func imageTaskUpdatePriorityCalled(_ task: ImageTask, priority: ImageRequest.Priority) { + queue.async { + self.tasks[task]?.setPriority(priority.taskPriority) + } + } + + private func dispatchCallback(to callbackQueue: DispatchQueue?, _ closure: @escaping () -> Void) { + if callbackQueue === self.queue { + closure() + } else { + (callbackQueue ?? self.configuration.callbackQueue).async(execute: closure) + } + } + + // MARK: - Task Factory (Private) + + // When you request an image or image data, the pipeline creates a graph of tasks + // (some tasks are added to the graph on demand). + // + // `loadImage()` call is represented by TaskLoadImage: + // + // TaskLoadImage -> TaskFetchDecodedImage -> TaskFetchOriginalImageData + // -> TaskProcessImage + // + // `loadData()` call is represented by TaskLoadData: + // + // TaskLoadData -> TaskFetchOriginalImageData + // + // + // Each task represents a resource or a piece of work required to produce the + // final result. The pipeline reduces the amount of duplicated work by coalescing + // the tasks that represent the same work. For example, if you all `loadImage()` + // and `loadData()` with the same request, only on `TaskFetchOriginalImageData` + // is created. The work is split between tasks to minimize any duplicated work. + + func makeTaskLoadImage(for request: ImageRequest) -> AsyncTask.Publisher { + tasksLoadImage.publisherForKey(request.makeImageLoadKey()) { + TaskLoadImage(self, request) + } + } + + func makeTaskLoadData(for request: ImageRequest) -> AsyncTask<(Data, URLResponse?), Error>.Publisher { + tasksLoadData.publisherForKey(request.makeImageLoadKey()) { + TaskLoadData(self, request) + } + } + + func makeTaskProcessImage(key: ImageProcessingKey, process: @escaping () throws -> ImageResponse) -> AsyncTask.Publisher { + tasksProcessImage.publisherForKey(key) { + OperationTask(self, configuration.imageProcessingQueue, process) + } + } + + func makeTaskFetchDecodedImage(for request: ImageRequest) -> AsyncTask.Publisher { + tasksFetchDecodedImage.publisherForKey(request.makeDecodedImageLoadKey()) { + TaskFetchDecodedImage(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) + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineCache.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineCache.swift new file mode 100644 index 00000000..327f09ad --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineCache.swift @@ -0,0 +1,261 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImagePipeline { + /// Provides a set of convenience APIs for managing the pipeline cache layers, + /// including ``ImageCaching`` (memory cache) and ``DataCaching`` (disk cache). + /// + /// - important: This class doesn't work with a `URLCache`. For more info, + /// see . + struct Cache: Sendable { + let pipeline: ImagePipeline + private var configuration: ImagePipeline.Configuration { pipeline.configuration } + } +} + +extension ImagePipeline.Cache { + // MARK: Subscript (Memory Cache) + + /// Returns an image from the memory cache for the given URL. + subscript(url: URL) -> ImageContainer? { + get { self[ImageRequest(url: url)] } + nonmutating set { self[ImageRequest(url: url)] = newValue } + } + + /// Returns an image from the memory cache for the given request. + subscript(request: ImageRequest) -> ImageContainer? { + get { + cachedImageFromMemoryCache(for: request) + } + nonmutating set { + if let image = newValue { + storeCachedImageInMemoryCache(image, for: request) + } else { + removeCachedImageFromMemoryCache(for: request) + } + } + } + + // MARK: Cached Images + + /// Returns a cached image any of the caches. + /// + /// - note: Respects request options such as its cache policy. + /// + /// - parameters: + /// - request: The request. Make sure to remove the processors if you want + /// to retrieve an original image (if it's stored). + /// - caches: `[.all]`, by default. + func cachedImage(for request: ImageRequest, caches: Caches = [.all]) -> ImageContainer? { + if caches.contains(.memory) { + if let image = cachedImageFromMemoryCache(for: request) { + return image + } + } + if caches.contains(.disk) { + if let data = cachedData(for: request), + let image = decodeImageData(data, for: request) { + return image + } + } + return nil + } + + /// Stores the image in all caches. To store image in the disk cache, it + /// will be encoded (see ``ImageEncoding``) + /// + /// - note: Respects request cache options. + /// + /// - note: Default ``DataCache`` stores data asynchronously, so it's safe + /// to call this method even from the main thread. + /// + /// - note: Image previews are not stored. + /// + /// - parameters: + /// - request: The request. Make sure to remove the processors if you want + /// to retrieve an original image (if it's stored). + /// - caches: `[.all]`, by default. + func storeCachedImage(_ image: ImageContainer, for request: ImageRequest, caches: Caches = [.all]) { + if caches.contains(.memory) { + storeCachedImageInMemoryCache(image, for: request) + } + if caches.contains(.disk) { + if let data = encodeImage(image, for: request) { + storeCachedData(data, for: request) + } + } + } + + /// Removes the image from all caches. + func removeCachedImage(for request: ImageRequest, caches: Caches = [.all]) { + if caches.contains(.memory) { + removeCachedImageFromMemoryCache(for: request) + } + if caches.contains(.disk) { + removeCachedData(for: request) + } + } + + /// Returns `true` if any of the caches contain the image. + func containsCachedImage(for request: ImageRequest, caches: Caches = [.all]) -> Bool { + if caches.contains(.memory) && cachedImageFromMemoryCache(for: request) != nil { + return true + } + if caches.contains(.disk), let dataCache = dataCache(for: request) { + let key = makeDataCacheKey(for: request) + return dataCache.containsData(for: key) + } + return false + } + + private func cachedImageFromMemoryCache(for request: ImageRequest) -> ImageContainer? { + guard !request.options.contains(.disableMemoryCacheReads) else { + return nil + } + guard let imageCache = imageCache(for: request) else { + return nil + } + return imageCache[makeImageCacheKey(for: request)] + } + + private func storeCachedImageInMemoryCache(_ image: ImageContainer, for request: ImageRequest) { + guard !request.options.contains(.disableMemoryCacheWrites) else { + return + } + guard !image.isPreview || configuration.isStoringPreviewsInMemoryCache else { + return + } + guard let imageCache = imageCache(for: request) else { + return + } + imageCache[makeImageCacheKey(for: request)] = image + } + + private func removeCachedImageFromMemoryCache(for request: ImageRequest) { + guard let imageCache = imageCache(for: request) else { + return + } + imageCache[makeImageCacheKey(for: request)] = nil + } + + // MARK: Cached Data + + /// Returns cached data for the given request. + func cachedData(for request: ImageRequest) -> Data? { + guard !request.options.contains(.disableDiskCacheReads) else { + return nil + } + guard let dataCache = dataCache(for: request) else { + return nil + } + let key = makeDataCacheKey(for: request) + return dataCache.cachedData(for: key) + } + + /// Stores data for the given request. + /// + /// - note: Default ``DataCache`` stores data asynchronously, so it's safe + /// to call this method even from the main thread. + func storeCachedData(_ data: Data, for request: ImageRequest) { + guard let dataCache = dataCache(for: request), + !request.options.contains(.disableDiskCacheWrites) else { + return + } + let key = makeDataCacheKey(for: request) + dataCache.storeData(data, for: key) + } + + /// Returns true if the data cache contains data for the given image + func containsData(for request: ImageRequest) -> Bool { + guard let dataCache = dataCache(for: request) else { + return false + } + return dataCache.containsData(for: makeDataCacheKey(for: request)) + } + + /// Removes cached data for the given request. + func removeCachedData(for request: ImageRequest) { + guard let dataCache = dataCache(for: request) else { + return + } + let key = makeDataCacheKey(for: request) + dataCache.removeData(for: key) + } + + // MARK: Keys + + /// Returns image cache (memory cache) key for the given request. + func makeImageCacheKey(for request: ImageRequest) -> ImageCacheKey { + if let customKey = pipeline.delegate.cacheKey(for: request, pipeline: pipeline) { + return ImageCacheKey(key: customKey) + } + return ImageCacheKey(request: request) // Use the default key + } + + /// Returns data cache (disk cache) key for the given request. + func makeDataCacheKey(for request: ImageRequest) -> String { + if let customKey = pipeline.delegate.cacheKey(for: request, pipeline: pipeline) { + return customKey + } + return request.makeDataCacheKey() // Use the default key + } + + // MARK: Misc + + /// Removes both images and data from all cache layes. + /// + /// - important: It clears only caches set in the pipeline configuration. If + /// you implement ``ImagePipelineDelegate`` that uses different caches for + /// different requests, this won't remove images from them. + func removeAll(caches: Caches = [.all]) { + if caches.contains(.memory) { + configuration.imageCache?.removeAll() + } + if caches.contains(.disk) { + configuration.dataCache?.removeAll() + } + } + + // MARK: Private + + private func decodeImageData(_ data: Data, for request: ImageRequest) -> ImageContainer? { + let context = ImageDecodingContext(request: request, data: data, isCompleted: true, urlResponse: nil, cacheType: .disk) + guard let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) else { + return nil + } + return (try? decoder.decode(context))?.container + } + + private func encodeImage(_ image: ImageContainer, for request: ImageRequest) -> Data? { + let context = ImageEncodingContext(request: request, image: image.image, urlResponse: nil) + let encoder = pipeline.delegate.imageEncoder(for: context, pipeline: pipeline) + return encoder.encode(image, context: context) + } + + private func imageCache(for request: ImageRequest) -> (any ImageCaching)? { + pipeline.delegate.imageCache(for: request, pipeline: pipeline) + } + + private func dataCache(for request: ImageRequest) -> (any DataCaching)? { + pipeline.delegate.dataCache(for: request, pipeline: pipeline) + } + + // MARK: Options + + /// Describes a set of cache layers to use. + struct Caches: OptionSet { + let rawValue: Int + init(rawValue: Int) { + self.rawValue = rawValue + } + + static let memory = Caches(rawValue: 1 << 0) + static let disk = Caches(rawValue: 1 << 1) + static let all: Caches = [.memory, .disk] + } +} + +extension ImagePipeline.Cache.Caches: Sendable {} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineConfiguration.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineConfiguration.swift new file mode 100644 index 00000000..eecb43ae --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineConfiguration.swift @@ -0,0 +1,245 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImagePipeline { + /// The pipeline configuration. + struct Configuration: @unchecked Sendable { + // MARK: - Dependencies + + /// Data loader used by the pipeline. + var dataLoader: any DataLoading + + /// Data cache used by the pipeline. + var dataCache: (any DataCaching)? + + /// Image cache used by the pipeline. + var imageCache: (any ImageCaching)? { + // This exists simply to ensure we don't init ImageCache.shared if the + // user provides their own instance. + get { isCustomImageCacheProvided ? customImageCache : ImageCache.shared } + set { + customImageCache = newValue + isCustomImageCacheProvided = true + } + } + private var customImageCache: (any ImageCaching)? + + /// Default implementation uses shared ``ImageDecoderRegistry`` to create + /// a decoder that matches the context. + var makeImageDecoder: @Sendable (ImageDecodingContext) -> (any ImageDecoding)? = { + ImageDecoderRegistry.shared.decoder(for: $0) + } + + /// Returns `ImageEncoders.Default()` by default. + var makeImageEncoder: @Sendable (ImageEncodingContext) -> any ImageEncoding = { _ in + ImageEncoders.Default() + } + + // MARK: - Options + + /// Decompresses the loaded images. By default, enabled on all platforms + /// except for `macOS`. + /// + /// Decompressing compressed image formats (such as JPEG) can significantly + /// improve drawing performance as it allows a bitmap representation to be + /// created in a background rather than on the main thread. + var isDecompressionEnabled: Bool { + get { _isDecompressionEnabled } + set { _isDecompressionEnabled = newValue } + } + + /// Set this to `true` to use native `preparingForDisplay()` method for + /// decompression on iOS and tvOS 15.0 and later. Disabled by default. + /// If disabled, CoreGraphics-based decompression is used. + var isUsingPrepareForDisplay: Bool = false + +#if os(macOS) + var _isDecompressionEnabled = false +#else + var _isDecompressionEnabled = true +#endif + + /// If you use an aggressive disk cache ``DataCaching``, you can specify + /// a cache policy with multiple available options and + /// ``ImagePipeline/DataCachePolicy/storeOriginalData`` used by default. + var dataCachePolicy = ImagePipeline.DataCachePolicy.storeOriginalData + + /// `true` by default. If `true` the pipeline avoids duplicated work when + /// loading images. The work only gets cancelled when all the registered + /// requests are. The pipeline also automatically manages the priority of the + /// deduplicated work. + /// + /// Let's take these two requests for example: + /// + /// ```swift + /// let url = URL(string: "http://example.com/image") + /// pipeline.loadImage(with: ImageRequest(url: url, processors: [ + /// ImageProcessors.Resize(size: CGSize(width: 44, height: 44)), + /// ImageProcessors.GaussianBlur(radius: 8) + /// ])) + /// pipeline.loadImage(with: ImageRequest(url: url, processors: [ + /// ImageProcessors.Resize(size: CGSize(width: 44, height: 44)) + /// ])) + /// ``` + /// + /// Nuke will load the image data only once, resize the image once and + /// apply the blur also only once. There is no duplicated work done at + /// any stage. + var isTaskCoalescingEnabled = true + + /// `true` by default. If `true` the pipeline will rate limit requests + /// to prevent trashing of the underlying systems (e.g. `URLSession`). + /// The rate limiter only comes into play when the requests are started + /// and cancelled at a high rate (e.g. scrolling through a collection view). + var isRateLimiterEnabled = true + + /// `false` by default. If `true` the pipeline will try to produce a new + /// image each time it receives a new portion of data from data loader. + /// The decoder used by the image loading session determines whether + /// to produce a partial image or not. The default image decoder + /// ``ImageDecoders/Default`` supports progressive JPEG decoding. + var isProgressiveDecodingEnabled = false + + /// `true` by default. If `true`, the pipeline will store all of the + /// progressively generated previews in the memory cache. All of the + /// previews have ``ImageContainer/isPreview`` flag set to `true`. + var isStoringPreviewsInMemoryCache = true + + /// If the data task is terminated (either because of a failure or a + /// cancellation) and the image was partially loaded, the next load will + /// resume where it left off. Supports both validators (`ETag`, + /// `Last-Modified`). Resumable downloads are enabled by default. + var isResumableDataEnabled = true + + /// A queue on which all callbacks, like `progress` and `completion` + /// callbacks are called. `.main` by default. + var callbackQueue = DispatchQueue.main + + // MARK: - Options (Shared) + + /// `false` by default. If `true`, enables `os_signpost` logging for + /// measuring performance. You can visually see all the performance + /// metrics in `os_signpost` Instrument. For more information see + /// https://developer.apple.com/documentation/os/logging and + /// https://developer.apple.com/videos/play/wwdc2018/405/. + static var isSignpostLoggingEnabled = false + + private var isCustomImageCacheProvided = false + + var debugIsSyncImageEncoding = false + + // MARK: - Operation Queues + + /// Data loading queue. Default maximum concurrent task count is 6. + var dataLoadingQueue = OperationQueue(maxConcurrentCount: 6) + + /// Data caching queue. Default maximum concurrent task count is 2. + var dataCachingQueue = OperationQueue(maxConcurrentCount: 2) + + /// Image decoding queue. Default maximum concurrent task count is 1. + var imageDecodingQueue = OperationQueue(maxConcurrentCount: 1) + + /// Image encoding queue. Default maximum concurrent task count is 1. + var imageEncodingQueue = OperationQueue(maxConcurrentCount: 1) + + /// Image processing queue. Default maximum concurrent task count is 2. + var imageProcessingQueue = OperationQueue(maxConcurrentCount: 2) + + /// Image decompressing queue. Default maximum concurrent task count is 2. + var imageDecompressingQueue = OperationQueue(maxConcurrentCount: 2) + + // MARK: - Initializer + + /// Instantiates a pipeline configuration. + /// + /// - parameter dataLoader: `DataLoader()` by default. + init(dataLoader: any DataLoading = DataLoader()) { + self.dataLoader = dataLoader + } + + // MARK: - Predefined Configurations + + /// A configuration with an HTTP disk cache (`URLCache`) with a size limit + /// of 150 MB. This is a default configuration. + /// + /// Also uses ``ImageCache/shared`` for in-memory caching with the size + /// that adjusts bsed on the amount of device memory. + static var withURLCache: Configuration { Configuration() } + + /// A configuration with an aggressive disk cache (``DataCache``) with a + /// size limit of 150 MB. An HTTP cache (`URLCache`) is disabled. + /// + /// Also uses ``ImageCache/shared`` for in-memory caching with the size + /// that adjusts bsed on the amount of device memory. + static var withDataCache: Configuration { + withDataCache() + } + + /// A configuration with an aggressive disk cache (``DataCache``) with a + /// size limit of 150 MB by default. An HTTP cache (`URLCache`) is disabled. + /// + /// Also uses ``ImageCache/shared`` for in-memory caching with the size + /// that adjusts bsed on the amount of device memory. + /// + /// - parameters: + /// - name: Data cache name. + /// - sizeLimit: Size limit, by default 150 MB. + static func withDataCache( + name: String = "com.github.kean.Nuke.DataCache", + sizeLimit: Int? = nil + ) -> Configuration { + let dataLoader: DataLoader = { + let config = URLSessionConfiguration.default + config.urlCache = nil + return DataLoader(configuration: config) + }() + + var config = Configuration() + config.dataLoader = dataLoader + + let dataCache = try? DataCache(name: name) + if let sizeLimit = sizeLimit { + dataCache?.sizeLimit = sizeLimit + } + config.dataCache = dataCache + + return config + } + } + + /// 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). + /// + /// - important: With this policy, the pipeline ``ImagePipeline/loadData(with:completion:)`` method + /// will not store the images in the disk cache for requests with + /// any processors applied – this method only loads data and doesn't + /// decode images. + case automatic + + /// For all requests, only store the original image data, unless + /// the resource is local (file:// or data:// scheme is used). + case storeOriginalData + + /// For all requests, encode and store decoded images after all + /// processors are applied. + /// + /// - note: This is useful if you want to store images in a format + /// different than provided by a server, e.g. decompressed. In other + /// scenarios, consider using ``automatic`` policy instead. + /// + /// - important: With this policy, the pipeline ``ImagePipeline/loadData(with:completion:)`` method + /// will not store the images in the disk cache – this method only + /// loads data and doesn't decode images. + case storeEncodedImages + + /// For requests with processors, encode and store processed images. + /// For all requests, store original image data. + case storeAll + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineDelegate.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineDelegate.swift new file mode 100644 index 00000000..89093eb8 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineDelegate.swift @@ -0,0 +1,104 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A delegate that allows you to customize the pipeline dynamically on a per-request basis. +/// +/// - important: The delegate methods are performed on the pipeline queue in the +/// background. +protocol ImagePipelineDelegate: ImageTaskDelegate, Sendable { + // MARK: Configuration + + /// Returns data loader for the given request. + func dataLoader(for request: ImageRequest, pipeline: ImagePipeline) -> any DataLoading + + /// Returns image decoder for the given context. + func imageDecoder(for context: ImageDecodingContext, pipeline: ImagePipeline) -> (any ImageDecoding)? + + /// Returns image encoder for the given context. + func imageEncoder(for context: ImageEncodingContext, pipeline: ImagePipeline) -> any ImageEncoding + + // MARK: Caching + + /// Returns in-memory image cache for the given request. Return `nil` to prevent cache reads and writes. + func imageCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any ImageCaching)? + + /// Returns disk cache for the given request. Return `nil` to prevent cache + /// reads and writes. + func dataCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any DataCaching)? + + /// Returns a cache key identifying the image produced for the given request + /// (including image processors). The key is used for both in-memory and + /// on-disk caches. + /// + /// Return `nil` to use a default key. + func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? + + /// Gets called when the pipeline is about to save data for the given request. + /// The implementation must call the completion closure passing `non-nil` data + /// to enable caching or `nil` to prevent it. + /// + /// This method calls only if the request parameters and data caching policy + /// of the pipeline already allow caching. + /// + /// - parameters: + /// - data: Either the original data or the encoded image in case of storing + /// a processed or re-encoded image. + /// - image: Non-nil in case storing an encoded image. + /// - request: The request for which image is being stored. + /// - completion: The implementation must call the completion closure + /// passing `non-nil` data to enable caching or `nil` to prevent it. You can + /// safely call it synchronously. The callback gets called on the background + /// thread. + func willCache(data: Data, image: ImageContainer?, for request: ImageRequest, pipeline: ImagePipeline, completion: @escaping (Data?) -> Void) + + // MARK: Decompression + + func shouldDecompress(response: ImageResponse, for request: ImageRequest, pipeline: ImagePipeline) -> Bool + + func decompress(response: ImageResponse, request: ImageRequest, pipeline: ImagePipeline) -> ImageResponse +} + +extension ImagePipelineDelegate { + func imageCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any ImageCaching)? { + pipeline.configuration.imageCache + } + + func dataLoader(for request: ImageRequest, pipeline: ImagePipeline) -> any DataLoading { + pipeline.configuration.dataLoader + } + + func dataCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any DataCaching)? { + pipeline.configuration.dataCache + } + + func imageDecoder(for context: ImageDecodingContext, pipeline: ImagePipeline) -> (any ImageDecoding)? { + pipeline.configuration.makeImageDecoder(context) + } + + func imageEncoder(for context: ImageEncodingContext, pipeline: ImagePipeline) -> any ImageEncoding { + pipeline.configuration.makeImageEncoder(context) + } + + func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? { + nil + } + + func willCache(data: Data, image: ImageContainer?, for request: ImageRequest, pipeline: ImagePipeline, completion: @escaping (Data?) -> Void) { + completion(data) + } + + func shouldDecompress(response: ImageResponse, for request: ImageRequest, pipeline: ImagePipeline) -> Bool { + pipeline.configuration.isDecompressionEnabled + } + + func decompress(response: ImageResponse, request: ImageRequest, pipeline: ImagePipeline) -> ImageResponse { + var response = response + response.container.image = ImageDecompression.decompress(image: response.image, isUsingPrepareForDisplay: pipeline.configuration.isUsingPrepareForDisplay) + return response + } +} + +final class ImagePipelineDefaultDelegate: ImagePipelineDelegate {} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineError.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineError.swift new file mode 100644 index 00000000..b0311703 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineError.swift @@ -0,0 +1,65 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImagePipeline { + /// Represents all possible image pipeline errors. + enum Error: Swift.Error, CustomStringConvertible, @unchecked Sendable { + /// Returned if data not cached and ``ImageRequest/Options-swift.struct/returnCacheDataDontLoad`` option is specified. + case dataMissingInCache + /// Data loader failed to load image data with a wrapped error. + case dataLoadingFailed(error: Swift.Error) + /// Data loader returned empty data. + case dataIsEmpty + /// No decoder registered for the given data. + /// + /// This error can only be thrown if the pipeline has custom decoders. + /// By default, the pipeline uses ``ImageDecoders/Default`` as a catch-all. + case decoderNotRegistered(context: ImageDecodingContext) + /// Decoder failed to produce a final image. + case decodingFailed(decoder: any ImageDecoding, context: ImageDecodingContext, error: Swift.Error) + /// Processor failed to produce a final image. + case processingFailed(processor: any ImageProcessing, context: ImageProcessingContext, error: Swift.Error) + /// Load image method was called with no image request. + case imageRequestMissing + /// Image pipeline is invalidated and no requests can be made. + case pipelineInvalidated + } +} + +extension ImagePipeline.Error { + /// Returns underlying data loading error. + var dataLoadingError: Swift.Error? { + switch self { + case .dataLoadingFailed(let error): + return error + default: + return nil + } + } + + var description: String { + switch self { + case .dataMissingInCache: + return "Failed to load data from cache and download is disabled." + case let .dataLoadingFailed(error): + return "Failed to load image data. Underlying error: \(error)." + case .dataIsEmpty: + return "Data loader returned empty data." + case .decoderNotRegistered: + return "No decoders registered for the downloaded data." + case let .decodingFailed(decoder, _, error): + let underlying = error is ImageDecodingError ? "" : " Underlying error: \(error)." + return "Failed to decode image data using decoder \(decoder).\(underlying)" + case let .processingFailed(processor, _, error): + let underlying = error is ImageProcessingError ? "" : " Underlying error: \(error)." + return "Failed to process the image using processor \(processor).\(underlying)" + case .imageRequestMissing: + return "Load image method was called with no image request or no URL." + case .pipelineInvalidated: + return "Image pipeline is invalidated and no requests can be made." + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Prefetching/ImagePrefetcher.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Prefetching/ImagePrefetcher.swift new file mode 100644 index 00000000..795075d7 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Prefetching/ImagePrefetcher.swift @@ -0,0 +1,224 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Prefetches and caches images to eliminate delays when requesting the same +/// images later. +/// +/// The prefetcher cancels all of the outstanding tasks when deallocated. +/// +/// 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 + /// (by default, there are only 2 at a time), and pause the rest. + var isPaused: Bool = false { + didSet { queue.isSuspended = isPaused } + } + + /// The priority of the requests. By default, ``ImageRequest/Priority-swift.enum/low``. + /// + /// Changing the priority also changes the priority of all of the outstanding + /// tasks managed by the prefetcher. + var priority: ImageRequest.Priority = .low { + didSet { + let newValue = priority + pipeline.queue.async { self.didUpdatePriority(to: newValue) } + } + } + private var _priority: ImageRequest.Priority = .low + + /// Prefetching destination. + enum Destination: Sendable { + /// Prefetches the image and stores it in both the memory and the disk + /// cache (make sure to enable it). + case memoryCache + + /// Prefetches the image data and stores it in disk caches. It does not + /// require decoding the image data and therefore requires less CPU. + /// + /// - important: This option is incompatible with ``ImagePipeline/DataCachePolicy/automatic`` + /// (for requests with processors) and ``ImagePipeline/DataCachePolicy/storeEncodedImages``. + case diskCache + } + + /// Initializes the ``ImagePrefetcher`` instance. + /// + /// - parameters: + /// - pipeline: The pipeline used for loading images. + /// - destination: By default load images in all cache layers. + /// - maxConcurrentRequestCount: 2 by default. + init(pipeline: ImagePipeline = ImagePipeline.shared, + destination: Destination = .memoryCache, + maxConcurrentRequestCount: Int = 2) { + self.pipeline = pipeline + 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 + pipeline.queue.async { + for task in tasks { + task.cancel() + } + } + + #if TRACK_ALLOCATIONS + Allocations.decrement("ImagePrefetcher") + #endif + } + + /// Starts prefetching images for the given URL. + /// + /// See also ``startPrefetching(with:)-718dg`` that works with ``ImageRequest``. + func startPrefetching(with urls: [URL]) { + startPrefetching(with: urls.map { ImageRequest(url: $0) }) + } + + /// Starts prefetching images for the given requests. + /// + /// When you need to display the same image later, use the ``ImagePipeline`` + /// or the view extensions to load it as usual. The pipeline will take care + /// of coalescing the requests to avoid any duplicate work. + /// + /// The priority of the requests is set to the priority of the prefetcher + /// (`.low` by default). + /// + /// 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) + } + } + } + + private func _startPrefetching(with request: ImageRequest) { + guard pipeline.cache[request] == nil else { + return // The image is already in memory cache + } + + let key = request.makeImageLoadKey() + guard tasks[key] == nil else { + return // Already started prefetching + } + + let task = Task(request: request, key: key) + task.operation = queue.add { [weak self] finish in + guard let self = self else { return finish() } + self.loadImage(task: task, finish: finish) + } + tasks[key] = task + } + + 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.onCancelled = finish + } + + private func _remove(_ task: Task) { + guard tasks[task.key] === task else { return } // Should never happen + tasks[task.key] = nil + if tasks.isEmpty { + didComplete?() + } + } + + /// Stops prefetching images for the given URLs and cancels outstanding + /// requests. + /// + /// See also ``stopPrefetching(with:)-8cdam`` that works with ``ImageRequest``. + func stopPrefetching(with urls: [URL]) { + stopPrefetching(with: urls.map { ImageRequest(url: $0) }) + } + + /// Stops prefetching images for the given requests and cancels outstanding + /// requests. + /// + /// You don't need to balance the number of `start` and `stop` requests. + /// If you have multiple screens with prefetching, create multiple instances + /// of ``ImagePrefetcher``. + /// + /// See also ``stopPrefetching(with:)-2tcyq`` that works with `URL`. + func stopPrefetching(with requests: [ImageRequest]) { + pipeline.queue.async { + for request in requests { + self._stopPrefetching(with: request) + } + } + } + + private func _stopPrefetching(with request: ImageRequest) { + if let task = tasks.removeValue(forKey: request.makeImageLoadKey()) { + task.cancel() + } + } + + /// Stops all prefetching tasks. + func stopPrefetching() { + pipeline.queue.async { + self.tasks.values.forEach { $0.cancel() } + self.tasks.removeAll() + } + } + + private func didUpdatePriority(to priority: ImageRequest.Priority) { + guard _priority != priority else { return } + _priority = priority + for task in tasks.values { + task.imageTask?.priority = priority + } + } + + private final class Task: @unchecked Sendable { + let key: ImageLoadKey + let request: ImageRequest + weak var imageTask: ImageTask? + weak var operation: Operation? + var onCancelled: (() -> Void)? + + init(request: ImageRequest, key: ImageLoadKey) { + self.request = request + self.key = key + } + + // When task is cancelled, it is removed from the prefetcher and can + // never get cancelled twice. + func cancel() { + operation?.cancel() + imageTask?.cancel() + onCancelled?() + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageDecompression.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageDecompression.swift new file mode 100644 index 00000000..d9db2014 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageDecompression.swift @@ -0,0 +1,28 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +enum ImageDecompression { + + static func decompress(image: PlatformImage, isUsingPrepareForDisplay: Bool = false) -> PlatformImage { + image.decompressed(isUsingPrepareForDisplay: isUsingPrepareForDisplay) ?? image + } + + // MARK: Managing Decompression State + + static var isDecompressionNeededAK = "ImageDecompressor.isDecompressionNeeded.AssociatedKey" + + static func setDecompressionNeeded(_ isDecompressionNeeded: Bool, for image: PlatformImage) { + withUnsafePointer(to: &isDecompressionNeededAK) { keyPointer in + objc_setAssociatedObject(image, keyPointer, isDecompressionNeeded, .OBJC_ASSOCIATION_RETAIN) + } + } + + static func isDecompressionNeeded(for image: PlatformImage) -> Bool? { + return withUnsafePointer(to: &isDecompressionNeededAK) { keyPointer in + objc_getAssociatedObject(image, keyPointer) as? Bool + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessing.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessing.swift new file mode 100644 index 00000000..ceb4b18a --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessing.swift @@ -0,0 +1,101 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Performs image processing. +/// +/// For basic processing needs, implement the following method: +/// +/// ```swift +/// func process(image: PlatformImage) -> PlatformImage? +/// ``` +/// +/// If your processor needs to manipulate image metadata (``ImageContainer``), or +/// get access to more information via the context (``ImageProcessingContext``), +/// there is an additional method that allows you to do that: +/// +/// ```swift +/// func process(image container: ImageContainer, context: ImageProcessingContext) -> ImageContainer? +/// ``` +/// +/// You must implement either one of those methods. +protocol ImageProcessing: Sendable { + /// Returns a processed image. By default, returns `nil`. + /// + /// - note: Gets called a background queue managed by the pipeline. + func process(_ image: PlatformImage) -> PlatformImage? + + /// Optional method. Returns a processed image. By default, this calls the + /// basic `process(image:)` method. + /// + /// - note: Gets called a background queue managed by the pipeline. + func process(_ container: ImageContainer, context: ImageProcessingContext) throws -> ImageContainer + + /// Returns a string that uniquely identifies the processor. + /// + /// Consider using the reverse DNS notation. + var identifier: String { get } + + /// Returns a unique processor identifier. + /// + /// The default implementation simply returns `var identifier: String` but + /// can be overridden as a performance optimization - creating and comparing + /// strings is _expensive_ so you can opt-in to return something which is + /// fast to create and to compare. See ``ImageProcessors/Resize`` for an example. + /// + /// - note: A common approach is to make your processor `Hashable` and return `self` + /// as a hashable identifier. + var hashableIdentifier: AnyHashable { get } +} + +extension ImageProcessing { + /// The default implementation simply calls the basic + /// `process(_ image: PlatformImage) -> PlatformImage?` method. + func process(_ container: ImageContainer, context: ImageProcessingContext) throws -> ImageContainer { + guard let output = process(container.image) else { + throw ImageProcessingError.unknown + } + var container = container + container.image = output + return container + } + + /// The default impleemntation simply returns `var identifier: String`. + var hashableIdentifier: AnyHashable { identifier } +} + +extension ImageProcessing where Self: Hashable { + var hashableIdentifier: AnyHashable { self } +} + +/// Image processing context used when selecting which processor to use. +struct ImageProcessingContext: Sendable { + var request: ImageRequest + var response: ImageResponse + var isCompleted: Bool + + init(request: ImageRequest, response: ImageResponse, isCompleted: Bool) { + self.request = request + self.response = response + self.isCompleted = isCompleted + } +} + +enum ImageProcessingError: Error, CustomStringConvertible, Sendable { + case unknown + + var description: String { "Unknown" } +} + +func == (lhs: [any ImageProcessing], rhs: [any ImageProcessing]) -> Bool { + guard lhs.count == rhs.count else { + return false + } + // Lazily creates `hashableIdentifiers` because for some processors the + // identifiers might be expensive to compute. + return zip(lhs, rhs).allSatisfy { + $0.hashableIdentifier == $1.hashableIdentifier + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessingOptions.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessingOptions.swift new file mode 100644 index 00000000..b71b6151 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessingOptions.swift @@ -0,0 +1,68 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if os(iOS) || os(tvOS) || os(watchOS) +import UIKit +#endif + +#if os(macOS) +import Cocoa +#endif + +/// A namespace with shared image processing options. +enum ImageProcessingOptions: Sendable { + + enum Unit: CustomStringConvertible, Sendable { + case points + case pixels + + var description: String { + switch self { + case .points: return "points" + case .pixels: return "pixels" + } + } + } + + /// Draws a border. + /// + /// - important: To make sure that the border looks the way you expect, + /// make sure that the images you display exactly match the size of the + /// views in which they get displayed. If you can't guarantee that, pleasee + /// consider adding border to a view layer. This should be your primary + /// option regardless. + struct Border: Hashable, CustomStringConvertible, @unchecked Sendable { + let width: CGFloat + + #if os(iOS) || os(tvOS) || os(watchOS) + let color: UIColor + + /// - parameters: + /// - color: Border color. + /// - width: Border width. + /// - unit: Unit of the width. + init(color: UIColor, width: CGFloat = 1, unit: Unit = .points) { + self.color = color + self.width = width.converted(to: unit) + } + #else + let color: NSColor + + /// - parameters: + /// - color: Border color. + /// - width: Border width. + /// - unit: Unit of the width. + init(color: NSColor, width: CGFloat = 1, unit: Unit = .points) { + self.color = color + self.width = width.converted(to: unit) + } + #endif + + var description: String { + "Border(color: \(color.hex), width: \(width) pixels)" + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Anonymous.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Anonymous.swift new file mode 100644 index 00000000..79f130ce --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Anonymous.swift @@ -0,0 +1,26 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImageProcessors { + /// Processed an image using a specified closure. + struct Anonymous: ImageProcessing, CustomStringConvertible { + let identifier: String + private let closure: @Sendable (PlatformImage) -> PlatformImage? + + init(id: String, _ closure: @Sendable @escaping (PlatformImage) -> PlatformImage?) { + self.identifier = id + self.closure = closure + } + + func process(_ image: PlatformImage) -> PlatformImage? { + closure(image) + } + + var description: String { + "AnonymousProcessor(identifier: \(identifier)" + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Circle.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Circle.swift new file mode 100644 index 00000000..738d0b22 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Circle.swift @@ -0,0 +1,32 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImageProcessors { + + /// Rounds the corners of an image into a circle. If the image is not a square, + /// crops it to a square first. + struct Circle: ImageProcessing, Hashable, CustomStringConvertible { + private let border: ImageProcessingOptions.Border? + + /// - parameter border: `nil` by default. + init(border: ImageProcessingOptions.Border? = nil) { + self.border = border + } + + func process(_ image: PlatformImage) -> PlatformImage? { + image.processed.byDrawingInCircle(border: border) + } + + var identifier: String { + let suffix = border.map { "?border=\($0)" } + return "com.github.kean/nuke/circle" + (suffix ?? "") + } + + var description: String { + "Circle(border: \(border?.description ?? "nil"))" + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Composition.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Composition.swift new file mode 100644 index 00000000..b2367638 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Composition.swift @@ -0,0 +1,61 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImageProcessors { + /// Composes multiple processors. + struct Composition: ImageProcessing, Hashable, CustomStringConvertible { + let processors: [any ImageProcessing] + + /// Composes multiple processors. + init(_ processors: [any ImageProcessing]) { + // note: multiple compositions are not flatten by default. + self.processors = processors + } + + /// Processes the given image by applying each processor in an order in + /// which they were added. If one of the processors fails to produce + /// an image the processing stops and `nil` is returned. + func process(_ image: PlatformImage) -> PlatformImage? { + processors.reduce(image) { image, processor in + autoreleasepool { + image.flatMap(processor.process) + } + } + } + + /// Processes the given image by applying each processor in an order in + /// which they were added. If one of the processors fails to produce + /// an image the processing stops and an error is thrown. + func process(_ container: ImageContainer, context: ImageProcessingContext) throws -> ImageContainer { + try processors.reduce(container) { container, processor in + try autoreleasepool { + try processor.process(container, context: context) + } + } + } + + /// Returns combined identifier of all the underlying processors. + var identifier: String { + processors.map({ $0.identifier }).joined() + } + + /// Creates a combined hash of all the given processors. + func hash(into hasher: inout Hasher) { + for processor in processors { + hasher.combine(processor.hashableIdentifier) + } + } + + /// Compares all the underlying processors for equality. + static func == (lhs: Composition, rhs: Composition) -> Bool { + lhs.processors == rhs.processors + } + + var description: String { + "Composition(processors: \(processors))" + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+CoreImage.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+CoreImage.swift new file mode 100644 index 00000000..d9fc8ac4 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+CoreImage.swift @@ -0,0 +1,113 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +#if os(iOS) || os(tvOS) || os(macOS) + +import Foundation +import CoreImage + +extension ImageProcessors { + + /// Applies Core Image filter (`CIFilter`) to the image. + /// + /// # Performance Considerations. + /// + /// Prefer chaining multiple `CIFilter` objects using `Core Image` facilities + /// instead of using multiple instances of `ImageProcessors.CoreImageFilter`. + /// + /// # References + /// + /// - [Core Image Programming Guide](https://developer.apple.com/library/ios/documentation/GraphicsImaging/Conceptual/CoreImaging/ci_intro/ci_intro.html) + /// - [Core Image Filter Reference](https://developer.apple.com/library/prerelease/ios/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html) + struct CoreImageFilter: ImageProcessing, CustomStringConvertible, @unchecked Sendable { + let name: String + let parameters: [String: Any] + let identifier: String + + /// - parameter identifier: Uniquely identifies the processor. + init(name: String, parameters: [String: Any], identifier: String) { + self.name = name + self.parameters = parameters + self.identifier = identifier + } + + init(name: String) { + self.name = name + self.parameters = [:] + self.identifier = "com.github.kean/nuke/core_image?name=\(name))" + } + + func process(_ image: PlatformImage) -> PlatformImage? { + try? _process(image) + } + + func process(_ container: ImageContainer, context: ImageProcessingContext) throws -> ImageContainer { + try container.map(_process(_:)) + } + + private func _process(_ image: PlatformImage) throws -> PlatformImage { + try CoreImageFilter.applyFilter(named: name, parameters: parameters, to: image) + } + + // MARK: - Apply Filter + + /// A default context shared between all Core Image filters. The context + /// has `.priorityRequestLow` option set to `true`. + static var context = CIContext(options: [.priorityRequestLow: true]) + + static func applyFilter(named name: String, parameters: [String: Any] = [:], to image: PlatformImage) throws -> PlatformImage { + guard let filter = CIFilter(name: name, parameters: parameters) else { + throw Error.failedToCreateFilter(name: name, parameters: parameters) + } + return try CoreImageFilter.apply(filter: filter, to: image) + } + + /// Applies filter to the given image. + static func apply(filter: CIFilter, to image: PlatformImage) throws -> PlatformImage { + func getCIImage() throws -> CoreImage.CIImage { + if let image = image.ciImage { + return image + } + if let image = image.cgImage { + return CoreImage.CIImage(cgImage: image) + } + throw Error.inputImageIsEmpty(inputImage: image) + } + filter.setValue(try getCIImage(), forKey: kCIInputImageKey) + guard let outputImage = filter.outputImage else { + throw Error.failedToApplyFilter(filter: filter) + } + guard let imageRef = context.createCGImage(outputImage, from: outputImage.extent) else { + throw Error.failedToCreateOutputCGImage(image: outputImage) + } + return PlatformImage.make(cgImage: imageRef, source: image) + } + + var description: String { + "CoreImageFilter(name: \(name), parameters: \(parameters))" + } + + enum Error: Swift.Error, CustomStringConvertible { + case failedToCreateFilter(name: String, parameters: [String: Any]) + case inputImageIsEmpty(inputImage: PlatformImage) + case failedToApplyFilter(filter: CIFilter) + case failedToCreateOutputCGImage(image: CIImage) + + var description: String { + switch self { + case let .failedToCreateFilter(name, parameters): + return "Failed to create filter named \(name) with parameters: \(parameters)" + case let .inputImageIsEmpty(inputImage): + return "Failed to create input CIImage for \(inputImage)" + case let .failedToApplyFilter(filter): + return "Failed to apply filter: \(filter.name)" + case let .failedToCreateOutputCGImage(image): + return "Failed to create output image for extent: \(image.extent) from \(image)" + } + } + } + } +} + +#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+GaussianBlur.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+GaussianBlur.swift new file mode 100644 index 00000000..6cc827c2 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+GaussianBlur.swift @@ -0,0 +1,46 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +#if os(iOS) || os(tvOS) || os(macOS) + +import Foundation +import CoreImage + +extension ImageProcessors { + /// Blurs an image using `CIGaussianBlur` filter. + struct GaussianBlur: ImageProcessing, Hashable, CustomStringConvertible { + private let radius: Int + + /// Initializes the receiver with a blur radius. + /// + /// - parameter radius: `8` by default. + init(radius: Int = 8) { + self.radius = radius + } + + /// Applies `CIGaussianBlur` filter to the image. + func process(_ image: PlatformImage) -> PlatformImage? { + try? _process(image) + } + + /// Applies `CIGaussianBlur` filter to the image. + func process(_ container: ImageContainer, context: ImageProcessingContext) throws -> ImageContainer { + try container.map(_process(_:)) + } + + private func _process(_ image: PlatformImage) throws -> PlatformImage { + try CoreImageFilter.applyFilter(named: "CIGaussianBlur", parameters: ["inputRadius": radius], to: image) + } + + var identifier: String { + "com.github.kean/nuke/gaussian_blur?radius=\(radius)" + } + + var description: String { + "GaussianBlur(radius: \(radius))" + } + } +} + +#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Resize.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Resize.swift new file mode 100644 index 00000000..9732578c --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Resize.swift @@ -0,0 +1,104 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation +import CoreGraphics + +extension ImageProcessors { + /// Scales an image to a specified size. + struct Resize: ImageProcessing, Hashable, CustomStringConvertible { + private let size: Size + private let contentMode: ContentMode + private let crop: Bool + private let upscale: Bool + + /// An option for how to resize the image. + enum ContentMode: CustomStringConvertible, 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" + } + } + } + + /// Initializes the processor with the given size. + /// + /// - parameters: + /// - size: The target size. + /// - unit: Unit of the target size. + /// - contentMode: A target content mode. + /// - crop: If `true` will crop the image to match the target size. + /// Does nothing with content mode .aspectFill. + /// - upscale: By default, upscaling is not allowed. + init(size: CGSize, unit: ImageProcessingOptions.Unit = .points, contentMode: ContentMode = .aspectFill, crop: Bool = false, upscale: Bool = false) { + self.size = Size(size: size, unit: unit) + self.contentMode = contentMode + self.crop = crop + self.upscale = upscale + } + + /// Scales an image to the given width preserving aspect ratio. + /// + /// - parameters: + /// - width: The target width. + /// - unit: Unit of the target size. + /// - upscale: `false` by default. + init(width: CGFloat, unit: ImageProcessingOptions.Unit = .points, upscale: Bool = false) { + self.init(size: CGSize(width: width, height: 9999), unit: unit, contentMode: .aspectFit, crop: false, upscale: upscale) + } + + /// Scales an image to the given height preserving aspect ratio. + /// + /// - parameters: + /// - height: The target height. + /// - unit: Unit of the target size. + /// - upscale: By default, upscaling is not allowed. + init(height: CGFloat, unit: ImageProcessingOptions.Unit = .points, upscale: Bool = false) { + self.init(size: CGSize(width: 9999, height: height), unit: unit, contentMode: .aspectFit, crop: false, upscale: upscale) + } + + func process(_ image: PlatformImage) -> PlatformImage? { + if crop && contentMode == .aspectFill { + return image.processed.byResizingAndCropping(to: size.cgSize) + } + return image.processed.byResizing(to: size.cgSize, contentMode: contentMode, upscale: upscale) + } + + var identifier: String { + "com.github.kean/nuke/resize?s=\(size.cgSize),cm=\(contentMode),crop=\(crop),upscale=\(upscale)" + } + + var description: String { + "Resize(size: \(size.cgSize) pixels, contentMode: \(contentMode), crop: \(crop), upscale: \(upscale))" + } + } +} + +// Adds Hashable without making changes to CGSize API +private struct Size: Hashable { + let cgSize: CGSize + + /// Creates the size in pixels by scaling to the input size to the screen scale + /// if needed. + init(size: CGSize, unit: ImageProcessingOptions.Unit) { + switch unit { + case .pixels: self.cgSize = size // The size is already in pixels + case .points: self.cgSize = size.scaled(by: Screen.scale) + } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(cgSize.width) + hasher.combine(cgSize.height) + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+RoundedCorners.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+RoundedCorners.swift new file mode 100644 index 00000000..d60ba88e --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+RoundedCorners.swift @@ -0,0 +1,41 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation +import CoreGraphics + +extension ImageProcessors { + /// Rounds the corners of an image to the specified radius. + /// + /// - important: In order for the corners to be displayed correctly, the image must exactly match the size + /// of the image view in which it will be displayed. See ``ImageProcessors/Resize`` for more info. + struct RoundedCorners: ImageProcessing, Hashable, CustomStringConvertible { + private let radius: CGFloat + private let border: ImageProcessingOptions.Border? + + /// Initializes the processor with the given radius. + /// + /// - parameters: + /// - radius: The radius of the corners. + /// - unit: Unit of the radius. + /// - border: An optional border drawn around the image. + init(radius: CGFloat, unit: ImageProcessingOptions.Unit = .points, border: ImageProcessingOptions.Border? = nil) { + self.radius = radius.converted(to: unit) + self.border = border + } + + func process(_ image: PlatformImage) -> PlatformImage? { + image.processed.byAddingRoundedCorners(radius: radius, border: border) + } + + var identifier: String { + let suffix = border.map { ",border=\($0)" } + return "com.github.kean/nuke/rounded_corners?radius=\(radius)" + (suffix ?? "") + } + + var description: String { + "RoundedCorners(radius: \(radius) pixels, border: \(border?.description ?? "nil"))" + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors.swift new file mode 100644 index 00000000..68bd86ef --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors.swift @@ -0,0 +1,115 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +#if os(iOS) || os(tvOS) || os(watchOS) +import UIKit +#endif + +#if os(macOS) +import Cocoa +#endif + +/// A namespace for all processors that implement ``ImageProcessing`` protocol. +enum ImageProcessors {} + +extension ImageProcessing where Self == ImageProcessors.Resize { + /// Scales an image to a specified size. + /// + /// - parameters + /// - size: The target size. + /// - unit: Unit of the target size. + /// - 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 { + ImageProcessors.Resize(size: size, unit: unit, contentMode: contentMode, crop: crop, upscale: upscale) + } + + /// Scales an image to the given width preserving aspect ratio. + /// + /// - parameters: + /// - width: The target width. + /// - unit: Unit of the target size. + /// - upscale: `false` by default. + static func resize(width: CGFloat, unit: ImageProcessingOptions.Unit = .points, upscale: Bool = false) -> ImageProcessors.Resize { + ImageProcessors.Resize(width: width, unit: unit, upscale: upscale) + } + + /// Scales an image to the given height preserving aspect ratio. + /// + /// - parameters: + /// - height: The target height. + /// - unit: Unit of the target size. + /// - upscale: `false` by default. + static func resize(height: CGFloat, unit: ImageProcessingOptions.Unit = .points, upscale: Bool = false) -> ImageProcessors.Resize { + ImageProcessors.Resize(height: height, unit: unit, upscale: upscale) + } +} + +extension ImageProcessing where Self == ImageProcessors.Circle { + /// Rounds the corners of an image into a circle. If the image is not a square, + /// crops it to a square first. + /// + /// - parameter border: `nil` by default. + static func circle(border: ImageProcessingOptions.Border? = nil) -> ImageProcessors.Circle { + ImageProcessors.Circle(border: border) + } +} + +extension ImageProcessing where Self == ImageProcessors.RoundedCorners { + /// Rounds the corners of an image to the specified radius. + /// + /// - parameters: + /// - radius: The radius of the corners. + /// - unit: Unit of the radius. + /// - border: An optional border drawn around the image. + /// + /// - important: In order for the corners to be displayed correctly, the image must exactly match the size + /// of the image view in which it will be displayed. See ``ImageProcessors/Resize`` for more info. + static func roundedCorners(radius: CGFloat, unit: ImageProcessingOptions.Unit = .points, border: ImageProcessingOptions.Border? = nil) -> ImageProcessors.RoundedCorners { + ImageProcessors.RoundedCorners(radius: radius, unit: unit, border: border) + } +} + +extension ImageProcessing where Self == ImageProcessors.Anonymous { + /// Creates a custom processor with a given closure. + /// + /// - parameters: + /// - id: Uniquely identifies the operation performed by the processor. + /// - closure: A closure that transforms the images. + static func process(id: String, _ closure: @Sendable @escaping (PlatformImage) -> PlatformImage?) -> ImageProcessors.Anonymous { + ImageProcessors.Anonymous(id: id, closure) + } +} + +#if os(iOS) || os(tvOS) || os(macOS) + +extension ImageProcessing where Self == ImageProcessors.CoreImageFilter { + /// Applies Core Image filter – `CIFilter` – to the image. + /// + /// - parameter identifier: Uniquely identifies the processor. + static func coreImageFilter(name: String, parameters: [String: Any], identifier: String) -> ImageProcessors.CoreImageFilter { + ImageProcessors.CoreImageFilter(name: name, parameters: parameters, identifier: identifier) + } + + /// Applies Core Image filter – `CIFilter` – to the image. + /// + static func coreImageFilter(name: String) -> ImageProcessors.CoreImageFilter { + ImageProcessors.CoreImageFilter(name: name) + } +} + +extension ImageProcessing where Self == ImageProcessors.GaussianBlur { + /// Blurs an image using `CIGaussianBlur` filter. + /// + /// - parameter radius: `8` by default. + static func gaussianBlur(radius: Int = 8) -> ImageProcessors.GaussianBlur { + ImageProcessors.GaussianBlur(radius: radius) + } +} + +#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/AsyncTask.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/AsyncTask.swift new file mode 100644 index 00000000..f4c555ea --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/AsyncTask.swift @@ -0,0 +1,379 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Represents a task with support for multiple observers, cancellation, +/// progress reporting, dependencies – everything that `ImagePipeline` needs. +/// +/// A `AsyncTask` can have zero or more subscriptions (`TaskSubscription`) which can +/// be used to later unsubscribe or change the priority of the subscription. +/// +/// The task has built-in support for operations (`Foundation.Operation`) – it +/// automatically cancels them, updates the priority, etc. Most steps in the +/// image pipeline are represented using Operation to take advantage of these features. +/// +/// - warning: Must be thread-confined! +class AsyncTask: AsyncTaskSubscriptionDelegate, @unchecked Sendable { + + private struct Subscription { + let closure: (Event) -> Void + weak var subscriber: AnyObject? + var priority: TaskPriority + } + + // In most situations, especially for intermediate tasks, the almost almost + // only one subscription. + private var inlineSubscription: Subscription? + private var subscriptions: [TaskSubscriptionKey: Subscription]? // Create lazily + private var nextSubscriptionKey = 0 + + var subscribers: [AnyObject] { + var output = [AnyObject?]() + output.append(inlineSubscription?.subscriber) + subscriptions?.values.forEach { output.append($0.subscriber) } + return output.compactMap { $0 } + } + + /// Returns `true` if the task was either cancelled, or was completed. + private(set) var isDisposed = false + private var isStarted = false + + /// Gets called when the task is either cancelled, or was completed. + var onDisposed: (() -> Void)? + + var onCancelled: (() -> Void)? + + var priority: TaskPriority = .normal { + didSet { + guard oldValue != priority else { return } + operation?.queuePriority = priority.queuePriority + dependency?.setPriority(priority) + dependency2?.setPriority(priority) + } + } + + /// A task might have a dependency. The task automatically unsubscribes + /// from the dependency when it gets cancelled, and also updates the + /// priority of the subscription to the dependency when its own + /// priority is updated. + var dependency: TaskSubscription? { + didSet { + dependency?.setPriority(priority) + } + } + + // 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 } + operation?.queuePriority = priority.queuePriority + } + } + + /// 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? { + guard !isDisposed else { return nil } + + let subscriptionKey = nextSubscriptionKey + nextSubscriptionKey += 1 + let subscription = TaskSubscription(task: self, key: subscriptionKey) + + if subscriptionKey == 0 { + inlineSubscription = Subscription(closure: closure, subscriber: subscriber, priority: priority) + } else { + if subscriptions == nil { subscriptions = [:] } + subscriptions![subscriptionKey] = Subscription(closure: closure, subscriber: subscriber, priority: priority) + } + + updatePriority(suggestedPriority: priority) + + if !isStarted { + isStarted = true + start() + } + + // The task may have been completed synchronously by `starter`. + guard !isDisposed else { return nil } + + return subscription + } + + // MARK: - TaskSubscriptionDelegate + + fileprivate func setPriority(_ priority: TaskPriority, for key: TaskSubscriptionKey) { + guard !isDisposed else { return } + + if key == 0 { + inlineSubscription?.priority = priority + } else { + subscriptions![key]?.priority = priority + } + updatePriority(suggestedPriority: priority) + } + + fileprivate func unsubsribe(key: TaskSubscriptionKey) { + if key == 0 { + guard inlineSubscription != nil else { return } + inlineSubscription = nil + } else { + guard subscriptions!.removeValue(forKey: key) != nil else { return } + } + + guard !isDisposed else { return } + + if inlineSubscription == nil && subscriptions?.isEmpty ?? true { + terminate(reason: .cancelled) + } else { + updatePriority(suggestedPriority: nil) + } + } + + // MARK: - Sending Events + + func send(value: Value, isCompleted: Bool = false) { + send(event: .value(value, isCompleted: isCompleted)) + } + + func send(error: Error) { + send(event: .error(error)) + } + + func send(progress: TaskProgress) { + send(event: .progress(progress)) + } + + private func send(event: Event) { + guard !isDisposed else { return } + + switch event { + case let .value(_, isCompleted): + if isCompleted { + terminate(reason: .finished) + } + case .progress: + break // Simply send the event + case .error: + terminate(reason: .finished) + } + + inlineSubscription?.closure(event) + if let subscriptions = subscriptions { + for subscription in subscriptions.values { + subscription.closure(event) + } + } + } + + // MARK: - Termination + + private enum TerminationReason { + case finished, cancelled + } + + private func terminate(reason: TerminationReason) { + guard !isDisposed else { return } + isDisposed = true + + if reason == .cancelled { + operation?.cancel() + dependency?.unsubscribe() + dependency2?.unsubscribe() + onCancelled?() + } + onDisposed?() + } + + // MARK: - Priority + + private func updatePriority(suggestedPriority: TaskPriority?) { + if let suggestedPriority = suggestedPriority, suggestedPriority >= priority { + // No need to recompute, won't go higher than that + priority = suggestedPriority + return + } + + var newPriority = inlineSubscription?.priority + // Same as subscriptions.map { $0?.priority }.max() but without allocating + // any memory for redundant arrays + if let subscriptions = subscriptions { + for subscription in subscriptions.values { + if newPriority == nil { + newPriority = subscription.priority + } else if subscription.priority > newPriority! { + newPriority = subscription.priority + } + } + } + self.priority = newPriority ?? .normal + } +} + +// MARK: - AsyncTask (Publisher) + +extension AsyncTask { + /// Publishes the results of the task. + struct Publisher { + fileprivate let task: AsyncTask + + /// Attaches the subscriber to the task. + /// - notes: Returns `nil` if the task is already disposed. + func subscribe(priority: TaskPriority = .normal, subscriber: AnyObject? = nil, _ closure: @escaping (Event) -> Void) -> TaskSubscription? { + task.subscribe(priority: priority, subscriber: subscriber, closure) + } + + /// Attaches the subscriber to the task. Automatically forwards progress + /// and error events to the given task. + /// - 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 } + switch event { + case let .value(value, isCompleted): + onValue(value, isCompleted) + case let .progress(progress): + task.send(progress: progress) + case let .error(error): + task.send(error: error) + } + } + } + } +} + +typealias TaskProgress = ImageTask.Progress // Using typealias for simplicity + +enum TaskPriority: Int, Comparable { + case veryLow = 0, low, normal, high, veryHigh + + var queuePriority: Operation.QueuePriority { + switch self { + case .veryLow: return .veryLow + case .low: return .low + case .normal: return .normal + case .high: return .high + case .veryHigh: return .veryHigh + } + } + + static func < (lhs: TaskPriority, rhs: TaskPriority) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +// MARK: - AsyncTask.Event { +extension AsyncTask { + enum Event { + case value(Value, isCompleted: Bool) + case progress(TaskProgress) + case error(Error) + + var isCompleted: Bool { + switch self { + case let .value(_, isCompleted): return isCompleted + case .progress: return false + case .error: return true + } + } + } +} + +extension AsyncTask.Event: Equatable where Value: Equatable, Error: Equatable {} + +// MARK: - TaskSubscription + +/// Represents a subscription to a task. The observer must retain a strong +/// reference to a subscription. +struct TaskSubscription: Sendable { + private let task: any AsyncTaskSubscriptionDelegate + private let key: TaskSubscriptionKey + + fileprivate init(task: any AsyncTaskSubscriptionDelegate, key: TaskSubscriptionKey) { + self.task = task + self.key = key + } + + /// Removes the subscription from the task. The observer won't receive any + /// more events from the task. + /// + /// If there are no more subscriptions attached to the task, the task gets + /// cancelled along with its dependencies. The cancelled task is + /// marked as disposed. + func unsubscribe() { + task.unsubsribe(key: key) + } + + /// Updates the priority of the subscription. The priority of the task is + /// calculated as the maximum priority out of all of its subscription. When + /// the priority of the task is updated, the priority of a dependency also is. + /// + /// - note: The priority also automatically gets updated when the subscription + /// is removed from the task. + func setPriority(_ priority: TaskPriority) { + task.setPriority(priority, for: key) + } +} + +private protocol AsyncTaskSubscriptionDelegate: AnyObject, Sendable { + func unsubsribe(key: TaskSubscriptionKey) + func setPriority(_ priority: TaskPriority, for observer: TaskSubscriptionKey) +} + +private typealias TaskSubscriptionKey = Int + +// MARK: - TaskPool + +/// Contains the tasks which haven't completed yet. +final class TaskPool { + private let isCoalescingEnabled: Bool + private var map = [Key: AsyncTask]() + + init(_ isCoalescingEnabled: Bool) { + self.isCoalescingEnabled = isCoalescingEnabled + } + + /// Creates a task with the given key. If there is an outstanding task with + /// the given key in the pool, the existing task is returned. Tasks are + /// automatically removed from the pool when they are disposed. + func publisherForKey(_ key: @autoclosure () -> Key, _ make: () -> AsyncTask) -> AsyncTask.Publisher { + guard isCoalescingEnabled else { + return make().publisher + } + let key = key() + if let task = map[key] { + return task.publisher + } + let task = make() + map[key] = task + task.onDisposed = { [weak self] in + self?.map[key] = nil + } + return task.publisher + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/ImagePipelineTask.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/ImagePipelineTask.swift new file mode 100644 index 00000000..1b776f78 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/ImagePipelineTask.swift @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 00000000..606f17c3 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/OperationTask.swift @@ -0,0 +1,35 @@ +// 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/TaskFetchDecodedImage.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchDecodedImage.swift new file mode 100644 index 00000000..82a36efc --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchDecodedImage.swift @@ -0,0 +1,84 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Receives data from ``TaskLoadImageData` and decodes it as it arrives. +final class TaskFetchDecodedImage: ImagePipelineTask { + private var decoder: (any ImageDecoding)? + + override func start() { + dependency = pipeline.makeTaskFetchOriginalImageData(for: request).subscribe(self) { [weak self] in + self?.didReceiveData($0.0, urlResponse: $0.1, isCompleted: $1) + } + } + + /// Receiving data from `OriginalDataTask`. + private func didReceiveData(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool) { + guard isCompleted || pipeline.configuration.isProgressiveDecodingEnabled else { + return + } + + if !isCompleted && operation != nil { + return // Back pressure - already decoding another progressive data chunk + } + + if isCompleted { + operation?.cancel() // Cancel any potential pending progressive decoding tasks + } + + let context = ImageDecodingContext(request: request, data: data, isCompleted: isCompleted, urlResponse: urlResponse, cacheType: nil) + guard let decoder = getDecoder(for: context) else { + if isCompleted { + send(error: .decoderNotRegistered(context: context)) + } else { + // Try again when more data is downloaded. + } + 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) + } + } + } + } + + private func didFinishDecoding(decoder: any ImageDecoding, context: ImageDecodingContext, result: Result) { + 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)) + } + } + } + + // 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 { + return decoder + } + let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) + self.decoder = decoder + return decoder + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImageData.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImageData.swift new file mode 100644 index 00000000..35518ef9 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImageData.swift @@ -0,0 +1,179 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 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?)> { + 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 { + // A malformed URL prevented a URL request from being initiated. + send(error: .dataLoadingFailed(error: URLError(.badURL))) + 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 { + return false + } + self.loadData(urlRequest: urlRequest) + return true + } + } else { // Start loading immediately. + loadData(urlRequest: urlRequest) + } + } + + private func loadData(urlRequest: URLRequest) { + if request.options.contains(.skipDataLoadingQueue) { + loadData(urlRequest: urlRequest, finish: { /* do nothing */ }) + } else { + // Wrap data request in an operation to limit the maximum number of + // concurrent data tasks. + operation = pipeline.configuration.dataLoadingQueue.add { [weak self] finish in + guard let self = self else { + return finish() + } + self.async { + self.loadData(urlRequest: urlRequest, finish: finish) + } + } + } + } + + // This methods gets called inside data loading operation (Operation). + private func loadData(urlRequest: URLRequest, finish: @escaping () -> Void) { + guard !isDisposed else { + return finish() + } + // Read and remove resumable data from cache (we're going to insert it + // back in the cache if the request fails to complete again). + var urlRequest = urlRequest + if pipeline.configuration.isResumableDataEnabled, + let resumableData = ResumableDataStorage.shared.removeResumableData(for: request, pipeline: pipeline) { + // Update headers to add "Range" and "If-Range" headers + resumableData.resume(request: &urlRequest) + // Save resumable data to be used later (before using it, the pipeline + // verifies that the server returns "206 Partial Content") + self.resumableData = resumableData + } + + signpost(self, "LoadImageData", .begin, "URL: \(urlRequest.url?.absoluteString ?? ""), resumable data: \(Formatter.bytes(resumableData?.data.count ?? 0))") + + let dataLoader = pipeline.delegate.dataLoader(for: request, pipeline: pipeline) + let dataTask = dataLoader.loadData(with: urlRequest, didReceiveData: { [weak self] data, response in + guard let self = self else { return } + self.async { + self.dataTask(didReceiveData: data, response: response) + } + }, completion: { [weak self] error in + finish() // Finish the operation! + guard let self = self else { return } + signpost(self, "LoadImageData", .end, "Finished with size \(Formatter.bytes(self.data.count))") + self.async { + self.dataTaskDidFinish(error: error) + } + }) + + onCancelled = { [weak self] in + guard let self = self else { return } + + signpost(self, "LoadImageData", .end, "Cancelled") + dataTask.cancel() + finish() // Finish the operation! + + self.tryToSaveResumableData() + } + } + + private func dataTask(didReceiveData chunk: Data, response: 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) { + data = resumableData.data + resumedDataCount = Int64(resumableData.data.count) + signpost(self, "LoadImageData", .event, "Resumed with data \(Formatter.bytes(resumedDataCount))") + } + resumableData = nil // Get rid of resumable data + } + + // Append data and save response + data.append(chunk) + urlResponse = response + + let progress = TaskProgress(completed: Int64(data.count), total: response.expectedContentLength + resumedDataCount) + send(progress: progress) + + // If the image hasn't been fully loaded yet, give decoder a change + // to decode the data chunk. In case `expectedContentLength` is `0`, + // progressive decoding doesn't run. + guard data.count < response.expectedContentLength else { return } + + send(value: (data, response)) + } + + private func dataTaskDidFinish(error: Swift.Error?) { + if let error = error { + tryToSaveResumableData() + send(error: .dataLoadingFailed(error: error)) + return + } + + // Sanity check, should never happen in practice + guard !data.isEmpty else { + send(error: .dataIsEmpty) + return + } + + // Store in data cache + storeDataInCacheIfNeeded(data) + + send(value: (data, urlResponse), isCompleted: true) + } + + private func tryToSaveResumableData() { + // Try to save resumable data in case the task was cancelled + // (`URLError.cancelled`) or failed to complete with other error. + if pipeline.configuration.isResumableDataEnabled, + let response = urlResponse, !data.isEmpty, + let resumableData = ResumableData(response: response, data: data) { + ResumableDataStorage.shared.storeResumableData(resumableData, for: request, pipeline: pipeline) + } + } +} + +extension ImagePipelineTask where Value == (Data, URLResponse?) { + func storeDataInCacheIfNeeded(_ data: Data) { + guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), shouldStoreDataInDiskCache() else { + return + } + let key = pipeline.cache.makeDataCacheKey(for: request) + pipeline.delegate.willCache(data: data, image: nil, for: request, pipeline: pipeline) { + guard let data = $0 else { return } + // Important! Storing directly ignoring `ImageRequest.Options`. + dataCache.storeData(data, for: key) + } + } + + private func shouldStoreDataInDiskCache() -> Bool { + guard (request.url?.isCacheable ?? false) || (request.publisher != nil) else { + return false + } + let policy = pipeline.configuration.dataCachePolicy + guard imageTasks.contains(where: { !$0.request.options.contains(.disableDiskCacheWrites) }) else { + return false + } + return policy == .storeOriginalData || policy == .storeAll || (policy == .automatic && imageTasks.contains { $0.request.processors.isEmpty }) + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchWithPublisher.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchWithPublisher.swift new file mode 100644 index 00000000..3fe422a6 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchWithPublisher.swift @@ -0,0 +1,72 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Fetches data using the publisher provided with the request. +/// Unlike `TaskFetchOriginalImageData`, there is no resumable data involved. +final class TaskFetchWithPublisher: ImagePipelineTask<(Data, URLResponse?)> { + private lazy var data = Data() + + override func start() { + if request.options.contains(.skipDataLoadingQueue) { + loadData(finish: { /* do nothing */ }) + } else { + // Wrap data request in an operation to limit the maximum number of + // concurrent data tasks. + operation = pipeline.configuration.dataLoadingQueue.add { [weak self] finish in + guard let self = self else { + return finish() + } + self.async { + self.loadData { finish() } + } + } + } + } + + // This methods gets called inside data loading operation (Operation). + private func loadData(finish: @escaping () -> Void) { + guard !isDisposed else { + return finish() + } + + guard let publisher = request.publisher else { + send(error: .dataLoadingFailed(error: URLError(.unknown))) // This is just a placeholder error, never thrown + return assertionFailure("This should never happen") + } + + let cancellable = publisher.sink(receiveCompletion: { [weak self] result in + finish() // Finish the operation! + guard let self = self else { return } + self.async { + self.dataTaskDidFinish(result) + } + }, receiveValue: { [weak self] data in + guard let self = self else { return } + self.async { + self.data.append(data) + } + }) + + onCancelled = { + finish() + cancellable.cancel() + } + } + + private func dataTaskDidFinish(_ result: PublisherCompletion) { + switch result { + case .finished: + guard !data.isEmpty else { + send(error: .dataIsEmpty) + return + } + storeDataInCacheIfNeeded(data) + send(value: (data, nil), isCompleted: true) + case .failure(let error): + send(error: .dataLoadingFailed(error: error)) + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadData.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadData.swift new file mode 100644 index 00000000..5b849f88 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadData.swift @@ -0,0 +1,47 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Wrapper for tasks created by `loadData` calls. +final class TaskLoadData: ImagePipelineTask<(Data, URLResponse?)> { + override func start() { + guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), + !request.options.contains(.disableDiskCacheReads) else { + loadData() + return + } + operation = pipeline.configuration.dataCachingQueue.add { [weak self] in + self?.getCachedData(dataCache: dataCache) + } + } + + private func getCachedData(dataCache: 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() + } + } + } + + private func loadData() { + 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 + self?.didReceiveData($0.0, urlResponse: $0.1, isCompleted: $1) + } + } + + private func didReceiveData(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool) { + send(value: (data, urlResponse), isCompleted: isCompleted) + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadImage.swift b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadImage.swift new file mode 100644 index 00000000..d4fa9a59 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadImage.swift @@ -0,0 +1,264 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 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 +/// scenarios in which coalescing can kick in). +final class TaskLoadImage: ImagePipelineTask { + override func start() { + // Memory cache lookup + if let image = pipeline.cache[request] { + let response = ImageResponse(container: image, request: request, cacheType: .memory) + send(value: response, isCompleted: !image.isPreview) + if !image.isPreview { + return // Already got the result! + } + } + + // Disk cache lookup + if let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), + !request.options.contains(.disableDiskCacheReads) { + operation = pipeline.configuration.dataCachingQueue.add { [weak self] in + self?.getCachedData(dataCache: dataCache) + } + return + } + + // Fetch image + fetchImage() + } + + // MARK: Disk Cache Lookup + + private func getCachedData(dataCache: any DataCaching) { + let data = signpost("ReadCachedProcessedImageData") { + pipeline.cache.cachedData(for: request) + } + async { + if let data = data { + self.didReceiveCachedData(data) + } else { + self.fetchImage() + } + } + } + + private func didReceiveCachedData(_ data: Data) { + guard !isDisposed else { return } + + let context = ImageDecodingContext(request: request, data: data, isCompleted: true, urlResponse: nil, 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() + } + + @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) + } + } + } + } + + private func didDecodeCachedData(_ response: ImageResponse?) { + if let response = response { + decompressImage(response, isCompleted: true, isFromDiskCache: true) + } else { + fetchImage() + } + } + + // MARK: Fetch Image + + private func fetchImage() { + // Memory cache lookup for intermediate images. + // For example, for processors ["p1", "p2"], check only ["p1"]. + // Then apply the remaining processors. + // + // We are not performing data cache lookup for intermediate requests + // for now (because it's not free), but maybe adding an option would be worth it. + // You can emulate this behavior by manually creating intermediate requests. + if request.processors.count > 1 { + var processors = request.processors + var remaining: [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) + } + } + } + + 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) + } + } else { + let request = self.request.withProcessors([]) + dependency = pipeline.makeTaskLoadImage(for: request).subscribe(self) { [weak self] in + self?.process($0, isCompleted: $1, processors: processors) + } + } + } + + // MARK: Processing + + /// - parameter processors: Remaining processors to by applied + private func process(_ response: ImageResponse, isCompleted: Bool, processors: [any ImageProcessing]) { + 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 + } + + 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) } + } + }).subscribe(priority: priority) { [weak self] event in + guard let self = self else { return } + if event.isCompleted { + self.dependency2 = nil + } + switch event { + case .value(let response, _): + self._process(response, isCompleted: isCompleted, processors: processors.dropLast()) + case .error(let error): + if isCompleted { + self.send(error: .processingFailed(processor: processor, context: context, error: error)) + } + case .progress: + break // Do nothing (Not reported by OperationTask) + } + } + } + + // MARK: Decompression + + private func decompressImage(_ response: ImageResponse, isCompleted: Bool, isFromDiskCache: Bool = false) { + guard isDecompressionNeeded(for: response) else { + storeImageInCaches(response, isFromDiskCache: isFromDiskCache) + send(value: response, isCompleted: isCompleted) + return + } + + if isCompleted { + operation?.cancel() // Cancel any potential pending progressive decompression tasks + } else if operation != nil { + return // Back-pressure: we are receiving data too fast + } + + guard !isDisposed else { return } + + operation = pipeline.configuration.imageDecompressingQueue.add { [weak self] in + guard let self = self else { return } + + let response = signpost("DecompressImage", isCompleted ? "FinalImage" : "ProgressiveImage") { + 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) + } + } + } + + private func isDecompressionNeeded(for response: ImageResponse) -> Bool { + (ImageDecompression.isDecompressionNeeded(for: response.image) ?? false) && + !request.options.contains(.skipDecompression) && + pipeline.delegate.shouldDecompress(response: response, for: request, pipeline: pipeline) + } + + // MARK: Caching + + private func storeImageInCaches(_ response: ImageResponse, isFromDiskCache: Bool) { + guard subscribers.contains(where: { $0 is ImageTask }) else { + return // Only store for direct requests + } + // Memory cache (ImageCaching) + pipeline.cache[request] = response.container + // Disk cache (DataCaching) + if !isFromDiskCache { + storeImageInDataCache(response) + } + } + + private func storeImageInDataCache(_ response: ImageResponse) { + guard !response.container.isPreview else { + return + } + guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), shouldStoreFinalImageInDiskCache() else { + return + } + let context = ImageEncodingContext(request: request, image: response.image, urlResponse: response.urlResponse) + let encoder = pipeline.delegate.imageEncoder(for: context, pipeline: pipeline) + let key = pipeline.cache.makeDataCacheKey(for: request) + pipeline.configuration.imageEncodingQueue.addOperation { [weak pipeline, request] in + guard let pipeline = pipeline else { return } + let encodedData = signpost("EncodeImage") { + encoder.encode(response.container, context: context) + } + guard let data = encodedData else { return } + pipeline.delegate.willCache(data: data, image: response.container, for: request, pipeline: pipeline) { + guard let data = $0 else { return } + // Important! Storing directly ignoring `ImageRequest.Options`. + dataCache.storeData(data, for: key) // This is instant, writes are async + } + } + if pipeline.configuration.debugIsSyncImageEncoding { // Only for debug + pipeline.configuration.imageEncodingQueue.waitUntilAllOperationsAreFinished() + } + } + + private func shouldStoreFinalImageInDiskCache() -> Bool { + guard request.url?.isCacheable ?? false else { + return false + } + let policy = pipeline.configuration.dataCachePolicy + return ((policy == .automatic || policy == .storeAll) && !request.processors.isEmpty) || policy == .storeEncodedImages + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageLoadingOptions.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageLoadingOptions.swift new file mode 100644 index 00000000..4baf1199 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageLoadingOptions.swift @@ -0,0 +1,228 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + + +#if !os(macOS) +import UIKit.UIImage +import UIKit.UIColor +#else +import AppKit.NSImage +#endif + +/// A set of options that control how the image is loaded and displayed. +struct ImageLoadingOptions { + /// Shared options. + static var shared = ImageLoadingOptions() + + /// Placeholder to be displayed when the image is loading. `nil` by default. + var placeholder: PlatformImage? + + /// Image to be displayed when the request fails. `nil` by default. + var failureImage: PlatformImage? + + #if os(iOS) || os(tvOS) || os(macOS) + + /// The image transition animation performed when displaying a loaded image. + /// Only runs when the image was not found in memory cache. `nil` by default. + var transition: Transition? + + /// The image transition animation performed when displaying a failure image. + /// `nil` by default. + var failureImageTransition: Transition? + + /// If true, the requested image will always appear with transition, even + /// when loaded from cache. + var alwaysTransition = false + + func transition(for response: ResponseType) -> Transition? { + switch response { + case .success: return transition + case .failure: return failureImageTransition + case .placeholder: return nil + } + } + + #endif + + /// If true, every time you request a new image for a view, the view will be + /// automatically prepared for reuse: image will be set to `nil`, and animations + /// will be removed. `true` by default. + var isPrepareForReuseEnabled = true + + /// If `true`, every progressively generated preview produced by the pipeline + /// is going to be displayed. `true` by default. + /// + /// - note: To enable progressive decoding, see `ImagePipeline.Configuration`, + /// `isProgressiveDecodingEnabled` option. + var isProgressiveRenderingEnabled = true + + /// Custom pipeline to be used. `nil` by default. + var pipeline: ImagePipeline? + + /// Image processors to be applied unless the processors are provided in the + /// request. `[]` by default. + var processors: [any ImageProcessing] = [] + + #if os(iOS) || os(tvOS) + + /// Content modes to be used for each image type (placeholder, success, + /// failure). `nil` by default (don't change content mode). + var contentModes: ContentModes? + + /// Custom content modes to be used for each image type (placeholder, success, + /// failure). + struct ContentModes { + /// Content mode to be used for the loaded image. + var success: UIView.ContentMode + /// Content mode to be used when displaying a `failureImage`. + var failure: UIView.ContentMode + /// Content mode to be used when displaying a `placeholder`. + var placeholder: UIView.ContentMode + + /// - parameters: + /// - success: A content mode to be used with a loaded image. + /// - failure: A content mode to be used with a `failureImage`. + /// - placeholder: A content mode to be used with a `placeholder`. + init(success: UIView.ContentMode, failure: UIView.ContentMode, placeholder: UIView.ContentMode) { + self.success = success; self.failure = failure; self.placeholder = placeholder + } + } + + func contentMode(for response: ResponseType) -> UIView.ContentMode? { + switch response { + case .success: return contentModes?.success + case .placeholder: return contentModes?.placeholder + case .failure: return contentModes?.failure + } + } + + /// Tint colors to be used for each image type (placeholder, success, + /// failure). `nil` by default (don't change tint color or rendering mode). + var tintColors: TintColors? + + /// Custom tint color to be used for each image type (placeholder, success, + /// failure). + struct TintColors { + /// Tint color to be used for the loaded image. + var success: UIColor? + /// Tint color to be used when displaying a `failureImage`. + var failure: UIColor? + /// Tint color to be used when displaying a `placeholder`. + var placeholder: UIColor? + + /// - parameters: + /// - success: A tint color to be used with a loaded image. + /// - failure: A tint color to be used with a `failureImage`. + /// - placeholder: A tint color to be used with a `placeholder`. + init(success: UIColor?, failure: UIColor?, placeholder: UIColor?) { + self.success = success; self.failure = failure; self.placeholder = placeholder + } + } + + func tintColor(for response: ResponseType) -> UIColor? { + switch response { + case .success: return tintColors?.success + case .placeholder: return tintColors?.placeholder + case .failure: return tintColors?.failure + } + } + + #endif + + #if os(iOS) || os(tvOS) + + /// - parameters: + /// - placeholder: Placeholder to be displayed when the image is loading. + /// - transition: The image transition animation performed when + /// displaying a loaded image. Only runs when the image was not found in + /// memory cache. + /// - failureImage: Image to be displayed when request fails. + /// - failureImageTransition: The image transition animation + /// performed when displaying a failure image. + /// - contentModes: Content modes to be used for each image type + /// (placeholder, success, failure). + init(placeholder: UIImage? = nil, transition: Transition? = nil, failureImage: UIImage? = nil, failureImageTransition: Transition? = nil, contentModes: ContentModes? = nil, tintColors: TintColors? = nil) { + self.placeholder = placeholder + self.transition = transition + self.failureImage = failureImage + self.failureImageTransition = failureImageTransition + self.contentModes = contentModes + self.tintColors = tintColors + } + + #elseif os(macOS) + + init(placeholder: NSImage? = nil, transition: Transition? = nil, failureImage: NSImage? = nil, failureImageTransition: Transition? = nil) { + self.placeholder = placeholder + self.transition = transition + self.failureImage = failureImage + self.failureImageTransition = failureImageTransition + } + + #elseif os(watchOS) + + init(placeholder: UIImage? = nil, failureImage: UIImage? = nil) { + self.placeholder = placeholder + self.failureImage = failureImage + } + + #endif + + /// An animated image transition. + struct Transition { + var style: Style + + #if os(iOS) || os(tvOS) + enum Style { // internal representation + case fadeIn(parameters: Parameters) + case custom((ImageDisplayingView, UIImage) -> Void) + } + + struct Parameters { // internal representation + let duration: TimeInterval + let options: UIView.AnimationOptions + } + + /// Fade-in transition (cross-fade in case the image view is already + /// displaying an image). + static func fadeIn(duration: TimeInterval, options: UIView.AnimationOptions = .allowUserInteraction) -> Transition { + Transition(style: .fadeIn(parameters: Parameters(duration: duration, options: options))) + } + + /// Custom transition. Only runs when the image was not found in memory cache. + static func custom(_ closure: @escaping (ImageDisplayingView, UIImage) -> Void) -> Transition { + Transition(style: .custom(closure)) + } + #elseif os(macOS) + enum Style { // internal representation + case fadeIn(parameters: Parameters) + case custom((ImageDisplayingView, NSImage) -> Void) + } + + struct Parameters { // internal representation + let duration: TimeInterval + } + + /// Fade-in transition. + static func fadeIn(duration: TimeInterval) -> Transition { + Transition(style: .fadeIn(parameters: Parameters(duration: duration))) + } + + /// Custom transition. Only runs when the image was not found in memory cache. + static func custom(_ closure: @escaping (ImageDisplayingView, NSImage) -> Void) -> Transition { + Transition(style: .custom(closure)) + } + #else + enum Style {} + #endif + } + + init() {} + + enum ResponseType { + case success, failure, placeholder + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift new file mode 100644 index 00000000..2e6aa4d8 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift @@ -0,0 +1,409 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation + + +#if !os(macOS) +import UIKit.UIImage +import UIKit.UIColor +#else +import AppKit.NSImage +#endif + +#if os(iOS) || os(tvOS) || os(macOS) + +/// Displays images. Add the conformance to this protocol to your views to make +/// them compatible with Nuke image loading extensions. +/// +/// The protocol is defined as `@objc` to make it possible to override its +/// methods in extensions (e.g. you can override `nuke_display(image:data:)` in +/// `UIImageView` subclass like `Gifu.ImageView). +/// +/// The protocol and its methods have prefixes to make sure they don't clash +/// with other similar methods and protocol in Objective-C runtime. +@MainActor +@objc protocol Nuke_ImageDisplaying { + /// Display a given image. + @objc func nuke_display(image: PlatformImage?, data: Data?) + + #if os(macOS) + @objc var layer: CALayer? { get } + #endif +} + +extension Nuke_ImageDisplaying { + func display(_ container: ImageContainer) { + nuke_display(image: container.image, data: container.data) + } +} + +#if os(macOS) +extension Nuke_ImageDisplaying { + var layer: CALayer? { nil } +} +#endif + +#if os(iOS) || os(tvOS) +import UIKit +/// A `UIView` that implements `ImageDisplaying` protocol. +typealias ImageDisplayingView = UIView & Nuke_ImageDisplaying + +extension UIImageView: Nuke_ImageDisplaying { + /// Displays an image. + func nuke_display(image: UIImage?, data: Data? = nil) { + self.image = image + } +} +#elseif os(macOS) +import Cocoa +/// An `NSObject` that implements `ImageDisplaying` and `Animating` protocols. +/// Can support `NSView` and `NSCell`. The latter can return nil for layer. +typealias ImageDisplayingView = NSObject & Nuke_ImageDisplaying + +extension NSImageView: Nuke_ImageDisplaying { + /// Displays an image. + func nuke_display(image: NSImage?, data: Data? = nil) { + self.image = image + } +} +#endif + +#if os(tvOS) +import TVUIKit + +extension TVPosterView: Nuke_ImageDisplaying { + /// Displays an image. + func nuke_display(image: UIImage?, data: Data? = nil) { + self.image = image + } +} +#endif + +// MARK: - ImageView Extensions + +/// Loads an image with the given request and displays it in the view. +/// +/// See the complete method signature for more information. +@MainActor +@discardableResult func loadImage( + with request: (any ImageRequestConvertible)?, + options: ImageLoadingOptions = ImageLoadingOptions.shared, + into view: ImageDisplayingView, + completion: @escaping (_ result: Result) -> Void +) -> ImageTask? { + loadImage(with: request, options: options, into: view, progress: nil, completion: completion) +} + +/// Loads an image with the given request and displays it in the view. +/// +/// Before loading a new image, the view is prepared for reuse by canceling any +/// outstanding requests and removing a previously displayed image. +/// +/// If the image is stored in the memory cache, it is displayed immediately with +/// no animations. If not, the image is loaded using an image pipeline. When the +/// image is loading, the `placeholder` is displayed. When the request +/// completes the loaded image is displayed (or `failureImage` in case of an error) +/// with the selected animation. +/// +/// - 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: (any ImageRequestConvertible)?, + options: ImageLoadingOptions = ImageLoadingOptions.shared, + 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) +} + +/// Cancels an outstanding request associated with the view. +@MainActor +func cancelRequest(for view: ImageDisplayingView) { + ImageViewController.controller(for: view).cancelOutstandingTask() +} + +// MARK: - ImageViewController + +/// Manages image requests on behalf of an image view. +/// +/// - note: With a few modifications this might become at some point, +/// however as it stands today `ImageViewController` is just a helper class, +/// making it wouldn't expose any additional functionality to the users. +@MainActor +private final class ImageViewController { + private weak var imageView: ImageDisplayingView? + private var task: ImageTask? + private var options: ImageLoadingOptions + + #if os(iOS) || os(tvOS) + // Image view used for cross-fade transition between images with different + // content modes. + private lazy var transitionImageView = UIImageView() + #endif + + // Automatically cancel the request when the view is deallocated. + deinit { + task?.cancel() + } + + init(view: /* weak */ ImageDisplayingView) { + self.imageView = view + self.options = .shared + } + + // MARK: - Associating Controller + + static var controllerAK = "ImageViewController.AssociatedKey" + + // Lazily create a controller for a given view and associate it with a view. + static func controller(for view: ImageDisplayingView) -> ImageViewController { + if let controller = withUnsafePointer(to: &ImageViewController.controllerAK, { keyPointer in + objc_getAssociatedObject(view, keyPointer) as? ImageViewController + }) { + return controller + } + + let controller = ImageViewController(view: view) + withUnsafePointer(to: &ImageViewController.controllerAK) { keyPointer in + objc_setAssociatedObject(view, keyPointer, controller, .OBJC_ASSOCIATION_RETAIN) + } + return controller + } + + // MARK: - Loading Images + + func loadImage( + with request: ImageRequest?, + options: ImageLoadingOptions, + progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? = nil, + completion: ((_ result: Result) -> Void)? = nil + ) -> ImageTask? { + cancelOutstandingTask() + + guard let imageView = imageView else { + return nil + } + + self.options = options + + if options.isPrepareForReuseEnabled { // enabled by default + #if os(iOS) || os(tvOS) + imageView.layer.removeAllAnimations() + #elseif os(macOS) + let layer = (imageView as? NSView)?.layer ?? imageView.layer + layer?.removeAllAnimations() + #endif + } + + // Handle a scenario where request is `nil` (in the same way as a failure) + guard var request = request else { + if options.isPrepareForReuseEnabled { + imageView.nuke_display(image: nil, data: nil) + } + let result: Result = .failure(.imageRequestMissing) + handle(result: result, isFromMemory: true) + completion?(result) + return nil + } + + let pipeline = options.pipeline ?? ImagePipeline.shared + if !options.processors.isEmpty && request.processors.isEmpty { + request.processors = options.processors + } + + // Quick synchronous memory cache lookup. + if let image = pipeline.cache[request] { + display(image, true, .success) + if !image.isPreview { // Final image was downloaded + completion?(.success(ImageResponse(container: image, request: request, cacheType: .memory))) + return nil // No task to perform + } + } + + // Display a placeholder. + if let placeholder = options.placeholder { + display(ImageContainer(image: placeholder), true, .placeholder) + } else if options.isPrepareForReuseEnabled { + imageView.nuke_display(image: nil, data: nil) // Remove previously displayed images (if any) + } + + task = pipeline.loadImage(with: request, queue: .main, progress: { [weak self] response, completedCount, totalCount in + if let response = response, options.isProgressiveRenderingEnabled { + self?.handle(partialImage: response) + } + progress?(response, completedCount, totalCount) + }, completion: { [weak self] result in + self?.handle(result: result, isFromMemory: false) + completion?(result) + }) + return task + } + + func cancelOutstandingTask() { + task?.cancel() // The pipeline guarantees no callbacks to be deliver after cancellation + task = nil + } + + // MARK: - Handling Responses + + private func handle(result: Result, isFromMemory: Bool) { + switch result { + case let .success(response): + display(response.container, isFromMemory, .success) + case .failure: + if let failureImage = options.failureImage { + display(ImageContainer(image: failureImage), isFromMemory, .failure) + } + } + self.task = nil + } + + private func handle(partialImage response: ImageResponse) { + display(response.container, false, .success) + } + + #if os(iOS) || os(tvOS) || os(macOS) + + private func display(_ image: ImageContainer, _ isFromMemory: Bool, _ response: ImageLoadingOptions.ResponseType) { + guard let imageView = imageView else { + return + } + + var image = image + + #if os(iOS) || os(tvOS) + if let tintColor = options.tintColor(for: response) { + image.image = image.image.withRenderingMode(.alwaysTemplate) + imageView.tintColor = tintColor + } + #endif + + if !isFromMemory || options.alwaysTransition, let transition = options.transition(for: response) { + switch transition.style { + case let .fadeIn(params): + runFadeInTransition(image: image, params: params, response: response) + case let .custom(closure): + // The user is responsible for both displaying an image and performing + // animations. + closure(imageView, image.image) + } + } else { + imageView.display(image) + } + + #if os(iOS) || os(tvOS) + if let contentMode = options.contentMode(for: response) { + imageView.contentMode = contentMode + } + #endif + } + + #elseif os(watchOS) + + private func display(_ image: ImageContainer, _ isFromMemory: Bool, _ response: ImageLoadingOptions.ResponseType) { + imageView?.display(image) + } + + #endif +} + +// MARK: - ImageViewController (Transitions) + +extension ImageViewController { + #if os(iOS) || os(tvOS) + + private func runFadeInTransition(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters, response: ImageLoadingOptions.ResponseType) { + guard let imageView = imageView else { + return + } + + // Special case where it animates between content modes, only works + // on imageView subclasses. + if let contentMode = options.contentMode(for: response), imageView.contentMode != contentMode, let imageView = imageView as? UIImageView, imageView.image != nil { + runCrossDissolveWithContentMode(imageView: imageView, image: image, params: params) + } else { + runSimpleFadeIn(image: image, params: params) + } + } + + private func runSimpleFadeIn(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters) { + guard let imageView = imageView else { + return + } + + UIView.transition( + with: imageView, + duration: params.duration, + options: params.options.union(.transitionCrossDissolve), + animations: { + imageView.nuke_display(image: image.image, data: image.data) + }, + completion: nil + ) + } + + /// Performs cross-dissolve animation alonside transition to a new content + /// mode. This isn't natively supported feature and it requires a second + /// image view. There might be better ways to implement it. + private func runCrossDissolveWithContentMode(imageView: UIImageView, image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters) { + // Lazily create a transition view. + let transitionView = self.transitionImageView + + // Create a transition view which mimics current view's contents. + transitionView.image = imageView.image + transitionView.contentMode = imageView.contentMode + imageView.addSubview(transitionView) + transitionView.frame = imageView.bounds + + // "Manual" cross-fade. + transitionView.alpha = 1 + imageView.alpha = 0 + imageView.display(image) // Display new image in current view + + UIView.animate( + withDuration: params.duration, + delay: 0, + options: params.options, + animations: { + transitionView.alpha = 0 + imageView.alpha = 1 + }, + completion: { isCompleted in + if isCompleted { + transitionView.removeFromSuperview() + } + } + ) + } + + #elseif os(macOS) + + private func runFadeInTransition(image: ImageContainer, params: ImageLoadingOptions.Transition.Parameters, response: ImageLoadingOptions.ResponseType) { + let animation = CABasicAnimation(keyPath: "opacity") + animation.duration = params.duration + animation.fromValue = 0 + animation.toValue = 1 + imageView?.layer?.add(animation, forKey: "imageTransition") + + imageView?.display(image) + } + + #endif +} + +#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/AnimatedImageView.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/AnimatedImageView.swift new file mode 100644 index 00000000..44dd5d0d --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/AnimatedImageView.swift @@ -0,0 +1,26 @@ +// 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 new file mode 100644 index 00000000..584e463c --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/FetchImage.swift @@ -0,0 +1,263 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2022 Alexander Grebenyuk (github.com/kean). + +import SwiftUI +import Combine + + +/// An observable object that simplifies image loading in SwiftUI. +@MainActor +final class FetchImage: ObservableObject, Identifiable { + /// Returns the current fetch result. + @Published private(set) var result: Result? + + /// Returns the fetched image. + /// + /// - note: In case pipeline has `isProgressiveDecodingEnabled` option enabled + /// and the image being downloaded supports progressive decoding, the `image` + /// might be updated multiple times during the download. + var image: PlatformImage? { imageContainer?.image } + + /// Returns the fetched image. + /// + /// - note: In case pipeline has `isProgressiveDecodingEnabled` option enabled + /// and the image being downloaded supports progressive decoding, the `image` + /// might be updated multiple times during the download. + @Published private(set) var imageContainer: ImageContainer? + + /// Returns `true` if the image is being loaded. + @Published private(set) var isLoading: Bool = false + + /// Animations to be used when displaying the loaded images. By default, `nil`. + /// + /// - note: Animation isn't used when image is available in memory cache. + var animation: Animation? + + /// The progress of the image download. + @Published private(set) var progress = ImageTask.Progress(completed: 0, total: 0) + + /// Updates the priority of the task, even if the task is already running. + /// `nil` by default + var priority: ImageRequest.Priority? { + didSet { priority.map { imageTask?.priority = $0 } } + } + + /// Gets called when the request is started. + var onStart: ((ImageTask) -> Void)? + + /// Gets called when a progressive image preview is produced. + var onPreview: ((ImageResponse) -> Void)? + + /// Gets called when the request progress is updated. + var onProgress: ((ImageTask.Progress) -> Void)? + + /// Gets called when the requests finished successfully. + var onSuccess: ((ImageResponse) -> Void)? + + /// Gets called when the requests fails. + var onFailure: ((Error) -> Void)? + + /// Gets called when the request is completed. + var onCompletion: ((Result) -> Void)? + + /// A pipeline used for performing image requests. + var pipeline: ImagePipeline = .shared + + /// Image processors to be applied unless the processors are provided in the + /// request. `[]` by default. + var processors: [any ImageProcessing] = [] + + private var imageTask: ImageTask? + + // publisher support + private var lastResponse: ImageResponse? + private var cancellable: AnyCancellable? + + deinit { + imageTask?.cancel() + } + + /// Initialiazes the image. To load an image, use one of the `load()` methods. + init() {} + + // MARK: Loading Images + + /// Loads an image with the given request. + func load(_ url: URL?) { + load(url.map { ImageRequest(url: $0) }) + } + + /// Loads an image with the given request. + func load(_ request: ImageRequest?) { + assert(Thread.isMainThread, "Must be called from the main thread") + + reset() + + guard var request = request else { + handle(result: .failure(ImagePipeline.Error.imageRequestMissing)) + return + } + + if !processors.isEmpty && request.processors.isEmpty { + request.processors = processors + } + if let priority = self.priority { + request.priority = priority + } + + // Quick synchronous memory cache lookup + if let image = pipeline.cache[request] { + if image.isPreview { + imageContainer = image // Display progressive image + } else { + let response = ImageResponse(container: image, request: request, cacheType: .memory) + handle(result: .success(response)) + return + } + } + + 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) { + self.handle(preview: response) + } + } else { + self.progress = progress + self.onProgress?(progress) + } + }, + completion: { [weak self] result in + guard let self = self else { return } + withAnimation(self.animation) { + self.handle(result: result.mapError { $0 }) + } + } + ) + imageTask = task + 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 + } + + private func handle(result: Result) { + isLoading = false + + if case .success(let response) = result { + self.imageContainer = response.container + } + self.result = result + + imageTask = nil + switch result { + case .success(let response): onSuccess?(response) + case .failure(let error): onFailure?(error) + } + onCompletion?(result) + } + + // MARK: Load (Async/Await) + + /// Loads and displays an image using the given async function. + /// + /// - parameter action: Fetched the image. + func load(_ action: @escaping () async throws -> ImageResponse) { + reset() + isLoading = true + + let task = Task { + do { + let response = try await action() + withAnimation(animation) { + handle(result: .success(response)) + } + } catch { + handle(result: .failure(error)) + } + } + cancellable = AnyCancellable { task.cancel() } + } + + // MARK: Load (Combine) + + /// Loads an image with the given publisher. + /// + /// - important: Some `FetchImage` features, such as progress reporting and + /// dynamically changing the request priority, are not available when + /// working with a publisher. + func load(_ publisher: P) where P.Output == ImageResponse { + reset() + + // Not using `first()` because it should support progressive decoding + isLoading = true + cancellable = publisher.sink(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + self.isLoading = false + switch completion { + case .finished: + if let response = self.lastResponse { + self.result = .success(response) + } // else was cancelled, do nothing + case .failure(let error): + self.result = .failure(error) + } + }, receiveValue: { [weak self] response in + guard let self = self else { return } + self.lastResponse = response + self.imageContainer = response.container + }) + } + + // MARK: Cancel + + /// Marks the request as being cancelled. Continues to display a downloaded image. + func cancel() { + // pipeline-based + imageTask?.cancel() // Guarantees that no more callbacks will be delivered + imageTask = nil + + // publisher-based + cancellable = nil + } + + /// Resets the `FetchImage` instance by cancelling the request and removing + /// all of the state including the loaded image. + func reset() { + cancel() + + // Avoid publishing unchanged values + if isLoading { isLoading = false } + if imageContainer != nil { imageContainer = nil } + if result != nil { result = nil } + lastResponse = nil // publisher-only + if progress != 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 new file mode 100644 index 00000000..8b6c13ab --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/AnimatedFrame.swift @@ -0,0 +1,31 @@ +#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 new file mode 100644 index 00000000..8a8ba92a --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/Animator.swift @@ -0,0 +1,197 @@ +#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 new file mode 100644 index 00000000..25a802e7 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/FrameStore.swift @@ -0,0 +1,284 @@ +#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 new file mode 100644 index 00000000..7e40a66a --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/GIFAnimatable.swift @@ -0,0 +1,208 @@ +#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 new file mode 100644 index 00000000..4b661b4c --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/GIFImageView.swift @@ -0,0 +1,21 @@ +#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 new file mode 100644 index 00000000..e643e09c --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/Array.swift @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..68a1e875 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/CGSize.swift @@ -0,0 +1,43 @@ +#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 new file mode 100644 index 00000000..c1368ac8 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/UIImage.swift @@ -0,0 +1,52 @@ +#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 new file mode 100644 index 00000000..7a0dc2ab --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/UIImageView.swift @@ -0,0 +1,5 @@ +#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 new file mode 100755 index 00000000..ff1af90f --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Helpers/ImageSourceHelpers.swift @@ -0,0 +1,85 @@ +#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 new file mode 100644 index 00000000..13330fcc --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Image.swift @@ -0,0 +1,112 @@ +// 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 new file mode 100644 index 00000000..5fa66e6b --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/ImageView.swift @@ -0,0 +1,237 @@ +// 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 new file mode 100644 index 00000000..75f804d1 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Internal.swift @@ -0,0 +1,122 @@ +// 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 + +import SwiftUI + + +#if os(macOS) +typealias _PlatformBaseView = NSView +typealias _PlatformImageView = NSImageView +typealias _PlatformColor = NSColor +#else +typealias _PlatformBaseView = UIView +typealias _PlatformImageView = UIImageView +typealias _PlatformColor = UIColor +#endif + +extension _PlatformBaseView { + @discardableResult + func pinToSuperview() -> [NSLayoutConstraint] { + translatesAutoresizingMaskIntoConstraints = false + let constraints = [ + 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 + } + + @discardableResult + func centerInSuperview() -> [NSLayoutConstraint] { + translatesAutoresizingMaskIntoConstraints = false + let constraints = [ + centerXAnchor.constraint(equalTo: superview!.centerXAnchor), + centerYAnchor.constraint(equalTo: superview!.centerYAnchor) + ] + NSLayoutConstraint.activate(constraints) + return constraints + } + + @discardableResult + func layout(with position: LazyImageView.SubviewPosition) -> [NSLayoutConstraint] { + switch position { + case .center: return centerInSuperview() + case .fill: return pinToSuperview() + } + } +} + +extension CALayer { + func animateOpacity(duration: CFTimeInterval) { + let animation = CABasicAnimation(keyPath: "opacity") + animation.duration = duration + animation.fromValue = 0 + animation.toValue = 1 + add(animation, forKey: "imageTransition") + } +} + +#if os(macOS) +extension NSView { + func setNeedsUpdateConstraints() { + needsUpdateConstraints = true + } + + func insertSubview(_ subivew: NSView, at index: Int) { + addSubview(subivew, positioned: .below, relativeTo: subviews.first) + } +} + +extension NSColor { + static var secondarySystemBackground: NSColor { + .controlBackgroundColor // Close-enough, but we should define a custom color + } +} +#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) +import UIKit + +extension UIColor { + static var secondarySystemBackground: UIColor { + lightGray.withAlphaComponent(0.5) + } +} +#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImage.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImage.swift new file mode 100644 index 00000000..37fb2c32 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImage.swift @@ -0,0 +1,329 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2021 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. +/// +/// ``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 +/// 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 + + // Options + private var makeContent: ((LazyImageState) -> Content)? + private var animation: Animation? = .default + private var processors: [any ImageProcessing]? + private var priority: ImageRequest.Priority? + 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 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``. + /// + /// - Parameters: + /// - url: The image URL. + init(url: URL?) where Content == NukeImage { + self.init(request: url.map { ImageRequest(url: $0) }) + } + + /// Loads and displays an image using ``Image``. + /// + /// - Parameters: + /// - request: The image request. + init(request: ImageRequest?) where Content == NukeImage { + self.request = request.map { HashableRequest(request: $0) } + } + + // 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) + } + + /// Loads an images and displays custom content for each state. + /// + /// - Parameters: + /// - request: The image request. + /// - content: The view to show for each of the image loading states. + /// + /// ```swift + /// LazyImage(request: $0) { state in + /// if let image = state.image { + /// image // Displays the loaded image. + /// } else if state.error != nil { + /// Color.red // Indicates an error. + /// } else { + /// Color.blue // Acts as a placeholder. + /// } + /// } + /// ``` + 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()) } + 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 + + /// 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 } + } + + /// Sets the priority of the requests. + func priority(_ priority: ImageRequest.Priority?) -> Self { + map { $0.priority = priority } + } + + /// Changes the underlying pipeline used for image loading. + func pipeline(_ pipeline: ImagePipeline) -> Self { + map { $0.pipeline = pipeline } + } + + enum DisappearBehavior { + /// Cancels the current request but keeps the presentation state of + /// the already displayed image. + case cancel + /// Lowers the request's priority to very low + 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 } + } + + /// Gets called when the 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 } + } +#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 + } + .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() + } + } + + @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 + } else { + Rectangle().foregroundColor(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) + } + + private func onDisappear() { + guard let behavior = onDisappearBehavior else { return } + switch behavior { + case .cancel: model.cancel() + case .lowerPriority: model.priority = .veryLow + } + } + + private func map(_ closure: (inout LazyImage) -> Void) -> Self { + var copy = self + closure(©) + return copy + } +} + +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 +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageState.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageState.swift new file mode 100644 index 00000000..5f18710a --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageState.swift @@ -0,0 +1,55 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). + +import Foundation + +import SwiftUI +import Combine + +/// Describes current image state. +struct LazyImageState { + /// Returns the current fetch result. + let result: Result? + + /// Returns a current error. + var error: Error? { + if case .failure(let error) = result { + return error + } + return nil + } + + /// Returns an image view. + @MainActor + var image: NukeImage? { +#if os(macOS) + return imageContainer.map { NukeImage($0) } +#elseif os(watchOS) + return imageContainer.map { NukeImage(uiImage: $0.image) } +#else + return imageContainer.map { NukeImage($0) } +#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 + } +} diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageView.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageView.swift new file mode 100644 index 00000000..dcb178ff --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageView.swift @@ -0,0 +1,462 @@ +// 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 + +/// Lazily loads and displays images. +/// +/// ``LazyImageView`` is a ``LazyImage`` counterpart for UIKit and AppKit with the equivalent set of APIs. +/// +/// ```swift +/// let imageView = LazyImageView() +/// imageView.placeholderView = UIActivityIndicatorView() +/// imageView.priority = .high +/// imageView.pipeline = customPipeline +/// imageView.onCompletion = { _ in print("Request completed") } +/// +/// imageView.url = URL(string: "https://example.com/image.jpeg") +/// ```` +@MainActor +final class LazyImageView: _PlatformBaseView { + + // MARK: Placeholder View + + /// An image to be shown while the request is in progress. + var placeholderImage: PlatformImage? { + didSet { setPlaceholderImage(placeholderImage) } + } + + /// A view to be shown while the request is in progress. For example, + /// a spinner. + var placeholderView: _PlatformBaseView? { + didSet { setPlaceholderView(oldValue, placeholderView) } + } + + /// The position of the placeholder. `.fill` by default. + /// + /// It also affects `placeholderImage` because it gets converted to a view. + var placeholderViewPosition: SubviewPosition = .fill { + didSet { + guard oldValue != placeholderViewPosition, + placeholderView != nil else { return } + setNeedsUpdateConstraints() + } + } + + private var placeholderViewConstraints: [NSLayoutConstraint] = [] + + // MARK: Failure View + + /// An image to be shown if the request fails. + var failureImage: PlatformImage? { + didSet { setFailureImage(failureImage) } + } + + /// A view to be shown if the request fails. + var failureView: _PlatformBaseView? { + didSet { setFailureView(oldValue, failureView) } + } + + /// The position of the failure vuew. `.fill` by default. + /// + /// It also affects `failureImage` because it gets converted to a view. + var failureViewPosition: SubviewPosition = .fill { + didSet { + guard oldValue != failureViewPosition, + failureView != nil else { return } + setNeedsUpdateConstraints() + } + } + + private var failureViewConstraints: [NSLayoutConstraint] = [] + + // MARK: Transition + + /// A animated transition to be performed when displaying a loaded image + /// By default, `.fadeIn(duration: 0.33)`. + var transition: Transition? + + /// An animated transition. + enum Transition { + /// Fade-in transition. + case fadeIn(duration: TimeInterval) + /// A custom image view transition. + /// + /// The closure will get called after the image is already displayed but + /// before `imageContainer` value is updated. + case custom(closure: (LazyImageView, ImageContainer) -> Void) + } + + // MARK: Underlying Views + + /// Returns the underlying image view. + let imageView = ImageView() + + // MARK: Managing Image Tasks + + /// Processors to be applied to the image. `nil` by default. + /// + /// If you pass an image requests with a non-empty list of processors as + /// a source, your processors will be applied instead. + var processors: [any ImageProcessing]? + + /// Sets the priority of the image task. The priorit can be changed + /// dynamically. `nil` by default. + var priority: ImageRequest.Priority? { + didSet { + if let priority = self.priority { + imageTask?.priority = priority + } + } + } + + /// Current image task. + var imageTask: ImageTask? + + /// The pipeline to be used for download. `shared` by default. + var pipeline: ImagePipeline = .shared + + // MARK: Callbacks + + /// Gets called when the request is started. + var onStart: ((ImageTask) -> Void)? + + /// Gets called when a progressive image preview is produced. + var onPreview: ((ImageResponse) -> Void)? + + /// Gets called when the request progress is updated. + var onProgress: ((ImageTask.Progress) -> Void)? + + /// Gets called when the requests finished successfully. + var onSuccess: ((ImageResponse) -> Void)? + + /// Gets called when the requests fails. + var onFailure: ((Error) -> Void)? + + /// Gets called when the request is completed. + var onCompletion: ((Result) -> Void)? + + // 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 + /// new download is started. You can disable it if you want to keep the + /// previous content while the new download is in progress. + var isResetEnabled = true + + // MARK: Private + + private var isResetNeeded = false + + // MARK: Initializers + + deinit { + imageTask?.cancel() + } + + override init(frame: CGRect) { + super.init(frame: frame) + didInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + didInit() + } + + private func didInit() { + imageView.isHidden = true + addSubview(imageView) + imageView.pinToSuperview() + + placeholderView = { + let view = _PlatformBaseView() + let color: _PlatformColor + if #available(iOS 13.0, *) { + color = .secondarySystemBackground + } else { + color = _PlatformColor.lightGray.withAlphaComponent(0.5) + } +#if os(macOS) + view.wantsLayer = true + view.layer?.backgroundColor = color.cgColor +#else + view.backgroundColor = color +#endif + + return view + }() + + transition = .fadeIn(duration: 0.33) + } + + /// Sets the given URL and immediately starts the download. + var url: URL? { + get { request?.url } + set { request = newValue.map { ImageRequest(url: $0) } } + } + + /// Sets the given request and immediately starts the download. + 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() + + updatePlaceholderViewConstraints() + updateFailureViewConstraints() + } + + /// Cancels current request and prepares the view for reuse. + func reset() { + cancel() + + imageView.imageContainer = nil + imageView.isHidden = true + + setPlaceholderViewHidden(true) + setFailureViewHidden(true) + + isResetNeeded = false + } + + /// Cancels current request. + func cancel() { + imageTask?.cancel() + imageTask = nil + } + + // MARK: Loading Images + + /// Loads an image with the given request. + private func load(_ request: ImageRequest?) { + assert(Thread.isMainThread, "Must be called from the main thread") + + cancel() + + if isResetEnabled { + reset() + } else { + isResetNeeded = true + } + + guard var request = request else { + handle(result: .failure(ImagePipeline.Error.imageRequestMissing), isSync: true) + return + } + + if let processors = self.processors, !processors.isEmpty, !request.processors.isEmpty { + request.processors = processors + } + if let priority = self.priority { + request.priority = priority + } + + // Quick synchronous memory cache lookup + if let image = pipeline.cache[request] { + if image.isPreview { + display(image, isFromMemory: true) // Display progressive preview + } else { + let response = ImageResponse(container: image, request: request, cacheType: .memory) + handle(result: .success(response), isSync: true) + return + } + } + + setPlaceholderViewHidden(false) + + let task = pipeline.loadImage( + with: request, + queue: .main, + 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.handle(preview: response) + self.onPreview?(response) + } else { + self.onProgress?(progress) + } + }, + completion: { [weak self] result in + self?.handle(result: result.mapError { $0 }, isSync: false) + } + ) + imageTask = task + onStart?(task) + } + + private func handle(preview: ImageResponse) { + guard isProgressiveImageRenderingEnabled else { + return + } + setPlaceholderViewHidden(true) + display(preview.container, isFromMemory: false) + } + + private func handle(result: Result, isSync: Bool) { + resetIfNeeded() + setPlaceholderViewHidden(true) + + switch result { + case let .success(response): + display(response.container, isFromMemory: isSync) + case .failure: + setFailureViewHidden(false) + } + + imageTask = nil + switch result { + case .success(let response): onSuccess?(response) + case .failure(let error): onFailure?(error) + } + onCompletion?(result) + } + + private func display(_ container: ImageContainer, isFromMemory: Bool) { + resetIfNeeded() + + imageView.imageContainer = container + imageView.isHidden = false + + if !isFromMemory, let transition = transition { + runTransition(transition, container) + } + } + + // MARK: Private (Placeholder View) + + private func setPlaceholderViewHidden(_ isHidden: Bool) { + placeholderView?.isHidden = isHidden + } + + private func setPlaceholderImage(_ placeholderImage: PlatformImage?) { + guard let placeholderImage = placeholderImage else { + placeholderView = nil + return + } + placeholderView = _PlatformImageView(image: placeholderImage) + } + + private func setPlaceholderView(_ oldView: _PlatformBaseView?, _ newView: _PlatformBaseView?) { + if let oldView = oldView { + oldView.removeFromSuperview() + } + if let newView = newView { + newView.isHidden = !imageView.isHidden + insertSubview(newView, at: 0) + setNeedsUpdateConstraints() +#if os(iOS) || os(tvOS) + if let spinner = newView as? UIActivityIndicatorView { + spinner.startAnimating() + } +#endif + } + } + + private func updatePlaceholderViewConstraints() { + NSLayoutConstraint.deactivate(placeholderViewConstraints) + placeholderViewConstraints = placeholderView?.layout(with: placeholderViewPosition) ?? [] + } + + // MARK: Private (Failure View) + + private func setFailureViewHidden(_ isHidden: Bool) { + failureView?.isHidden = isHidden + } + + private func setFailureImage(_ failureImage: PlatformImage?) { + guard let failureImage = failureImage else { + failureView = nil + return + } + failureView = _PlatformImageView(image: failureImage) + } + + private func setFailureView(_ oldView: _PlatformBaseView?, _ newView: _PlatformBaseView?) { + if let oldView = oldView { + oldView.removeFromSuperview() + } + if let newView = newView { + newView.isHidden = true + insertSubview(newView, at: 0) + setNeedsUpdateConstraints() + } + } + + private func updateFailureViewConstraints() { + NSLayoutConstraint.deactivate(failureViewConstraints) + failureViewConstraints = failureView?.layout(with: failureViewPosition) ?? [] + } + + // MARK: Private (Transitions) + + private func runTransition(_ transition: Transition, _ image: ImageContainer) { + switch transition { + case .fadeIn(let duration): + runFadeInTransition(duration: duration) + case .custom(let closure): + closure(self, image) + } + } + +#if os(iOS) || os(tvOS) + + private func runFadeInTransition(duration: TimeInterval) { + guard !imageView.isHidden else { return } + imageView.alpha = 0 + UIView.animate(withDuration: duration, delay: 0, options: [.allowUserInteraction]) { + 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 + + enum SubviewPosition { + /// Center in the superview. + case center + + /// Fill the superview. + case fill + } + + private func resetIfNeeded() { + if isResetNeeded { + reset() + isResetNeeded = false + } + } +} + +#endif diff --git a/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/NukeVideoPlayerView.swift b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/NukeVideoPlayerView.swift new file mode 100644 index 00000000..f85a0f62 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeUI/NukeVideoPlayerView.swift @@ -0,0 +1,191 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). + +import AVKit +import Foundation + +#if !os(watchOS) + +@MainActor +final class NukeVideoPlayerView: _PlatformBaseView { + // MARK: Configuration + + /// `.resizeAspectFill` by default. + var videoGravity: AVLayerVideoGravity = .resizeAspectFill { + didSet { + _playerLayer?.videoGravity = videoGravity + } + } + + /// `true` by default. If disabled, will only play a video once. + var isLooping = true { + didSet { + guard isLooping != oldValue else { return } + player?.actionAtItemEnd = isLooping ? .none : .pause + if isLooping, !(player?.nowPlaying ?? false) { + restart() + } + } + } + + /// Add if you want to do something at the end of the video + var onVideoFinished: (() -> Void)? + + // MARK: Initialization + + var playerLayer: AVPlayerLayer { + if let layer = _playerLayer { + return layer + } + let playerLayer = AVPlayerLayer() +#if os(macOS) + wantsLayer = true + self.layer?.addSublayer(playerLayer) +#else + self.layer.addSublayer(playerLayer) +#endif + playerLayer.frame = bounds + playerLayer.videoGravity = videoGravity + _playerLayer = playerLayer + return playerLayer + } + + private var _playerLayer: AVPlayerLayer? + + #if os(iOS) || os(tvOS) + override func layoutSubviews() { + super.layoutSubviews() + + _playerLayer?.frame = bounds + } + #elseif os(macOS) + override func layout() { + super.layout() + + _playerLayer?.frame = bounds + } +#endif + + // MARK: Private + + private var player: AVPlayer? { + didSet { + registerNotifications() + } + } + + private var playerObserver: AnyObject? + + func reset() { + _playerLayer?.player = nil + player = nil + playerObserver = nil + } + + var asset: AVAsset? { + didSet { assetDidChange() } + } + + private func assetDidChange() { + if asset == nil { + reset() + } + } + + private func registerNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(playerItemDidPlayToEndTimeNotification(_:)), + name: .AVPlayerItemDidPlayToEndTime, + object: player?.currentItem + ) + +#if os(iOS) || os(tvOS) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) +#endif + } + + func restart() { + player?.seek(to: CMTime.zero) + player?.play() + } + + func play() { + guard let asset = asset else { + return + } + + let playerItem = AVPlayerItem(asset: asset) + let player = AVQueuePlayer(playerItem: playerItem) + player.isMuted = true + player.preventsDisplaySleepDuringVideoPlayback = false + player.actionAtItemEnd = isLooping ? .none : .pause + self.player = player + + playerLayer.player = player + + playerObserver = player.observe(\.status, options: [.new, .initial]) { player, _ in + Task { @MainActor in + if player.status == .readyToPlay { + player.play() + } + } + } + } + + @objc private func playerItemDidPlayToEndTimeNotification(_ notification: Notification) { + guard let playerItem = notification.object as? AVPlayerItem else { + return + } + if isLooping { + playerItem.seek(to: CMTime.zero, completionHandler: nil) + } else { + onVideoFinished?() + } + } + + @objc private func applicationWillEnterForeground() { + if shouldResumeOnInterruption { + player?.play() + } + } + +#if os(iOS) || os(tvOS) + override func willMove(toWindow newWindow: UIWindow?) { + if newWindow != nil && shouldResumeOnInterruption { + player?.play() + } + } +#endif + + private var shouldResumeOnInterruption: Bool { + return player?.nowPlaying == false && + player?.status == .readyToPlay && + isLooping + } +} + +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/NSImage+SwiftyGif.swift b/Sources/StreamChatSwiftUI/StreamSwiftyGif/NSImage+SwiftyGif.swift new file mode 100755 index 00000000..05c1fb33 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamSwiftyGif/NSImage+SwiftyGif.swift @@ -0,0 +1,337 @@ +// +// NSImage+SwiftyGif.swift +// + +#if os(macOS) + +import ImageIO +import AppKit + +typealias GifLevelOfIntegrity = Float + +extension GifLevelOfIntegrity { + static let highestNoFrameSkipping: GifLevelOfIntegrity = 1 + static let `default`: GifLevelOfIntegrity = 0.8 + static let lowForManyGifs: GifLevelOfIntegrity = 0.5 + static let lowForTooManyGifs: GifLevelOfIntegrity = 0.2 + static let superLowForSlideShow: GifLevelOfIntegrity = 0.1 +} + +enum GifParseError: Error { + case invalidFilename + case noImages + case noProperties + case noGifDictionary + case noTimingInfo +} + +extension GifParseError: LocalizedError { + var errorDescription: String? { + switch self { + case .invalidFilename: + return "Invalid file name" + case .noImages,.noProperties, .noGifDictionary,.noTimingInfo: + return "Invalid gif file " + } + } +} + +extension NSImage { + /// Convenience initializer. Creates a gif with its backing data. + /// + /// - 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 { + do { + try self.init(gifData: imageData, levelOfIntegrity: levelOfIntegrity) + } catch { + self.init(data: imageData) + } + } + + /// Convenience initializer. Creates a image with its backing data. + /// + /// - Parameter imageName: Filename + /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping + convenience init?(imageName: String, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { + self.init() + + do { + try setGif(imageName, levelOfIntegrity: levelOfIntegrity) + } catch { + self.init(named: imageName) + } + } +} + +// MARK: - Inits + +extension NSImage { + + /// Convenience initializer. Creates a gif with its backing data. + /// + /// - Parameter gifData: The actual gif data + /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping + convenience init(gifData:Data, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { + self.init() + try setGifFromData(gifData, levelOfIntegrity: levelOfIntegrity) + } + + /// Convenience initializer. Creates a gif with its backing data. + /// + /// - Parameter gifName: Filename + /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping + convenience init(gifName: String, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { + self.init() + try setGif(gifName, levelOfIntegrity: levelOfIntegrity) + } + + /// Set backing data for this gif. Overwrites any existing data. + /// + /// - Parameter data: The actual gif data + /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping + func setGifFromData(_ data: Data, levelOfIntegrity: GifLevelOfIntegrity) throws { + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else { return } + self.imageSource = imageSource + imageData = data + + calculateFrameDelay(try delayTimes(imageSource), levelOfIntegrity: levelOfIntegrity) + calculateFrameSize() + } + + /// Set backing data for this gif. Overwrites any existing data. + /// + /// - Parameter name: Filename + func setGif(_ name: String) throws { + try setGif(name, levelOfIntegrity: .default) + } + + /// Check the number of frame for this gif + /// + /// - Return number of frames + func framesCount() -> Int { + return displayOrder?.count ?? 0 + } + + /// Set backing data for this gif. Overwrites any existing data. + /// + /// - Parameter name: Filename + /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping + 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) { + try setGifFromData(data, levelOfIntegrity: levelOfIntegrity) + } + } else { + throw GifParseError.invalidFilename + } + } + + func clear() { + imageData = nil + imageSource = nil + displayOrder = nil + imageCount = nil + imageSize = nil + displayRefreshFactor = nil + } + + // MARK: Logic + + private func convertToDelay(_ pointer:UnsafeRawPointer?) -> Float? { + if pointer == nil { + return nil + } + + return unsafeBitCast(pointer, to:AnyObject.self).floatValue + } + + /// Get delay times for each frames + /// + /// - Parameter imageSource: reference to the gif image source + /// - Returns array of delays + private func delayTimes(_ imageSource:CGImageSource) throws -> [Float] { + let imageCount = CGImageSourceGetCount(imageSource) + + guard imageCount > 0 else { + throw GifParseError.noImages + } + + var imageProperties = [CFDictionary]() + + for i in 0.. CFDictionary in + let key = Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque() + let value = CFDictionaryGetValue(dict, key) + + if value == nil { + throw GifParseError.noGifDictionary + } + + return unsafeBitCast(value, to: CFDictionary.self) + } + + let EPS:Float = 1e-6 + + let frameDelays:[Float] = try frameProperties.map() { + let unclampedKey = Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque() + let unclampedPointer:UnsafeRawPointer? = CFDictionaryGetValue($0, unclampedKey) + + if let value = convertToDelay(unclampedPointer), value >= EPS { + return value + } + + let clampedKey = Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque() + let clampedPointer:UnsafeRawPointer? = CFDictionaryGetValue($0, clampedKey) + + if let value = convertToDelay(clampedPointer) { + return value + } + + throw GifParseError.noTimingInfo + } + + return frameDelays + } + + /// Compute backing data for this gif + /// + /// - 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) { + let levelOfIntegrity = max(0, min(1, levelOfIntegrity)) + var delays = delaysArray + + // Factors send to CADisplayLink.frameInterval + let displayRefreshFactors = [60, 30, 20, 15, 12, 10, 6, 5, 4, 3, 2, 1] + + // maxFramePerSecond,default is 60 + let maxFramePerSecond = displayRefreshFactors[0] + + // frame numbers per second + let displayRefreshRates = displayRefreshFactors.map { maxFramePerSecond / $0 } + + // time interval per frame + let displayRefreshDelayTime = displayRefreshRates.map { 1 / Float($0) } + + // caclulate the time when each frame should be displayed at(start at 0) + for i in delays.indices.dropFirst() { + delays[i] += delays[i - 1] + } + + //find the appropriate Factors then BREAK + for (i, delayTime) in displayRefreshDelayTime.enumerated() { + let displayPosition = delays.map { Int($0 / delayTime) } + var frameLoseCount: Float = 0 + + for j in displayPosition.indices.dropFirst() where displayPosition[j] == displayPosition[j - 1] { + frameLoseCount += 1 + } + + if displayPosition.first == 0 { + frameLoseCount += 1 + } + + if frameLoseCount <= Float(displayPosition.count) * (1 - levelOfIntegrity) || i == displayRefreshDelayTime.count - 1 { + imageCount = displayPosition.last + displayRefreshFactor = displayRefreshFactors[i] + displayOrder = [] + var oldIndex = 0 + var newIndex = 1 + let imageCount = self.imageCount ?? 0 + + while newIndex <= imageCount && oldIndex < displayPosition.count { + if newIndex <= displayPosition[oldIndex] { + displayOrder?.append(oldIndex) + newIndex += 1 + } else { + oldIndex += 1 + } + } + + break + } + } + } + + /// Compute frame size for this gif + private func calculateFrameSize(){ + guard let imageSource = imageSource, + let imageCount = imageCount, + let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { + return + } + + + let image = NSImage(cgImage: cgImage, size: .zero) + imageSize = Int(image.size.height * image.size.width * 4) * imageCount / 1_000_000 + } +} + +// 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) + +extension NSImage { + + var imageSource: CGImageSource? { + get { + let result = objc_getAssociatedObject(self, _imageSourceKey!) + return result == nil ? nil : (result as! CGImageSource) + } + set { + objc_setAssociatedObject(self, _imageSourceKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + var displayRefreshFactor: Int?{ + get { return objc_getAssociatedObject(self, _displayRefreshFactorKey!) as? Int } + set { objc_setAssociatedObject(self, _displayRefreshFactorKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var imageSize: Int?{ + get { return objc_getAssociatedObject(self, _imageSizeKey!) as? Int } + set { objc_setAssociatedObject(self, _imageSizeKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var imageCount: Int?{ + get { return objc_getAssociatedObject(self, _imageCountKey!) as? Int } + set { objc_setAssociatedObject(self, _imageCountKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var displayOrder: [Int]?{ + get { return objc_getAssociatedObject(self, _displayOrderKey!) as? [Int] } + set { objc_setAssociatedObject(self, _displayOrderKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var imageData:Data? { + get { + let result = objc_getAssociatedObject(self, _imageDataKey!) + return result == nil ? nil : (result as? Data) + } + set { + objc_setAssociatedObject(self, _imageDataKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +extension String { + fileprivate func pathExtension() -> String { + return (self as NSString).pathExtension + } +} + +#endif diff --git a/Sources/StreamChatSwiftUI/StreamSwiftyGif/NSImageView+SwiftyGif.swift b/Sources/StreamChatSwiftUI/StreamSwiftyGif/NSImageView+SwiftyGif.swift new file mode 100755 index 00000000..26792b6b --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamSwiftyGif/NSImageView+SwiftyGif.swift @@ -0,0 +1,493 @@ +// +// NSImageView+SwiftyGif.swift +// + +#if os(macOS) + +import ImageIO +import AppKit + +@objc protocol SwiftyGifDelegate { + @objc optional func gifDidStart(sender: NSImageView) + @objc optional func gifDidLoop(sender: NSImageView) + @objc optional func gifDidStop(sender: NSImageView) + @objc optional func gifURLDidFinish(sender: NSImageView) + @objc optional func gifURLDidFail(sender: NSImageView, url: URL, error: Error?) +} + +extension NSImageView { + /// Set an image and a manager to an existing NSImageView. If the image is not an GIF image, set it in normal way and remove self form SwiftyGifManager + /// + /// WARNING : this overwrite any previous gif. + /// - Parameter gifImage: The NSImage containing the gif backing data + /// - Parameter manager: The manager to handle the gif display + /// - Parameter loopCount: The number of loops we want for this gif. -1 means infinite. + func setImage(_ image: NSImage, manager: SwiftyGifManager = .defaultManager, loopCount: Int = -1) { + if let _ = image.imageData { + setGifImage(image, manager: manager, loopCount: loopCount) + } else { + manager.deleteImageView(self) + self.image = image + } + } +} + +extension NSImageView { + + // MARK: - Inits + + /// Convenience initializer. Creates a gif holder (defaulted to infinite loop). + /// + /// - Parameter gifImage: The NSImage containing the gif backing data + /// - Parameter manager: The manager to handle the gif display + convenience init(gifImage: NSImage, manager: SwiftyGifManager = .defaultManager, loopCount: Int = -1) { + self.init() + setGifImage(gifImage,manager: manager, loopCount: loopCount) + } + + /// Convenience initializer. Creates a gif holder (defaulted to infinite loop). + /// + /// - Parameter gifImage: The NSImage containing the gif backing data + /// - Parameter manager: The manager to handle the gif display + convenience init(gifURL: URL, manager: SwiftyGifManager = .defaultManager, loopCount: Int = -1) { + self.init() + setGifFromURL(gifURL, manager: manager, loopCount: loopCount) + } + + /// Set a gif image and a manager to an existing NSImageView. + /// + /// WARNING : this overwrite any previous gif. + /// - Parameter gifImage: The NSImage containing the gif backing data + /// - Parameter manager: The manager to handle the gif display + /// - Parameter loopCount: The number of loops we want for this gif. -1 means infinite. + func setGifImage(_ gifImage: NSImage, manager: SwiftyGifManager = .defaultManager, loopCount: Int = -1) { + if let imageData = gifImage.imageData, (gifImage.imageCount ?? 0) < 1 { + image = NSImage(data: imageData) + return + } + + self.loopCount = loopCount + self.gifImage = gifImage + animationManager = manager + syncFactor = 0 + displayOrderIndex = 0 + cache = NSCache() + haveCache = false + + if let source = gifImage.imageSource, let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) { + currentImage = NSImage(cgImage: cgImage, size: .zero) + + if manager.addImageView(self) { + startDisplay() + startAnimatingGif() + } + } + } +} + +// MARK: - Download gif + +extension NSImageView { + + /// Download gif image and sets it. + /// + /// - Parameters: + /// - url: The URL pointing to the gif data + /// - manager: The manager to handle the gif display + /// - loopCount: The number of loops we want for this gif. -1 means infinite. + /// - showLoader: Show UIActivityIndicatorView or not + /// - Returns: An URL session task. Note: You can cancel the downloading task if it needed. + @discardableResult + func setGifFromURL(_ url: URL, + manager: SwiftyGifManager = .defaultManager, + loopCount: Int = -1, + levelOfIntegrity: GifLevelOfIntegrity = .default, + session: URLSession = URLSession.shared, + showLoader: Bool = true, + customLoader: NSView? = nil) -> URLSessionDataTask? { + + if let data = manager.remoteCache[url] { + self.parseDownloadedGif(url: url, + data: data, + error: nil, + manager: manager, + loopCount: loopCount, + levelOfIntegrity: levelOfIntegrity) + return nil + } + + stopAnimatingGif() + + let loader: NSView? = showLoader ? createLoader(from: customLoader) : nil + + let task = session.dataTask(with: url) { [weak self] data, _, error in + DispatchQueue.main.async { + loader?.removeFromSuperview() + self?.parseDownloadedGif(url: url, + data: data, + error: error, + manager: manager, + loopCount: loopCount, + levelOfIntegrity: levelOfIntegrity) + } + } + + task.resume() + + return task + } + + private func createLoader(from view: NSView? = nil) -> NSView { + let loader = view ?? { + let indicator = NSProgressIndicator() + indicator.style = .spinning + return indicator + }() + + addSubview(loader) + loader.translatesAutoresizingMaskIntoConstraints = false + + addConstraint(NSLayoutConstraint( + item: loader, + attribute: .centerX, + relatedBy: .equal, + toItem: self, + attribute: .centerX, + multiplier: 1, + constant: 0)) + + addConstraint(NSLayoutConstraint( + item: loader, + attribute: .centerY, + relatedBy: .equal, + toItem: self, + attribute: .centerY, + multiplier: 1, + constant: 0)) + + (loader as? NSProgressIndicator)?.startAnimation(nil) + + return loader + } + + private func parseDownloadedGif(url: URL, + data: Data?, + error: Error?, + manager: SwiftyGifManager, + loopCount: Int, + levelOfIntegrity: GifLevelOfIntegrity) { + guard let data = data else { + report(url: url, error: error) + return + } + + do { + let image = try NSImage(gifData: data, levelOfIntegrity: levelOfIntegrity) + manager.remoteCache[url] = data + setGifImage(image, manager: manager, loopCount: loopCount) + startAnimatingGif() + delegate?.gifURLDidFinish?(sender: self) + } catch { + report(url: url, error: error) + } + } + + private func report(url: URL, error: Error?) { + delegate?.gifURLDidFail?(sender: self, url: url, error: error) + } +} + +// MARK: - Logic + +extension NSImageView { + + /// Start displaying the gif for this NSImageView. + private func startDisplay() { + displaying = true + updateCache() + } + + /// Stop displaying the gif for this NSImageView. + private func stopDisplay() { + displaying = false + updateCache() + } + + /// Start displaying the gif for this NSImageView. + func startAnimatingGif() { + isPlaying = true + } + + /// Stop displaying the gif for this NSImageView. + func stopAnimatingGif() { + isPlaying = false + } + + /// Check if this imageView is currently playing a gif + /// + /// - Returns wether the gif is currently playing + func isAnimatingGif() -> Bool{ + return isPlaying + } + + /// Show a specific frame based on a delta from current frame + /// + /// - Parameter delta: The delsta from current frame we want + func showFrameForIndexDelta(_ delta: Int) { + guard let gifImage = gifImage else { return } + var nextIndex = displayOrderIndex + delta + + while nextIndex >= gifImage.framesCount() { + nextIndex -= gifImage.framesCount() + } + + while nextIndex < 0 { + nextIndex += gifImage.framesCount() + } + + showFrameAtIndex(nextIndex) + } + + /// Show a specific frame + /// + /// - Parameter index: The index of frame to show + func showFrameAtIndex(_ index: Int) { + displayOrderIndex = index + updateFrame() + } + + /// Update cache for the current imageView. + func updateCache() { + guard let animationManager = animationManager else { return } + + if animationManager.hasCache(self) && !haveCache { + prepareCache() + haveCache = true + } else if !animationManager.hasCache(self) && haveCache { + cache?.removeAllObjects() + haveCache = false + } + } + + /// Update current image displayed. This method is called by the manager. + func updateCurrentImage() { + if displaying { + updateFrame() + updateIndex() + + if loopCount == 0 || !isDisplayedInScreen(self) || !isPlaying { + stopDisplay() + } + } else { + if isDisplayedInScreen(self) && loopCount != 0 && isPlaying { + startDisplay() + } + + if isDiscarded(self) { + animationManager?.deleteImageView(self) + } + } + } + + /// Force update frame + private func updateFrame() { + if haveCache, let image = cache?.object(forKey: displayOrderIndex as AnyObject) as? NSImage { + currentImage = image + } else { + currentImage = frameAtIndex(index: currentFrameIndex()) + } + } + + /// Get current frame index + func currentFrameIndex() -> Int{ + return displayOrderIndex + } + + /// Get frame at specific index + func frameAtIndex(index: Int) -> NSImage { + guard let gifImage = gifImage, + let imageSource = gifImage.imageSource, + let displayOrder = gifImage.displayOrder, index < displayOrder.count, + let cgImage = CGImageSourceCreateImageAtIndex(imageSource, displayOrder[index], nil) else { + return NSImage() + } + + return NSImage(cgImage: cgImage, size: .zero) + } + + /// 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: NSView?) -> Bool { + return imageView?.superview == nil + } + + /// Check if the imageView is displayed. + /// + /// - Returns : A boolean for weather the imageView is displayed + func isDisplayedInScreen(_ imageView: NSView?) -> Bool { + guard !isHidden, let imageView = imageView else { + return false + } + + let screenRect = NSScreen.main?.visibleFrame ?? .zero + let viewRect = imageView.convert(bounds, to:nil) + let intersectionRect = viewRect.intersection(screenRect) + + return window != nil && !intersectionRect.isEmpty && !intersectionRect.isNull + } + + func clear() { + if let gifImage = gifImage { + gifImage.clear() + } + + gifImage = nil + currentImage = nil + cache?.removeAllObjects() + animationManager = nil + image = nil + } + + /// Update loop count and sync factor. + private func updateIndex() { + guard let gif = self.gifImage, + let displayRefreshFactor = gif.displayRefreshFactor, + displayRefreshFactor > 0 else { + return + } + + syncFactor = (syncFactor + 1) % displayRefreshFactor + + if syncFactor == 0, let imageCount = gif.imageCount, imageCount > 0 { + displayOrderIndex = (displayOrderIndex+1) % imageCount + + if displayOrderIndex == 0 { + if loopCount == -1 { + delegate?.gifDidLoop?(sender: self) + } else if loopCount > 1 { + delegate?.gifDidLoop?(sender: self) + loopCount -= 1 + } else { + delegate?.gifDidStop?(sender: self) + loopCount -= 1 + } + } + } + } + + /// Prepare the cache by adding every images of the gif to an NSCache object. + private func prepareCache() { + guard let cache = self.cache else { return } + + cache.removeAllObjects() + + guard let gif = self.gifImage, + let displayOrder = gif.displayOrder, + let imageSource = gif.imageSource else { return } + + for (i, order) in displayOrder.enumerated() { + guard let cgImage = CGImageSourceCreateImageAtIndex(imageSource, order, nil) else { continue } + + cache.setObject(NSImage(cgImage: cgImage, size: .zero), forKey: i as AnyObject) + } + } +} + +// 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) + +extension NSImageView { + + var gifImage: NSImage? { + get { return possiblyNil(_gifImageKey) } + set { objc_setAssociatedObject(self, _gifImageKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var currentImage: NSImage? { + get { return possiblyNil(_currentImageKey) } + set { objc_setAssociatedObject(self, _currentImageKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + private var displayOrderIndex: Int { + get { return value(_displayOrderIndexKey, 0) } + set { objc_setAssociatedObject(self, _displayOrderIndexKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + private var syncFactor: Int { + get { return value(_syncFactorKey, 0) } + set { objc_setAssociatedObject(self, _syncFactorKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var loopCount: Int { + get { return value(_loopCountKey, 0) } + set { objc_setAssociatedObject(self, _loopCountKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var animationManager: SwiftyGifManager? { + get { return (objc_getAssociatedObject(self, _animationManagerKey!) as? SwiftyGifManager) } + set { objc_setAssociatedObject(self, _animationManagerKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var delegate: SwiftyGifDelegate? { + get { return (objc_getAssociatedWeakObject(self, _delegateKey!) as? SwiftyGifDelegate) } + set { objc_setAssociatedWeakObject(self, _delegateKey!, newValue) } + } + + private var haveCache: Bool { + get { return value(_haveCacheKey, false) } + set { objc_setAssociatedObject(self, _haveCacheKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var displaying: Bool { + get { return value(_displayingKey, false) } + set { objc_setAssociatedObject(self, _displayingKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + private var isPlaying: Bool { + get { + return value(_isPlayingKey, false) + } + set { + objc_setAssociatedObject(self, _isPlayingKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + if newValue { + delegate?.gifDidStart?(sender: self) + } else { + delegate?.gifDidStop?(sender: self) + } + } + } + + 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 { + return (objc_getAssociatedObject(self, key!) as? T) ?? defaultValue + } + + private func possiblyNil(_ key:UnsafeMutableRawPointer?) -> T? { + let result = objc_getAssociatedObject(self, key!) + + if result == nil { + return nil + } + + return (result as? T) + } +} + +#endif diff --git a/Sources/StreamChatSwiftUI/StreamSwiftyGif/ObjcAssociatedWeakObject.swift b/Sources/StreamChatSwiftUI/StreamSwiftyGif/ObjcAssociatedWeakObject.swift new file mode 100644 index 00000000..bf99ac25 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamSwiftyGif/ObjcAssociatedWeakObject.swift @@ -0,0 +1,18 @@ +// +// ObjcAssociatedWeakObject.swift +// + +import Foundation + +func objc_getAssociatedWeakObject(_ object: AnyObject, _ key: UnsafeRawPointer) -> AnyObject? { + let block: (() -> AnyObject?)? = objc_getAssociatedObject(object, key) as? (() -> AnyObject?) + return block != nil ? block?() : nil +} + +func objc_setAssociatedWeakObject(_ object: AnyObject, _ key: UnsafeRawPointer, _ value: AnyObject?) { + weak var weakValue = value + let block: (() -> AnyObject?)? = { + return weakValue + } + objc_setAssociatedObject(object, key, block, .OBJC_ASSOCIATION_COPY) +} diff --git a/Sources/StreamChatSwiftUI/StreamSwiftyGif/SwiftyGifManager.swift b/Sources/StreamChatSwiftUI/StreamSwiftyGif/SwiftyGifManager.swift new file mode 100755 index 00000000..3d0f0692 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamSwiftyGif/SwiftyGifManager.swift @@ -0,0 +1,173 @@ +// +// SwiftyGifManager.swift +// +// +import ImageIO + +#if os(macOS) +import AppKit +import CoreVideo +#else +import UIKit +#endif + +#if os(macOS) +typealias PlatformImageView = NSImageView +#else +typealias PlatformImageView = UIImageView +#endif + +class SwiftyGifManager { + + // A convenient default manager if we only have one gif to display here and there + static var defaultManager = SwiftyGifManager(memoryLimit: 50) + + #if os(macOS) + fileprivate var timer: CVDisplayLink? + #else + fileprivate var timer: CADisplayLink? + #endif + + fileprivate var displayViews: [PlatformImageView] = [] + fileprivate var totalGifSize: Int + fileprivate var memoryLimit: Int + var haveCache: Bool + var remoteCache : [URL : Data] = [:] + + /// Initialize a manager + /// + /// - Parameter memoryLimit: The number of Mb max for this manager + init(memoryLimit: Int) { + self.memoryLimit = memoryLimit + totalGifSize = 0 + haveCache = true + } + + deinit { + stopTimer() + } + + func startTimerIfNeeded() { + guard timer == nil else { + return + } + + #if os(macOS) + + func displayLinkOutputCallback(displayLink: CVDisplayLink, + _ inNow: UnsafePointer, + _ inOutputTime: UnsafePointer, + _ flagsIn: CVOptionFlags, + _ flagsOut: UnsafeMutablePointer, + _ displayLinkContext: UnsafeMutableRawPointer?) -> CVReturn { + unsafeBitCast(displayLinkContext!, to: SwiftyGifManager.self).updateImageView() + return kCVReturnSuccess + } + + CVDisplayLinkCreateWithActiveCGDisplays(&timer) + CVDisplayLinkSetOutputCallback(timer!, displayLinkOutputCallback, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())) + CVDisplayLinkStart(timer!) + + #else + + timer = CADisplayLink(target: self, selector: #selector(updateImageView)) + + #if swift(>=4.2) + timer?.add(to: .main, forMode: .common) + #else + timer?.add(to: .main, forMode: RunLoopMode.commonModes) + #endif + + #endif + } + + func stopTimer() { + #if os(macOS) + CVDisplayLinkStop(timer!) + #else + timer?.invalidate() + #endif + + timer = nil + } + + /// Add a new imageView to this manager if it doesn't exist + /// - Parameter imageView: The image view we're adding to this manager + func addImageView(_ imageView: PlatformImageView) -> Bool { + if containsImageView(imageView) { + startTimerIfNeeded() + return false + } + + updateCacheSize(for: imageView, add: true) + displayViews.append(imageView) + startTimerIfNeeded() + + return true + } + + /// Delete an imageView from this manager if it exists + /// - Parameter imageView: The image view we want to delete + func deleteImageView(_ imageView: PlatformImageView) { + guard let index = displayViews.firstIndex(of: imageView) else { + return + } + + displayViews.remove(at: index) + updateCacheSize(for: imageView, add: false) + } + + func updateCacheSize(for imageView: PlatformImageView, add: Bool) { + totalGifSize += (add ? 1 : -1) * (imageView.gifImage?.imageSize ?? 0) + haveCache = totalGifSize <= memoryLimit + + for imageView in displayViews { + DispatchQueue.global(qos: .userInteractive).sync(execute: imageView.updateCache) + } + } + + func clear() { + displayViews.forEach { $0.clear() } + displayViews = [] + stopTimer() + } + + /// 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 + 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 + func hasCache(_ imageView: PlatformImageView) -> Bool { + return imageView.displaying && (imageView.loopCount == -1 || imageView.loopCount >= 5) ? haveCache : false + } + + /// Update imageView current image. This method is called by the main loop. + /// This is what create the animation. + @objc func updateImageView() { + guard !displayViews.isEmpty else { + stopTimer() + return + } + + #if os(macOS) + let queue = DispatchQueue.main + #else + let queue = DispatchQueue.global(qos: .userInteractive) + #endif + + for imageView in displayViews { + queue.sync { + imageView.image = imageView.currentImage + } + + if imageView.isAnimatingGif() { + queue.sync(execute: imageView.updateCurrentImage) + } + } + } +} diff --git a/Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImage+SwiftyGif.swift b/Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImage+SwiftyGif.swift new file mode 100755 index 00000000..96fe8fe2 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImage+SwiftyGif.swift @@ -0,0 +1,347 @@ +// +// UIImage+SwiftyGif.swift +// + +#if !os(macOS) + +import ImageIO +import UIKit + +typealias GifLevelOfIntegrity = Float + +extension GifLevelOfIntegrity { + static let highestNoFrameSkipping: GifLevelOfIntegrity = 1 + static let `default`: GifLevelOfIntegrity = 0.8 + static let lowForManyGifs: GifLevelOfIntegrity = 0.5 + static let lowForTooManyGifs: GifLevelOfIntegrity = 0.2 + static let superLowForSlideShow: GifLevelOfIntegrity = 0.1 +} + +enum GifParseError: Error { + case invalidFilename + case noImages + case noProperties + case noGifDictionary + case noTimingInfo +} + +extension GifParseError: LocalizedError { + var errorDescription: String? { + switch self { + case .invalidFilename: + return "Invalid file name" + case .noImages,.noProperties, .noGifDictionary,.noTimingInfo: + return "Invalid gif file " + } + } +} + +extension UIImage { + /// Convenience initializer. Creates a gif with its backing data. + /// + /// - 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 { + do { + try self.init(gifData: imageData, levelOfIntegrity: levelOfIntegrity) + } catch { + self.init(data: imageData) + } + } + + /// Convenience initializer. Creates a image with its backing data. + /// + /// - Parameter imageName: Filename + /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping + convenience init?(imageName: String, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { + self.init() + + do { + try setGif(imageName, levelOfIntegrity: levelOfIntegrity) + } catch { + self.init(named: imageName) + } + } +} + +// MARK: - Inits + +extension UIImage { + + /// Convenience initializer. Creates a gif with its backing data. + /// + /// - Parameter gifData: The actual gif data + /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping + convenience init(gifData:Data, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { + self.init() + try setGifFromData(gifData, levelOfIntegrity: levelOfIntegrity) + } + + /// Convenience initializer. Creates a gif with its backing data. + /// + /// - Parameter gifName: Filename + /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping + convenience init(gifName: String, levelOfIntegrity: GifLevelOfIntegrity = .default) throws { + self.init() + try setGif(gifName, levelOfIntegrity: levelOfIntegrity) + } + + /// Set backing data for this gif. Overwrites any existing data. + /// + /// - Parameter data: The actual gif data + /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping + func setGifFromData(_ data: Data, levelOfIntegrity: GifLevelOfIntegrity) throws { + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else { return } + self.imageSource = imageSource + imageData = data + + calculateFrameDelay(try delayTimes(imageSource), levelOfIntegrity: levelOfIntegrity) + calculateFrameSize() + } + + /// Set backing data for this gif. Overwrites any existing data. + /// + /// - Parameter name: Filename + func setGif(_ name: String) throws { + try setGif(name, levelOfIntegrity: .default) + } + + /// Check the number of frame for this gif + /// + /// - Return number of frames + func framesCount() -> Int { + return displayOrder?.count ?? 0 + } + + /// Set backing data for this gif. Overwrites any existing data. + /// + /// - Parameter name: Filename + /// - Parameter levelOfIntegrity: 0 to 1, 1 meaning no frame skipping + 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) { + try setGifFromData(data, levelOfIntegrity: levelOfIntegrity) + } + } else { + throw GifParseError.invalidFilename + } + } + + func clear() { + imageData = nil + imageSource = nil + displayOrder = nil + imageCount = nil + imageSize = nil + displayRefreshFactor = nil + } + + // MARK: Logic + + private func convertToDelay(_ pointer:UnsafeRawPointer?) -> Float? { + if pointer == nil { + return nil + } + + return unsafeBitCast(pointer, to:AnyObject.self).floatValue + } + + /// Get delay times for each frames + /// + /// - Parameter imageSource: reference to the gif image source + /// - Returns array of delays + private func delayTimes(_ imageSource:CGImageSource) throws -> [Float] { + let imageCount = CGImageSourceGetCount(imageSource) + + guard imageCount > 0 else { + throw GifParseError.noImages + } + + var imageProperties = [CFDictionary]() + + for i in 0.. CFDictionary in + let key = Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque() + let value = CFDictionaryGetValue(dict, key) + + if value == nil { + throw GifParseError.noGifDictionary + } + + return unsafeBitCast(value, to: CFDictionary.self) + } + + let EPS:Float = 1e-6 + + let frameDelays:[Float] = try frameProperties.map() { + let unclampedKey = Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque() + let unclampedPointer:UnsafeRawPointer? = CFDictionaryGetValue($0, unclampedKey) + + if let value = convertToDelay(unclampedPointer), value >= EPS { + return value + } + + let clampedKey = Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque() + let clampedPointer:UnsafeRawPointer? = CFDictionaryGetValue($0, clampedKey) + + if let value = convertToDelay(clampedPointer) { + return value + } + + throw GifParseError.noTimingInfo + } + + return frameDelays + } + + /// Compute backing data for this gif + /// + /// - 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) { + let levelOfIntegrity = max(0, min(1, levelOfIntegrity)) + var delays = delaysArray + + var displayRefreshFactors = [Int]() + + if #available(iOS 10.3, *) { + // Will be 120 on devices with ProMotion display, 60 otherwise. + displayRefreshFactors.append(UIScreen.main.maximumFramesPerSecond) + } + + if let first = displayRefreshFactors.first, first != 60 { + // Append 60 if needed. + displayRefreshFactors.append(60) + } + + displayRefreshFactors.append(contentsOf: [30, 20, 15, 12, 10, 6, 5, 4, 3, 2, 1]) + + // maxFramePerSecond,default is 60 + let maxFramePerSecond = displayRefreshFactors[0] + + // frame numbers per second + let displayRefreshRates = displayRefreshFactors.map { maxFramePerSecond / $0 } + + // time interval per frame + let displayRefreshDelayTime = displayRefreshRates.map { 1 / Float($0) } + + // caclulate the time when each frame should be displayed at(start at 0) + for i in delays.indices.dropFirst() { + delays[i] += delays[i - 1] + } + + //find the appropriate Factors then BREAK + for (i, delayTime) in displayRefreshDelayTime.enumerated() { + let displayPosition = delays.map { Int($0 / delayTime) } + var frameLoseCount: Float = 0 + + for j in displayPosition.indices.dropFirst() where displayPosition[j] == displayPosition[j - 1] { + frameLoseCount += 1 + } + + if displayPosition.first == 0 { + frameLoseCount += 1 + } + + if frameLoseCount <= Float(displayPosition.count) * (1 - levelOfIntegrity) || i == displayRefreshDelayTime.count - 1 { + imageCount = displayPosition.last + displayRefreshFactor = displayRefreshFactors[i] + displayOrder = [] + var oldIndex = 0 + var newIndex = 1 + let imageCount = self.imageCount ?? 0 + + while newIndex <= imageCount && oldIndex < displayPosition.count { + if newIndex <= displayPosition[oldIndex] { + displayOrder?.append(oldIndex) + newIndex += 1 + } else { + oldIndex += 1 + } + } + + break + } + } + } + + /// Compute frame size for this gif + private func calculateFrameSize(){ + guard let imageSource = imageSource, + let imageCount = imageCount, + let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { + return + } + + let image = UIImage(cgImage: cgImage) + imageSize = Int(image.size.height * image.size.width * 4) * imageCount / 1_000_000 + } +} + +// 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) + +extension UIImage { + + var imageSource: CGImageSource? { + get { + let result = objc_getAssociatedObject(self, _imageSourceKey!) + return result == nil ? nil : (result as! CGImageSource) + } + set { + objc_setAssociatedObject(self, _imageSourceKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + var displayRefreshFactor: Int?{ + get { return objc_getAssociatedObject(self, _displayRefreshFactorKey!) as? Int } + set { objc_setAssociatedObject(self, _displayRefreshFactorKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var imageSize: Int?{ + get { return objc_getAssociatedObject(self, _imageSizeKey!) as? Int } + set { objc_setAssociatedObject(self, _imageSizeKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var imageCount: Int?{ + get { return objc_getAssociatedObject(self, _imageCountKey!) as? Int } + set { objc_setAssociatedObject(self, _imageCountKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var displayOrder: [Int]?{ + get { return objc_getAssociatedObject(self, _displayOrderKey!) as? [Int] } + set { objc_setAssociatedObject(self, _displayOrderKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var imageData:Data? { + get { + let result = objc_getAssociatedObject(self, _imageDataKey!) + return result == nil ? nil : (result as? Data) + } + set { + objc_setAssociatedObject(self, _imageDataKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +extension String { + fileprivate func pathExtension() -> String { + return (self as NSString).pathExtension + } +} + +#endif diff --git a/Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImageView+SwiftyGif.swift b/Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImageView+SwiftyGif.swift new file mode 100755 index 00000000..88d6c7d3 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImageView+SwiftyGif.swift @@ -0,0 +1,486 @@ +// +// UIImageView+SwiftyGif.swift +// + +#if !os(macOS) + +import ImageIO +import UIKit + +@objc protocol SwiftyGifDelegate { + @objc optional func gifDidStart(sender: UIImageView) + @objc optional func gifDidLoop(sender: UIImageView) + @objc optional func gifDidStop(sender: UIImageView) + @objc optional func gifURLDidFinish(sender: UIImageView) + @objc optional func gifURLDidFail(sender: UIImageView, url: URL, error: Error?) +} + +extension UIImageView { + /// Set an image and a manager to an existing UIImageView. If the image is not an GIF image, set it in normal way and remove self form SwiftyGifManager + /// + /// WARNING : this overwrite any previous gif. + /// - Parameter gifImage: The UIImage containing the gif backing data + /// - Parameter manager: The manager to handle the gif display + /// - Parameter loopCount: The number of loops we want for this gif. -1 means infinite. + func setImage(_ image: UIImage, manager: SwiftyGifManager = .defaultManager, loopCount: Int = -1) { + if let _ = image.imageData { + setGifImage(image, manager: manager, loopCount: loopCount) + } else { + manager.deleteImageView(self) + self.image = image + } + } +} + +extension UIImageView { + + // MARK: - Inits + + /// Convenience initializer. Creates a gif holder (defaulted to infinite loop). + /// + /// - Parameter gifImage: The UIImage containing the gif backing data + /// - Parameter manager: The manager to handle the gif display + convenience init(gifImage: UIImage, manager: SwiftyGifManager = .defaultManager, loopCount: Int = -1) { + self.init() + setGifImage(gifImage,manager: manager, loopCount: loopCount) + } + + /// Convenience initializer. Creates a gif holder (defaulted to infinite loop). + /// + /// - Parameter gifImage: The UIImage containing the gif backing data + /// - Parameter manager: The manager to handle the gif display + convenience init(gifURL: URL, manager: SwiftyGifManager = .defaultManager, loopCount: Int = -1) { + self.init() + setGifFromURL(gifURL, manager: manager, loopCount: loopCount) + } + + /// Set a gif image and a manager to an existing UIImageView. + /// + /// WARNING : this overwrite any previous gif. + /// - Parameter gifImage: The UIImage containing the gif backing data + /// - Parameter manager: The manager to handle the gif display + /// - Parameter loopCount: The number of loops we want for this gif. -1 means infinite. + func setGifImage(_ gifImage: UIImage, manager: SwiftyGifManager = .defaultManager, loopCount: Int = -1) { + if let imageData = gifImage.imageData, (gifImage.imageCount ?? 0) < 1 { + image = UIImage(data: imageData) + return + } + + self.loopCount = loopCount + self.gifImage = gifImage + animationManager = manager + syncFactor = 0 + displayOrderIndex = 0 + cache = NSCache() + haveCache = false + + if let source = gifImage.imageSource, let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) { + currentImage = UIImage(cgImage: cgImage) + + if manager.addImageView(self) { + startDisplay() + startAnimatingGif() + } + } + } +} + +// MARK: - Download gif + +extension UIImageView { + + /// Download gif image and sets it. + /// + /// - Parameters: + /// - url: The URL pointing to the gif data + /// - manager: The manager to handle the gif display + /// - loopCount: The number of loops we want for this gif. -1 means infinite. + /// - showLoader: Show UIActivityIndicatorView or not + /// - Returns: An URL session task. Note: You can cancel the downloading task if it needed. + @discardableResult + func setGifFromURL(_ url: URL, + manager: SwiftyGifManager = .defaultManager, + loopCount: Int = -1, + levelOfIntegrity: GifLevelOfIntegrity = .default, + session: URLSession = URLSession.shared, + showLoader: Bool = true, + customLoader: UIView? = nil) -> URLSessionDataTask? { + + if let data = manager.remoteCache[url] { + self.parseDownloadedGif(url: url, + data: data, + error: nil, + manager: manager, + loopCount: loopCount, + levelOfIntegrity: levelOfIntegrity) + return nil + } + + stopAnimatingGif() + + let loader: UIView? = showLoader ? createLoader(from: customLoader) : nil + + let task = session.dataTask(with: url) { [weak self] data, _, error in + DispatchQueue.main.async { + loader?.removeFromSuperview() + self?.parseDownloadedGif(url: url, + data: data, + error: error, + manager: manager, + loopCount: loopCount, + levelOfIntegrity: levelOfIntegrity) + } + } + + task.resume() + + return task + } + + private func createLoader(from view: UIView? = nil) -> UIView { + let loader = view ?? UIActivityIndicatorView() + addSubview(loader) + loader.translatesAutoresizingMaskIntoConstraints = false + + addConstraint(NSLayoutConstraint( + item: loader, + attribute: .centerX, + relatedBy: .equal, + toItem: self, + attribute: .centerX, + multiplier: 1, + constant: 0)) + + addConstraint(NSLayoutConstraint( + item: loader, + attribute: .centerY, + relatedBy: .equal, + toItem: self, + attribute: .centerY, + multiplier: 1, + constant: 0)) + + (loader as? UIActivityIndicatorView)?.startAnimating() + + return loader + } + + private func parseDownloadedGif(url: URL, + data: Data?, + error: Error?, + manager: SwiftyGifManager, + loopCount: Int, + levelOfIntegrity: GifLevelOfIntegrity) { + guard let data = data else { + report(url: url, error: error) + return + } + + do { + let image = try UIImage(gifData: data, levelOfIntegrity: levelOfIntegrity) + manager.remoteCache[url] = data + setGifImage(image, manager: manager, loopCount: loopCount) + startAnimatingGif() + delegate?.gifURLDidFinish?(sender: self) + } catch { + report(url: url, error: error) + } + } + + private func report(url: URL, error: Error?) { + delegate?.gifURLDidFail?(sender: self, url: url, error: error) + } +} + +// MARK: - Logic + +extension UIImageView { + + /// Start displaying the gif for this UIImageView. + private func startDisplay() { + displaying = true + updateCache() + } + + /// Stop displaying the gif for this UIImageView. + private func stopDisplay() { + displaying = false + updateCache() + } + + /// Start displaying the gif for this UIImageView. + func startAnimatingGif() { + isPlaying = true + } + + /// Stop displaying the gif for this UIImageView. + func stopAnimatingGif() { + isPlaying = false + } + + /// Check if this imageView is currently playing a gif + /// + /// - Returns wether the gif is currently playing + func isAnimatingGif() -> Bool{ + return isPlaying + } + + /// Show a specific frame based on a delta from current frame + /// + /// - Parameter delta: The delsta from current frame we want + func showFrameForIndexDelta(_ delta: Int) { + guard let gifImage = gifImage else { return } + var nextIndex = displayOrderIndex + delta + + while nextIndex >= gifImage.framesCount() { + nextIndex -= gifImage.framesCount() + } + + while nextIndex < 0 { + nextIndex += gifImage.framesCount() + } + + showFrameAtIndex(nextIndex) + } + + /// Show a specific frame + /// + /// - Parameter index: The index of frame to show + func showFrameAtIndex(_ index: Int) { + displayOrderIndex = index + updateFrame() + } + + /// Update cache for the current imageView. + func updateCache() { + guard let animationManager = animationManager else { return } + + if animationManager.hasCache(self) && !haveCache { + prepareCache() + haveCache = true + } else if !animationManager.hasCache(self) && haveCache { + cache?.removeAllObjects() + haveCache = false + } + } + + /// Update current image displayed. This method is called by the manager. + func updateCurrentImage() { + if displaying { + updateFrame() + updateIndex() + + if loopCount == 0 || !isDisplayedInScreen(self) || !isPlaying { + stopDisplay() + } + } else { + if isDisplayedInScreen(self) && loopCount != 0 && isPlaying { + startDisplay() + } + + if isDiscarded(self) { + animationManager?.deleteImageView(self) + } + } + } + + /// Force update frame + private func updateFrame() { + if haveCache, let image = cache?.object(forKey: displayOrderIndex as AnyObject) as? UIImage { + currentImage = image + } else { + currentImage = frameAtIndex(index: currentFrameIndex()) + } + } + + /// Get current frame index + func currentFrameIndex() -> Int{ + return displayOrderIndex + } + + /// Get frame at specific index + func frameAtIndex(index: Int) -> UIImage { + guard let gifImage = gifImage, + let imageSource = gifImage.imageSource, + let displayOrder = gifImage.displayOrder, index < displayOrder.count, + let cgImage = CGImageSourceCreateImageAtIndex(imageSource, displayOrder[index], nil) else { + return UIImage() + } + + return UIImage(cgImage: cgImage) + } + + /// 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 + } + + /// 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 + } + + 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() { + if let gifImage = gifImage { + gifImage.clear() + } + + gifImage = nil + currentImage = nil + cache?.removeAllObjects() + animationManager = nil + image = nil + } + + /// Update loop count and sync factor. + private func updateIndex() { + guard let gif = self.gifImage, + let displayRefreshFactor = gif.displayRefreshFactor, + displayRefreshFactor > 0 else { + return + } + + syncFactor = (syncFactor + 1) % displayRefreshFactor + + if syncFactor == 0, let imageCount = gif.imageCount, imageCount > 0 { + displayOrderIndex = (displayOrderIndex+1) % imageCount + + if displayOrderIndex == 0 { + if loopCount == -1 { + delegate?.gifDidLoop?(sender: self) + } else if loopCount > 1 { + delegate?.gifDidLoop?(sender: self) + loopCount -= 1 + } else { + delegate?.gifDidStop?(sender: self) + loopCount -= 1 + } + } + } + } + + /// Prepare the cache by adding every images of the gif to an NSCache object. + private func prepareCache() { + guard let cache = self.cache else { return } + + cache.removeAllObjects() + + guard let gif = self.gifImage, + let displayOrder = gif.displayOrder, + let imageSource = gif.imageSource else { return } + + for (i, order) in displayOrder.enumerated() { + guard let cgImage = CGImageSourceCreateImageAtIndex(imageSource, order, nil) else { continue } + + cache.setObject(UIImage(cgImage: cgImage), forKey: i as AnyObject) + } + } +} + +// 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) + +extension UIImageView { + + var gifImage: UIImage? { + get { return possiblyNil(_gifImageKey) } + set { objc_setAssociatedObject(self, _gifImageKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var currentImage: UIImage? { + get { return possiblyNil(_currentImageKey) } + set { objc_setAssociatedObject(self, _currentImageKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + private var displayOrderIndex: Int { + get { return value(_displayOrderIndexKey, 0) } + set { objc_setAssociatedObject(self, _displayOrderIndexKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + private var syncFactor: Int { + get { return value(_syncFactorKey, 0) } + set { objc_setAssociatedObject(self, _syncFactorKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var loopCount: Int { + get { return value(_loopCountKey, 0) } + set { objc_setAssociatedObject(self, _loopCountKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var animationManager: SwiftyGifManager? { + get { return (objc_getAssociatedObject(self, _animationManagerKey!) as? SwiftyGifManager) } + set { objc_setAssociatedObject(self, _animationManagerKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var delegate: SwiftyGifDelegate? { + get { return (objc_getAssociatedWeakObject(self, _delegateKey!) as? SwiftyGifDelegate) } + set { objc_setAssociatedWeakObject(self, _delegateKey!, newValue) } + } + + private var haveCache: Bool { + get { return value(_haveCacheKey, false) } + set { objc_setAssociatedObject(self, _haveCacheKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var displaying: Bool { + get { return value(_displayingKey, false) } + set { objc_setAssociatedObject(self, _displayingKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + private var isPlaying: Bool { + get { + return value(_isPlayingKey, false) + } + set { + objc_setAssociatedObject(self, _isPlayingKey!, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + if newValue { + delegate?.gifDidStart?(sender: self) + } + } + } + + 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 { + return (objc_getAssociatedObject(self, key!) as? T) ?? defaultValue + } + + private func possiblyNil(_ key:UnsafeMutableRawPointer?) -> T? { + let result = objc_getAssociatedObject(self, key!) + + if result == nil { + return nil + } + + return (result as? T) + } +} + +#endif diff --git a/Sources/StreamChatSwiftUI/Utils.swift b/Sources/StreamChatSwiftUI/Utils.swift index 004da6d4..b84029eb 100644 --- a/Sources/StreamChatSwiftUI/Utils.swift +++ b/Sources/StreamChatSwiftUI/Utils.swift @@ -26,6 +26,7 @@ public class Utils { public var snapshotCreator: SnapshotCreator public var messageIdBuilder: MessageIdBuilder public var sortReactions: (MessageReactionType, MessageReactionType) -> Bool + public var channelHeaderLoader: ChannelHeaderLoader var messageCachingUtils = MessageCachingUtils() var messageListDateUtils: MessageListDateUtils @@ -48,6 +49,7 @@ public class Utils { chatUserNamer: ChatUserNamer = DefaultChatUserNamer(), snapshotCreator: SnapshotCreator = DefaultSnapshotCreator(), messageIdBuilder: MessageIdBuilder = DefaultMessageIdBuilder(), + channelHeaderLoader: ChannelHeaderLoader = ChannelHeaderLoader(), sortReactions: @escaping (MessageReactionType, MessageReactionType) -> Bool = Utils.defaultSortReactions, shouldSyncChannelControllerOnAppear: @escaping (ChatChannelController) -> Bool = { _ in true } ) { @@ -69,6 +71,7 @@ public class Utils { self.messageIdBuilder = messageIdBuilder self.shouldSyncChannelControllerOnAppear = shouldSyncChannelControllerOnAppear self.sortReactions = sortReactions + self.channelHeaderLoader = channelHeaderLoader messageListDateUtils = MessageListDateUtils(messageListConfig: messageListConfig) } diff --git a/Sources/StreamChatSwiftUI/Utils/Common/ChatMessage+Extensions.swift b/Sources/StreamChatSwiftUI/Utils/Common/ChatMessage+Extensions.swift index 66debe45..6fa6f782 100644 --- a/Sources/StreamChatSwiftUI/Utils/Common/ChatMessage+Extensions.swift +++ b/Sources/StreamChatSwiftUI/Utils/Common/ChatMessage+Extensions.swift @@ -85,4 +85,13 @@ public extension ChatMessage { var adjustedText: String { InjectedValues[\.utils].composerConfig.adjustMessageOnRead(text) } + + var isRightAligned: Bool { + let config = InjectedValues[\.utils].messageListConfig + let messageListAlignment = config.messageListAlignment + if messageListAlignment == .leftAligned { + return false + } + return isSentByCurrentUser + } } diff --git a/Sources/StreamChatSwiftUI/Utils/Common/NukeImageProcessor.swift b/Sources/StreamChatSwiftUI/Utils/Common/NukeImageProcessor.swift index ccadc069..c4747bdc 100644 --- a/Sources/StreamChatSwiftUI/Utils/Common/NukeImageProcessor.swift +++ b/Sources/StreamChatSwiftUI/Utils/Common/NukeImageProcessor.swift @@ -2,7 +2,6 @@ // Copyright © 2023 Stream.io Inc. All rights reserved. // -import Nuke import UIKit public protocol ImageProcessor { diff --git a/Sources/StreamChatSwiftUI/Utils/LazyImageExtensions.swift b/Sources/StreamChatSwiftUI/Utils/LazyImageExtensions.swift index d1d47bf5..f6462007 100644 --- a/Sources/StreamChatSwiftUI/Utils/LazyImageExtensions.swift +++ b/Sources/StreamChatSwiftUI/Utils/LazyImageExtensions.swift @@ -2,13 +2,11 @@ // Copyright © 2023 Stream.io Inc. All rights reserved. // -import Nuke -import NukeUI import SwiftUI extension LazyImage { - public init(imageURL: URL?) where Content == NukeUI.Image { + public init(imageURL: URL?) where Content == NukeImage { let imageCDN = InjectedValues[\.utils].imageCDN guard let imageURL = imageURL else { #if COCOAPODS diff --git a/Sources/StreamChatSwiftUI/Utils/NukeImageLoader.swift b/Sources/StreamChatSwiftUI/Utils/NukeImageLoader.swift index 80d09210..8e1ed454 100644 --- a/Sources/StreamChatSwiftUI/Utils/NukeImageLoader.swift +++ b/Sources/StreamChatSwiftUI/Utils/NukeImageLoader.swift @@ -2,13 +2,13 @@ // Copyright © 2023 Stream.io Inc. All rights reserved. // -import Nuke import StreamChat import UIKit /// The class which is resposible for loading images from URLs. /// Internally uses `Nuke`'s shared object of `ImagePipeline` to load the image. open class NukeImageLoader: ImageLoading { + public init() { // Public init. } @@ -16,7 +16,6 @@ open class NukeImageLoader: ImageLoading { open func loadImage( using urlRequest: URLRequest, cachingKey: String?, - priority: ImageRequest.Priority = .normal, completion: @escaping ((Result) -> Void) ) { var userInfo: [ImageRequest.UserInfoKey: Any]? @@ -60,7 +59,7 @@ open class NukeImageLoader: ImageLoading { group.enter() - loadImage(using: imageRequest, cachingKey: cachingKey, priority: .low) { result in + loadImage(using: imageRequest, cachingKey: cachingKey) { result in switch result { case let .success(image): images.append(image) diff --git a/StreamChatSwiftUI-XCFramework.podspec b/StreamChatSwiftUI-XCFramework.podspec new file mode 100644 index 00000000..f07e3926 --- /dev/null +++ b/StreamChatSwiftUI-XCFramework.podspec @@ -0,0 +1,25 @@ +Pod::Spec.new do |spec| + spec.name = "StreamChatSwiftUI-XCFramework" + spec.version = "4.39.0" + spec.summary = "StreamChat SwiftUI Chat Components" + spec.description = "StreamChatSwiftUI SDK offers flexible SwiftUI components able to display data provided by StreamChat SDK." + + spec.homepage = "https://getstream.io/chat/" + 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.2' + spec.platform = :ios, "14.0" + spec.requires_arc = true + + spec.module_name = "StreamChatSwiftUI" + spec.source = { :http => "https://github.com/GetStream/stream-chat-swiftui/releases/download/#{spec.version}/#{spec.module_name}.zip" } + spec.vendored_frameworks = "#{spec.module_name}.xcframework" + spec.preserve_paths = "#{spec.module_name}.xcframework/*" + + spec.framework = "Foundation", "UIKit", "SwiftUI" + + spec.dependency "StreamChat-XCFramework", "~> 4.39.0" + + spec.cocoapods_version = ">= 1.11.0" +end diff --git a/StreamChatSwiftUI.podspec b/StreamChatSwiftUI.podspec index 22eb0fe6..4318e481 100644 --- a/StreamChatSwiftUI.podspec +++ b/StreamChatSwiftUI.podspec @@ -2,8 +2,8 @@ Pod::Spec.new do |spec| spec.name = "StreamChatSwiftUI" spec.version = "4.39.0" spec.summary = "StreamChat SwiftUI Chat Components" - spec.description = "StreamChatUI SDK offers flexible SwiftUI components able to display data provided by StreamChat SDK." - + spec.description = "StreamChatSwiftUI SDK offers flexible SwiftUI components able to display data provided by StreamChat SDK." + spec.homepage = "https://getstream.io/chat/" spec.license = { :type => "BSD-3", :file => "LICENSE" } spec.author = { "getstream.io" => "support@getstream.io" } @@ -12,15 +12,13 @@ Pod::Spec.new do |spec| spec.platform = :ios, "14.0" spec.source = { :git => "https://github.com/GetStream/stream-chat-swiftui.git" } spec.requires_arc = true - - spec.source_files = "Sources/StreamChatSwiftUI/**/*.swift" + + spec.source_files = ["Sources/StreamChatSwiftUI/**/*.swift"] spec.exclude_files = ["Sources/StreamChatSwiftUI/**/*_Tests.swift", "Sources/StreamChatSwiftUI/**/*_Mock.swift"] spec.resource_bundles = { "StreamChatSwiftUI" => ["Sources/StreamChatSwiftUI/Resources/**/*"] } - + spec.framework = "Foundation", "UIKit", "SwiftUI" - - spec.dependency "StreamChat", "~> 4.39.0" - spec.dependency "SwiftyGif", "~> 5.0" - spec.dependency "NukeUI", "0.8.0" + + spec.dependency "StreamChat", "~> 4.40.0" end - + diff --git a/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj index aa11b289..1375865c 100644 --- a/StreamChatSwiftUI.xcodeproj/project.pbxproj +++ b/StreamChatSwiftUI.xcodeproj/project.pbxproj @@ -24,6 +24,93 @@ 82A1814428FD69AE005F9D43 /* SlowMode_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82A1814328FD69AE005F9D43 /* SlowMode_Tests.swift */; }; 82A1814728FD69F8005F9D43 /* MessageDeliveryStatus_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82A1814628FD69F8005F9D43 /* MessageDeliveryStatus_Tests.swift */; }; 82A1814928FD6A0C005F9D43 /* MessageDeliveryStatus+ChannelList_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82A1814828FD6A0C005F9D43 /* MessageDeliveryStatus+ChannelList_Tests.swift */; }; + 82D64B662AD7E5AC00C5C79E /* UIImageView+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B602AD7E5AC00C5C79E /* UIImageView+SwiftyGif.swift */; }; + 82D64B672AD7E5AC00C5C79E /* NSImage+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B612AD7E5AC00C5C79E /* NSImage+SwiftyGif.swift */; }; + 82D64B682AD7E5AC00C5C79E /* ObjcAssociatedWeakObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D64B622AD7E5AC00C5C79E /* ObjcAssociatedWeakObject.swift */; }; + 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 */; }; 8400A345282C05F60067D3A0 /* StreamChatWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400A344282C05F60067D3A0 /* StreamChatWrapper.swift */; }; 8400A34A282C07D60067D3A0 /* InternetConnectionMonitor_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400A349282C07D60067D3A0 /* InternetConnectionMonitor_Mock.swift */; }; 8400A34C282C081E0067D3A0 /* OHHTTPStubs in Frameworks */ = {isa = PBXBuildFile; productRef = 8400A34B282C081E0067D3A0 /* OHHTTPStubs */; }; @@ -34,6 +121,7 @@ 8402EAD4282BFCCA00CCA696 /* UserCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8402EAD3282BFCCA00CCA696 /* UserCredentials.swift */; }; 840A3F3828193AB20084E9CC /* ChatChannelInfoView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840A3F3728193AB20084E9CC /* ChatChannelInfoView_Tests.swift */; }; 8413D90227A9654600A89432 /* SearchResultsView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8413D90127A9654600A89432 /* SearchResultsView_Tests.swift */; }; + 8417AE922ADEDB6400445021 /* UserRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8417AE912ADEDB6400445021 /* UserRepository.swift */; }; 841B2EF4278DB9E500ED619E /* MessageListHelperViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841B2EF3278DB9E500ED619E /* MessageListHelperViews.swift */; }; 841B2EF6278F108700ED619E /* MessageReadIndicatorView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841B2EF5278F108700ED619E /* MessageReadIndicatorView_Tests.swift */; }; 841B64C427744DB60016FF3B /* ComposerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841B64C327744DB60016FF3B /* ComposerModels.swift */; }; @@ -79,6 +167,8 @@ 844EF8ED2809AACD00CC82F9 /* NoContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844EF8EC2809AACD00CC82F9 /* NoContentView.swift */; }; 84507C98281AC40F0081DDC2 /* AddUsersViewModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84507C97281AC40F0081DDC2 /* AddUsersViewModel_Tests.swift */; }; 84507C9A281ACCD70081DDC2 /* AddUsersView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84507C99281ACCD70081DDC2 /* AddUsersView_Tests.swift */; }; + 8451617E2AE7B093000A9230 /* AppleMessageComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8451617D2AE7B093000A9230 /* AppleMessageComposerView.swift */; }; + 845161802AE7C4E2000A9230 /* WhatsAppChannelHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8451617F2AE7C4E2000A9230 /* WhatsAppChannelHeader.swift */; }; 8463D9262836617F002B1894 /* ChannelListPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8463D9252836617F002B1894 /* ChannelListPage.swift */; }; 8465FBBE2746873A00AF091E /* StreamChatSwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */; }; 8465FCBF27468B6900AF091E /* DemoAppSwiftUIApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8465FCBE27468B6900AF091E /* DemoAppSwiftUIApp.swift */; }; @@ -223,6 +313,8 @@ 8492974B27ABDDCB00A8EEB0 /* NotificationsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8492974827ABDDBF00A8EEB0 /* NotificationsHandler.swift */; }; 8492975227B156D100A8EEB0 /* SlowModeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8492975127B156D000A8EEB0 /* SlowModeView.swift */; }; 8492975427B1725B00A8EEB0 /* MessageComposerView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8492975327B1725B00A8EEB0 /* MessageComposerView_Tests.swift */; }; + 849894952AD96CCC004ACB41 /* ChatChannelListItemView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849894942AD96CCC004ACB41 /* ChatChannelListItemView_Tests.swift */; }; + 849988B02AE6BE4800CC95C9 /* PaddingsConfig_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849988AF2AE6BE4800CC95C9 /* PaddingsConfig_Tests.swift */; }; 849CDD942768E0E1003C7A51 /* MessageActionsResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849CDD932768E0E1003C7A51 /* MessageActionsResolver.swift */; }; 849FD5112811B05C00952934 /* ChatInfoParticipantsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849FD5102811B05C00952934 /* ChatInfoParticipantsView.swift */; }; 84A1CACD2816BC420046595A /* ChatChannelInfoHelperViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A1CACC2816BC420046595A /* ChatChannelInfoHelperViews.swift */; }; @@ -432,7 +524,94 @@ 82A1814328FD69AE005F9D43 /* SlowMode_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlowMode_Tests.swift; sourceTree = ""; }; 82A1814628FD69F8005F9D43 /* MessageDeliveryStatus_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeliveryStatus_Tests.swift; sourceTree = ""; }; 82A1814828FD6A0C005F9D43 /* MessageDeliveryStatus+ChannelList_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageDeliveryStatus+ChannelList_Tests.swift"; sourceTree = ""; }; + 82D64B602AD7E5AC00C5C79E /* UIImageView+SwiftyGif.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImageView+SwiftyGif.swift"; sourceTree = ""; }; + 82D64B612AD7E5AC00C5C79E /* NSImage+SwiftyGif.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSImage+SwiftyGif.swift"; sourceTree = ""; }; + 82D64B622AD7E5AC00C5C79E /* ObjcAssociatedWeakObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjcAssociatedWeakObject.swift; sourceTree = ""; }; + 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 = ""; }; 8400A344282C05F60067D3A0 /* StreamChatWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamChatWrapper.swift; sourceTree = ""; }; 8400A349282C07D60067D3A0 /* InternetConnectionMonitor_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetConnectionMonitor_Mock.swift; sourceTree = ""; }; @@ -445,6 +624,7 @@ 8402EAD3282BFCCA00CCA696 /* UserCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCredentials.swift; sourceTree = ""; }; 840A3F3728193AB20084E9CC /* ChatChannelInfoView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelInfoView_Tests.swift; sourceTree = ""; }; 8413D90127A9654600A89432 /* SearchResultsView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsView_Tests.swift; sourceTree = ""; }; + 8417AE912ADEDB6400445021 /* UserRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepository.swift; sourceTree = ""; }; 841B2EF3278DB9E500ED619E /* MessageListHelperViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageListHelperViews.swift; sourceTree = ""; }; 841B2EF5278F108700ED619E /* MessageReadIndicatorView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReadIndicatorView_Tests.swift; sourceTree = ""; }; 841B64C327744DB60016FF3B /* ComposerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerModels.swift; sourceTree = ""; }; @@ -490,6 +670,8 @@ 844EF8EC2809AACD00CC82F9 /* NoContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoContentView.swift; sourceTree = ""; }; 84507C97281AC40F0081DDC2 /* AddUsersViewModel_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUsersViewModel_Tests.swift; sourceTree = ""; }; 84507C99281ACCD70081DDC2 /* AddUsersView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUsersView_Tests.swift; sourceTree = ""; }; + 8451617D2AE7B093000A9230 /* AppleMessageComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleMessageComposerView.swift; sourceTree = ""; }; + 8451617F2AE7C4E2000A9230 /* WhatsAppChannelHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsAppChannelHeader.swift; sourceTree = ""; }; 8463D9252836617F002B1894 /* ChannelListPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListPage.swift; sourceTree = ""; }; 8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StreamChatSwiftUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 8465FBBD2746873A00AF091E /* StreamChatSwiftUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StreamChatSwiftUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -637,6 +819,8 @@ 8492974827ABDDBF00A8EEB0 /* NotificationsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsHandler.swift; sourceTree = ""; }; 8492975127B156D000A8EEB0 /* SlowModeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlowModeView.swift; sourceTree = ""; }; 8492975327B1725B00A8EEB0 /* MessageComposerView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposerView_Tests.swift; sourceTree = ""; }; + 849894942AD96CCC004ACB41 /* ChatChannelListItemView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelListItemView_Tests.swift; sourceTree = ""; }; + 849988AF2AE6BE4800CC95C9 /* PaddingsConfig_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingsConfig_Tests.swift; sourceTree = ""; }; 849CDD932768E0E1003C7A51 /* MessageActionsResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageActionsResolver.swift; sourceTree = ""; }; 849FD5102811B05C00952934 /* ChatInfoParticipantsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoParticipantsView.swift; sourceTree = ""; }; 84A1CACC2816BC420046595A /* ChatChannelInfoHelperViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelInfoHelperViews.swift; sourceTree = ""; }; @@ -836,6 +1020,235 @@ path = "Message Delivery Status"; sourceTree = ""; }; + 82D64B5F2AD7E5AC00C5C79E /* StreamSwiftyGif */ = { + 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 */, + ); + path = StreamSwiftyGif; + sourceTree = ""; + }; + 82D64B6C2AD7E5B600C5C79E /* StreamNuke */ = { + isa = PBXGroup; + children = ( + 82D64B6D2AD7E5B600C5C79E /* NukeExtensions */, + 82D64B702AD7E5B600C5C79E /* NukeUI */, + 82D64B882AD7E5B600C5C79E /* Nuke */, + ); + name = StreamNuke; + path = Sources/StreamChatSwiftUI/StreamNuke; + sourceTree = SOURCE_ROOT; + }; + 82D64B6D2AD7E5B600C5C79E /* NukeExtensions */ = { + isa = PBXGroup; + children = ( + 82D64B6E2AD7E5B600C5C79E /* ImageLoadingOptions.swift */, + 82D64B6F2AD7E5B600C5C79E /* ImageViewExtensions.swift */, + ); + path = NukeExtensions; + sourceTree = ""; + }; + 82D64B702AD7E5B600C5C79E /* NukeUI */ = { + 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 */, + ); + path = NukeUI; + sourceTree = ""; + }; + 82D64B752AD7E5B600C5C79E /* Gifu */ = { + isa = PBXGroup; + children = ( + 82D64B762AD7E5B600C5C79E /* Classes */, + 82D64B7C2AD7E5B600C5C79E /* Extensions */, + 82D64B812AD7E5B600C5C79E /* Helpers */, + ); + path = Gifu; + sourceTree = ""; + }; + 82D64B762AD7E5B600C5C79E /* Classes */ = { + isa = PBXGroup; + children = ( + 82D64B772AD7E5B600C5C79E /* FrameStore.swift */, + 82D64B782AD7E5B600C5C79E /* GIFAnimatable.swift */, + 82D64B792AD7E5B600C5C79E /* AnimatedFrame.swift */, + 82D64B7A2AD7E5B600C5C79E /* Animator.swift */, + 82D64B7B2AD7E5B600C5C79E /* GIFImageView.swift */, + ); + path = Classes; + sourceTree = ""; + }; + 82D64B7C2AD7E5B600C5C79E /* Extensions */ = { + isa = PBXGroup; + children = ( + 82D64B7D2AD7E5B600C5C79E /* Array.swift */, + 82D64B7E2AD7E5B600C5C79E /* CGSize.swift */, + 82D64B7F2AD7E5B600C5C79E /* UIImage.swift */, + 82D64B802AD7E5B600C5C79E /* UIImageView.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 82D64B812AD7E5B600C5C79E /* Helpers */ = { + isa = PBXGroup; + children = ( + 82D64B822AD7E5B600C5C79E /* ImageSourceHelpers.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 82D64B882AD7E5B600C5C79E /* 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 */, + ); + path = Nuke; + sourceTree = ""; + }; + 82D64B892AD7E5B600C5C79E /* Pipeline */ = { + isa = PBXGroup; + children = ( + 82D64B8A2AD7E5B600C5C79E /* ImagePipeline.swift */, + 82D64B8B2AD7E5B600C5C79E /* ImagePipelineError.swift */, + 82D64B8C2AD7E5B600C5C79E /* ImagePipelineConfiguration.swift */, + 82D64B8D2AD7E5B600C5C79E /* ImagePipelineCache.swift */, + 82D64B8E2AD7E5B600C5C79E /* ImagePipelineDelegate.swift */, + ); + path = Pipeline; + sourceTree = ""; + }; + 82D64B902AD7E5B600C5C79E /* Tasks */ = { + 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 */, + ); + path = Tasks; + sourceTree = ""; + }; + 82D64B992AD7E5B600C5C79E /* Loading */ = { + isa = PBXGroup; + children = ( + 82D64B9A2AD7E5B600C5C79E /* DataLoading.swift */, + 82D64B9B2AD7E5B600C5C79E /* DataLoader.swift */, + ); + path = Loading; + sourceTree = ""; + }; + 82D64B9C2AD7E5B600C5C79E /* Processing */ = { + 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 */, + ); + path = Processing; + sourceTree = ""; + }; + 82D64BA82AD7E5B700C5C79E /* Prefetching */ = { + isa = PBXGroup; + children = ( + 82D64BA92AD7E5B700C5C79E /* ImagePrefetcher.swift */, + ); + path = Prefetching; + sourceTree = ""; + }; + 82D64BAA2AD7E5B700C5C79E /* Internal */ = { + 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 */, + ); + path = Internal; + sourceTree = ""; + }; + 82D64BB82AD7E5B700C5C79E /* Encoding */ = { + isa = PBXGroup; + children = ( + 82D64BB92AD7E5B700C5C79E /* ImageEncoders+Default.swift */, + 82D64BBA2AD7E5B700C5C79E /* ImageEncoders.swift */, + 82D64BBB2AD7E5B700C5C79E /* ImageEncoding.swift */, + 82D64BBC2AD7E5B700C5C79E /* ImageEncoders+ImageIO.swift */, + ); + path = Encoding; + sourceTree = ""; + }; + 82D64BBD2AD7E5B700C5C79E /* Decoding */ = { + isa = PBXGroup; + children = ( + 82D64BBE2AD7E5B700C5C79E /* ImageDecoders+Video.swift */, + 82D64BBF2AD7E5B700C5C79E /* ImageDecoders+Default.swift */, + 82D64BC02AD7E5B700C5C79E /* AssetType.swift */, + 82D64BC12AD7E5B700C5C79E /* ImageDecoders+Empty.swift */, + 82D64BC22AD7E5B700C5C79E /* ImageDecoding.swift */, + 82D64BC32AD7E5B700C5C79E /* ImageDecoderRegistry.swift */, + ); + path = Decoding; + sourceTree = ""; + }; + 82D64BC72AD7E5B700C5C79E /* Caching */ = { + isa = PBXGroup; + children = ( + 82D64BC82AD7E5B700C5C79E /* ImageCache.swift */, + 82D64BC92AD7E5B700C5C79E /* DataCache.swift */, + 82D64BCA2AD7E5B700C5C79E /* NukeCache.swift */, + 82D64BCB2AD7E5B700C5C79E /* ImageCaching.swift */, + 82D64BCC2AD7E5B700C5C79E /* DataCaching.swift */, + ); + path = Caching; + sourceTree = ""; + }; 8400A352282E6BE30067D3A0 /* StreamChatSwiftUITestsAppTests */ = { isa = PBXGroup; children = ( @@ -1010,6 +1423,9 @@ 8465FDDC2747A14700AF091E /* CustomComposerAttachmentView.swift */, 84335013274BAB15007A1B81 /* ViewFactoryExamples.swift */, 84FF723E2782FB2E006E26C8 /* iMessagePocView.swift */, + 8451617D2AE7B093000A9230 /* AppleMessageComposerView.swift */, + 8451617F2AE7C4E2000A9230 /* WhatsAppChannelHeader.swift */, + 8417AE912ADEDB6400445021 /* UserRepository.swift */, 84EDBC36274FE5CD0057218D /* Localizable.strings */, 8465FCCA27468B7500AF091E /* Info.plist */, 8465FCC227468B6A00AF091E /* Assets.xcassets */, @@ -1054,6 +1470,8 @@ 8465FD312746A95600AF091E /* Utils */, 8465FCEC2746A95600AF091E /* Generated */, 8465FCF32746A95600AF091E /* Resources */, + 82D64B6C2AD7E5B600C5C79E /* StreamNuke */, + 82D64B5F2AD7E5AC00C5C79E /* StreamSwiftyGif */, 8465FD5D2746A95700AF091E /* README.md */, 8465FD602746A95700AF091E /* StreamChatSwiftUI.h */, 8465FD682746A95700AF091E /* Info.plist */, @@ -1067,6 +1485,7 @@ children = ( 8465FCED2746A95600AF091E /* L10n.swift */, 8465FCEE2746A95600AF091E /* L10n_template.stencil */, + 82FA42432AE67FF900C7390B /* SystemEnvironment+Version.swift */, ); path = Generated; sourceTree = ""; @@ -1334,6 +1753,7 @@ 84DEC8DC2760A10500172876 /* NoChannelsView_Tests.swift */, 8413D90127A9654600A89432 /* SearchResultsView_Tests.swift */, 84D6B55927DF6EC7009C6D07 /* LoadingView_Tests.swift */, + 849894942AD96CCC004ACB41 /* ChatChannelListItemView_Tests.swift */, ); path = ChatChannelList; sourceTree = ""; @@ -1495,6 +1915,7 @@ 84C94D61275A5BB7007FE2B9 /* ChatChannelNamer_Tests.swift */, 91B79FD8284E7E9C005B6E4F /* ChatUserNamer_Tests.swift */, 84E1D8272976CCAF00060491 /* SortReactions_Tests.swift */, + 849988AF2AE6BE4800CC95C9 /* PaddingsConfig_Tests.swift */, ); path = Utils; sourceTree = ""; @@ -1877,30 +2298,48 @@ buildActionMask = 2147483647; files = ( 8465FD962746A95700AF091E /* ReactionsOverlayViewModel.swift in Sources */, + 82D64BE92AD7E5B700C5C79E /* TaskLoadData.swift in Sources */, 847CEFEE27C38ABE00606257 /* MessageCachingUtils.swift in Sources */, + 82D64BF62AD7E5B700C5C79E /* ImageProcessors+CoreImage.swift in Sources */, 8465FD792746A95700AF091E /* DeletedMessageView.swift in Sources */, 8492975227B156D100A8EEB0 /* SlowModeView.swift in Sources */, + 82D64B662AD7E5AC00C5C79E /* UIImageView+SwiftyGif.swift in Sources */, 84AB7B242773528300631A10 /* CommandsContainerView.swift in Sources */, 8465FDB72746A95700AF091E /* ChatMessageReactionAppeareance.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 */, + 82D64BD32AD7E5B700C5C79E /* FrameStore.swift in Sources */, 8465FDB22746A95700AF091E /* InputTextView.swift in Sources */, 8465FDB32746A95700AF091E /* NSLayoutConstraint+Extensions.swift in Sources */, + 82D64BCF2AD7E5B700C5C79E /* LazyImageState.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 */, + 82D64C032AD7E5B700C5C79E /* RateLimiter.swift in Sources */, + 82D64BEA2AD7E5B700C5C79E /* TaskFetchOriginalImageData.swift in Sources */, + 82D64BDB2AD7E5B700C5C79E /* UIImageView.swift in Sources */, + 82D64BD02AD7E5B700C5C79E /* NukeVideoPlayerView.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 */, + 82D64C182AD7E5B700C5C79E /* ImageCache.swift in Sources */, 849CDD942768E0E1003C7A51 /* MessageActionsResolver.swift in Sources */, 84F2908E276B92A40045472D /* GalleryHeaderView.swift in Sources */, 8465FD7B2746A95700AF091E /* GiphyBadgeView.swift in Sources */, @@ -1908,30 +2347,52 @@ 8465FD8D2746A95700AF091E /* AddedImageAttachmentsView.swift in Sources */, 8465FDAA2746A95700AF091E /* DateFormatter+Extensions.swift in Sources */, 841B64D42775F5540016FF3B /* GiphyCommandHandler.swift in Sources */, + 82D64BDD2AD7E5B700C5C79E /* AnimatedImageView.swift in Sources */, 8434E58127707F19001E1B83 /* GridPhotosView.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 */, + 82D64BE02AD7E5B700C5C79E /* LazyImage.swift in Sources */, + 82D64C052AD7E5B700C5C79E /* Deprecated.swift in Sources */, 84DEC8EC27611CAE00172876 /* SendInChannelView.swift in Sources */, + 82D64BD12AD7E5B700C5C79E /* Image.swift in Sources */, + 82D64BD52AD7E5B700C5C79E /* AnimatedFrame.swift in Sources */, 8465FD9F2746A95700AF091E /* ChatChannelExtensions.swift in Sources */, 844D1D6628510304000CCCB9 /* ChannelControllerFactory.swift in Sources */, 8465FD882746A95700AF091E /* SendMessageButton.swift in Sources */, 8465FDC82746A95700AF091E /* ChatChannelListItem.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 */, 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 */, 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 */, @@ -1942,9 +2403,11 @@ 8465FD7F2746A95700AF091E /* MessageTypeResolver.swift in Sources */, 8465FDA42746A95700AF091E /* NukeImageLoader.swift in Sources */, 8465FD842746A95700AF091E /* MessageAvatarView.swift in Sources */, + 82D64B672AD7E5AC00C5C79E /* NSImage+SwiftyGif.swift in Sources */, 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 */, @@ -1960,12 +2423,19 @@ 8465FD892746A95700AF091E /* ComposerTextInputView.swift in Sources */, 8465FDBC2746A95700AF091E /* ChannelAvatarsMerger.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 */, + 82D64C082AD7E5B700C5C79E /* Operation.swift in Sources */, + 82D64BEB2AD7E5B700C5C79E /* ImagePipelineTask.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 */, 8465FDAB2746A95700AF091E /* StringExtensions.swift in Sources */, @@ -1973,24 +2443,40 @@ 8465FDBB2746A95700AF091E /* LoadingView.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 */, 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 */, + 82D64C162AD7E5B700C5C79E /* ImageRequest.swift in Sources */, + 82D64BF82AD7E5B700C5C79E /* ImageProcessors+Circle.swift in Sources */, 8465FDD32746A95800AF091E /* ColorPalette.swift in Sources */, 8465FD782746A95700AF091E /* FileAttachmentView.swift in Sources */, + 82D64C152AD7E5B700C5C79E /* ImageContainer.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 */, 8465FDAE2746A95700AF091E /* UIColor+Extensions.swift in Sources */, 8465FD6F2746A95700AF091E /* StreamChat.swift in Sources */, 8465FD8F2746A95700AF091E /* AttachmentUploadingStateView.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 */, + 82D64C1B2AD7E5B700C5C79E /* ImageCaching.swift in Sources */, + 82D64C062AD7E5B700C5C79E /* Graphics.swift in Sources */, 8465FDCB2746A95700AF091E /* ChatChannelListView.swift in Sources */, 841B2EF4278DB9E500ED619E /* MessageListHelperViews.swift in Sources */, 84C0C9A328CF18F700CD0136 /* SnapshotCreator.swift in Sources */, @@ -2006,33 +2492,55 @@ 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 */, + 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 */, + 82D64BDF2AD7E5B700C5C79E /* ImageView.swift in Sources */, + 82D64BE82AD7E5B700C5C79E /* TaskFetchDecodedImage.swift in Sources */, 8465FDB02746A95700AF091E /* DateUtils.swift in Sources */, 84DEC8E12760D24100172876 /* MessageRepliesView.swift in Sources */, 8423C33F277C9A5F0092DCF1 /* UnmuteCommandHandler.swift in Sources */, 8421BCEE27A43E14000F977D /* SearchBar.swift in Sources */, 841B64CA2775BBC10016FF3B /* Errors.swift in Sources */, 8465FD7A2746A95700AF091E /* VideoPlayerView.swift in Sources */, + 82D64BFC2AD7E5B700C5C79E /* ImageDecompression.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 */, 84733EC627FDBF82006926E0 /* NetworkReachability.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 */, 8465FD7E2746A95700AF091E /* VideoAttachmentView.swift in Sources */, + 82D64BF52AD7E5B700C5C79E /* ImageProcessors+GaussianBlur.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 */, 8465FDAC2746A95700AF091E /* UIFont+Extensions.swift in Sources */, @@ -2047,6 +2555,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 849894952AD96CCC004ACB41 /* ChatChannelListItemView_Tests.swift in Sources */, 84E04791284A444E00BAFA17 /* MockNetworkURLProtocol.swift in Sources */, 848399EC275FB41B003075E4 /* ChatChannelListView_Tests.swift in Sources */, 84C94D54275A1380007FE2B9 /* DateUtils_Tests.swift in Sources */, @@ -2085,6 +2594,7 @@ 84C94D4D2758FD5C007FE2B9 /* MessageComposerViewModel_Tests.swift in Sources */, 84C94D62275A5BB7007FE2B9 /* ChatChannelNamer_Tests.swift in Sources */, 840A3F3828193AB20084E9CC /* ChatChannelInfoView_Tests.swift in Sources */, + 849988B02AE6BE4800CC95C9 /* PaddingsConfig_Tests.swift in Sources */, 84C94D492758BE1C007FE2B9 /* ChatChannelViewModel_Tests.swift in Sources */, 842F036D288E93BF00496D49 /* ChatMessage_AdjustedText_Tests.swift in Sources */, 84507C98281AC40F0081DDC2 /* AddUsersViewModel_Tests.swift in Sources */, @@ -2166,11 +2676,13 @@ 84335016274BABF3007A1B81 /* NewChatView.swift in Sources */, 84335018274BAD4B007A1B81 /* NewChatViewModel.swift in Sources */, 84B288CD274C544B00DD090B /* CreateGroupView.swift in Sources */, + 845161802AE7C4E2000A9230 /* WhatsAppChannelHeader.swift in Sources */, 8465FCDC274694D200AF091E /* SceneDelegate.swift in Sources */, 8465FCD9274694D200AF091E /* LaunchAnimationState.swift in Sources */, 84B288D1274CEDD000DD090B /* GroupNameView.swift in Sources */, 84335014274BAB15007A1B81 /* ViewFactoryExamples.swift in Sources */, 8465FCDE274694D200AF091E /* CustomChannelHeader.swift in Sources */, + 8451617E2AE7B093000A9230 /* AppleMessageComposerView.swift in Sources */, 84B288D3274D23AF00DD090B /* LoginView.swift in Sources */, 84B288D5274D286500DD090B /* LoginViewModel.swift in Sources */, 8465FCBF27468B6900AF091E /* DemoAppSwiftUIApp.swift in Sources */, @@ -2180,6 +2692,7 @@ 8465FCDA274694D200AF091E /* AppDelegate.swift in Sources */, 8465FCD8274694D200AF091E /* LaunchScreen.swift in Sources */, 84B288CF274C545900DD090B /* CreateGroupViewModel.swift in Sources */, + 8417AE922ADEDB6400445021 /* UserRepository.swift in Sources */, 84FF723F2782FB2E006E26C8 /* iMessagePocView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2748,7 +3261,7 @@ repositoryURL = "https://github.com/GetStream/stream-chat-swift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.39.0; + minimumVersion = 4.40.0; }; }; A3571601283E9D9F0014E3B0 /* XCRemoteSwiftPackageReference "swifter" */ = { diff --git a/StreamChatSwiftUIArtifacts.json b/StreamChatSwiftUIArtifacts.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/StreamChatSwiftUIArtifacts.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/LazyImageExtensions_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/LazyImageExtensions_Tests.swift index 34c82732..d8bc7ef2 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/LazyImageExtensions_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/LazyImageExtensions_Tests.swift @@ -2,7 +2,6 @@ // Copyright © 2023 Stream.io Inc. All rights reserved. // -import NukeUI import SnapshotTesting @testable import StreamChat @testable import StreamChatSwiftUI diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift new file mode 100644 index 00000000..ca785665 --- /dev/null +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift @@ -0,0 +1,225 @@ +// +// Copyright © 2023 Stream.io Inc. All rights reserved. +// + +import SnapshotTesting +@testable import StreamChat +@testable import StreamChatSwiftUI +@testable import StreamChatTestTools +import XCTest + +final class ChatChannelListItemView_Tests: StreamChatTestCase { + + func test_channelListItem_audioMessage() throws { + // Given + let message = try mockAudioMessage(text: "Audio", isSentByCurrentUser: true) + let channel = ChatChannel.mock(cid: .unique, latestMessages: [message]) + + // When + let view = ChatChannelListItem( + channel: channel, + channelName: "Test", + avatar: .circleImage, + onlineIndicatorShown: true, + onItemTap: { _ in } + ) + .frame(width: defaultScreenSize.width) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: snapshotPrecision)) + } + + func test_channelListItem_imageMessage() throws { + // Given + let message = try mockImageMessage(text: "Image", isSentByCurrentUser: true) + let channel = ChatChannel.mock(cid: .unique, latestMessages: [message]) + + // When + let view = ChatChannelListItem( + channel: channel, + channelName: "Test", + avatar: .circleImage, + onlineIndicatorShown: true, + onItemTap: { _ in } + ) + .frame(width: defaultScreenSize.width) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: snapshotPrecision)) + } + + func test_channelListItem_videoMessage() throws { + // Given + let message = try mockVideoMessage(text: "Video", isSentByCurrentUser: true) + let channel = ChatChannel.mock(cid: .unique, latestMessages: [message]) + + // When + let view = ChatChannelListItem( + channel: channel, + channelName: "Test", + avatar: .circleImage, + onlineIndicatorShown: true, + onItemTap: { _ in } + ) + .frame(width: defaultScreenSize.width) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: snapshotPrecision)) + } + + func test_channelListItem_fileMessage() throws { + // Given + let message = try mockFileMessage(title: "Filename", text: "File", isSentByCurrentUser: true) + let channel = ChatChannel.mock(cid: .unique, latestMessages: [message]) + + // When + let view = ChatChannelListItem( + channel: channel, + channelName: "Test", + avatar: .circleImage, + onlineIndicatorShown: true, + onItemTap: { _ in } + ) + .frame(width: defaultScreenSize.width) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: snapshotPrecision)) + } + + func test_channelListItem_giphyMessage() throws { + // Given + let message = try mockGiphyMessage(text: "Giphy", isSentByCurrentUser: true) + let channel = ChatChannel.mock(cid: .unique, latestMessages: [message]) + + // When + let view = ChatChannelListItem( + channel: channel, + channelName: "Test", + avatar: .circleImage, + onlineIndicatorShown: true, + onItemTap: { _ in } + ) + .frame(width: defaultScreenSize.width) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: snapshotPrecision)) + } + + // MARK: - private + + private func mockAudioMessage(text: String, isSentByCurrentUser: Bool) throws -> ChatMessage { + .mock( + id: .unique, + cid: .unique, + text: text, + type: .regular, + author: .mock(id: "user", name: "User"), + createdAt: Date(timeIntervalSince1970: 100), + attachments: [ + .dummy( + type: .audio, + payload: try JSONEncoder().encode(AudioAttachmentPayload( + title: "Some Audio", + audioRemoteURL: URL(string: "url")!, + file: .init(type: .mp3, size: 123, mimeType: nil), + extraData: nil + )) + ) + ], + localState: nil, + isSentByCurrentUser: isSentByCurrentUser + ) + } + + private func mockImageMessage(text: String, isSentByCurrentUser: Bool) throws -> ChatMessage { + .mock( + id: .unique, + cid: .unique, + text: text, + type: .regular, + author: .mock(id: "user", name: "User"), + createdAt: Date(timeIntervalSince1970: 100), + attachments: [ + .dummy( + type: .image, + payload: try JSONEncoder().encode(ImageAttachmentPayload( + title: "Test", + imageRemoteURL: URL(string: "Url")! + )) + ) + ], + localState: nil, + isSentByCurrentUser: isSentByCurrentUser + ) + } + + private func mockVideoMessage(text: String, isSentByCurrentUser: Bool) throws -> ChatMessage { + .mock( + id: .unique, + cid: .unique, + text: text, + type: .regular, + author: .mock(id: "user", name: "User"), + createdAt: Date(timeIntervalSince1970: 100), + attachments: [ + .dummy( + type: .video, + payload: try JSONEncoder().encode(VideoAttachmentPayload( + title: "Test", + videoRemoteURL: URL(string: "Url")!, + file: .init(type: .mp4, size: 123, mimeType: nil), + extraData: nil + )) + ) + ], + localState: nil, + isSentByCurrentUser: isSentByCurrentUser + ) + } + + private func mockFileMessage(title: String?, text: String, isSentByCurrentUser: Bool) throws -> ChatMessage { + .mock( + id: .unique, + cid: .unique, + text: text, + type: .regular, + author: .mock(id: "user", name: "User"), + createdAt: Date(timeIntervalSince1970: 100), + attachments: [ + .dummy( + type: .file, + payload: try JSONEncoder().encode(FileAttachmentPayload( + title: title, + assetRemoteURL: URL(string: "Url")!, + file: .init(type: .pdf, size: 123, mimeType: nil), + extraData: nil + )) + ) + ], + localState: nil, + isSentByCurrentUser: isSentByCurrentUser + ) + } + + private func mockGiphyMessage(text: String, isSentByCurrentUser: Bool) throws -> ChatMessage { + .mock( + id: .unique, + cid: .unique, + text: text, + type: .regular, + author: .mock(id: "user", name: "User"), + createdAt: Date(timeIntervalSince1970: 100), + attachments: [ + .dummy( + type: .giphy, + payload: try JSONEncoder().encode(GiphyAttachmentPayload( + title: "Test", + previewURL: URL(string: "Url")! + )) + ) + ], + localState: nil, + isSentByCurrentUser: isSentByCurrentUser + ) + } +} diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_audioMessage.1.png b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_audioMessage.1.png new file mode 100644 index 00000000..fa88ba7d Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_audioMessage.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_fileMessage.1.png b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_fileMessage.1.png new file mode 100644 index 00000000..e2c6df6c Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_fileMessage.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_giphyMessage.1.png b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_giphyMessage.1.png new file mode 100644 index 00000000..a4e86ff4 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_giphyMessage.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_imageMessage.1.png b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_imageMessage.1.png new file mode 100644 index 00000000..f2274e5a Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_imageMessage.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_videoMessage.1.png b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_videoMessage.1.png new file mode 100644 index 00000000..9762b9dd Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_videoMessage.1.png differ diff --git a/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift b/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift index 793ba562..03c94bd8 100644 --- a/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift +++ b/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift @@ -12,6 +12,7 @@ import XCTest open class StreamChatTestCase: XCTestCase { public static var currentUserId: String = .unique + public let snapshotPrecision: Float = 0.95 public var chatClient: ChatClient = { let client = ChatClient.mock(isLocalStorageEnabled: false) diff --git a/StreamChatSwiftUITests/Tests/Utils/PaddingsConfig_Tests.swift b/StreamChatSwiftUITests/Tests/Utils/PaddingsConfig_Tests.swift new file mode 100644 index 00000000..2a133cb5 --- /dev/null +++ b/StreamChatSwiftUITests/Tests/Utils/PaddingsConfig_Tests.swift @@ -0,0 +1,23 @@ +// +// Copyright © 2023 Stream.io Inc. All rights reserved. +// + +import StreamChat +@testable import StreamChatSwiftUI +import XCTest + +final class PaddingsConfig_Tests: XCTestCase { + + func test_paddingsConfig() { + // Given + let paddingsConfig = PaddingsConfig(top: 4, bottom: 4, leading: 8, trailing: 8) + + // Then + XCTAssert(paddingsConfig.horizontal == 16) + XCTAssert(paddingsConfig.vertical == 8) + XCTAssert(paddingsConfig.bottom == 4) + XCTAssert(paddingsConfig.top == 4) + XCTAssert(paddingsConfig.leading == 8) + XCTAssert(paddingsConfig.trailing == 8) + } +} diff --git a/docusaurus/docs/iOS/basics/integration.md b/docusaurus/docs/iOS/basics/integration.md index e6697b58..d06bac7f 100644 --- a/docusaurus/docs/iOS/basics/integration.md +++ b/docusaurus/docs/iOS/basics/integration.md @@ -36,6 +36,8 @@ You can learn more about [our Module Stable XCFrameworks here](#xcframeworks) - `https://github.com/getstream/stream-chat-swift-spm` - For the UIKit components (**StreamChatUI**, which depends on **StreamChat**) use: - `https://github.com/getstream/stream-chat-swift-spm` +- For the SwiftUI components (**StreamChatSwiftUI**, which depends on **StreamChat**) use: + - `https://github.com/getstream/stream-chat-swiftui-spm`

diff --git a/fastlane/Fastfile b/fastlane/Fastfile index bfc8d1f5..d9ac0242 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -30,25 +30,71 @@ after_all do |lane| stop_sinatra if lane == :test_e2e_mock end +lane :build_xcframeworks do + match_me + output_directory = "#{Dir.pwd}/../Products" + team_id = File.read('Matchfile').match(/team_id\("(.*)"\)/)[1] + codesign = ["codesign --timestamp -v --sign 'Apple Distribution: Stream.io Inc (#{team_id})'"] + sdk_names.each do |sdk| + create_xcframework( + project: xcode_project, + scheme: sdk, + destinations: ['iOS'], + include_BCSymbolMaps: true, + include_debug_symbols: true, + xcframework_output_directory: output_directory, + remove_xcarchives: true + ) + sh('../Scripts/removeUnneededSymbols.sh', sdk, output_directory) + codesign << lane_context[SharedValues::XCFRAMEWORK_OUTPUT_PATH] + end + sh(codesign.join(' ')) # We need to sign all frameworks at once +end + desc 'Release a new version' lane :release do |options| + previous_version_number = last_git_tag + artifacts_path = File.absolute_path('../StreamChatSwiftUIArtifacts.json') + swift_environment_path = File.absolute_path('../Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift') + extra_changes = lambda do |release_version| + # Set the framework version on the artifacts + artifacts = JSON.parse(File.read(artifacts_path)) + artifacts[release_version.to_s] = "https://github.com/#{github_repo}/releases/download/#{release_version}/StreamChatSwiftUI.zip" + File.write(artifacts_path, JSON.dump(artifacts)) + + # Set the framework version in SystemEnvironment+Version.swift + new_content = File.read(swift_environment_path).gsub!(previous_version_number, release_version) + File.open(swift_environment_path, 'w') { |f| f.puts(new_content) } + end + pod_lint version_number = release_ios_sdk( version: options[:version], bump_type: options[:type], sdk_names: sdk_names, - github_repo: github_repo + podspec_names: ['StreamChatSwiftUI', 'StreamChatSwiftUI-XCFramework'], + github_repo: github_repo, + extra_changes: extra_changes ) publish_release(version: version_number) end desc "Publish a new release to GitHub and CocoaPods" lane :publish_release do |options| + clean_products + build_xcframeworks + compress_frameworks + clean_products + publish_ios_sdk( version: options[:version], sdk_names: sdk_names, - github_repo: github_repo + podspec_names: ['StreamChatSwiftUI', 'StreamChatSwiftUI-XCFramework'], + github_repo: github_repo, + upload_assets: ['Products/StreamChatSwiftUI.zip'] ) + + update_spm(version: options[:version]) end private_lane :appstore_api_key do @@ -180,6 +226,91 @@ lane :build_demo do |options| ) end +desc 'Compresses the XCFrameworks into zip files' +lane :compress_frameworks do + Dir.chdir('..') do + FileUtils.cp('LICENSE', 'Products/LICENSE') + Dir.chdir('Products') do + sdk_names.each do |framework| + sh("zip -r #{framework} ./#{framework}.xcframework ./LICENSE") + sh("swift package compute-checksum #{framework}.zip") + end + sh('zip -r "StreamChat-All" ./*.xcframework ./LICENSE') if sdk_names.size > 1 + end + end +end + +desc 'Cleans Products and DerivedData folders' +lane :clean_products do + Dir.chdir('..') do + ['*.xcframework', '*.bundle', '*.BCSymbolMaps', '*.dSYMs', 'LICENSE'].each do |f| + sh("rm -rf Products/#{f}") + end + end +end + +desc 'Update XCFrameworks and submit to the SPM repository' +private_lane :update_spm do |options| + version = options[:version] || '' + UI.user_error!('You need to pass the version of the release you want to obtain the changelog from') unless version.length > 0 + + # Generate Checksums + stream_chat_swiftui_checksum = sh('swift package compute-checksum ../Products/StreamChatSwiftUI.zip').strip + + # Update SPM Repo + spm_directory_name = 'StreamSPM' + spm_directory = "../../#{spm_directory_name}" + sh("git clone git@github.com:#{github_repo}-spm.git #{spm_directory}") + + Dir.chdir(spm_directory) do + result = sh('basename `git rev-parse --show-toplevel`').strip + UI.error("Not using #{spm_directory_name} repo") unless result.to_s == spm_directory_name + + file_lines = File.readlines('Package.swift') + file_data = '' + previous_module = '' + + file_lines.each do |line| + formatted_line = + case previous_module + when "StreamChatSwiftUI" + line.gsub(/(checksum: ")[a-z0-9]+(")/, "\\1#{stream_chat_swiftui_checksum}\\2") + else + line + end + + url_pattern = %r{(releases/download/)[.0-9]+(/)} + if line.match(url_pattern) + formatted_line = line.gsub(url_pattern, "\\1#{version}\\2") + previous_module = line.match(/([a-zA-Z]+).zip/).to_s.gsub(/.zip/, '') + end + + file_data << formatted_line + end + + # Write the new changes + File.open('./Package.swift', 'w') { |file| file << file_data } + + # Update the repo + sh('git add -A') + sh("git commit -m 'Bump #{version}'") + sh('git push') + + github_release = set_github_release( + repository_name: "#{github_repo}-spm", + api_token: ENV.fetch('GITHUB_TOKEN', nil), + name: version, + tag_name: version, + commitish: 'main', + description: "https://github.com/#{github_repo}/releases/tag/#{version}" + ) + UI.success("New SPM release available: #{github_release['html_url']}") + end + + # Clean Up + sh("rm -rf #{spm_directory}") +end + private_lane :update_testplan_on_ci do |options| update_testplan(path: options[:path], env_vars: { key: 'CI', value: 'TRUE' }) if is_ci end diff --git a/fastlane/Matchfile b/fastlane/Matchfile new file mode 100644 index 00000000..f26ef457 --- /dev/null +++ b/fastlane/Matchfile @@ -0,0 +1,5 @@ +git_url("git@github.com:GetStream/ios-certificates.git") + +storage_mode("git") + +team_id("EHV7XZLAHA") diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile index 392b377e..8f281085 100644 --- a/fastlane/Pluginfile +++ b/fastlane/Pluginfile @@ -6,3 +6,4 @@ gem 'fastlane-plugin-versioning' gem 'fastlane-plugin-emerge' gem 'fastlane-plugin-sonarcloud_metric_kit' gem 'fastlane-plugin-stream_actions', '0.3.20' +gem 'fastlane-plugin-create_xcframework' diff --git a/hooks/pre-commit.sh b/hooks/pre-commit.sh index 963833b7..6b8f3552 100755 --- a/hooks/pre-commit.sh +++ b/hooks/pre-commit.sh @@ -1,6 +1,6 @@ #!/bin/bash -./hooks/git-format-staged --formatter 'mint run swiftformat --config .swiftformat stdin' 'Sources/*.swift' '!*Generated*' +./hooks/git-format-staged --formatter 'mint run swiftformat --config .swiftformat stdin' 'Sources/*.swift' '!*Generated*' '!*StreamNuke*' '!*StreamSwiftyGif*' ./hooks/git-format-staged --formatter 'mint run swiftformat --config .swiftformat stdin' 'StreamChatSwiftUITests/*.swift' ./hooks/git-format-staged --formatter 'mint run swiftformat --config .swiftformat stdin' 'DemoAppSwiftUI/*.swift'