diff --git a/Modules/Package.swift b/Modules/Package.swift index 56e09fb13add..ce52c9e78da7 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -21,6 +21,8 @@ let package = Package( .library(name: "WordPressShared", targets: ["WordPressShared"]), .library(name: "WordPressUI", targets: ["WordPressUI"]), .library(name: "WordPressReader", targets: ["WordPressReader"]), + .library(name: "WordPressCore", targets: ["WordPressCore"]), + .library(name: "WordPressCoreProtocols", targets: ["WordPressCore"]), ], dependencies: [ .package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"), @@ -137,7 +139,7 @@ let package = Package( name: "Support", dependencies: [ "AsyncImageKit", - "WordPressCore", + "WordPressCoreProtocols", ] ), .target(name: "TextBundle"), @@ -152,10 +154,15 @@ let package = Package( ], swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressFlux", swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressCore", dependencies: [ + "WordPressCoreProtocols", "WordPressShared", - .product(name: "WordPressAPI", package: "wordpress-rs") + .product(name: "WordPressAPI", package: "wordpress-rs"), ] ), + .target(name: "WordPressCoreProtocols", dependencies: [ + // This package should never have dependencies – it exists to expose protocols implemented in WordPressCore + // to UI code, because `wordpress-rs` doesn't work nicely with previews. + ]), .target(name: "WordPressLegacy", dependencies: ["DesignSystem", "WordPressShared"]), .target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), .target( diff --git a/Modules/Sources/Support/Extensions/Foundation.swift b/Modules/Sources/Support/Extensions/Foundation.swift index a7e0fa8d3960..c30fd3ad62b7 100644 --- a/Modules/Sources/Support/Extensions/Foundation.swift +++ b/Modules/Sources/Support/Extensions/Foundation.swift @@ -93,4 +93,20 @@ extension Task where Failure == Error { return try await MainActor.run(body: operation) } } + + enum RunForAtLeastResult: Sendable where T: Sendable { + case result(T) + case wait + } + + static func runForAtLeast( + _ duration: C.Instant.Duration, + operation: @escaping @Sendable () async throws -> Success, + clock: C = .continuous + ) async throws -> Success where C: Clock { + async let waitResult: () = try await clock.sleep(for: duration) + async let performTask = try await operation() + + return try await (waitResult, performTask).1 + } } diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index a5aa146aec6e..ef32e504d8f5 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -1,5 +1,5 @@ import Foundation -import WordPressCore +import WordPressCoreProtocols // This file is all module-internal and provides sample data for UI development @@ -8,7 +8,8 @@ extension SupportDataProvider { applicationLogProvider: InternalLogDataProvider(), botConversationDataProvider: InternalBotConversationDataProvider(), userDataProvider: InternalUserDataProvider(), - supportConversationDataProvider: InternalSupportConversationDataProvider() + supportConversationDataProvider: InternalSupportConversationDataProvider(), + diagnosticsDataProvider: InternalDiagnosticsDataProvider() ) static let applicationLog = ApplicationLog(path: URL(filePath: #filePath), createdAt: Date(), modifiedAt: Date()) @@ -391,3 +392,31 @@ actor InternalSupportConversationDataProvider: SupportConversationDataProvider { self.conversations[value.id] = value } } + +actor InternalDiagnosticsDataProvider: DiagnosticsDataProvider { + + func fetchDiskCacheUsage() async throws -> WordPressCoreProtocols.DiskCacheUsage { + DiskCacheUsage(fileCount: 64, byteCount: 623_423_562) + } + + func clearDiskCache(progress: @Sendable (CacheDeletionProgress) async throws -> Void) async throws { + let totalFiles = 12 + + // Initial progress (0%) + try await progress(CacheDeletionProgress(filesDeleted: 0, totalFileCount: totalFiles)) + + for i in 1...totalFiles { + // Pretend each file takes a short time to delete + try await Task.sleep(for: .milliseconds(150)) + + // Report incremental progress + try await progress(CacheDeletionProgress(filesDeleted: i, totalFileCount: totalFiles)) + } + } + + func resetPluginRecommendations() async throws { + if Bool.random() { + throw CocoaError(.fileNoSuchFile) + } + } +} diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index 84f003d6401e..7723747dfcab 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -1,5 +1,5 @@ import Foundation -import WordPressCore +import WordPressCoreProtocols public enum SupportFormAction { case viewApplicationLogList @@ -23,6 +23,7 @@ public enum SupportFormAction { case viewDiagnostics case emptyDiskCache(bytesSaved: Int64) + case resetPluginRecommendations } @MainActor @@ -32,6 +33,7 @@ public final class SupportDataProvider: ObservableObject, Sendable { private let botConversationDataProvider: BotConversationDataProvider private let userDataProvider: CurrentUserDataProvider private let supportConversationDataProvider: SupportConversationDataProvider + private let diagnosticsDataProvider: DiagnosticsDataProvider private weak var supportDelegate: SupportDelegate? @@ -40,12 +42,14 @@ public final class SupportDataProvider: ObservableObject, Sendable { botConversationDataProvider: BotConversationDataProvider, userDataProvider: CurrentUserDataProvider, supportConversationDataProvider: SupportConversationDataProvider, + diagnosticsDataProvider: DiagnosticsDataProvider, delegate: SupportDelegate? = nil ) { self.applicationLogProvider = applicationLogProvider self.botConversationDataProvider = botConversationDataProvider self.userDataProvider = userDataProvider self.supportConversationDataProvider = supportConversationDataProvider + self.diagnosticsDataProvider = diagnosticsDataProvider self.supportDelegate = delegate } @@ -161,6 +165,20 @@ public final class SupportDataProvider: ObservableObject, Sendable { self.userDid(.deleteAllApplicationLogs) try await self.applicationLogProvider.deleteAllApplicationLogs() } + + // Diagnostics + public func fetchDiskCacheUsage() async throws -> DiskCacheUsage { + try await self.diagnosticsDataProvider.fetchDiskCacheUsage() + } + + public func clearDiskCache(progress: (@Sendable @escaping (CacheDeletionProgress) async throws -> Void)) async throws { + try await self.diagnosticsDataProvider.clearDiskCache(progress: progress) + } + + public func resetPluginRecommendations() async throws { + self.userDid(.resetPluginRecommendations) + try await self.diagnosticsDataProvider.resetPluginRecommendations() + } } public protocol SupportFormDataProvider { @@ -211,6 +229,13 @@ public protocol CurrentUserDataProvider: Actor { nonisolated func fetchCurrentSupportUser() throws -> any CachedAndFetchedResult } +public protocol DiagnosticsDataProvider: Actor { + func fetchDiskCacheUsage() async throws -> DiskCacheUsage + func clearDiskCache(progress: (@Sendable @escaping (CacheDeletionProgress) async throws -> Void)) async throws + + func resetPluginRecommendations() async throws +} + public protocol ApplicationLogDataProvider: Actor { func readApplicationLog(_ log: ApplicationLog) async throws -> String func fetchApplicationLogs() async throws -> [ApplicationLog] diff --git a/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift b/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift index 8a6eac6cf227..c1f2ef317fd9 100644 --- a/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift +++ b/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift @@ -1,5 +1,4 @@ import SwiftUI -import WordPressCore public struct DiagnosticsView: View { @@ -15,6 +14,7 @@ public struct DiagnosticsView: View { .foregroundStyle(.secondary) EmptyDiskCacheView() + ResetPluginRecommendationsView() } .padding() } diff --git a/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift index 5b328ce02762..1a55f59d3968 100644 --- a/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift +++ b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift @@ -1,5 +1,5 @@ import SwiftUI -import WordPressCore +import WordPressCoreProtocols struct EmptyDiskCacheView: View { @@ -8,7 +8,7 @@ struct EmptyDiskCacheView: View { enum ViewState: Equatable { case loading - case loaded(usage: DiskCache.DiskCacheUsage) + case loaded(usage: DiskCacheUsage) case clearing(progress: Double, result: String) case error(Error) @@ -51,8 +51,6 @@ struct EmptyDiskCacheView: View { @State var state: ViewState = .loading - private let cache = DiskCache() - var body: some View { // Clear Disk Cache card DiagnosticCard( @@ -112,7 +110,7 @@ struct EmptyDiskCacheView: View { private func fetchDiskCacheUsage() async { do { - let usage = try await cache.diskUsage() + let usage = try await dataProvider.fetchDiskCacheUsage() await MainActor.run { self.state = .loaded(usage: usage) } @@ -134,18 +132,12 @@ struct EmptyDiskCacheView: View { self.state = .clearing(progress: 0, result: "") do { - try await cache.removeAll { count, total in - let progress: Double - - if count > 0 && total > 0 { - progress = Double(count) / Double(total) - } else { - progress = 0 - } - - await MainActor.run { - withAnimation { - self.state = .clearing(progress: progress, result: "Working") + try await Task.runForAtLeast(.seconds(1.5)) { + try await dataProvider.clearDiskCache { progress in + await MainActor.run { + withAnimation { + self.state = .clearing(progress: progress.progress, result: "Working") + } } } } @@ -166,5 +158,5 @@ struct EmptyDiskCacheView: View { } #Preview { - EmptyDiskCacheView() + EmptyDiskCacheView().environmentObject(SupportDataProvider.testing) } diff --git a/Modules/Sources/Support/UI/Diagnostics/ResetPluginRecommendations.swift b/Modules/Sources/Support/UI/Diagnostics/ResetPluginRecommendations.swift new file mode 100644 index 000000000000..ef22a7888c4f --- /dev/null +++ b/Modules/Sources/Support/UI/Diagnostics/ResetPluginRecommendations.swift @@ -0,0 +1,153 @@ +import SwiftUI + +struct ResetPluginRecommendationsView: View { + + @EnvironmentObject + private var dataProvider: SupportDataProvider + + enum ViewState: Equatable { + case idle + case resetting + case error(Error) + case complete + + var buttonIsDisabled: Bool { + switch self { + case .idle: false + case .resetting: true + case .error: false + case .complete: true + } + } + + static func == (lhs: ViewState, rhs: ViewState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle): return true + case (.resetting, .resetting): return true + case (.error, .error): return false // Errors aren't equatable, so always redraw the view + case (.complete, .complete): return true + default: return false + } + } + } + + @State + var state: ViewState = .idle + + var body: some View { + DiagnosticCard( + title: Strings.title, + subtitle: Strings.subtitle, + systemImage: "puzzlepiece.extension" + ) { + VStack(alignment: .leading, spacing: 12) { + Button { + Task { await resetRecommendations() } + } label: { + Label(buttonLabel, systemImage: buttonIcon) + } + .buttonStyle(.borderedProminent) + .disabled(state.buttonIsDisabled) + + if case .error(let error) = state { + Text(String(format: Strings.error, error.localizedDescription)) + .font(.footnote) + .foregroundStyle(.red) + } + } + } + } + + private var buttonLabel: String { + switch state { + case .idle: + return Strings.buttonIdle + case .resetting, .error: + return Strings.buttonResetting + case .complete: + return Strings.buttonComplete + } + } + + private var buttonIcon: String { + switch state { + case .idle: + return "arrow.counterclockwise" + case .resetting: + return "hourglass" + case .error: + return "exclamationmark.triangle" + case .complete: + return "checkmark" + } + } + + private func resetRecommendations() async { + await MainActor.run { + withAnimation { + state = .resetting + } + } + + do { + try await Task.runForAtLeast(.seconds(1)) { + try await dataProvider.resetPluginRecommendations() + } + + await MainActor.run { + withAnimation { + state = .complete + } + } + } catch { + await MainActor.run { + withAnimation { + state = .error(error) + } + } + } + } +} + +private enum Strings { + static let title = NSLocalizedString( + "diagnostics.reset-plugin-recommendations.title", + value: "Reset Plugin Recommendations", + comment: "Title for the reset plugin recommendations diagnostic card" + ) + + static let subtitle = NSLocalizedString( + "diagnostics.reset-plugin-recommendations.subtitle", + value: "Clear saved plugin recommendation preferences to see prompts again.", + comment: "Subtitle explaining what resetting plugin recommendations does" + ) + + static let buttonIdle = NSLocalizedString( + "diagnostics.reset-plugin-recommendations.button.idle", + value: "Reset Recommendations", + comment: "Button label to reset plugin recommendations" + ) + + static let buttonResetting = NSLocalizedString( + "diagnostics.reset-plugin-recommendations.button.resetting", + value: "Resetting…", + comment: "Button label shown while resetting plugin recommendations" + ) + + static let buttonComplete = NSLocalizedString( + "diagnostics.reset-plugin-recommendations.button.complete", + value: "Reset Complete", + comment: "Button label shown after plugin recommendations have been reset" + ) + + static let error = NSLocalizedString( + "diagnostics.reset-plugin-recommendations.complete.message", + value: "Error: %@", + comment: "Error message shown if resetting plugin recommendations doesn't work" + ) +} + +#Preview { + ResetPluginRecommendationsView() + .environmentObject(SupportDataProvider.testing) +} diff --git a/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift b/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift index 062c74295296..6a2d0e8cb707 100644 --- a/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift +++ b/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift @@ -1,24 +1,5 @@ import Foundation - -public protocol CachedAndFetchedResult: Sendable { - associatedtype T - - var cachedResult: @Sendable () async throws -> T? { get } - var fetchedResult: @Sendable () async throws -> T { get } -} - -/// A type that isn't actually cached (like Preview data providers) -public struct UncachedResult: CachedAndFetchedResult { - public let cachedResult: @Sendable () async throws -> T? - public let fetchedResult: @Sendable () async throws -> T - - public init( - fetchedResult: @Sendable @escaping () async throws -> T - ) { - self.cachedResult = { nil } - self.fetchedResult = fetchedResult - } -} +import WordPressCoreProtocols /// Represents a double-returning promise – initially for a cached result that may be empty, and eventually for an expensive fetched result (usually from a server). /// @@ -47,14 +28,14 @@ public struct DiskCachedAndFetchedResult: CachedAndFetchedResult where T: Cod public func fetchAndCache() async throws -> T { let result = try await userProvidedFetchBlock() - try await DiskCache().store(result, forKey: self.cacheKey) + try await DiskCache.shared.store(result, forKey: self.cacheKey) return result } // We can ignore decoding failures here because the data format may change over time. Treating it as a cache // miss is preferable to returning an error because the cache will simply be updated on the next remote fetch. private func readFromCache() async throws -> T? { - try await DiskCache().read(T.self, forKey: self.cacheKey) + try await DiskCache.shared.read(T.self, forKey: self.cacheKey) } } diff --git a/Modules/Sources/WordPressCore/DataStore/InMemoryDataStore.swift b/Modules/Sources/WordPressCore/DataStore/InMemoryDataStore.swift index faafa64da189..ded0e6f1a902 100644 --- a/Modules/Sources/WordPressCore/DataStore/InMemoryDataStore.swift +++ b/Modules/Sources/WordPressCore/DataStore/InMemoryDataStore.swift @@ -39,6 +39,10 @@ public actor InMemoryDataStore: DataStore where T.ID /// A `Dictionary` to store the data in memory. private var storage: [T.ID: T] = [:] + public var isEmpty: Bool { + storage.isEmpty + } + /// A publisher for sending and subscribing data changes. /// /// The publisher emits events when data changes, with identifiers of changed models. diff --git a/Modules/Sources/WordPressCore/DiskCache.swift b/Modules/Sources/WordPressCore/DiskCache.swift index 61ddab5d23b0..2b1fcb066f11 100644 --- a/Modules/Sources/WordPressCore/DiskCache.swift +++ b/Modules/Sources/WordPressCore/DiskCache.swift @@ -1,37 +1,38 @@ import Foundation +import WordPressCoreProtocols /// A super-basic on-disk cache for `Codable` objects. /// -public actor DiskCache { +public actor DiskCache: DiskCacheProtocol { - public struct DiskCacheUsage: Sendable, Equatable { - public let fileCount: Int - public let byteCount: Int64 - - public var diskUsage: Measurement { - Measurement(value: Double(byteCount), unit: .bytes) - } - - public var formattedDiskUsage: String { - return diskUsage.formatted(.byteCount(style: .file, allowedUnits: [.mb, .gb], spellsOutZero: true)) - } - - public var isEmpty: Bool { - fileCount == 0 - } - } + public static let shared = DiskCache() private let cacheRoot: URL = URL.cachesDirectory - public init() {} - - public func read(_ type: T.Type, forKey key: String) throws -> T? where T: Decodable { + public func read( + _ type: T.Type, + forKey key: String, + notOlderThan interval: TimeInterval? = nil + ) throws -> T? where T: Decodable { let path = self.path(forKey: key) guard FileManager.default.fileExists(at: path) else { return nil } + if let interval { + let attributes = try FileManager.default.attributesOfItem(atPath: path.absoluteString) + + // If we can't get a modification date, assume it's invalid + guard let lastModifiedAt = attributes[.modificationDate] as? Date else { + return nil + } + + if Date.now.addingTimeInterval(interval * -1) < lastModifiedAt { + return nil + } + } + let data = try Data(contentsOf: path) // We can ignore decoding failures here because the data format may change over time. Treating it as a cache @@ -52,16 +53,16 @@ public actor DiskCache { try FileManager.default.removeItem(at: self.path(forKey: key)) } - public func removeAll(progress: (@Sendable (Int, Int) async throws -> Void)? = nil) async throws { + public func removeAll(progress: (@Sendable (CacheDeletionProgress) async throws -> Void)? = nil) async throws { let files = try await fetchCacheEntries() let count = files.count - try await progress?(0, count) + try await progress?(CacheDeletionProgress(filesDeleted: 0, totalFileCount: count)) for file in files.enumerated() { try FileManager.default.removeItem(at: file.element) - try await progress?(file.offset + 1, count) + try await progress?(CacheDeletionProgress(filesDeleted: file.offset + 1, totalFileCount: count)) } } diff --git a/Modules/Sources/WordPressCore/Extensions/Foundation.swift b/Modules/Sources/WordPressCore/Extensions/Foundation.swift new file mode 100644 index 000000000000..6645599d1bd6 --- /dev/null +++ b/Modules/Sources/WordPressCore/Extensions/Foundation.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Date { + /// Is this date in the past? + var hasPast: Bool { + Date.now > self + } +} diff --git a/Modules/Sources/WordPressCore/Plugins/PluginServiceProtocol.swift b/Modules/Sources/WordPressCore/PluginServiceProtocol.swift similarity index 100% rename from Modules/Sources/WordPressCore/Plugins/PluginServiceProtocol.swift rename to Modules/Sources/WordPressCore/PluginServiceProtocol.swift diff --git a/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift new file mode 100644 index 000000000000..391896e3049e --- /dev/null +++ b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift @@ -0,0 +1,147 @@ +import Foundation +import WordPressAPI +import WordPressAPIInternal +import WordPressCoreProtocols + +public actor PluginRecommendationService: PluginRecommendationServiceProtocol { + + public typealias Feature = WordPressCoreProtocols.PluginRecommendationFeature + public typealias Frequency = WordPressCoreProtocols.PluginRecommendationFrequency + + private let dotOrgClient: WordPressOrgApiClient + private let userDefaults: UserDefaults + + public init( + dotOrgClient: WordPressOrgApiClient = WordPressOrgApiClient(urlSession: .shared), + userDefaults: UserDefaults = .standard + ) { + self.dotOrgClient = dotOrgClient + self.userDefaults = userDefaults + } + + public func recommendedPluginSlug(for feature: Feature) async throws -> String { + feature.recommendedPlugin + } + + public func recommendPlugin(for feature: Feature) async throws -> RecommendedPlugin { + if let cachedPlugin = try await fetchCachedPlugin(for: feature.recommendedPlugin) { + return cachedPlugin + } + + let plugin = try await dotOrgClient.pluginInformation(slug: .init(slug: feature.recommendedPlugin)) + + return RecommendedPlugin( + name: plugin.name, + slug: plugin.slug.slug, + usageTitle: "Install \(unescapePluginTitle(plugin.name) ?? plugin.slug.slug)", + usageDescription: feature.explanation, + successMessage: feature.successMessage, + imageUrl: try await cachePluginHeader(for: plugin), + helpUrl: feature.helpArticleUrl + ) + } + + public func shouldRecommendPlugin(for feature: Feature, frequency: Frequency) -> Bool { + let featureTimestamp = self.userDefaults.double(forKey: feature.cacheKey) + let globalTimestamp = self.userDefaults.double(forKey: "plugin-last-recommended") + + if featureTimestamp == 0 && globalTimestamp == 0 { + return true + } + + let earliestFeatureDate = Date(timeIntervalSince1970: featureTimestamp + frequency.timeInterval) + let earliestGlobalDate = Date(timeIntervalSince1970: globalTimestamp + frequency.timeInterval) + + return earliestFeatureDate.hasPast && earliestGlobalDate.hasPast + } + + public func displayedRecommendation(for feature: Feature, at date: Date = Date()) { + self.userDefaults.set(date.timeIntervalSince1970, forKey: feature.cacheKey) + self.userDefaults.set(date.timeIntervalSince1970, forKey: "plugin-last-recommended") + } + + public func resetRecommendations() { + for feature in Feature.allCases { + self.userDefaults.removeObject(forKey: feature.cacheKey) + } + self.userDefaults.removeObject(forKey: "plugin-last-recommended") + } + + private func unescapePluginTitle(_ string: String) -> String? { + string + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "–", with: "–") + .removingPercentEncoding + } +} + +public extension RecommendedPlugin { + var pluginSlug: PluginWpOrgDirectorySlug { + PluginWpOrgDirectorySlug(slug: self.slug) + } +} + +private extension PluginRecommendationService.Feature { + var cacheKey: String { + "plugin-recommendation-\(self)-\(recommendedPlugin)" + } +} + +// MARK: - RecommendedPlugin Cache +private extension PluginRecommendationService { + private func cachedPluginData(for plugin: RecommendedPlugin) async throws { + let cacheKey = "plugin-recommendation-\(plugin.slug)" + try await DiskCache.shared.store(plugin, forKey: cacheKey) + } + + private func fetchCachedPlugin(for slug: String) async throws -> RecommendedPlugin? { + let cacheKey = "plugin-recommendation-\(slug)" + return try await DiskCache.shared.read(RecommendedPlugin.self, forKey: cacheKey) + } +} + +// MARK: - Plugin Banner Cache +private extension PluginRecommendationService { + func cachePluginHeader(for plugin: PluginInformation) async throws -> URL? { + guard let pluginUrl = plugin.bannerUrl, let bannerFileName = plugin.bannerFileName else { + return nil + } + + let cachePath = self.storagePath(for: plugin, filename: bannerFileName) + + try FileManager.default.createDirectory( + at: cachePath.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + return try await cacheAsset(pluginUrl, at: cachePath) + } + + func cacheAsset(_ url: URL, at path: URL) async throws -> URL { + if FileManager.default.fileExists(at: path) { + return path + } + + let (tempPath, _) = try await URLSession.shared.download(from: url) + try FileManager.default.moveItem(at: tempPath, to: path) + + return path + } + + func storagePath(for plugin: PluginInformation, filename: String) -> URL { + URL.cachesDirectory + .appendingPathComponent("plugin-assets") + .appendingPathComponent(plugin.slug.slug) + .appendingPathComponent(filename) + } +} + +fileprivate extension PluginInformation { + var bannerFileName: String? { + bannerUrl?.lastPathComponent + } + + var bannerUrl: URL? { + URL(string: self.banners.high) + } +} diff --git a/Modules/Sources/WordPressCore/Plugins/PluginService.swift b/Modules/Sources/WordPressCore/Plugins/PluginService.swift index 4161877e2bc2..0d35f0168fa7 100644 --- a/Modules/Sources/WordPressCore/Plugins/PluginService.swift +++ b/Modules/Sources/WordPressCore/Plugins/PluginService.swift @@ -12,25 +12,36 @@ public actor PluginService: PluginServiceProtocol { private let updateChecksDataStore = PluginUpdateChecksDataStore() private let urlSession: URLSession + private var installedPluginsTask: Task<[InstalledPlugin], Error> + public init(client: WordPressClient, wordpressCoreVersion: String?) { self.client = client self.wordpressCoreVersion = wordpressCoreVersion self.urlSession = URLSession(configuration: .ephemeral) - wpOrgClient = WordPressOrgApiClient(urlSession: urlSession) + self.wpOrgClient = WordPressOrgApiClient(urlSession: urlSession) + + self.installedPluginsTask = Task { + try await client.api + .plugins + .listWithViewContext(params: PluginListParams()) + .data + .map { InstalledPlugin(plugin: $0) } + } } public func fetchInstalledPlugins() async throws { - let response = try await self.client.api.plugins.listWithViewContext(params: .init()) - let plugins = response.data.map(InstalledPlugin.init(plugin:)) + let plugins = try await self.installedPluginsTask.value try await installedPluginDataStore.store(plugins) + } - // Check for plugin updates in the background. No need to block the current task from completion. - // We could move this call out and make the UI invoke it explicitly. However, currently the `checkPluginUpdates` - // function takes a REST API response type, which is not exposed as a public API of `PluginService`. - // We could refactor this API if we need to call `checkPluginUpdates` directly. - Task.detached { - try await self.checkPluginUpdates(plugins: response.data) - } + public func checkForUpdates() async throws { + let latestInstalledPlugins = try await self.client + .api + .plugins + .listWithViewContext(params: PluginListParams(status: .active)) + .data + + try await self.checkPluginUpdates(plugins: latestInstalledPlugins) } public func fetchPluginInformation(slug: PluginWpOrgDirectorySlug) async throws { @@ -44,8 +55,16 @@ public actor PluginService: PluginServiceProtocol { try await pluginDirectoryDataStore.store([plugin]) } + public func hasInstalledPlugin(slug: PluginWpOrgDirectorySlug) async throws -> Bool { + try await findInstalledPlugin(slug: slug) != nil + } + public func findInstalledPlugin(slug: PluginWpOrgDirectorySlug) async throws -> InstalledPlugin? { - try await installedPluginDataStore.list(query: .slug(slug)).first + if await installedPluginDataStore.isEmpty { + try await installedPluginDataStore.store(installedPluginsTask.value) + } + + return try await installedPluginDataStore.list(query: .slug(slug)).first } public func installedPlugins(query: PluginDataStoreQuery) async throws -> [InstalledPlugin] { diff --git a/Modules/Sources/WordPressCore/Users/DisplayUser.swift b/Modules/Sources/WordPressCore/Users/DisplayUser.swift index b1ab6300616a..99a9cea65d69 100644 --- a/Modules/Sources/WordPressCore/Users/DisplayUser.swift +++ b/Modules/Sources/WordPressCore/Users/DisplayUser.swift @@ -1,7 +1,7 @@ import Foundation import WordPressAPI -public struct DisplayUser: Identifiable, Codable, Hashable, Sendable { +public struct DisplayUser: Identifiable, Hashable, Sendable { public let id: Int64 public let handle: String public let username: String diff --git a/Modules/Sources/WordPressCore/Users/User+Extensions.swift b/Modules/Sources/WordPressCore/Users/User+Extensions.swift index 5873d96c9563..ec552b7e7f7d 100644 --- a/Modules/Sources/WordPressCore/Users/User+Extensions.swift +++ b/Modules/Sources/WordPressCore/Users/User+Extensions.swift @@ -7,19 +7,6 @@ public extension UserRole { } } -extension UserRole: @retroactive Codable { - public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - let string: String = try container.decode(String.self) - self = .custom(string) - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(self.rawValue) - } -} - extension UserRole: @retroactive Comparable { public static func < (lhs: UserRole, rhs: UserRole) -> Bool { diff --git a/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index 23d31f4628f8..7e2a4a85d014 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -1,13 +1,76 @@ import Foundation import WordPressAPI +import WordPressAPIInternal public actor WordPressClient { + public enum Feature { + /// Theme styles allow us to style the editor + case themeStyles + + /// Application Password Extras grants additional capabilities using Application Passwords + case applicationPasswordExtras + + /// WordPress.com sites don't all support plugins + case plugins + } + public let api: WordPressAPI public let rootUrl: String + private var apiRoot: WpApiDetails? = nil + private var currentUser: UserWithEditContext? = nil + + private var loadSiteInfoTask: Task<(WpApiDetails, UserWithEditContext), Error> + public init(api: WordPressAPI, rootUrl: ParsedUrl) { self.api = api self.rootUrl = rootUrl.url() + self.loadSiteInfoTask = Task { [api] in + async let apiRootTask = try await api.apiRoot.get().data + async let currentUserTask = try await api.users.retrieveMeWithEditContext().data + + return try await (apiRootTask, currentUserTask) + } + } + + public func currentUserCan(_ capability: UserCapability) async throws -> Bool { + try await fetchCurrentUser().capabilities.keys.contains(capability) + } + + private func fetchCurrentUser() async throws -> UserWithEditContext { + // Wait for the `loadSiteInfoTask` to finish the initial load then use that value + return try await loadSiteInfoTask.value.1 + } + + public func supports(_ feature: Feature, forSiteId siteId: Int? = nil) async throws -> Bool { + let apiRoot = try await fetchApiRoot() + + if let siteId { + return switch feature { + case .themeStyles: apiRoot.hasRoute(route: "/wp-block-editor/v1/sites/\(siteId)/settings") + case .plugins: apiRoot.hasRoute(route: "/wp/v2/plugins") + case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax") + } + } + + return switch feature { + case .themeStyles: apiRoot.hasRoute(route: "/wp-block-editor/v1/settings") + case .plugins: apiRoot.hasRoute(route: "/wp/v2/plugins") + case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax") + } + } + + private func fetchApiRoot() async throws -> WpApiDetails { + // Wait for the `loadSiteInfoTask` to finish the initial load then use that value + return try await loadSiteInfoTask.value.0 + } + + private func setApiRoot(_ newValue: WpApiDetails) { + self.apiRoot = newValue + } + + private func setCurrentUser(_ newValue: UserWithEditContext) { + self.currentUser = newValue } } diff --git a/Modules/Sources/WordPressCoreProtocols/CachedAndFetchedResult.swift b/Modules/Sources/WordPressCoreProtocols/CachedAndFetchedResult.swift new file mode 100644 index 000000000000..2d802c0c1c0b --- /dev/null +++ b/Modules/Sources/WordPressCoreProtocols/CachedAndFetchedResult.swift @@ -0,0 +1,21 @@ +import Foundation + +public protocol CachedAndFetchedResult: Sendable { + associatedtype T + + var cachedResult: @Sendable () async throws -> T? { get } + var fetchedResult: @Sendable () async throws -> T { get } +} + +/// A type that isn't actually cached (like Preview data providers) +public struct UncachedResult: CachedAndFetchedResult { + public let cachedResult: @Sendable () async throws -> T? + public let fetchedResult: @Sendable () async throws -> T + + public init( + fetchedResult: @Sendable @escaping () async throws -> T + ) { + self.cachedResult = { nil } + self.fetchedResult = fetchedResult + } +} diff --git a/Modules/Sources/WordPressCoreProtocols/DiskCacheProtocol.swift b/Modules/Sources/WordPressCoreProtocols/DiskCacheProtocol.swift new file mode 100644 index 000000000000..5253c10e06af --- /dev/null +++ b/Modules/Sources/WordPressCoreProtocols/DiskCacheProtocol.swift @@ -0,0 +1,59 @@ +import Foundation + +public protocol DiskCacheProtocol: Actor { + func read( + _ type: T.Type, + forKey key: String, + notOlderThan interval: TimeInterval? + ) throws -> T? where T: Decodable + + func store(_ value: T, forKey key: String) throws where T: Encodable + + func remove(key: String) throws + + func removeAll(progress: (@Sendable (CacheDeletionProgress) async throws -> Void)?) async throws + + func count() async throws -> Int + + func diskUsage() async throws -> DiskCacheUsage +} + +public struct CacheDeletionProgress: Sendable, Equatable { + public let filesDeleted: Int + public let totalFileCount: Int + + public var progress: Double { + if filesDeleted > 0 && totalFileCount > 0 { + return Double(filesDeleted) / Double(totalFileCount) + } + + return 0 + } + + public init(filesDeleted: Int, totalFileCount: Int) { + self.filesDeleted = filesDeleted + self.totalFileCount = totalFileCount + } +} + +public struct DiskCacheUsage: Sendable, Equatable { + public let fileCount: Int + public let byteCount: Int64 + + public init(fileCount: Int, byteCount: Int64) { + self.fileCount = fileCount + self.byteCount = byteCount + } + + public var diskUsage: Measurement { + Measurement(value: Double(byteCount), unit: .bytes) + } + + public var formattedDiskUsage: String { + return diskUsage.formatted(.byteCount(style: .file, allowedUnits: [.mb, .gb], spellsOutZero: true)) + } + + public var isEmpty: Bool { + fileCount == 0 + } +} diff --git a/Modules/Sources/WordPressCoreProtocols/Plugins/PluginRecommendationServiceProtocol.swift b/Modules/Sources/WordPressCoreProtocols/Plugins/PluginRecommendationServiceProtocol.swift new file mode 100644 index 000000000000..6202f87df576 --- /dev/null +++ b/Modules/Sources/WordPressCoreProtocols/Plugins/PluginRecommendationServiceProtocol.swift @@ -0,0 +1,92 @@ +import Foundation + +public enum PluginRecommendationFeature: CaseIterable { + case themeStyles + case postPreviews + case editorCompatibility + + public var explanation: String { + switch self { + case .themeStyles: NSLocalizedString( + "org.wordpress.plugin-recommendations.explanations.gutenberg-for-theme-styles", + value: "The Gutenberg Plugin is required to use your theme's styles in the editor.", + comment: "A short message explaining why we're recommending this plugin" + ) + case .postPreviews: NSLocalizedString( + "org.wordpress.plugin-recommendations.explanations.jetpack-for-post-previews", + value: "The Jetpack Plugin is required for post previews.", + comment: "A short message explaining why we're recommending this plugin" + ) + case .editorCompatibility: NSLocalizedString( + "org.wordpress.plugin-recommendations.explanations.jetpack-for-editor-compatibility", + value: "The Jetpack Plugin improves compatibility with plugins that provide blocks.", + comment: "A short message explaining why we're recommending this plugin" + ) + } + } + + public var successMessage: String { + return switch self { + case .themeStyles: NSLocalizedString( + "org.wordpress.plugin-recommendations.success.theme-styles", + value: "The editor will now display content exactly how it appears on your site.", + comment: "A short message explaining what the user can do now that they've installed this plugin" + ) + case .postPreviews: NSLocalizedString( + "org.wordpress.plugin-recommendations.success.post-previews", + value: "You can now preview posts within the app.", + comment: "A short message explaining what the user can do now that they've installed this plugin" + ) + case .editorCompatibility: NSLocalizedString( + "org.wordpress.plugin-recommendations.success.editor-compatibility", + value: "Your blocks will render correctly in the editor.", + comment: "A short message explaining what the user can do now that they've installed this plugin" + ) + } + } + + public var helpArticleUrl: URL { + // TODO: We need to write these articles and update the URLs + let url = switch self { + case .themeStyles: "https://wordpress.com/support/plugins/install-a-plugin/" + case .postPreviews: "https://wordpress.com/support/plugins/install-a-plugin/" + case .editorCompatibility: "https://wordpress.com/support/plugins/install-a-plugin/" + } + + return URL(string: url)! + } + + public var recommendedPlugin: String { + switch self { + case .themeStyles: "gutenberg" + case .postPreviews: "jetpack" + case .editorCompatibility: "jetpack" + } + } +} + +public enum PluginRecommendationFrequency { + case daily + case weekly + case monthly + + public var timeInterval: TimeInterval { + return switch self { + case .daily: 86_400 + case .weekly: 604_800 + case .monthly: 14_515_200 + } + } +} + +public protocol PluginRecommendationServiceProtocol: Actor { + func recommendedPluginSlug(for feature: PluginRecommendationFeature) async throws -> String + func recommendPlugin(for feature: PluginRecommendationFeature) async throws -> RecommendedPlugin + func shouldRecommendPlugin( + for feature: PluginRecommendationFeature, + frequency: PluginRecommendationFrequency + ) -> Bool + + func displayedRecommendation(for feature: PluginRecommendationFeature, at date: Date) + func resetRecommendations() +} diff --git a/Modules/Sources/WordPressCoreProtocols/Plugins/RecommendedPlugin.swift b/Modules/Sources/WordPressCoreProtocols/Plugins/RecommendedPlugin.swift new file mode 100644 index 000000000000..7e7a8cde4db9 --- /dev/null +++ b/Modules/Sources/WordPressCoreProtocols/Plugins/RecommendedPlugin.swift @@ -0,0 +1,49 @@ +import Foundation + +public struct RecommendedPlugin: Codable, Sendable { + + /// The plugin name – this will be inserted into headers and buttons + public let name: String + + /// The plugin slug – this is its identifier in the WordPress.org Plugins Directory + public let slug: String + + /// An explanation of what you're asking the user to do. + /// + /// For example: + /// - Gutenberg Required + /// - Install Jetpack for a better experience + public let usageTitle: String + + /// An explanation for how installing this plugin will help the user. + /// + /// This is _not_ the plugin's description from the WP.org directory. + public let usageDescription: String + + /// An explanation for the new capabilities the user has because this plugin was installed. + public let successMessage: String + + /// The banner image for this plugin + public let imageUrl: URL? + + /// URL to a help article explaining why this is needed + public let helpUrl: URL + + public init( + name: String, + slug: String, + usageTitle: String, + usageDescription: String, + successMessage: String, + imageUrl: URL?, + helpUrl: URL + ) { + self.name = name + self.slug = slug + self.usageTitle = usageTitle + self.usageDescription = usageDescription + self.successMessage = successMessage + self.imageUrl = imageUrl + self.helpUrl = helpUrl + } +} diff --git a/Modules/Tests/WordPressCoreTests/Plugins/PluginRecommendationService.swift b/Modules/Tests/WordPressCoreTests/Plugins/PluginRecommendationService.swift new file mode 100644 index 000000000000..f826ef933b7d --- /dev/null +++ b/Modules/Tests/WordPressCoreTests/Plugins/PluginRecommendationService.swift @@ -0,0 +1,38 @@ +import Testing +import Foundation +import WordPressCore + +@Suite(.serialized) +struct PluginRecommendationServiceTests { + let service: PluginRecommendationService + + init() async { + self.service = PluginRecommendationService(userDefaults: UserDefaults()) + await self.service.resetRecommendations() + } + + @Test func `test recommendations should always be shown if none have been shown before`() async throws { + #expect(await service.shouldRecommendPlugin(for: .themeStyles, frequency: .monthly)) + } + + @Test func `test recommendations shouldn't be shown if they have been shown within the given frequency`() async throws { + await service.didRecommendPlugin(for: .themeStyles) + #expect(await service.shouldRecommendPlugin(for: .themeStyles, frequency: .daily) == false) + } + + @Test func `test recommendations should be shown again once the cooldown period has passed`() async throws { + await service.didRecommendPlugin(for: .themeStyles, at: Date().addingTimeInterval(-100_000)) + #expect(await service.shouldRecommendPlugin(for: .themeStyles, frequency: .daily)) + } + + @Test func `test recommendations can be reset`() async throws { + await service.didRecommendPlugin(for: .themeStyles) + await service.resetRecommendations() + #expect(await service.shouldRecommendPlugin(for: .themeStyles, frequency: .daily)) + } + + @Test func `test that only one notification type is shown per day`() async throws { + await service.didRecommendPlugin(for: .themeStyles) + #expect(await service.shouldRecommendPlugin(for: .editorCompatibility, frequency: .daily) == false) + } +} diff --git a/Sources/WordPressData/Swift/Blog+SelfHosted.swift b/Sources/WordPressData/Swift/Blog+SelfHosted.swift index c7122c85750c..1cfd76796bcc 100644 --- a/Sources/WordPressData/Swift/Blog+SelfHosted.swift +++ b/Sources/WordPressData/Swift/Blog+SelfHosted.swift @@ -185,6 +185,20 @@ public extension WpApiApplicationPasswordDetails { } public enum WordPressSite { + + public enum Identifier: Sendable, Hashable { + case siteId(Int) + case siteUrl(String) + + /// A string representation of this object – guaranteed to be URL-safe + public var description: String { + switch self { + case .siteId(let siteId): "\(siteId)" + case .siteUrl(let siteUrl): "siteUrl_\(siteUrl)" + } + } + } + case dotCom(siteId: Int, authToken: String) case selfHosted(blogId: TaggedManagedObjectID, apiRootURL: ParsedUrl, username: String, authToken: String) @@ -238,4 +252,11 @@ public enum WordPressSite { return id } } + + public var identifier: Identifier { + switch self { + case .dotCom(let siteId, _): .siteId(siteId) + case .selfHosted(_, let apiRootUrl, _, _): .siteUrl(apiRootUrl.url()) + } + } } diff --git a/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift b/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift index 5096c9eac650..002f07996764 100644 --- a/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift +++ b/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift @@ -28,7 +28,7 @@ struct ApplicationPasswordRequiredView: View { } else if showLoading { ProgressView() } else if let site { - builder(WordPressClient(site: site)) + builder(WordPressClient.for(site: site)) } else { RestApiUpgradePrompt(localizedFeatureName: localizedFeatureName) { Task { diff --git a/WordPress/Classes/Models/Blog/Blog+Clients.swift b/WordPress/Classes/Models/Blog/Blog+Clients.swift new file mode 100644 index 000000000000..fc1fc93b09bf --- /dev/null +++ b/WordPress/Classes/Models/Blog/Blog+Clients.swift @@ -0,0 +1,18 @@ +import Foundation +import WordPressCore +import WordPressData + +extension Blog { + + /// This function is expensive – prefer passing the `WordPressClient` from the top of the navigation heirarchy instead. + /// + /// This function tries to re-use `WordPressClient` objects where possible to retain cached data. + /// + func wordPressClient() -> WordPressClient? { + guard let site = try? WordPressSite(blog: self) else { + return nil + } + + return WordPressClient.for(site: site) + } +} diff --git a/WordPress/Classes/Networking/WordPressClient.swift b/WordPress/Classes/Networking/WordPressClient.swift index 6fa599adcc8b..d89cb6854999 100644 --- a/WordPress/Classes/Networking/WordPressClient.swift +++ b/WordPress/Classes/Networking/WordPressClient.swift @@ -5,13 +5,40 @@ import WordPressAPIInternal // Required for `WpAuthenticationProvider` import WordPressCore import WordPressData import WordPressShared +import Synchronization extension WordPressClient { + typealias ClientCache = [WordPressSite.Identifier: WordPressClient] + + @available(iOS 18.0, *) + private static let cachedClients = Mutex(ClientCache()) + static var requestedWithInvalidAuthenticationNotification: Foundation.Notification.Name { .init("WordPressClient.requestedWithInvalidAuthenticationNotification") } - init(site: WordPressSite) { + /// Tries to get an existing `WordPressClient` object for the given `site`. + /// + /// On iOS 17 and earlier, there is no caching behaviour. This exists to get around problems with passing around the client object, but should be replaced + /// as soon as possible. + static func `for`(site: WordPressSite) -> WordPressClient { + // client caching only available on iOS 18+ + if #available(iOS 18.0, *) { + return cachedClients.withLock { value in + if let existingClient = value[site.identifier] { + return existingClient + } + + let newClient = WordPressClient(site: site) + value[site.identifier] = newClient + return newClient + } + } else { + return WordPressClient(site: site) + } + } + + private init(site: WordPressSite) { // Currently, the app supports both account passwords and application passwords. // When a site is initially signed in with an account password, WordPress login cookies are stored // in `URLSession.shared`. After switching the site to application password authentication, @@ -138,6 +165,7 @@ private class AppNotifier: @unchecked Sendable, WpAppNotifier { let blogId = site.blogId(in: coreDataStack) NotificationCenter.default.post(name: WordPressClient.requestedWithInvalidAuthenticationNotification, object: blogId) } + } private extension WordPressSite { diff --git a/WordPress/Classes/Services/ApplicationPasswordRepository.swift b/WordPress/Classes/Services/ApplicationPasswordRepository.swift index e87450619fee..b8758da25912 100644 --- a/WordPress/Classes/Services/ApplicationPasswordRepository.swift +++ b/WordPress/Classes/Services/ApplicationPasswordRepository.swift @@ -334,7 +334,7 @@ private extension ApplicationPasswordRepository { siteUsername = username } else if let dotComId, let dotComAuthToken { let site = WordPressSite.dotCom(siteId: dotComId.intValue, authToken: dotComAuthToken) - let client = WordPressClient(site: site) + let client = WordPressClient.for(site: site) siteUsername = try await client.api.users.retrieveMeWithEditContext().data.username try await coreDataStack.performAndSave { context in let blog = try context.existingObject(with: blogId) diff --git a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift index 495e7035febe..d5c42ff947bf 100644 --- a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift +++ b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift @@ -1,6 +1,7 @@ import Foundation import WordPressData import WordPressKit +import WordPressCore /// Provides service remote instances for CommentService @objc public class CommentServiceRemoteFactory: NSObject { @@ -18,7 +19,7 @@ import WordPressKit // The REST API does not have information about comment "likes". We'll continue to use WordPress.com API for now. if let site = try? WordPressSite(blog: blog) { - return CommentServiceRemoteCoreRESTAPI(client: .init(site: site)) + return CommentServiceRemoteCoreRESTAPI(client: WordPressClient.for(site: site)) } if let api = blog.xmlrpcApi, diff --git a/WordPress/Classes/Services/MediaRepository.swift b/WordPress/Classes/Services/MediaRepository.swift index fcb734c01c87..3014ac202ad5 100644 --- a/WordPress/Classes/Services/MediaRepository.swift +++ b/WordPress/Classes/Services/MediaRepository.swift @@ -99,7 +99,7 @@ private extension MediaRepository { // compatibility with WordPress.com-specific features such as video upload restrictions // and storage limits based on the site's plan. if let site = try? WordPressSite(blog: blog) { - return MediaServiceRemoteCoreREST(client: .init(site: site)) + return MediaServiceRemoteCoreREST(client: .for(site: site)) } if let username = blog.username, let password = blog.password, let api = blog.xmlrpcApi { diff --git a/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift b/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift index b1f74c4f41c3..32712b276b09 100644 --- a/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift +++ b/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift @@ -10,7 +10,7 @@ import WordPressAPI @objc public convenience init?(blog: Blog) { guard let site = try? WordPressSite(blog: blog) else { return nil } - self.init(client: .init(site: site)) + self.init(client: .for(site: site)) } init(client: WordPressClient) { diff --git a/WordPress/Classes/Services/WordPressClient+UIProtocols.swift b/WordPress/Classes/Services/WordPressClient+UIProtocols.swift new file mode 100644 index 000000000000..8cd036ed353a --- /dev/null +++ b/WordPress/Classes/Services/WordPressClient+UIProtocols.swift @@ -0,0 +1,13 @@ +import WordPressAPI +import WordPressCore + +extension WordPressClient: PluginInstallerProtocol { + func installAndActivatePlugin(slug: String) async throws { + let params = PluginCreateParams( + slug: PluginWpOrgDirectorySlug(slug: slug), + status: .active + ) + + _ = try await self.api.plugins.create(params: params) + } +} diff --git a/WordPress/Classes/Users/UserProvider.swift b/WordPress/Classes/Users/UserProvider.swift index f5c0300b4218..d4cff035f81b 100644 --- a/WordPress/Classes/Users/UserProvider.swift +++ b/WordPress/Classes/Users/UserProvider.swift @@ -36,9 +36,7 @@ actor MockUserProvider: UserServiceProtocol { // Do nothing try await Task.sleep(for: .seconds(24 * 60 * 60)) case .dummyData: - let dummyDataUrl = URL(string: "https://my.api.mockaroo.com/users.json?key=067c9730")! - let response = try await URLSession.shared.data(from: dummyDataUrl) - let users = try JSONDecoder().decode([DisplayUser].self, from: response.0) + let users = [DisplayUser.mockUser] try await userDataStore.delete(query: .all) try await userDataStore.store(users) case .error: diff --git a/WordPress/Classes/Users/Views/UserDetailsView.swift b/WordPress/Classes/Users/Views/UserDetailsView.swift index 5d6f5c3bd838..7fc00649260b 100644 --- a/WordPress/Classes/Users/Views/UserDetailsView.swift +++ b/WordPress/Classes/Users/Views/UserDetailsView.swift @@ -55,7 +55,7 @@ struct UserDetailsView: View { .listRowInsets(.zero) Section { - makeRow(title: Strings.roleFieldTitle, content: user.role.displayString) + makeRow(title: Strings.roleFieldTitle, content: user.role.rawValue) makeRow(title: Strings.emailAddressFieldTitle, content: user.emailAddress, link: user.emailAddress.asEmail()) if let website = user.websiteUrl, !website.isEmpty { makeRow(title: Strings.websiteFieldTitle, content: website, link: URL(string: website)) diff --git a/WordPress/Classes/Utility/AccountHelper.swift b/WordPress/Classes/Utility/AccountHelper.swift index 1a968ef95ce4..894912802e18 100644 --- a/WordPress/Classes/Utility/AccountHelper.swift +++ b/WordPress/Classes/Utility/AccountHelper.swift @@ -115,7 +115,7 @@ import WordPressData try await BlockEditorCache.shared.deleteAll() // Delete everything in the disk cache - try await DiskCache().removeAll() + try await DiskCache.shared.removeAll() } catch { debugPrint("Unable to delete all block editor settings: \(error)") } diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 77ef8acc5bd7..5f4b260bfe05 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -60,6 +60,8 @@ import WordPressShared case gutenbergEditorBlockInserted case gutenbergEditorBlockMoved + case gutenbergPluginInstallationPrompt + // Notifications Permissions case pushNotificationsPrimerSeen case pushNotificationsPrimerAllowTapped @@ -631,6 +633,7 @@ import WordPressShared case applicationPasswordLogin case wpcomWebSignIn + case applicationPasswordClientInitializationFailed // MARK: - Jetpack Stats @@ -781,6 +784,8 @@ import WordPressShared return "editor_block_inserted" case .gutenbergEditorBlockMoved: return "editor_block_moved" + case .gutenbergPluginInstallationPrompt: + return "gutenberg_plugin_installation_prompt" // Notifications permissions case .pushNotificationsPrimerSeen: return "notifications_primer_seen" @@ -1761,6 +1766,9 @@ import WordPressShared case .applicationPasswordLogin: return "application_password_login" + case .applicationPasswordClientInitializationFailed: + return "application_password_client_initialization_failed" + case .wpcomWebSignIn: return "wpcom_web_sign_in" diff --git a/WordPress/Classes/Utility/Editor/EditorFactory.swift b/WordPress/Classes/Utility/Editor/EditorFactory.swift index 31e16bb9efd2..7e849ac5918e 100644 --- a/WordPress/Classes/Utility/Editor/EditorFactory.swift +++ b/WordPress/Classes/Utility/Editor/EditorFactory.swift @@ -1,4 +1,5 @@ import Foundation +import WordPressCore import WordPressData /// This class takes care of instantiating the correct editor based on the App settings, feature flags, @@ -13,10 +14,14 @@ class EditorFactory { // MARK: - Editor: Instantiation - func instantiateEditor(for post: AbstractPost, replaceEditor: @escaping ReplaceEditorBlock) -> EditorViewController { + func instantiateEditor( + for post: AbstractPost, + replaceEditor: @escaping ReplaceEditorBlock, + wordPressClient: WordPressClient? = nil + ) -> EditorViewController { if gutenbergSettings.mustUseGutenberg(for: post) { - if RemoteFeatureFlag.newGutenberg.enabled() { - return NewGutenbergViewController(post: post, replaceEditor: replaceEditor) + if RemoteFeatureFlag.newGutenberg.enabled(), let client = wordPressClient { + return NewGutenbergViewController(post: post, replaceEditor: replaceEditor, wordPressClient: client) } return createGutenbergVC(with: post, replaceEditor: replaceEditor) } else { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/BlogDashboardViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/BlogDashboardViewController.swift index dbeab371023e..a9a9e92bc7fb 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/BlogDashboardViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/BlogDashboardViewController.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressCore import WordPressData import WordPressShared @@ -12,7 +13,7 @@ final class BlogDashboardViewController: UIViewController { private let embeddedInScrollView: Bool private lazy var viewModel: BlogDashboardViewModel = { - BlogDashboardViewModel(viewController: self, blog: blog) + BlogDashboardViewModel(viewController: self, blog: blog, wordPressClient: self.wordPressClient) }() lazy var collectionView: DynamicHeightCollectionView = { @@ -35,10 +36,23 @@ final class BlogDashboardViewController: UIViewController { return view.superview?.superview as? UIScrollView } + let wordPressClient: WordPressClient? + // MARK: - Init @objc init(blog: Blog, embeddedInScrollView: Bool) { self.blog = blog + + do { + let site = try WordPressSite(blog: blog) + self.wordPressClient = WordPressClient.for(site: site) + } catch { + self.wordPressClient = nil + WPAnalytics.track(.applicationPasswordClientInitializationFailed, properties: [ + "error": error.localizedDescription + ]) + } + self.embeddedInScrollView = embeddedInScrollView super.init(nibName: nil, bundle: nil) } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPagesListCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPagesListCardCell.swift index d734c868648e..518996d7dd1c 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPagesListCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPagesListCardCell.swift @@ -91,7 +91,7 @@ extension DashboardPagesListCardCell { configureContextMenu(blog: blog) - viewModel = PagesCardViewModel(blog: blog, view: self) + viewModel = PagesCardViewModel(blog: blog, wordPressClient: viewController?.wordPressClient, view: self) viewModel?.viewDidLoad() tableView.dataSource = viewModel?.diffableDataSource viewModel?.refresh() @@ -132,7 +132,13 @@ extension DashboardPagesListCardCell { guard let blog, let presentingViewController else { return } - PageListViewController.showForBlog(blog, from: presentingViewController) + + PageListViewController.showForBlog( + blog, + from: presentingViewController, + wordPressClient: presentingViewController.wordPressClient + ) + WPAppAnalytics.track(.openedPages, properties: [WPAppAnalyticsKeyTapSource: source.rawValue], blog: blog) } } @@ -155,9 +161,13 @@ extension DashboardPagesListCardCell: UITableViewDelegate { let presentingViewController else { return } - PageEditorPresenter.handle(page: page, - in: presentingViewController, - entryPoint: .dashboard) + + PageEditorPresenter.handle( + page: page, + in: presentingViewController, + wordPressClient: presentingViewController.wordPressClient, + entryPoint: .dashboard + ) viewModel?.trackPageTapped() } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/PagesCardViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/PagesCardViewModel.swift index e5ce4cf2d6ba..7cb663600cd2 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/PagesCardViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/PagesCardViewModel.swift @@ -1,6 +1,7 @@ import Foundation import CoreData import UIKit +import WordPressCore import WordPressData import WordPressUI @@ -32,6 +33,8 @@ enum PagesListItem: Hashable { class PagesCardViewModel: NSObject { var blog: Blog + private let wordPressClient: WordPressClient? + private let managedObjectContext: NSManagedObjectContext private var filter: PostListFilter = PostListFilter.allNonTrashedFilter() @@ -72,8 +75,14 @@ class PagesCardViewModel: NSObject { } - init(blog: Blog, view: PagesCardView, managedObjectContext: NSManagedObjectContext = ContextManager.shared.mainContext) { + init( + blog: Blog, + wordPressClient: WordPressClient?, + view: PagesCardView, + managedObjectContext: NSManagedObjectContext = ContextManager.shared.mainContext + ) { self.blog = blog + self.wordPressClient = wordPressClient self.view = view self.managedObjectContext = managedObjectContext @@ -110,7 +119,12 @@ class PagesCardViewModel: NSObject { guard let blog = self?.blog else { return } - let editorViewController = EditPageViewController(blog: blog, postTitle: selectedLayout?.title, content: selectedLayout?.content) + let editorViewController = EditPageViewController( + blog: blog, + postTitle: selectedLayout?.title, + content: selectedLayout?.content, + wordPressClient: self?.wordPressClient + ) viewController.present(editorViewController, animated: false) } trackCreateSectionTapped() diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/DashboardPostsListCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/DashboardPostsListCardCell.swift index 3d496972fc3f..de62cb0e8f96 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/DashboardPostsListCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/DashboardPostsListCardCell.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressCore import WordPressData import WordPressShared @@ -37,6 +38,7 @@ class DashboardPostsListCardCell: UICollectionViewCell, Reusable { private var viewModel: PostsCardViewModel? private var blog: Blog? + private var wordPressClient: WordPressClient? private var status: BasePost.Status = .draft /// The VC presenting this cell @@ -161,7 +163,13 @@ extension DashboardPostsListCardCell { return } - PostListViewController.showForBlog(blog, from: viewController, withPostStatus: status) + PostListViewController.showForBlog( + blog, + from: viewController, + wordPressClient: wordPressClient, + withPostStatus: status + ) + WPAppAnalytics.track(.openedPosts, properties: [WPAppAnalyticsKeyTabSource: "dashboard", WPAppAnalyticsKeyTapSource: "posts_card"], blog: blog) } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift index 25043aa3cad8..3cd0c55149db 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift @@ -497,7 +497,11 @@ private extension DashboardPromptsCardCell { } WPAnalytics.track(.promptsDashboardCardAnswerPrompt) - let editor = EditPostViewController(blog: blog, prompt: prompt) + let editor = EditPostViewController( + blog: blog, + prompt: prompt, + wordPressClient: self.presenterViewController?.wordPressClient + ) editor.modalPresentationStyle = .fullScreen editor.entryPoint = .bloggingPromptsDashboardCard presenterViewController?.present(editor, animated: true) @@ -513,7 +517,11 @@ private extension DashboardPromptsCardCell { } WPAnalytics.track(.promptsDashboardCardMenuViewMore) - BloggingPromptsViewController.show(for: blog, from: presenterViewController) + BloggingPromptsViewController.show( + for: blog, + wordPressClient: self.presenterViewController?.wordPressClient, + from: presenterViewController + ) } func skipMenuTapped() { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift index 3504546d8815..67698eb80fec 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift @@ -94,10 +94,18 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab switch items[indexPath.row].action { case .posts: trackQuickActionsEvent(.openedPosts, blog: blog) - PostListViewController.showForBlog(blog, from: parentViewController) + PostListViewController.showForBlog( + blog, + from: parentViewController, + wordPressClient: parentViewController.wordPressClient + ) case .pages: trackQuickActionsEvent(.openedPages, blog: blog) - PageListViewController.showForBlog(blog, from: parentViewController) + PageListViewController.showForBlog( + blog, + from: parentViewController, + wordPressClient: parentViewController.wordPressClient + ) case .comments: if let viewController = CommentsViewController(blog: blog) { trackQuickActionsEvent(.openedComments, blog: blog) @@ -105,6 +113,7 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab } case .media: trackQuickActionsEvent(.openedMediaLibrary, blog: blog) + let client = self.parentViewController?.wordPressClient let controller = SiteMediaViewController(blog: blog) parentViewController.show(controller, sender: nil) case .stats: diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift index 700bfe586185..401bd1483a8b 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift @@ -31,7 +31,7 @@ final class BlogDashboardViewModel { private var error: Error? - private let wordpressClient: WordPressClient? + private let wordPressClient: WordPressClient? private var currentCards: [DashboardCardModel] = [] @@ -98,7 +98,8 @@ final class BlogDashboardViewModel { init( viewController: BlogDashboardViewController, managedObjectContext: NSManagedObjectContext = ContextManager.shared.mainContext, - blog: Blog + blog: Blog, + wordPressClient: WordPressClient? ) { self.viewController = viewController self.managedObjectContext = managedObjectContext @@ -106,19 +107,9 @@ final class BlogDashboardViewModel { self.personalizationService = BlogDashboardPersonalizationService(siteID: blog.dotComID?.intValue ?? 0) self.blazeViewModel = DashboardBlazeCardCellViewModel(blog: blog) self.quickActionsViewModel = DashboardQuickActionsViewModel(blog: blog, personalizationService: personalizationService) - - var _error: Error? - - do { - self.wordpressClient = try WordPressClient(site: .init(blog: self.blog)) - } catch { - _error = error - self.wordpressClient = nil - } + self.wordPressClient = wordPressClient ?? blog.wordPressClient() registerNotifications() - - self.error = _error } /// Apply the initial configuration when the view loaded diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.swift index f6b7d0c6a2be..4c29ce2e4af4 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressCore import WordPressData import WordPressUI @@ -10,6 +11,8 @@ class BloggingPromptsViewController: UIViewController, NoResultsViewHost { @IBOutlet private weak var filterTabBar: FilterTabBar! private var blog: Blog? + private var wordPressClient: WordPressClient? + private var prompts: [BloggingPrompt] = [] { didSet { tableView.reloadData() @@ -31,15 +34,16 @@ class BloggingPromptsViewController: UIViewController, NoResultsViewHost { // MARK: - Init - class func controllerWithBlog(_ blog: Blog) -> BloggingPromptsViewController { + class func controllerWithBlog(_ blog: Blog, wordPressClient: WordPressClient?) -> BloggingPromptsViewController { let controller = BloggingPromptsViewController.loadFromStoryboard() controller.blog = blog + controller.wordPressClient = wordPressClient ?? blog.wordPressClient() return controller } - class func show(for blog: Blog, from presentingViewController: UIViewController) { + class func show(for blog: Blog, wordPressClient: WordPressClient?, from presentingViewController: UIViewController) { WPAnalytics.track(.promptsListViewed) - let controller = BloggingPromptsViewController.controllerWithBlog(blog) + let controller = BloggingPromptsViewController.controllerWithBlog(blog, wordPressClient: wordPressClient) presentingViewController.navigationController?.pushViewController(controller, animated: true) } @@ -168,7 +172,7 @@ extension BloggingPromptsViewController: UITableViewDataSource, UITableViewDeleg return } - let editor = EditPostViewController(blog: blog, prompt: prompt) + let editor = EditPostViewController(blog: blog, prompt: prompt, wordPressClient: self.wordPressClient) editor.modalPresentationStyle = .fullScreen editor.entryPoint = .bloggingPromptsListView present(editor, animated: true) diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift index 783c00923c36..9887a39f4815 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift @@ -57,7 +57,7 @@ extension SiteSettingsViewController { @objc public func showCustomTaxonomies() { let viewController: UIViewController - if let client = try? WordPressClient(site: .init(blog: blog)) { + if let client = try? WordPressClient.for(site: .init(blog: blog)) { let rootView = SiteCustomTaxonomiesView(blog: self.blog, api: client.api) viewController = UIHostingController(rootView: rootView) } else { diff --git a/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift index 07f8adc79df4..c7a06ed05dcf 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift @@ -194,7 +194,7 @@ class JetpackConnectionService { } self.blogId = TaggedManagedObjectID(blog) - self.client = .init(site: site) + self.client = .for(site: site) self.jetpackConnectionClient = .init( apiRootUrl: apiRootURL, urlSession: .init(configuration: .ephemeral), diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaStorageDetailsView.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaStorageDetailsView.swift index a12ccf647880..6fc663b1afc2 100644 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaStorageDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaStorageDetailsView.swift @@ -297,7 +297,7 @@ final class MediaStorageDetailsViewModel: ObservableObject { assert(blog.dotComID != nil) self.blog = blog - client = try WordPressClient(site: WordPressSite(blog: blog)) + client = try WordPressClient.for(site: WordPressSite(blog: blog)) service = MediaServiceRemoteCoreREST(client: client) updateUsage() diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index fca8dff431f9..ffcba00d6ceb 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -4,10 +4,14 @@ import AsyncImageKit import AutomatticTracks import GutenbergKit import SafariServices +import WordPressAPI +import WordPressCore +import WordPressCoreProtocols import WordPressData import WordPressShared import WebKit import CocoaLumberjackSwift +import OSLog class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor { @@ -26,6 +30,12 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor /// - .dependenciesReady case loadingDependencies(_ task: Task) + /// There's a plugin the user should have that'll make the editor work better, and it's not installed. We'll recommend they install it before continuing. + /// + /// Valid states to transition to: + /// - .loadingDependencies + case suggestingPlugin(RecommendedPlugin) + /// We cancelled loading the editor's dependencies /// /// Valid states to transition to: @@ -96,6 +106,8 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } } + let blogID: TaggedManagedObjectID + let navigationBarManager: PostEditorNavigationBarManager // MARK: - Private variables @@ -125,10 +137,19 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor private var suggestionViewBottomConstraint: NSLayoutConstraint? private var currentSuggestionsController: GutenbergSuggestionsViewController? - private var editorState: EditorLoadingState = .uninitialized + private var editorState: EditorLoadingState = .uninitialized { + willSet { + // TODO: Cancel tasks + } + didSet { + self.evaluateEditorState() + } + } private var dependencyLoadingError: Error? private var editorLoadingTask: Task? + private let wordPressClient: WordPressClient + // TODO: remove (none of these APIs are needed for the new editor) func prepopulateMediaItems(_ media: [Media]) {} var debouncer = WordPressShared.Debouncer(delay: 10) @@ -146,11 +167,14 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor func getHTML() -> String { post.content ?? "" } private let blockEditorSettingsService: RawBlockEditorSettingsService + private let pluginRecommendationService = PluginRecommendationService() + private let pluginService: PluginService // MARK: - Initializers required convenience init( post: AbstractPost, replaceEditor: @escaping ReplaceEditorCallback, + wordPressClient: WordPressClient, editorSession: PostEditorAnalyticsSession? ) { self.init( @@ -163,7 +187,8 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor // // The reason we need this init at all even though the other one does the same job is // to conform to the PostEditor protocol. - navigationBarManager: nil + navigationBarManager: nil, + wordPressClient: wordPressClient ) } @@ -171,10 +196,13 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor post: AbstractPost, replaceEditor: @escaping ReplaceEditorCallback, editorSession: PostEditorAnalyticsSession? = nil, - navigationBarManager: PostEditorNavigationBarManager? = nil + navigationBarManager: PostEditorNavigationBarManager? = nil, + wordPressClient: WordPressClient ) { self.post = post + self.blogID = TaggedManagedObjectID(post.blog) + self.wordPressClient = wordPressClient self.replaceEditor = replaceEditor self.editorSession = PostEditorAnalyticsSession(editor: .gutenbergKit, post: post) @@ -184,6 +212,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor self.editorViewController = GutenbergKit.EditorViewController(configuration: editorConfiguration) self.blockEditorSettingsService = RawBlockEditorSettingsService(blog: post.blog) + self.pluginService = PluginService(client: wordPressClient, wordpressCoreVersion: nil) super.init(nibName: nil, bundle: nil) @@ -228,7 +257,8 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor // DDLogError("Error syncing JETPACK: \(String(describing: error))") // }) - onViewDidLoad() + // TODO: We might need some of this functionality back +// onViewDidLoad() } override func viewWillAppear(_ animated: Bool) { @@ -261,8 +291,9 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor case .uninitialized: preconditionFailure("Dependencies must be initialized") case .loadingDependencies: preconditionFailure("Dependencies should not still be loading") case .loadingCancelled: preconditionFailure("Dependency loading should not be cancelled") + case .suggestingPlugin(let plugin): self.recommendPlugin(plugin) case .dependencyError(let error): self.showEditorError(error) - case .dependenciesReady(let dependencies): try await self.startEditor(settings: dependencies.settings) + case .dependenciesReady(let dependencies): self.startEditor(settings: dependencies.settings) case .started: preconditionFailure("The editor should not already be started") } } catch { @@ -348,7 +379,15 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } func showEditorError(_ error: Error) { - // TODO: We should have a unified way to do this + let controller = UIAlertController( + title: "Error loading editor", + message: error.localizedDescription, + preferredStyle: .actionSheet + ) + + controller.addAction(UIAlertAction(title: "Dismiss", style: .cancel)) + + self.present(controller, animated: true) } func showFeedbackView() { @@ -361,12 +400,26 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } } + func evaluateEditorState() { + switch self.editorState { + case .uninitialized: break + case .loadingDependencies: break + case .loadingCancelled: break + case .suggestingPlugin(let plugin): self.recommendPlugin(plugin) + case .dependencyError(let error): self.showEditorError(error) + case .dependenciesReady(let dependencies): self.startEditor(settings: dependencies.settings) + case .started: break + } + } + func startLoadingDependencies() { switch self.editorState { case .uninitialized: break // This is fine – we're loading for the first time case .loadingDependencies: preconditionFailure("`startLoadingDependencies` should not be called while in the `.loadingDependencies` state") + case .suggestingPlugin: + break // This is fine – we're loading after suggesting a plugin to the user case .loadingCancelled: break // This is fine – we're loading after quickly switching posts case .dependencyError: @@ -379,16 +432,36 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor self.editorState = .loadingDependencies(Task { do { - let dependencies = try await fetchEditorDependencies() - self.editorState = .dependenciesReady(dependencies) + try await fetchEditorDependencies() } catch { - self.editorState = .dependencyError(error) + await MainActor.run { + self.editorState = .dependencyError(error) + } } }) } @MainActor - func startEditor(settings: String?) async throws { + func recommendPlugin(_ plugin: RecommendedPlugin) { + let controller = PluginInstallationPromptViewController( + plugin: plugin, + installer: self.wordPressClient + ) { result in + self.recordPluginPromptResult(result) + self.startLoadingDependencies() + } + + if let sheet = controller.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + sheet.prefersEdgeAttachedInCompactHeight = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = true + } + self.navigationController?.present(controller, animated: true) + } + + @MainActor + func startEditor(settings: String?) { guard case .dependenciesReady = self.editorState else { preconditionFailure("`startEditor` should only be called when the editor is in the `.dependenciesReady` state.") } @@ -473,18 +546,60 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } // MARK: - Editor Setup - private func fetchEditorDependencies() async throws -> EditorDependencies { - let settings: String? - do { + private func fetchEditorDependencies() async throws { + let dotComId = try await ContextManager.shared.performQuery { context in + let blog = try context.existingObject(with: self.blogID) + return blog.dotComID?.intValue + } + + if let plugin = try await self.fetchPluginRecommendation(client: self.wordPressClient) { + self.editorState = .suggestingPlugin(plugin) + return + } + + var settings: String? = nil + + if try await self.wordPressClient.supports(.themeStyles, forSiteId: dotComId) { settings = try await blockEditorSettingsService.getSettingsString(allowingCachedResponse: true) - } catch { - DDLogError("Failed to fetch editor settings: \(error)") - settings = nil } let loaded = await loadAuthenticationCookiesAsync() - return EditorDependencies(settings: settings, didLoadCookies: loaded) + let dependencies = EditorDependencies(settings: settings, didLoadCookies: loaded) + self.editorState = .dependenciesReady(dependencies) + } + + private func fetchPluginRecommendation(client: WordPressClient) async throws -> RecommendedPlugin? { + // Don't make plugin recommendations for WordPress – that app only supports features available in Core + guard AppConfiguration.isJetpack else { + return nil + } + + guard + try await wordPressClient.supports(.plugins), + try await wordPressClient.currentUserCan(.installPlugins) + else { + return nil + } + + let features: [PluginRecommendationService.Feature] = [.themeStyles, .editorCompatibility] + + for feature in features { + if await pluginRecommendationService.shouldRecommendPlugin(for: feature, frequency: .weekly) { + let plugin = try await pluginRecommendationService.recommendPlugin(for: feature) + + let pluginIsAlreadyInstalled = try await pluginService.hasInstalledPlugin(slug: plugin.pluginSlug) + + if pluginIsAlreadyInstalled { + continue + } + + await pluginRecommendationService.displayedRecommendation(for: feature) + return plugin + } + } + + return nil } private func loadAuthenticationCookiesAsync() async -> Bool { @@ -1099,3 +1214,32 @@ private extension NewGutenbergViewController { // Extend Gutenberg JavaScript exception struct to conform the protocol defined in the Crash Logging service extension GutenbergJSException.StacktraceLine: @retroactive AutomatticTracks.JSStacktraceLine {} extension GutenbergJSException: @retroactive AutomatticTracks.JSException {} + +extension NewGutenbergViewController { + fileprivate func recordPluginPromptResult(_ result: PluginInstallationResult) { + switch result.installationState { + case .start: + WPAnalytics.track(.gutenbergPluginInstallationPrompt, properties: [ + "subaction": "dismissed-before-installing", + "plugin": result.pluginDetails.slug + ]) + case .installationError(let error): + WPAnalytics.track(.gutenbergPluginInstallationPrompt, properties: [ + "subaction": "installation-error", + "error": error.localizedDescription, + "plugin": result.pluginDetails.slug + ]) + case .installationCancelled: + WPAnalytics.track(.gutenbergPluginInstallationPrompt, properties: [ + "subaction": "installation-cancelled", + "plugin": result.pluginDetails.slug + ]) + case .installationComplete: + WPAnalytics.track(.gutenbergPluginInstallationPrompt, properties: [ + "subaction": "installed", + "plugin": result.pluginDetails.slug + ]) + case .installing: break // This shouldn't be possible + } + } +} diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index d2b5992bb112..85e2ec24720e 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -5,6 +5,7 @@ import SwiftUI import WordPressAPI import WordPressAPIInternal // Needed for `SupportUserIdentity` import WordPressCore +import WordPressCoreProtocols import WordPressData import WordPressShared import CocoaLumberjack @@ -21,6 +22,7 @@ extension SupportDataProvider { ), supportConversationDataProvider: WpSupportConversationDataProvider( wpcomClient: WordPressDotComClient()), + diagnosticsDataProvider: WpDiagnosticsDataProvider(), delegate: WpSupportDelegate() ) } @@ -134,6 +136,10 @@ class WpSupportDelegate: NSObject, SupportDelegate { "subaction": "empty-disk-cache", "bytes-saved": bytesSaved ]) + case .resetPluginRecommendations: + WPAnalytics.track(.diagnostics, properties: [ + "subaction": "reset-plugin-recommendations", + ]) } } } @@ -319,6 +325,20 @@ actor WpSupportConversationDataProvider: SupportConversationDataProvider { } } +actor WpDiagnosticsDataProvider: DiagnosticsDataProvider { + func fetchDiskCacheUsage() async throws -> WordPressCoreProtocols.DiskCacheUsage { + try await DiskCache.shared.diskUsage() + } + + func clearDiskCache(progress: @Sendable @escaping (WordPressCoreProtocols.CacheDeletionProgress) async throws -> Void) async throws { + try await DiskCache.shared.removeAll(progress: progress) + } + + func resetPluginRecommendations() async throws { + await PluginRecommendationService().resetRecommendations() + } +} + extension WPComApiClient: @retroactive @unchecked Sendable {} extension WpComUserInfo { diff --git a/WordPress/Classes/ViewRelated/Pages/Controllers/EditPageViewController.swift b/WordPress/Classes/ViewRelated/Pages/Controllers/EditPageViewController.swift index 3e6b4ec3a376..f220890cd1e6 100644 --- a/WordPress/Classes/ViewRelated/Pages/Controllers/EditPageViewController.swift +++ b/WordPress/Classes/ViewRelated/Pages/Controllers/EditPageViewController.swift @@ -1,5 +1,6 @@ import UIKit import SwiftUI +import WordPressCore import WordPressData class EditPageViewController: UIViewController { @@ -11,19 +12,28 @@ class EditPageViewController: UIViewController { fileprivate var hasShownEditor = false var onClose: (() -> Void)? - convenience init(page: Page) { - self.init(page: page, blog: page.blog, postTitle: nil, content: nil) + private let wordPressClient: WordPressClient? + + convenience init(page: Page, wordPressClient: WordPressClient? = nil) { + self.init(page: page, blog: page.blog, postTitle: nil, content: nil, wordPressClient: wordPressClient) } - convenience init(blog: Blog, postTitle: String?, content: String?) { - self.init(page: nil, blog: blog, postTitle: postTitle, content: content) + convenience init(blog: Blog, postTitle: String?, content: String?, wordPressClient: WordPressClient? = nil) { + self.init(page: nil, blog: blog, postTitle: postTitle, content: content, wordPressClient: wordPressClient) } - fileprivate init(page: Page?, blog: Blog, postTitle: String?, content: String?) { + fileprivate init( + page: Page?, + blog: Blog, + postTitle: String?, + content: String?, + wordPressClient: WordPressClient? = nil + ) { self.page = page self.blog = blog self.postTitle = postTitle self.content = content + self.wordPressClient = wordPressClient ?? blog.wordPressClient() super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen @@ -70,7 +80,9 @@ class EditPageViewController: UIViewController { for: page, replaceEditor: { [weak self] (editor, replacement) in self?.replaceEditor(editor: editor, replacement: replacement) - }) + }, + wordPressClient: self.wordPressClient + ) show(editorViewController) } diff --git a/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController+Menu.swift b/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController+Menu.swift index 64de63a82373..90337d234bf5 100644 --- a/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController+Menu.swift +++ b/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController+Menu.swift @@ -6,7 +6,7 @@ extension PageListViewController: InteractivePostViewDelegate { func edit(_ apost: AbstractPost) { guard let page = apost as? Page else { return } - PageEditorPresenter.handle(page: page, in: self, entryPoint: .pagesList) + PageEditorPresenter.handle(page: page, in: self, wordPressClient: wordPressClient, entryPoint: .pagesList) } func view(_ apost: AbstractPost) { @@ -73,7 +73,7 @@ extension PageListViewController: InteractivePostViewDelegate { newPage.postTitle = page.postTitle newPage.content = page.content // Open Editor - let editorViewController = EditPageViewController(page: newPage) + let editorViewController = EditPageViewController(page: newPage, wordPressClient: wordPressClient) present(editorViewController, animated: false) } } diff --git a/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift b/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift index 2e7c29f005dd..b08f26789031 100644 --- a/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift +++ b/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressCore import WordPressData import WordPressShared import WordPressFlux @@ -53,15 +54,16 @@ final class PageListViewController: AbstractPostListViewController { private var fetchAllPagesTask: Task<[TaggedManagedObjectID], Error>? // MARK: - Convenience constructors - - class func controllerWithBlog(_ blog: Blog) -> PageListViewController { - let vc = PageListViewController() - vc.blog = blog - return vc + class func controllerWithBlog(_ blog: Blog, wordPressClient: WordPressClient? = nil) -> PageListViewController { + return PageListViewController(blog: blog, wordPressClient: wordPressClient ?? blog.wordPressClient()) } - static func showForBlog(_ blog: Blog, from sourceController: UIViewController) { - let controller = PageListViewController.controllerWithBlog(blog) + static func showForBlog( + _ blog: Blog, + from sourceController: UIViewController, + wordPressClient: WordPressClient? = nil + ) { + let controller = PageListViewController.controllerWithBlog(blog, wordPressClient: wordPressClient) controller.navigationItem.largeTitleDisplayMode = .never sourceController.navigationController?.pushViewController(controller, animated: true) } diff --git a/WordPress/Classes/ViewRelated/Pages/PageEditorPresenter.swift b/WordPress/Classes/ViewRelated/Pages/PageEditorPresenter.swift index 43833ba0d805..06b7acd0aadc 100644 --- a/WordPress/Classes/ViewRelated/Pages/PageEditorPresenter.swift +++ b/WordPress/Classes/ViewRelated/Pages/PageEditorPresenter.swift @@ -1,10 +1,16 @@ import UIKit +import WordPressCore import WordPressData import WordPressFlux struct PageEditorPresenter { @discardableResult - static func handle(page: Page, in presentingViewController: UIViewController, entryPoint: PostEditorEntryPoint) -> Bool { + static func handle( + page: Page, + in presentingViewController: UIViewController, + wordPressClient: WordPressClient?, + entryPoint: PostEditorEntryPoint + ) -> Bool { guard !page.isSitePostsPage else { showSitePostPageUneditableNotice() return false @@ -27,7 +33,7 @@ struct PageEditorPresenter { /// by `EditPostViewController` due to its unconventional setup. NotificationCenter.default.post(name: .postListEditorPresenterWillShowEditor, object: nil) - let editorViewController = EditPageViewController(page: page) + let editorViewController = EditPageViewController(page: page, wordPressClient: wordPressClient) editorViewController.entryPoint = entryPoint editorViewController.onClose = { NotificationCenter.default.post(name: .postListEditorPresenterDidHideEditor, object: nil) diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt+UIKit.swift b/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt+UIKit.swift new file mode 100644 index 000000000000..b031386a6f72 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt+UIKit.swift @@ -0,0 +1,21 @@ +import UIKit +import SwiftUI +import WordPressCoreProtocols + +class PluginInstallationPromptViewController: UIHostingController { + + typealias ActionCallback = (PluginInstallationResult) -> Void + + @MainActor + public init(plugin: RecommendedPlugin, installer: any PluginInstallerProtocol, wasDismissed: ActionCallback? = nil) { + super.init(rootView: PluginInstallationPrompt( + plugin: plugin, + installer: installer, + wasDismissed: wasDismissed + )) + } + + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt.swift b/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt.swift new file mode 100644 index 000000000000..d10affebfdb8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt.swift @@ -0,0 +1,349 @@ +import SwiftUI +import WordPressCoreProtocols + +struct PluginInstallationResult { + let pluginDetails: RecommendedPlugin + let installationState: PluginInstallationState +} + +enum PluginInstallationState: Equatable { + case start + case installing + case installationError(Error) + case installationCancelled + case installationComplete + + static func == (lhs: PluginInstallationState, rhs: PluginInstallationState) -> Bool { + return switch (lhs, rhs) { + case (.start, .start): true + case (.installing, .installing): true + case (.installationError, .installationError): true + case (.installationCancelled, .installationCancelled): true + case (.installationComplete, .installationComplete): true + default: false + } + } +} + +protocol PluginInstallerProtocol { + func installAndActivatePlugin(slug: String) async throws +} + +struct PluginInstallationPrompt: View { + @Environment(\.dismiss) private var _dismiss + @Environment(\.openURL) private var openURL + + let pluginDetails: RecommendedPlugin + let installer: PluginInstallerProtocol + let wasDismissed: ((PluginInstallationResult) -> Void)? + + @State + private var state: PluginInstallationState = .start + + @State + private var error: Error? = nil + + @State + private var isCancelling: Bool = false + + @State + private var installationTask: Task? = nil + + public init( + plugin: RecommendedPlugin, + installer: PluginInstallerProtocol, + wasDismissed: ((PluginInstallationResult) -> Void)? = nil + ) { + self.pluginDetails = plugin + self.installer = installer + self.wasDismissed = wasDismissed + } + + var body: some View { + VStack(alignment: .leading) { + if let imageUrl = self.pluginDetails.imageUrl { + AsyncImage(url: imageUrl) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + .ignoresSafeArea() + } placeholder: { + ProgressView() + } + } + + Group { + switch self.state { + case .start: + self.installationPrompt + case .installationError(let error): self.installationProgress(error: error) + case .installing, .installationComplete: self.installationProgress() + case .installationCancelled: + self.installationCancelled + } + }.padding() + } + .presentationDetents(self.presentationDetents) + .presentationDragIndicator(.visible) + } + + @ViewBuilder + var installationPrompt: some View { + VStack(alignment: .leading) { + Text(pluginDetails.usageTitle) + .font(.title) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + + Text(pluginDetails.usageDescription) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + + Link("Learn More", destination: pluginDetails.helpUrl) + .environment(\.openURL, OpenURLAction { url in print("Open \(url)") + return .handled + }) + + Spacer() + + Button { + self.installPlugin() + } label: { + Text("Install Plugin") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier("installPluginButton") + + Button { + self.dismiss() + } label: { + Spacer() + Text("Dismiss") + Spacer() + } + .buttonStyle(.bordered) + .controlSize(.large) + .accessibilityIdentifier("dismissInstallPromptButton") + } + } + + @ViewBuilder + func installationProgress(error: Error? = nil) -> some View { + VStack(alignment: .leading) { + HStack(alignment: .top) { + Text(self.progressHeader) + .font(.title) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + } + + Text(self.progressBody) + .font(.body) + .foregroundStyle(.secondary) + .lineLimit(5) + .multilineTextAlignment(.leading) + + if case .installing = state { + Spacer() + HStack { + Spacer() + ProgressView().controlSize(.extraLarge) + Spacer() + } + } + + Spacer() + + if case .installationComplete = self.state { + Button(role: .none) { + self.dismiss() + } label: { + Text("Done").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier("dismissPluginInstallationButton") + } + + if case .installationError = self.state { + Button(role: .none) { + self.installPlugin() + } label: { + Text("Retry").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier("installPluginButton") + + Button(role: .destructive) { + self.isCancelling = true + } label: { + Text("Cancel").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + .accessibilityIdentifier("cancelPluginInstallationButton") + } + + }.alert("Are you sure you want to cancel installation?", isPresented: self.$isCancelling) { + + Button("Continue Installation", role: .cancel) { + self.isCancelling = false + } + + Button("Cancel Installation", role: .destructive) { + self.dismiss() + } + } + } + + @ViewBuilder + var installationCancelled: some View { + Text("Installation Cancelled") + .font(.title) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + + Spacer() + + Button(role: .none) { + self.dismiss() + } label: { + Text("Done").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier("dismissPluginInstallationButton") + } + + func installPlugin() { + self.installationTask = Task { + self.state = .installing + + do { + try await self.installer.installAndActivatePlugin(slug: pluginDetails.slug) + self.state = .installationComplete + } catch { + self.state = .installationError(error) + } + } + } + + func dismiss() { + let result = PluginInstallationResult( + pluginDetails: self.pluginDetails, + installationState: self.state + ) + + self.wasDismissed?(result) + self._dismiss() + } + + func cancelPluginInstallation() { + self.installationTask?.cancel() + self.state = .installationCancelled + } + + private var progressHeader: String { + return switch self.state { + case .installing: "Installing \(pluginDetails.name)" + case .installationError: "Installation Failed" + case .installationComplete: "Installation Complete" + default: preconditionFailure("Unhandled state") + } + } + + private var progressBody: String { + return switch self.state { + case .installing: "Installing the \(pluginDetails.name) Plugin on your site. This should only take a moment." + case .installationError(let error): error.localizedDescription + case .installationComplete: pluginDetails.successMessage + default: preconditionFailure("Unhandled state") + } + } + + private var presentationDetents: Set { + return switch UIDevice.current.userInterfaceIdiom { + case .phone: [.medium] + case .pad, .mac: [.large] + default: preconditionFailure("Unhandled device idiom") + } + } +} + +fileprivate struct DummyInstaller: PluginInstallerProtocol { + func installAndActivatePlugin(slug: String) async throws { + + try await Task.sleep(for: .seconds(1)) + + if Bool.random() { + throw NSError(domain: "org.wordpress.plugins", code: 1, userInfo: nil) + } + } +} + +fileprivate let gutenbergDetails = RecommendedPlugin( + name: "Gutenberg", + slug: "gutenberg", + usageTitle: "Install Gutenberg", + usageDescription: "To see your theme styles as you write, you'll need to install the Gutenberg plugin.", + successMessage: "Now you can see your theme styles as you write.", + imageUrl: URL(string: "https://ps.w.org/gutenberg/assets/banner-1544x500.jpg?rev=1718710"), + helpUrl: URL(string: "https://jetpack.com/support/")! +) + +fileprivate let jetpackDetails = RecommendedPlugin( + name: "Jetpack – WP Security, Backup, Speed, & Growth", + slug: "jetpack", + usageTitle: "Install Jetpack to continue", + usageDescription: "To preview posts and pages you'll need to install the Jetpack plugin.", + successMessage: "Now you can preview and edit your content.", + imageUrl: URL(string: "https://ps.w.org/jetpack/assets/banner-1544x500.png"), + helpUrl: URL(string: "https://wordpress.org/support/article/managing-plugins/#installing-plugins")! +) + +fileprivate let noBannerDetails = RecommendedPlugin( + name: "No Banner", + slug: "no-banner", + usageTitle: "Install No Banner to continue", + usageDescription: "To preview posts and pages you'll need to install the Jetpack plugin.", + successMessage: "Now you can preview and edit your content.", + imageUrl: nil, + helpUrl: URL(string: "https://wordpress.org/support/article/managing-plugins/#installing-plugins")! +) + +#Preview("Gutenberg") { + NavigationStack { + Text("") + }.sheet(isPresented: .constant(true)) { + PluginInstallationPrompt( + plugin: gutenbergDetails, + installer: DummyInstaller() + ).presentationDetents([.medium, .large]) + } +} + +#Preview("Jetpack") { + NavigationStack { + Text("") + }.sheet(isPresented: .constant(true)) { + PluginInstallationPrompt( + plugin: jetpackDetails, + installer: DummyInstaller() + ).presentationDetents([.medium, .large]) + } +} + +#Preview("No Banner") { + NavigationStack { + Text("") + }.sheet(isPresented: .constant(true)) { + PluginInstallationPrompt( + plugin: noBannerDetails, + installer: DummyInstaller() + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift b/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift index 2b2a8ffd2e53..543225e1016c 100644 --- a/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift @@ -1,6 +1,7 @@ import Foundation import CoreData import Gridicons +import WordPressCore import WordPressData import WordPressShared import WordPressFlux @@ -37,6 +38,8 @@ class AbstractPostListViewController: UIViewController, var blog: Blog! + var wordPressClient: WordPressClient? + /// This closure will be executed whenever the noResultsView must be visually refreshed. It's up /// to the subclass to define this property. /// @@ -82,6 +85,12 @@ class AbstractPostListViewController: UIViewController, private var pendingChanges: [(UITableView) -> Void] = [] + init(blog: Blog, wordPressClient: WordPressClient? = nil) { + self.blog = blog + self.wordPressClient = wordPressClient + super.init(nibName: nil, bundle: nil) + } + override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) } diff --git a/WordPress/Classes/ViewRelated/Post/Controllers/PostListViewController.swift b/WordPress/Classes/ViewRelated/Post/Controllers/PostListViewController.swift index e06b9e2f102a..05fd9f5be563 100644 --- a/WordPress/Classes/ViewRelated/Post/Controllers/PostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Controllers/PostListViewController.swift @@ -1,4 +1,5 @@ import Foundation +import WordPressCore import WordPressData import WordPressShared import Gridicons @@ -11,14 +12,17 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP // MARK: - Convenience constructors - class func controllerWithBlog(_ blog: Blog) -> PostListViewController { - let vc = PostListViewController() - vc.blog = blog - return vc + class func controllerWithBlog(_ blog: Blog, wordPressClient: WordPressClient? = nil) -> PostListViewController { + return PostListViewController(blog: blog, wordPressClient: wordPressClient ?? blog.wordPressClient()) } - static func showForBlog(_ blog: Blog, from sourceController: UIViewController, withPostStatus postStatus: BasePost.Status? = nil) { - let controller = PostListViewController.controllerWithBlog(blog) + static func showForBlog( + _ blog: Blog, + from sourceController: UIViewController, + wordPressClient: WordPressClient? = nil, + withPostStatus postStatus: BasePost.Status? = nil + ) { + let controller = PostListViewController.controllerWithBlog(blog, wordPressClient: wordPressClient) controller.navigationItem.largeTitleDisplayMode = .never controller.initialFilterWithPostStatus = postStatus sourceController.navigationController?.pushViewController(controller, animated: true) @@ -180,7 +184,7 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP // MARK: - Post Actions override func createPost() { - let editor = EditPostViewController(blog: blog) + let editor = EditPostViewController(blog: blog, wordPressClient: wordPressClient) editor.modalPresentationStyle = .fullScreen editor.entryPoint = .postsList present(editor, animated: false, completion: nil) @@ -191,14 +195,14 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP guard let post = post as? Post else { return } - PostListEditorPresenter.handle(post: post, in: self, entryPoint: .postsList) + PostListEditorPresenter.handle(post: post, in: self, entryPoint: .postsList, wordPressClient: wordPressClient) } - private func editDuplicatePost(_ post: AbstractPost) { + private func editDuplicatePost(_ post: AbstractPost, wordPressClient: WordPressClient? = nil) { guard let post = post.latest() as? Post else { return wpAssertionFailure("unexpected post type") } - PostListEditorPresenter.handleCopy(post: post, in: self) + PostListEditorPresenter.handleCopy(post: post, in: self, wordPressClient: wordPressClient) } // MARK: - InteractivePostViewDelegate diff --git a/WordPress/Classes/ViewRelated/Post/EditPostViewController.swift b/WordPress/Classes/ViewRelated/Post/EditPostViewController.swift index 4b4e384d6b75..f481335c2cd6 100644 --- a/WordPress/Classes/ViewRelated/Post/EditPostViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/EditPostViewController.swift @@ -1,5 +1,6 @@ import UIKit import SwiftUI +import WordPressCore import WordPressData import WordPressShared @@ -25,6 +26,8 @@ class EditPostViewController: UIViewController { fileprivate var editingExistingPost = false fileprivate let blog: Blog + private let wordPressClient: WordPressClient? + @objc var onClose: (() -> ())? @objc var afterDismiss: (() -> Void)? @@ -54,12 +57,26 @@ class EditPostViewController: UIViewController { self.init(post: nil, blog: blog) } + /// Initialize as an editor with the provided post + /// + /// - Parameter post: post to edit + convenience init(post: Post, wordPressClient: WordPressClient?) { + self.init(post: post, blog: post.blog, wordPressClient: wordPressClient) + } + + /// Initialize as an editor to create a new post for the provided blog + /// + /// - Parameter blog: blog to create a new post for + convenience init(blog: Blog, wordPressClient: WordPressClient?) { + self.init(post: nil, blog: blog, wordPressClient: wordPressClient) + } + /// Initialize as an editor to create a new post for the provided blog and prompt /// /// - Parameter blog: blog to create a new post for /// - Parameter prompt: blogging prompt to configure the new post for - convenience init(blog: Blog, prompt: BloggingPrompt) { - self.init(post: nil, blog: blog, prompt: prompt) + convenience init(blog: Blog, prompt: BloggingPrompt, wordPressClient: WordPressClient? = nil) { + self.init(post: nil, blog: blog, prompt: prompt, wordPressClient: wordPressClient) } /// Initialize as an editor with a specified post to edit and blog to post too. @@ -68,7 +85,7 @@ class EditPostViewController: UIViewController { /// - post: the post to edit /// - blog: the blog to create a post for, if post is nil /// - Note: it's preferable to use one of the convenience initializers - fileprivate init(post: Post?, blog: Blog, prompt: BloggingPrompt? = nil) { + fileprivate init(post: Post?, blog: Blog, prompt: BloggingPrompt? = nil, wordPressClient: WordPressClient? = nil) { self.post = post if let post { if !post.originalIsDraft() { @@ -79,6 +96,8 @@ class EditPostViewController: UIViewController { } self.blog = blog self.prompt = prompt + self.wordPressClient = wordPressClient ?? blog.wordPressClient() + super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen modalTransitionStyle = .coverVertical @@ -129,7 +148,9 @@ class EditPostViewController: UIViewController { for: post, replaceEditor: { [weak self] (editor, replacement) in self?.replaceEditor(editor: editor, replacement: replacement) - }) + }, + wordPressClient: self.wordPressClient + ) editor.postIsReblogged = postIsReblogged editor.entryPoint = entryPoint showEditor(editor) @@ -275,11 +296,19 @@ extension EditPostViewController { return nil } + let wordPressClient: WordPressClient? + + if let site = try? WordPressSite(blog: post.blog) { + wordPressClient = .for(site: site) + } else { + wordPressClient = nil + } + switch post { case let post as Post: - return EditPostViewController(post: post) + return EditPostViewController(post: post, wordPressClient: wordPressClient) case let page as Page: - return EditPageViewController(page: page) + return EditPageViewController(page: page, wordPressClient: wordPressClient) default: wpAssertionFailure("unexpected post type", userInfo: [ "post_type": type(of: post) diff --git a/WordPress/Classes/ViewRelated/Post/PostListEditorPresenter.swift b/WordPress/Classes/ViewRelated/Post/PostListEditorPresenter.swift index 105deba581dd..333c89d8eef8 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListEditorPresenter.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListEditorPresenter.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import WordPressCore import WordPressData typealias EditorPresenterViewController = UIViewController & EditorAnalyticsProperties @@ -15,7 +16,12 @@ protocol EditorAnalyticsProperties: AnyObject { /// Analytics are also tracked. struct PostListEditorPresenter { - static func handle(post: Post, in postListViewController: EditorPresenterViewController, entryPoint: PostEditorEntryPoint = .unknown) { + static func handle( + post: Post, + in postListViewController: EditorPresenterViewController, + entryPoint: PostEditorEntryPoint = .unknown, + wordPressClient: WordPressClient? = nil + ) { // Return early if a post is still uploading when the editor's requested. guard !PostCoordinator.shared.isUpdating(post) else { return // It's clear from the UI that the cells are not interactive @@ -30,10 +36,14 @@ struct PostListEditorPresenter { return } - openEditor(with: post, in: postListViewController, entryPoint: entryPoint) + openEditor(with: post, in: postListViewController, entryPoint: entryPoint, wordPressClient: wordPressClient) } - static func handleCopy(post: Post, in postListViewController: EditorPresenterViewController) { + static func handleCopy( + post: Post, + in postListViewController: EditorPresenterViewController, + wordPressClient: WordPressClient? = nil + ) { // Copy Post let newPost = post.blog.createDraftPost() newPost.postTitle = post.postTitle @@ -41,17 +51,22 @@ struct PostListEditorPresenter { newPost.categories = post.categories newPost.postFormat = post.postFormat - openEditor(with: newPost, in: postListViewController) + openEditor(with: newPost, in: postListViewController, wordPressClient: wordPressClient) WPAppAnalytics.track(.postListDuplicateAction, properties: postListViewController.propertiesForAnalytics(), post: post) } - private static func openEditor(with post: Post, in postListViewController: EditorPresenterViewController, entryPoint: PostEditorEntryPoint = .unknown) { - /// This is a workaround for the lack of vie wapperance callbacks send + private static func openEditor( + with post: Post, + in postListViewController: EditorPresenterViewController, + entryPoint: PostEditorEntryPoint = .unknown, + wordPressClient: WordPressClient? = nil + ) { + /// This is a workaround for the lack of viewapperance callbacks send /// by `EditPostViewController` due to its weird setup. NotificationCenter.default.post(name: .postListEditorPresenterWillShowEditor, object: nil) - let editor = EditPostViewController(post: post) + let editor = EditPostViewController(post: post, wordPressClient: wordPressClient) editor.modalPresentationStyle = .fullScreen editor.entryPoint = entryPoint editor.onClose = { diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index db6900d00345..6f8ade2c895b 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -1164,6 +1164,14 @@ ); target = E16AB92914D978240047A2E5 /* WordPressTest */; }; + 24D7C6312E839F14003D0EEC /* Exceptions for "Classes" folder in "Miniature" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ViewRelated/Plugins/PluginInstallationPrompt.swift, + "ViewRelated/Plugins/PluginInstallationPrompt+UIKit.swift", + ); + target = 0C3313B62E0439A8000C3760 /* Miniature */; + }; 3F1A64F82DA7ABC300786B92 /* Exceptions for "Classes" folder in "Reader" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -1357,6 +1365,7 @@ 4ABCAB352DE531B6005A6B84 /* Exceptions for "Classes" folder in "JetpackIntents" target */, 3F1A64F82DA7ABC300786B92 /* Exceptions for "Classes" folder in "Reader" target */, 0C5C46F42D98343300F2CD55 /* Exceptions for "Classes" folder in "Keystone" target */, + 24D7C6312E839F14003D0EEC /* Exceptions for "Classes" folder in "Miniature" target */, ); path = Classes; sourceTree = "";