diff --git a/Modules/Package.swift b/Modules/Package.swift index 513237db4e23..85e1bb172ba3 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -8,10 +8,10 @@ let package = Package( .iOS(.v16), ], products: XcodeSupport.products + [ - .library(name: "JetpackStatsWidgetsCore", targets: ["JetpackStatsWidgetsCore"]), + .library(name: "AsyncImageKit", targets: ["AsyncImageKit"]), .library(name: "DesignSystem", targets: ["DesignSystem"]), + .library(name: "JetpackStatsWidgetsCore", targets: ["JetpackStatsWidgetsCore"]), .library(name: "WordPressFlux", targets: ["WordPressFlux"]), - .library(name: "AsyncImageKit", targets: ["AsyncImageKit"]), .library(name: "WordPressShared", targets: ["WordPressShared"]), .library(name: "WordPressUI", targets: ["WordPressUI"]), ], @@ -52,16 +52,17 @@ let package = Package( .package(url: "https://github.com/Automattic/color-studio", branch: "trunk"), ], targets: XcodeSupport.targets + [ - .target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]), + .target(name: "AsyncImageKit", dependencies: [ + .product(name: "Collections", package: "swift-collections"), + .product(name: "Gifu", package: "Gifu"), + ]), .target(name: "DesignSystem", swiftSettings: [.swiftLanguageMode(.v5)]), + .target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "UITestsFoundation", dependencies: [ .product(name: "ScreenObject", package: "ScreenObject"), .product(name: "XCUITestHelpers", package: "XCUITestHelpers"), ], swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressFlux", swiftSettings: [.swiftLanguageMode(.v5)]), - .target(name: "AsyncImageKit", dependencies: [ - .product(name: "Collections", package: "swift-collections"), - ]), .target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressShared", dependencies: [.target(name: "WordPressSharedObjC")], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressTesting", resources: [.process("Resources")]), diff --git a/Modules/Sources/AsyncImageKit/AnimagedImage.swift b/Modules/Sources/AsyncImageKit/Helpers/AnimagedImage.swift similarity index 100% rename from Modules/Sources/AsyncImageKit/AnimagedImage.swift rename to Modules/Sources/AsyncImageKit/Helpers/AnimagedImage.swift diff --git a/Modules/Sources/AsyncImageKit/FaviconService.swift b/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift similarity index 100% rename from Modules/Sources/AsyncImageKit/FaviconService.swift rename to Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift diff --git a/Modules/Sources/AsyncImageKit/ImageDecoder.swift b/Modules/Sources/AsyncImageKit/Helpers/ImageDecoder.swift similarity index 100% rename from Modules/Sources/AsyncImageKit/ImageDecoder.swift rename to Modules/Sources/AsyncImageKit/Helpers/ImageDecoder.swift diff --git a/Modules/Sources/AsyncImageKit/MemoryCache.swift b/Modules/Sources/AsyncImageKit/Helpers/MemoryCache.swift similarity index 100% rename from Modules/Sources/AsyncImageKit/MemoryCache.swift rename to Modules/Sources/AsyncImageKit/Helpers/MemoryCache.swift diff --git a/Modules/Sources/AsyncImageKit/ImageDownloader.swift b/Modules/Sources/AsyncImageKit/ImageDownloader.swift index 2076816867b6..d634bd537c54 100644 --- a/Modules/Sources/AsyncImageKit/ImageDownloader.swift +++ b/Modules/Sources/AsyncImageKit/ImageDownloader.swift @@ -3,8 +3,9 @@ import UIKit /// The system that downloads and caches images, and prepares them for display. @ImageDownloaderActor public final class ImageDownloader { + public nonisolated static let shared = ImageDownloader() + private nonisolated let cache: MemoryCacheProtocol - private let authenticator: MediaRequestAuthenticatorProtocol? private let urlSession = URLSession { $0.urlCache = nil @@ -21,14 +22,12 @@ public final class ImageDownloader { private var tasks: [String: ImageDataTask] = [:] public nonisolated init( - cache: MemoryCacheProtocol = MemoryCache.shared, - authenticator: MediaRequestAuthenticatorProtocol? + cache: MemoryCacheProtocol = MemoryCache.shared ) { self.cache = cache - self.authenticator = authenticator } - public func image(from url: URL, host: MediaHost? = nil, options: ImageRequestOptions = .init()) async throws -> UIImage { + public func image(from url: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = .init()) async throws -> UIImage { try await image(for: ImageRequest(url: url, host: host, options: options)) } @@ -55,8 +54,8 @@ public final class ImageDownloader { switch request.source { case .url(let url, let host): var request: URLRequest - if let host, let authenticator { - request = try await authenticator.authenticatedRequest(for: url, host: host) + if let host { + request = try await host.authenticatedRequest(for: url) } else { request = URLRequest(url: url) } @@ -195,6 +194,6 @@ private extension URLSession { } } -public protocol MediaRequestAuthenticatorProtocol: Sendable { - @MainActor func authenticatedRequest(for url: URL, host: MediaHost) async throws -> URLRequest +public protocol MediaHostProtocol: Sendable { + @MainActor func authenticatedRequest(for url: URL) async throws -> URLRequest } diff --git a/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift b/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift index d40ace3039ce..623d60ac2aa2 100644 --- a/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift +++ b/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift @@ -15,7 +15,10 @@ public final class ImagePrefetcher { } } - public nonisolated init(downloader: ImageDownloader, maxConcurrentTasks: Int = 2) { + public nonisolated init( + downloader: ImageDownloader = .shared, + maxConcurrentTasks: Int = 2 + ) { self.downloader = downloader self.maxConcurrentTasks = maxConcurrentTasks } diff --git a/Modules/Sources/AsyncImageKit/ImageRequest.swift b/Modules/Sources/AsyncImageKit/ImageRequest.swift index 0c299c489bba..5a4ada4df736 100644 --- a/Modules/Sources/AsyncImageKit/ImageRequest.swift +++ b/Modules/Sources/AsyncImageKit/ImageRequest.swift @@ -2,7 +2,7 @@ import UIKit public final class ImageRequest: Sendable { public enum Source: Sendable { - case url(URL, MediaHost?) + case url(URL, MediaHostProtocol?) case urlRequest(URLRequest) var url: URL? { @@ -16,7 +16,7 @@ public final class ImageRequest: Sendable { let source: Source let options: ImageRequestOptions - public init(url: URL, host: MediaHost? = nil, options: ImageRequestOptions = .init()) { + public init(url: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = .init()) { self.source = .url(url, host) self.options = options } diff --git a/WordPress/Classes/Utility/Media/AsyncImageView.swift b/Modules/Sources/AsyncImageKit/Views/AsyncImageView.swift similarity index 73% rename from WordPress/Classes/Utility/Media/AsyncImageView.swift rename to Modules/Sources/AsyncImageKit/Views/AsyncImageView.swift index 32dd51be6c8a..4a6409b49b7c 100644 --- a/WordPress/Classes/Utility/Media/AsyncImageView.swift +++ b/Modules/Sources/AsyncImageKit/Views/AsyncImageView.swift @@ -1,46 +1,47 @@ import UIKit import Gifu -import AsyncImageKit /// A simple image view that supports rendering both static and animated images /// (see ``AnimatedImage``). @MainActor -final class AsyncImageView: UIView { +public final class AsyncImageView: UIView { private let imageView = GIFImageView() private var errorView: UIImageView? private var spinner: UIActivityIndicatorView? private let controller = ImageLoadingController() - enum LoadingStyle { + public enum LoadingStyle { /// Shows a secondary background color during the download. case background /// Shows a spinner during the download. case spinner } - struct Configuration { + public struct Configuration { /// Image tint color. - var tintColor: UIColor? + public var tintColor: UIColor? /// Image view content mode. - var contentMode: UIView.ContentMode? + public var contentMode: UIView.ContentMode? /// Enabled by default and shows an error icon on failures. - var isErrorViewEnabled = true + public var isErrorViewEnabled = true /// By default, `background`. - var loadingStyle = LoadingStyle.background + public var loadingStyle = LoadingStyle.background - var passTouchesToSuperview = false + public var passTouchesToSuperview = false + + public init() {} } - var configuration = Configuration() { + public var configuration = Configuration() { didSet { didUpdateConfiguration(configuration) } } /// The currently displayed image. If the image is animated, returns an /// instance of ``AnimatedImage``. - var image: UIImage? { + public var image: UIImage? { didSet { if let image { imageView.configure(image: image) @@ -50,12 +51,12 @@ final class AsyncImageView: UIView { } } - override init(frame: CGRect) { + public override init(frame: CGRect) { super.init(frame: frame) setupView() } - required init?(coder: NSCoder) { + public required init?(coder: NSCoder) { super.init(coder: coder) setupView() } @@ -65,7 +66,12 @@ final class AsyncImageView: UIView { addSubview(imageView) imageView.translatesAutoresizingMaskIntoConstraints = false - pinSubviewToAllEdges(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + ]) imageView.clipsToBounds = true imageView.contentMode = .scaleAspectFill @@ -75,22 +81,22 @@ final class AsyncImageView: UIView { } /// Removes the current image and stops the outstanding downloads. - func prepareForReuse() { + public func prepareForReuse() { controller.prepareForReuse() image = nil } /// - parameter size: Target image size in pixels. - func setImage( + public func setImage( with imageURL: URL, - host: MediaHost? = nil, + host: MediaHostProtocol? = nil, size: ImageSize? = nil ) { let request = ImageRequest(url: imageURL, host: host, options: ImageRequestOptions(size: size)) controller.setImage(with: request) } - func setImage(with request: ImageRequest, completion: (@MainActor (Result) -> Void)? = nil) { + public func setImage(with request: ImageRequest, completion: (@MainActor (Result) -> Void)? = nil) { controller.setImage(with: request, completion: completion) } @@ -134,7 +140,10 @@ final class AsyncImageView: UIView { let spinner = UIActivityIndicatorView() addSubview(spinner) spinner.translatesAutoresizingMaskIntoConstraints = false - pinSubviewAtCenter(spinner) + NSLayoutConstraint.activate([ + spinner.centerXAnchor.constraint(equalTo: centerXAnchor), + spinner.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) self.spinner = spinner return spinner } @@ -147,12 +156,15 @@ final class AsyncImageView: UIView { errorView.tintColor = .separator addSubview(errorView) errorView.translatesAutoresizingMaskIntoConstraints = false - pinSubviewAtCenter(errorView) + NSLayoutConstraint.activate([ + errorView.centerXAnchor.constraint(equalTo: centerXAnchor), + errorView.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) self.errorView = errorView return errorView } - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if configuration.passTouchesToSuperview && self.bounds.contains(point) { // Pass the touch to the superview return nil @@ -164,7 +176,7 @@ final class AsyncImageView: UIView { extension GIFImageView { /// If the image is an instance of `AnimatedImage` type, plays it as an /// animated image. - func configure(image: UIImage) { + public func configure(image: UIImage) { if let gif = image as? AnimatedImage, let data = gif.gifData { self.animate(withGIFData: data) } else { diff --git a/WordPress/Classes/Utility/Media/CachedAsyncImage.swift b/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift similarity index 78% rename from WordPress/Classes/Utility/Media/CachedAsyncImage.swift rename to Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift index 8841e311fca4..d6ef77690540 100644 --- a/WordPress/Classes/Utility/Media/CachedAsyncImage.swift +++ b/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift @@ -1,15 +1,13 @@ import SwiftUI -import DesignSystem -import AsyncImageKit /// Asynchronous Image View that replicates the public API of `SwiftUI.AsyncImage`. /// It uses `ImageDownloader` to fetch and cache the images. -struct CachedAsyncImage: View where Content: View { +public struct CachedAsyncImage: View where Content: View { @State private var phase: AsyncImagePhase = .empty private let url: URL? private let content: (AsyncImagePhase) -> Content private let imageDownloader: ImageDownloader - private let host: MediaHost? + private let host: MediaHostProtocol? public var body: some View { content(phase) @@ -20,19 +18,24 @@ struct CachedAsyncImage: View where Content: View { /// Initializes an image without any customization. /// Provides a plain color as placeholder - init(url: URL?) where Content == _ConditionalContent { + public init(url: URL?) where Content == _ConditionalContent { self.init(url: url) { phase in if let image = phase.image { image } else { - Color(uiColor: UIAppColor.gray(.shade40)) + Color(uiColor: .secondarySystemBackground) } } } /// Allows content customization and providing a placeholder that will be shown /// until the image download is finalized. - init(url: URL?, host: MediaHost? = nil, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I: View, P: View { + public init( + url: URL?, + host: MediaHostProtocol? = nil, + @ViewBuilder content: @escaping (Image) -> I, + @ViewBuilder placeholder: @escaping () -> P + ) where Content == _ConditionalContent, I: View, P: View { self.init(url: url, host: host) { phase in if let image = phase.image { content(image) @@ -42,9 +45,9 @@ struct CachedAsyncImage: View where Content: View { } } - init( + public init( url: URL?, - host: MediaHost? = nil, + host: MediaHostProtocol? = nil, imageDownloader: ImageDownloader = .shared, @ViewBuilder content: @escaping (AsyncImagePhase) -> Content ) { diff --git a/Modules/Sources/AsyncImageKit/Views/ImageLoadingController.swift b/Modules/Sources/AsyncImageKit/Views/ImageLoadingController.swift new file mode 100644 index 000000000000..064dfae9bd48 --- /dev/null +++ b/Modules/Sources/AsyncImageKit/Views/ImageLoadingController.swift @@ -0,0 +1,53 @@ +import UIKit + +/// A convenience class for managing image downloads for individual views. +@MainActor +public final class ImageLoadingController { + public var downloader: ImageDownloader = .shared + public var onStateChanged: (State) -> Void = { _ in } + + public private(set) var task: Task? + + public enum State { + case loading + case success(UIImage) + case failure(Error) + } + + deinit { + task?.cancel() + } + + public init() {} + + public func prepareForReuse() { + task?.cancel() + task = nil + } + + /// - parameter completion: Gets called on completion _after_ `onStateChanged`. + public func setImage(with request: ImageRequest, completion: (@MainActor (Result) -> Void)? = nil) { + task?.cancel() + + if let image = downloader.cachedImage(for: request) { + onStateChanged(.success(image)) + completion?(.success(image)) + } else { + onStateChanged(.loading) + task = Task { @MainActor [downloader, weak self] in + do { + let image = try await downloader.image(for: request) + // This line guarantees that if you cancel on the main thread, + // none of the `onStateChanged` callbacks get called. + guard !Task.isCancelled else { return } + self?.onStateChanged(.success(image)) + completion?(.success(image)) + } catch { + guard !Task.isCancelled else { return } + self?.onStateChanged(.failure(error)) + completion?(.failure(error)) + } + } + } + } +} diff --git a/WordPress/Classes/Utility/Media/UIImageView+ImageDownloader.swift b/Modules/Sources/AsyncImageKit/Views/UIImageView+ImageDownloader.swift similarity index 78% rename from WordPress/Classes/Utility/Media/UIImageView+ImageDownloader.swift rename to Modules/Sources/AsyncImageKit/Views/UIImageView+ImageDownloader.swift index 1ea7a3647b0f..422a61af70d5 100644 --- a/WordPress/Classes/Utility/Media/UIImageView+ImageDownloader.swift +++ b/Modules/Sources/AsyncImageKit/Views/UIImageView+ImageDownloader.swift @@ -1,18 +1,16 @@ -import Foundation import UIKit import Gifu -import AsyncImageKit extension UIImageView { @MainActor - var wp: ImageViewExtensions { ImageViewExtensions(imageView: self) } + public var wp: ImageViewExtensions { ImageViewExtensions(imageView: self) } } @MainActor -struct ImageViewExtensions { +public struct ImageViewExtensions { var imageView: UIImageView - func prepareForReuse() { + public func prepareForReuse() { controller.prepareForReuse() if let gifView = imageView as? GIFImageView, gifView.isAnimatingGIF { @@ -22,15 +20,15 @@ struct ImageViewExtensions { } } - func setImage(with imageURL: URL, host: MediaHost? = nil, size: ImageSize? = nil) { + public func setImage(with imageURL: URL, host: MediaHostProtocol? = nil, size: ImageSize? = nil) { setImage(with: ImageRequest(url: imageURL, host: host, options: ImageRequestOptions(size: size))) } - func setImage(with request: ImageRequest, completion: (@MainActor (Result) -> Void)? = nil) { + public func setImage(with request: ImageRequest, completion: (@MainActor (Result) -> Void)? = nil) { controller.setImage(with: request, completion: completion) } - var controller: ImageLoadingController { + public var controller: ImageLoadingController { if let controller = objc_getAssociatedObject(imageView, ImageViewExtensions.controllerKey) as? ImageLoadingController { return controller } diff --git a/Modules/Sources/AsyncImageKit/MediaHost.swift b/WordPress/Classes/Networking/MediaHost.swift similarity index 91% rename from Modules/Sources/AsyncImageKit/MediaHost.swift rename to WordPress/Classes/Networking/MediaHost.swift index 66f8afce31f0..3aad15939825 100644 --- a/Modules/Sources/AsyncImageKit/MediaHost.swift +++ b/WordPress/Classes/Networking/MediaHost.swift @@ -1,8 +1,9 @@ import Foundation +import AsyncImageKit /// Defines a media host for request authentication purposes. /// -public enum MediaHost: Equatable, Sendable { +public enum MediaHost: Equatable, Sendable, MediaHostProtocol { case publicSite case publicWPComSite case privateSelfHostedSite @@ -90,4 +91,10 @@ public enum MediaHost: Equatable, Sendable { self = .privateAtomicWPComSite(siteID: siteID, username: username, authToken: authToken) } + + // MARK: - MediaHostProtocol + + public func authenticatedRequest(for url: URL) async throws -> URLRequest { + try await MediaRequestAuthenticator().authenticatedRequest(for: url, host: self) + } } diff --git a/WordPress/Classes/Networking/MediaRequestAuthenticator.swift b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift index e6af940a29c1..f0a8a646719e 100644 --- a/WordPress/Classes/Networking/MediaRequestAuthenticator.swift +++ b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift @@ -18,8 +18,7 @@ extension URL { /// /// This also includes regular and photon URLs. /// -struct MediaRequestAuthenticator: MediaRequestAuthenticatorProtocol { - +struct MediaRequestAuthenticator { /// Errors conditions that this class can find. /// enum Error: Swift.Error { @@ -56,7 +55,7 @@ struct MediaRequestAuthenticator: MediaRequestAuthenticatorProtocol { /// authentication. /// - fail: the closure that will be called upon finding an error condition. /// - func authenticatedRequest( + private func authenticatedRequest( for url: URL, from host: MediaHost, onComplete provide: @escaping (URLRequest) -> (), diff --git a/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduler.swift b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduler.swift index 63c31026a227..d083a0e054e6 100644 --- a/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduler.swift +++ b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduler.swift @@ -17,7 +17,7 @@ extension InteractiveNotificationsManager: PushNotificationAuthorizer { /// Main interface for scheduling blogging reminders /// -final class BloggingRemindersScheduler { +class BloggingRemindersScheduler { // MARK: - Convenience Typealiases diff --git a/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift b/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift index e2ab2f650ebd..c592ed942089 100644 --- a/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift +++ b/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift @@ -1,16 +1,6 @@ import Foundation import AsyncImageKit -extension ImageDownloader { - nonisolated static let shared = ImageDownloader(authenticator: MediaRequestAuthenticator()) -} - -extension ImagePrefetcher { - convenience nonisolated init() { - self.init(downloader: .shared) - } -} - // MARK: - ImageDownloader (Closures) extension ImageDownloader { diff --git a/WordPress/Classes/Utility/Media/ImageLoadingController.swift b/WordPress/Classes/Utility/Media/ImageLoadingController.swift deleted file mode 100644 index 102a33d415a4..000000000000 --- a/WordPress/Classes/Utility/Media/ImageLoadingController.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -import UIKit -import AsyncImageKit - -/// A convenience class for managing image downloads for individual views. -@MainActor -final class ImageLoadingController { - var downloader: ImageDownloader = .shared - var service: MediaImageService = .shared - var onStateChanged: (State) -> Void = { _ in } - - private(set) var task: Task? - - enum State { - case loading - case success(UIImage) - case failure(Error) - } - - deinit { - task?.cancel() - } - - func prepareForReuse() { - task?.cancel() - task = nil - } - - /// - parameter completion: Gets called on completion _after_ `onStateChanged`. - func setImage(with request: ImageRequest, completion: (@MainActor (Result) -> Void)? = nil) { - task?.cancel() - - if let image = downloader.cachedImage(for: request) { - onStateChanged(.success(image)) - completion?(.success(image)) - } else { - onStateChanged(.loading) - task = Task { @MainActor [downloader, weak self] in - do { - let image = try await downloader.image(for: request) - // This line guarantees that if you cancel on the main thread, - // none of the `onStateChanged` callbacks get called. - guard !Task.isCancelled else { return } - self?.onStateChanged(.success(image)) - completion?(.success(image)) - } catch { - guard !Task.isCancelled else { return } - self?.onStateChanged(.failure(error)) - completion?(.failure(error)) - } - } - } - } - - func setImage(with media: Media, size: MediaImageService.ImageSize) { - task?.cancel() - - if let image = service.getCachedThumbnail(for: .init(media), size: size) { - onStateChanged(.success(image)) - } else { - onStateChanged(.loading) - task = Task { @MainActor [service, weak self] in - do { - let image = try await service.image(for: media, size: size) - // This line guarantees that if you cancel on the main thread, - // none of the `onStateChanged` callbacks get called. - guard !Task.isCancelled else { return } - self?.onStateChanged(.success(image)) - } catch { - guard !Task.isCancelled else { return } - self?.onStateChanged(.failure(error)) - } - } - } - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconView.swift b/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconView.swift index ae4bd41274e0..65a761cdc047 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconView.swift @@ -1,5 +1,6 @@ import UIKit import SwiftUI +import AsyncImageKit import DesignSystem import WordPressShared diff --git a/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewModel.swift b/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewModel.swift index 621b79190a11..c7b15044b923 100644 --- a/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewModel.swift +++ b/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewModel.swift @@ -2,7 +2,7 @@ import Foundation import UIKit import WordPressUI -final class CompliancePopoverViewModel: ObservableObject { +class CompliancePopoverViewModel: ObservableObject { @Published var isAnalyticsEnabled: Bool = !WPAppAnalytics.userHasOptedOut() diff --git a/WordPress/Classes/ViewRelated/Gravatar/UIImageView+Gravatar.swift b/WordPress/Classes/ViewRelated/Gravatar/UIImageView+Gravatar.swift index 9c61bdf2b8d0..694c530778b7 100644 --- a/WordPress/Classes/ViewRelated/Gravatar/UIImageView+Gravatar.swift +++ b/WordPress/Classes/ViewRelated/Gravatar/UIImageView+Gravatar.swift @@ -1,4 +1,5 @@ import Foundation +import AsyncImageKit import GravatarUI import WordPressUI @@ -114,10 +115,12 @@ fileprivate struct GravatarDefaults { extension AvatarURL { - public static func url(for email: String, - preferredSize: ImageSize? = nil, - gravatarRating: Rating? = nil, - defaultAvatarOption: DefaultAvatarOption? = .status404) -> URL? { + public static func url( + for email: String, + preferredSize: Gravatar.ImageSize? = nil, + gravatarRating: Rating? = nil, + defaultAvatarOption: DefaultAvatarOption? = .status404 + ) -> URL? { AvatarURL( with: .email(email), // Passing GravatarDefaults.imageSize to keep the previous default. diff --git a/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxImagePageViewController.swift b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxImagePageViewController.swift index 075015b84353..a71ace71fbde 100644 --- a/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxImagePageViewController.swift +++ b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxImagePageViewController.swift @@ -5,6 +5,7 @@ import AsyncImageKit final class LightboxImagePageViewController: UIViewController { private(set) var scrollView = LightboxImageScrollView() private let controller = ImageLoadingController() + private let siteMediaImageLoadingController = SiteMediaImageLoadingController() private let item: LightboxItem private let activityIndicator = UIActivityIndicatorView() private var errorView: UIImageView? @@ -35,6 +36,10 @@ final class LightboxImagePageViewController: UIViewController { self?.setState($0) } + siteMediaImageLoadingController.onStateChanged = { [weak self] in + self?.setState($0) + } + startFetching() } @@ -54,7 +59,7 @@ final class LightboxImagePageViewController: UIViewController { case .asset(let asset): controller.setImage(with: ImageRequest(url: asset.sourceURL, host: asset.host)) case .media(let media): - controller.setImage(with: media, size: .original) + siteMediaImageLoadingController.setImage(with: media, size: .original) } } diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Helpers/SiteMediaImageLoadingController.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Helpers/SiteMediaImageLoadingController.swift new file mode 100644 index 000000000000..cc6cf8cac6b3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Helpers/SiteMediaImageLoadingController.swift @@ -0,0 +1,44 @@ +import UIKit +import AsyncImageKit + +/// A convenience class for managing image downloads for individual views. +@MainActor +final class SiteMediaImageLoadingController { + var service: MediaImageService = .shared + var onStateChanged: (State) -> Void = { _ in } + + private(set) var task: Task? + + typealias State = ImageLoadingController.State + + deinit { + task?.cancel() + } + + func prepareForReuse() { + task?.cancel() + task = nil + } + + func setImage(with media: Media, size: MediaImageService.ImageSize) { + task?.cancel() + + if let image = service.getCachedThumbnail(for: .init(media), size: size) { + onStateChanged(.success(image)) + } else { + onStateChanged(.loading) + task = Task { @MainActor [service, weak self] in + do { + let image = try await service.image(for: media, size: size) + // This line guarantees that if you cancel on the main thread, + // none of the `onStateChanged` callbacks get called. + guard !Task.isCancelled else { return } + self?.onStateChanged(.success(image)) + } catch { + guard !Task.isCancelled else { return } + self?.onStateChanged(.failure(error)) + } + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.xib b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.xib index 76877ca18fdc..f400684b4880 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.xib +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.xib @@ -1,9 +1,9 @@ - + - + @@ -12,7 +12,7 @@ - + diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift index dbe0f4e51d47..bfc1b4b8fc3e 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AsyncImageKit import WordPressUI protocol ReaderDetailHeaderViewDelegate: AnyObject { diff --git a/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift index 277d8aa1283a..5046e3b9e77b 100644 --- a/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift +++ b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift @@ -1,5 +1,6 @@ import SwiftUI import Gravatar +import AsyncImageKit import DesignSystem import WordPressUI diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 927371beb713..416831a84ab3 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -185,6 +185,7 @@ 0C0DF8942C2DF14600011B7D /* LoginFacadeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0C0DF8932C2DF12A00011B7D /* LoginFacadeTests.m */; }; 0C2155A62C39A24D00EFE2C0 /* XcodeTarget_UITests in Frameworks */ = {isa = PBXBuildFile; productRef = 0C2155A52C39A24D00EFE2C0 /* XcodeTarget_UITests */; }; 0C2155A82C39A25400EFE2C0 /* XcodeTarget_UITests in Frameworks */ = {isa = PBXBuildFile; productRef = 0C2155A72C39A25400EFE2C0 /* XcodeTarget_UITests */; }; + 0C22EE0B2D2749A40058F329 /* MediaHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C22EE0A2D2749A40058F329 /* MediaHostTests.swift */; }; 0C235BD22C3862D400D0E163 /* XcodeTarget_WordPressTests in Frameworks */ = {isa = PBXBuildFile; productRef = 0C235BD12C3862D400D0E163 /* XcodeTarget_WordPressTests */; }; 0C2518AE2ABE1F2800381D31 /* iphone-photo.heic in Resources */ = {isa = PBXBuildFile; fileRef = 0C2518AD2ABE1EA000381D31 /* iphone-photo.heic */; }; 0C35FFF429CBA6DA00D224EB /* BlogDashboardPersonalizationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C35FFF329CBA6DA00D224EB /* BlogDashboardPersonalizationViewModelTests.swift */; }; @@ -2015,6 +2016,7 @@ 0A69300A28B5AA5E00E98DE1 /* FullScreenCommentReplyViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCommentReplyViewModelTests.swift; sourceTree = ""; }; 0A9687BB28B40771009DCD2F /* FullScreenCommentReplyViewModelMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCommentReplyViewModelMock.swift; sourceTree = ""; }; 0C0DF8932C2DF12A00011B7D /* LoginFacadeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LoginFacadeTests.m; sourceTree = ""; }; + 0C22EE0A2D2749A40058F329 /* MediaHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaHostTests.swift; sourceTree = ""; }; 0C2518AD2ABE1EA000381D31 /* iphone-photo.heic */ = {isa = PBXFileReference; lastKnownFileType = file; path = "iphone-photo.heic"; sourceTree = ""; }; 0C35FFF329CBA6DA00D224EB /* BlogDashboardPersonalizationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationViewModelTests.swift; sourceTree = ""; }; 0C38581F2CA74DC7004880ED /* AppSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsTests.swift; sourceTree = ""; }; @@ -6032,6 +6034,7 @@ isa = PBXGroup; children = ( F1450CF82437EEBB00A28BFE /* MediaRequestAuthenticatorTests.swift */, + 0C22EE0A2D2749A40058F329 /* MediaHostTests.swift */, 4688E6CB26AB571D00A5D894 /* RequestAuthenticatorTests.swift */, E15027641E03E54100B847E3 /* PinghubTests.swift */, 4AB6A35F2B7C3EB500769115 /* PinghubWebSocketTests.swift */, @@ -9966,6 +9969,7 @@ 572FB401223A806000933C76 /* NoticeStoreTests.swift in Sources */, 748437EE1F1D4A7300E8DDAF /* RichContentFormatterTests.swift in Sources */, FE9438B22A050251006C40EC /* BlockEditorSettings_GutenbergEditorSettingsTests.swift in Sources */, + 0C22EE0B2D2749A40058F329 /* MediaHostTests.swift in Sources */, C81CCD6A243AEE1100A83E27 /* TenorAPIResponseTests.swift in Sources */, 8BE7C84123466927006EDE70 /* I18n.swift in Sources */, C396C80B280F2401006FE7AC /* SiteDesignTests.swift in Sources */, diff --git a/Modules/Tests/AsyncImageKitTests/MediaHostTests.swift b/WordPress/WordPressTest/MediaHostTests.swift similarity index 100% rename from Modules/Tests/AsyncImageKitTests/MediaHostTests.swift rename to WordPress/WordPressTest/MediaHostTests.swift