From 0e4d80d6026bd47a3a6482d8a146cae97491f10d Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Thu, 19 Oct 2023 11:00:09 +0100 Subject: [PATCH] Embed third-party dependencies --- .slather.yml | 2 + Makefile | 21 + Scripts/removePublicDeclarations.sh | 66 +++ Scripts/removeUnneededSymbols.sh | 51 ++ Scripts/run-linter.sh | 2 +- Scripts/updateDependency.sh | 60 ++ .../ChatChannel/ChatChannelViewModel.swift | 5 +- .../Gallery/ZoomableScrollView.swift | 6 +- .../MessageList/GiphyAttachmentView.swift | 4 +- .../MessageList/ImageAttachmentView.swift | 2 - .../MessageList/LinkAttachmentView.swift | 2 - .../MessageList/MessageAvatarView.swift | 1 - .../MessageList/MessageContainerView.swift | 2 - .../MessageList/QuotedMessageView.swift | 2 - .../StreamNuke/Nuke/Caching/DataCache.swift | 510 +++++++++++++++++ .../StreamNuke/Nuke/Caching/DataCaching.swift | 27 + .../StreamNuke/Nuke/Caching/ImageCache.swift | 126 +++++ .../Nuke/Caching/ImageCaching.swift | 37 ++ .../StreamNuke/Nuke/Caching/NukeCache.swift | 204 +++++++ .../StreamNuke/Nuke/Decoding/AssetType.swift | 90 +++ .../Nuke/Decoding/ImageDecoderRegistry.swift | 75 +++ .../Nuke/Decoding/ImageDecoders+Default.swift | 215 ++++++++ .../Nuke/Decoding/ImageDecoders+Empty.swift | 36 ++ .../Nuke/Decoding/ImageDecoders+Video.swift | 53 ++ .../Nuke/Decoding/ImageDecoding.swift | 64 +++ .../Nuke/Encoding/ImageEncoders+Default.swift | 39 ++ .../Nuke/Encoding/ImageEncoders+ImageIO.swift | 62 +++ .../Nuke/Encoding/ImageEncoders.swift | 20 + .../Nuke/Encoding/ImageEncoding.swift | 35 ++ .../StreamNuke/Nuke/ImageContainer.swift | 94 ++++ .../StreamNuke/Nuke/ImageRequest.swift | 516 ++++++++++++++++++ .../StreamNuke/Nuke/ImageResponse.swift | 61 +++ .../StreamNuke/Nuke/ImageTask.swift | 196 +++++++ .../Nuke/Internal/AVDataAsset.swift | 76 +++ .../Nuke/Internal/Allocations.swift | 81 +++ .../Nuke/Internal/DataPublisher.swift | 53 ++ .../StreamNuke/Nuke/Internal/Deprecated.swift | 158 ++++++ .../StreamNuke/Nuke/Internal/Extensions.swift | 74 +++ .../StreamNuke/Nuke/Internal/Graphics.swift | 338 ++++++++++++ .../Nuke/Internal/ImagePublisher.swift | 86 +++ .../Nuke/Internal/ImageRequestKeys.swift | 115 ++++ .../StreamNuke/Nuke/Internal/LinkedList.swift | 85 +++ .../StreamNuke/Nuke/Internal/Log.swift | 53 ++ .../StreamNuke/Nuke/Internal/Operation.swift | 106 ++++ .../Nuke/Internal/RateLimiter.swift | 118 ++++ .../Nuke/Internal/ResumableData.swift | 134 +++++ .../StreamNuke/Nuke/Loading/DataLoader.swift | 243 +++++++++ .../StreamNuke/Nuke/Loading/DataLoading.swift | 21 + .../Nuke/Pipeline/ImagePipeline.swift | 511 +++++++++++++++++ .../Nuke/Pipeline/ImagePipelineCache.swift | 261 +++++++++ .../Pipeline/ImagePipelineConfiguration.swift | 245 +++++++++ .../Nuke/Pipeline/ImagePipelineDelegate.swift | 104 ++++ .../Nuke/Pipeline/ImagePipelineError.swift | 65 +++ .../Nuke/Prefetching/ImagePrefetcher.swift | 224 ++++++++ .../Nuke/Processing/ImageDecompression.swift | 24 + .../Nuke/Processing/ImageProcessing.swift | 101 ++++ .../Processing/ImageProcessingOptions.swift | 68 +++ .../ImageProcessors+Anonymous.swift | 26 + .../Processing/ImageProcessors+Circle.swift | 32 ++ .../ImageProcessors+Composition.swift | 61 +++ .../ImageProcessors+CoreImage.swift | 113 ++++ .../ImageProcessors+GaussianBlur.swift | 46 ++ .../Processing/ImageProcessors+Resize.swift | 104 ++++ .../ImageProcessors+RoundedCorners.swift | 41 ++ .../Nuke/Processing/ImageProcessors.swift | 115 ++++ .../StreamNuke/Nuke/Tasks/AsyncTask.swift | 379 +++++++++++++ .../Nuke/Tasks/ImagePipelineTask.swift | 43 ++ .../StreamNuke/Nuke/Tasks/OperationTask.swift | 35 ++ .../Nuke/Tasks/TaskFetchDecodedImage.swift | 84 +++ .../Tasks/TaskFetchOriginalImageData.swift | 179 ++++++ .../Nuke/Tasks/TaskFetchWithPublisher.swift | 72 +++ .../StreamNuke/Nuke/Tasks/TaskLoadData.swift | 47 ++ .../StreamNuke/Nuke/Tasks/TaskLoadImage.swift | 264 +++++++++ .../NukeExtensions/ImageLoadingOptions.swift | 228 ++++++++ .../NukeExtensions/ImageViewExtensions.swift | 404 ++++++++++++++ .../StreamNuke/NukeUI/AnimatedImageView.swift | 26 + .../StreamNuke/NukeUI/FetchImage.swift | 263 +++++++++ .../NukeUI/Gifu/Classes/AnimatedFrame.swift | 31 ++ .../NukeUI/Gifu/Classes/Animator.swift | 197 +++++++ .../NukeUI/Gifu/Classes/FrameStore.swift | 284 ++++++++++ .../NukeUI/Gifu/Classes/GIFAnimatable.swift | 208 +++++++ .../NukeUI/Gifu/Classes/GIFImageView.swift | 21 + .../NukeUI/Gifu/Extensions/Array.swift | 5 + .../NukeUI/Gifu/Extensions/CGSize.swift | 43 ++ .../NukeUI/Gifu/Extensions/UIImage.swift | 52 ++ .../NukeUI/Gifu/Extensions/UIImageView.swift | 5 + .../Gifu/Helpers/ImageSourceHelpers.swift | 85 +++ .../StreamNuke/NukeUI/Image.swift | 112 ++++ .../StreamNuke/NukeUI/ImageView.swift | 237 ++++++++ .../StreamNuke/NukeUI/Internal.swift | 122 +++++ .../StreamNuke/NukeUI/LazyImage.swift | 329 +++++++++++ .../StreamNuke/NukeUI/LazyImageState.swift | 55 ++ .../StreamNuke/NukeUI/LazyImageView.swift | 462 ++++++++++++++++ .../NukeUI/NukeVideoPlayerView.swift | 191 +++++++ .../StreamSwiftyGif/NSImage+SwiftyGif.swift | 337 ++++++++++++ .../NSImageView+SwiftyGif.swift | 493 +++++++++++++++++ .../ObjcAssociatedWeakObject.swift | 18 + .../StreamSwiftyGif/SwiftyGifManager.swift | 173 ++++++ .../StreamSwiftyGif/UIImage+SwiftyGif.swift | 347 ++++++++++++ .../UIImageView+SwiftyGif.swift | 486 +++++++++++++++++ .../Utils/Common/NukeImageProcessor.swift | 1 - .../Utils/LazyImageExtensions.swift | 4 +- .../Utils/NukeImageLoader.swift | 5 +- StreamChatSwiftUI.podspec | 16 +- StreamChatSwiftUI.xcodeproj/project.pbxproj | 489 +++++++++++++++++ .../LazyImageExtensions_Tests.swift | 1 - hooks/pre-commit.sh | 2 +- 107 files changed, 13366 insertions(+), 37 deletions(-) create mode 100644 Makefile create mode 100755 Scripts/removePublicDeclarations.sh create mode 100755 Scripts/removeUnneededSymbols.sh create mode 100755 Scripts/updateDependency.sh create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCache.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/DataCaching.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCache.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/ImageCaching.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Caching/NukeCache.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/AssetType.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoderRegistry.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Default.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Empty.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoders+Video.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Decoding/ImageDecoding.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+Default.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders+ImageIO.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoders.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Encoding/ImageEncoding.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageContainer.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageRequest.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageResponse.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/ImageTask.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/AVDataAsset.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Allocations.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/DataPublisher.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Deprecated.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Extensions.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Graphics.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImagePublisher.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ImageRequestKeys.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/LinkedList.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Log.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/Operation.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/RateLimiter.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Internal/ResumableData.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoader.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Loading/DataLoading.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipeline.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineCache.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineConfiguration.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineDelegate.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Pipeline/ImagePipelineError.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Prefetching/ImagePrefetcher.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageDecompression.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessing.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessingOptions.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Anonymous.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Circle.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Composition.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+CoreImage.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+GaussianBlur.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+Resize.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors+RoundedCorners.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageProcessors.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/AsyncTask.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/ImagePipelineTask.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/OperationTask.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchDecodedImage.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchOriginalImageData.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskFetchWithPublisher.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadData.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/Nuke/Tasks/TaskLoadImage.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageLoadingOptions.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/AnimatedImageView.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/FetchImage.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/AnimatedFrame.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/Animator.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/FrameStore.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/GIFAnimatable.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Classes/GIFImageView.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/Array.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/CGSize.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/UIImage.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Extensions/UIImageView.swift create mode 100755 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Gifu/Helpers/ImageSourceHelpers.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Image.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/ImageView.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/Internal.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImage.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageState.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/LazyImageView.swift create mode 100644 Sources/StreamChatSwiftUI/StreamNuke/NukeUI/NukeVideoPlayerView.swift create mode 100755 Sources/StreamChatSwiftUI/StreamSwiftyGif/NSImage+SwiftyGif.swift create mode 100755 Sources/StreamChatSwiftUI/StreamSwiftyGif/NSImageView+SwiftyGif.swift create mode 100644 Sources/StreamChatSwiftUI/StreamSwiftyGif/ObjcAssociatedWeakObject.swift create mode 100755 Sources/StreamChatSwiftUI/StreamSwiftyGif/SwiftyGifManager.swift create mode 100755 Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImage+SwiftyGif.swift create mode 100755 Sources/StreamChatSwiftUI/StreamSwiftyGif/UIImageView+SwiftyGif.swift 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/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/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..8395bca2 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/StreamChatUI/StreamNuke,Sources/StreamChatUI/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/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/Gallery/ZoomableScrollView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/ZoomableScrollView.swift index e7c64739..e3f764a4 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Gallery/ZoomableScrollView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/ZoomableScrollView.swift @@ -89,14 +89,14 @@ private struct ZoomableScrollViewImpl: UIViewControllerRepresenta updateConstraintsCancellable = scrollView.publisher(for: \.bounds).map(\.size).removeDuplicates() .sink { [unowned self] _ in view.setNeedsUpdateConstraints() - } - doubleTapCancellable = doubleTap.sink { [unowned self] in handleDoubleTap() } + } as! any Cancellable // FIXME + doubleTapCancellable = doubleTap.sink { [unowned self] in handleDoubleTap() } as! any Cancellable // FIXME } func update(content: Content, doubleTap: AnyPublisher) { coordinator.hostingController.rootView = content scrollView.setNeedsUpdateConstraints() - doubleTapCancellable = doubleTap.sink { [unowned self] in handleDoubleTap() } + doubleTapCancellable = doubleTap.sink { [unowned self] in handleDoubleTap() } as! any Cancellable // FIXME } func handleDoubleTap() { 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/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift index e6a4e413..f6d48838 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 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/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..5b287074 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/Nuke/Processing/ImageDecompression.swift @@ -0,0 +1,24 @@ +// 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) { + objc_setAssociatedObject(image, &isDecompressionNeededAK, isDecompressionNeeded, .OBJC_ASSOCIATION_RETAIN) + } + + static func isDecompressionNeeded(for image: PlatformImage) -> Bool? { + objc_getAssociatedObject(image, &isDecompressionNeededAK) as? Bool + } +} diff --git a/Sources/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..bc2c0865 --- /dev/null +++ b/Sources/StreamChatSwiftUI/StreamNuke/NukeExtensions/ImageViewExtensions.swift @@ -0,0 +1,404 @@ +// 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 = objc_getAssociatedObject(view, &ImageViewController.controllerAK) as? ImageViewController { + return controller + } + let controller = ImageViewController(view: view) + objc_setAssociatedObject(view, &ImageViewController.controllerAK, controller, .OBJC_ASSOCIATION_RETAIN) + return controller + } + + // MARK: - Loading Images + + func loadImage( + with request: ImageRequest?, + options: ImageLoadingOptions, + progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? = nil, + completion: ((_ result: Result) -> Void)? = nil + ) -> ImageTask? { + cancelOutstandingTask() + + guard let imageView = imageView else { + return nil + } + + self.options = options + + if options.isPrepareForReuseEnabled { // enabled by default + #if os(iOS) || os(tvOS) + imageView.layer.removeAllAnimations() + #elseif os(macOS) + let layer = (imageView as? NSView)?.layer ?? imageView.layer + layer?.removeAllAnimations() + #endif + } + + // Handle a scenario where request is `nil` (in the same way as a failure) + guard 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/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.podspec b/StreamChatSwiftUI.podspec index 22eb0fe6..571fa9fd 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", "Sources/StreamNuke/**/*.swift", "Sources/StreamSwiftyGif/**/*.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" end - + diff --git a/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj index 59e34f65..f452a691 100644 --- a/StreamChatSwiftUI.xcodeproj/project.pbxproj +++ b/StreamChatSwiftUI.xcodeproj/project.pbxproj @@ -22,6 +22,92 @@ 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 */; }; 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 */; }; @@ -431,6 +517,92 @@ 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 = ""; }; 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 = ""; }; @@ -834,6 +1006,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 = ( @@ -1052,6 +1453,8 @@ 8465FD312746A95600AF091E /* Utils */, 8465FCEC2746A95600AF091E /* Generated */, 8465FCF32746A95600AF091E /* Resources */, + 82D64B6C2AD7E5B600C5C79E /* StreamNuke */, + 82D64B5F2AD7E5AC00C5C79E /* StreamSwiftyGif */, 8465FD5D2746A95700AF091E /* README.md */, 8465FD602746A95700AF091E /* StreamChatSwiftUI.h */, 8465FD682746A95700AF091E /* Info.plist */, @@ -1874,30 +2277,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 */, @@ -1905,30 +2326,51 @@ 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 */, + 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 */, @@ -1939,9 +2381,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 */, @@ -1957,12 +2401,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 */, @@ -1970,24 +2421,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 */, @@ -2003,33 +2470,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 */, diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/LazyImageExtensions_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/LazyImageExtensions_Tests.swift index f3a770fd..0de0d519 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/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'