From 286ea9befd712efdd4f2061e60a82c2e9e576fc7 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:55:08 -0600 Subject: [PATCH 01/12] Add plugin installation prompt --- Modules/Package.swift | 1 + .../Plugins/PluginRecommendationService.swift | 231 +++++++++++++ .../Plugins/PluginRecommendationService.swift | 38 ++ .../WordPressClient+UIProtocols.swift | 13 + .../NewGutenbergViewController.swift | 76 +++- .../PluginInstallationPrompt+UIKit.swift | 21 ++ .../Plugins/PluginInstallationPrompt.swift | 327 ++++++++++++++++++ WordPress/WordPress.xcodeproj/project.pbxproj | 9 + 8 files changed, 705 insertions(+), 11 deletions(-) create mode 100644 Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift create mode 100644 Modules/Tests/WordPressCoreTests/Plugins/PluginRecommendationService.swift create mode 100644 WordPress/Classes/Services/WordPressClient+UIProtocols.swift create mode 100644 WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt+UIKit.swift create mode 100644 WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt.swift diff --git a/Modules/Package.swift b/Modules/Package.swift index 56e09fb13add..d286f43f98df 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -21,6 +21,7 @@ let package = Package( .library(name: "WordPressShared", targets: ["WordPressShared"]), .library(name: "WordPressUI", targets: ["WordPressUI"]), .library(name: "WordPressReader", targets: ["WordPressReader"]), + .library(name: "WordPressCore", targets: ["WordPressCore"]), ], dependencies: [ .package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"), diff --git a/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift new file mode 100644 index 000000000000..233d4a7b5395 --- /dev/null +++ b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift @@ -0,0 +1,231 @@ +import Foundation +import WordPressAPI +import WordPressAPIInternal + +public struct RecommendedPlugin { + + /// 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 + } +} + +public actor PluginRecommendationService { + + public enum Feature: CaseIterable { + case themeStyles + case postPreviews + case editorCompatibility + + 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" + ) + } + } + + 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" + ) + } + } + + 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)! + } + + var recommendedPlugin: PluginWpOrgDirectorySlug { + let slug = switch self { + case .themeStyles: "gutenberg" + case .postPreviews: "jetpack" + case .editorCompatibility: "jetpack" + } + + return PluginWpOrgDirectorySlug(slug: slug) + } + + fileprivate var cacheKey: String { + return "plugin-recommendation-\(self)-\(recommendedPlugin.slug)" + } + } + + public enum Frequency { + case daily + case weekly + case monthly + + var timeInterval: TimeInterval { + return switch self { + case .daily: 86_400 + case .weekly: 604_800 + case .monthly: 14_515_200 + } + } + } + + 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 -> PluginWpOrgDirectorySlug { + feature.recommendedPlugin + } + + public func recommendPlugin(for feature: Feature) async throws -> RecommendedPlugin { + let plugin = try await dotOrgClient.pluginInformation(slug: feature.recommendedPlugin) + + return RecommendedPlugin( + name: plugin.name, + slug: plugin.slug.slug, + usageTitle: "Install \(plugin.name)", + 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 - frequency.timeInterval + let earliestGlobalDate = Date().timeIntervalSince1970 - 86_400 + + return earliestFeatureDate > featureTimestamp && earliestGlobalDate > globalTimestamp + } + + public func didRecommendPlugin(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 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) + return try await cacheAsset(pluginUrl, at: cachePath) + } + + private 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 + } + + private 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/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/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/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index fca8dff431f9..1ddd84ef5d9d 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -4,6 +4,8 @@ import AsyncImageKit import AutomatticTracks import GutenbergKit import SafariServices +import WordPressAPI +import WordPressCore import WordPressData import WordPressShared import WebKit @@ -26,6 +28,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 +104,8 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } } + let blogID: TaggedManagedObjectID + let navigationBarManager: PostEditorNavigationBarManager // MARK: - Private variables @@ -175,6 +185,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor ) { self.post = post + self.blogID = TaggedManagedObjectID(post.blog) self.replaceEditor = replaceEditor self.editorSession = PostEditorAnalyticsSession(editor: .gutenbergKit, post: post) @@ -228,7 +239,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,6 +273,7 @@ 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 .started: preconditionFailure("The editor should not already be started") @@ -367,6 +380,8 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor 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,14 +394,33 @@ 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) } }) } + @MainActor + func recommendPlugin(_ plugin: RecommendedPlugin) { + guard let site = try? WordPressSite(blog: self.post.blog) else { + return + } + + let controller = PluginInstallationPromptViewController( + plugin: plugin, + installer: WordPressClient(site: site)) { _ in + 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?) async throws { guard case .dependenciesReady = self.editorState else { @@ -473,18 +507,38 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } // MARK: - Editor Setup - private func fetchEditorDependencies() async throws -> EditorDependencies { - let settings: String? - do { - settings = try await blockEditorSettingsService.getSettingsString(allowingCachedResponse: true) - } catch { - DDLogError("Failed to fetch editor settings: \(error)") - settings = nil + private func fetchEditorDependencies() async throws { + let site = try await ContextManager.shared.performQuery { context in + let blog = try context.existingObject(with: self.blogID) + return try WordPressSite(blog: blog) + } + + let client = WordPressClient(site: site) + let pluginService = PluginService(client: client, wordpressCoreVersion: nil) + let pluginRecommendationService = PluginRecommendationService() + + let features: [PluginRecommendationService.Feature] = [.themeStyles, .editorCompatibility] + + // Don't make plugin recommendations for WordPress + if AppConfiguration.isJetpack { + for feature in features { + if pluginRecommendationService.shouldRecommendPlugin(for: feature, frequency: .weekly) { + let plugin = try await pluginRecommendationService.recommendPlugin(for: feature) + + guard try await pluginService.hasInstalledPlugin(slug: plugin.pluginSlug) else { + pluginRecommendationService.didRecommendPlugin(for: feature) + self.editorState = .suggestingPlugin(plugin) + return + } + } + } } + let settings = try await blockEditorSettingsService.getSettingsString(allowingCachedResponse: true) let loaded = await loadAuthenticationCookiesAsync() - return EditorDependencies(settings: settings, didLoadCookies: loaded) + let dependencies = EditorDependencies(settings: settings, didLoadCookies: loaded) + self.editorState = .dependenciesReady(dependencies) } private func loadAuthenticationCookiesAsync() async -> Bool { diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt+UIKit.swift b/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt+UIKit.swift new file mode 100644 index 000000000000..f6c4ff2f920e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt+UIKit.swift @@ -0,0 +1,21 @@ +import UIKit +import SwiftUI +import WordPressCore + +class PluginInstallationPromptViewController: UIHostingController { + + typealias ActionCallback = (PluginInstallationState) -> 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..cbc6f341311a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt.swift @@ -0,0 +1,327 @@ +import SwiftUI +import WordPressCore + +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: ((PluginInstallationState) -> 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: ((PluginInstallationState) -> 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() { + self.wasDismissed?(self.state) + 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", + 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?rev=2653649"), + 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") { + PluginInstallationPrompt( + plugin: gutenbergDetails, + installer: DummyInstaller() + ) +} + +#Preview("Jetpack") { + PluginInstallationPrompt( + plugin: jetpackDetails, + installer: DummyInstaller() + ) +} + +#Preview("No Banner") { + PluginInstallationPrompt( + plugin: noBannerDetails, + installer: DummyInstaller() + ) +} 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 = ""; From f1c7085e490cc2e59f6435e744d614354da94b86 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 24 Sep 2025 22:14:49 -0600 Subject: [PATCH 02/12] Add Disk Cache --- .../WordPressCore/DataStore/DiskCache.swift | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 Modules/Sources/WordPressCore/DataStore/DiskCache.swift diff --git a/Modules/Sources/WordPressCore/DataStore/DiskCache.swift b/Modules/Sources/WordPressCore/DataStore/DiskCache.swift new file mode 100644 index 000000000000..92b1b2c5ba52 --- /dev/null +++ b/Modules/Sources/WordPressCore/DataStore/DiskCache.swift @@ -0,0 +1,52 @@ +import Foundation + +public actor DiskCache { + + struct Wrapper: Codable where T: Codable { + let date: Date + let data: T + } + + public func store(object: T, for key: String) throws where T: Codable { + try self.ensureCacheDirectoryExists() + + let wrapper = Wrapper(date: Date(), data: object) + let data = try JSONEncoder().encode(wrapper) + + FileManager.default.createFile(atPath: self.cacheURL(for: key).path(), contents: data) + } + + public func retrieve(for key: String, notOlderThan date: Date? = nil) throws -> T? where T: Codable { + try self.ensureCacheDirectoryExists() + + let path = self.cacheURL(for: key) + guard FileManager.default.fileExists(at: path) else { + return nil + } + + let data = try Data(contentsOf: path) + let wrapper = try JSONDecoder().decode(Wrapper.self, from: data) + + if let date { + if wrapper.date > date { + return nil + } + } + + return wrapper.data + } + + private func ensureCacheDirectoryExists() throws { + try FileManager.default.createDirectory( + at: cacheURL(for: "").deletingLastPathComponent(), + withIntermediateDirectories: true + ) + } + + private func cacheURL(for key: String) -> URL { + URL.cachesDirectory + .appendingPathComponent("object-cache") + .appendingPathComponent(key) + .appendingPathExtension("json") + } +} From 8a00d3bfe5dd1671881c5dcb89bf952403073012 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 24 Sep 2025 22:15:02 -0600 Subject: [PATCH 03/12] Plugin recommendations --- .../Plugins/PluginRecommendationService.swift | 43 ++++++-- .../WordPressCore/Plugins/PluginService.swift | 4 + .../WordPressCore/WordPressClient.swift | 45 +++++++++ .../Classes/Networking/WordPressClient.swift | 1 + .../NewGutenbergViewController.swift | 99 ++++++++++++++----- 5 files changed, 162 insertions(+), 30 deletions(-) diff --git a/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift index 233d4a7b5395..d30c067e479e 100644 --- a/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift +++ b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift @@ -2,7 +2,7 @@ import Foundation import WordPressAPI import WordPressAPIInternal -public struct RecommendedPlugin { +public struct RecommendedPlugin: Codable, Sendable { /// The plugin name – this will be inserted into headers and buttons public let name: String @@ -48,6 +48,10 @@ public struct RecommendedPlugin { self.imageUrl = imageUrl self.helpUrl = helpUrl } + + public var pluginSlug: PluginWpOrgDirectorySlug { + PluginWpOrgDirectorySlug(slug: self.slug) + } } public actor PluginRecommendationService { @@ -139,6 +143,7 @@ public actor PluginRecommendationService { private let dotOrgClient: WordPressOrgApiClient private let userDefaults: UserDefaults + private let diskCache = DiskCache() public init( dotOrgClient: WordPressOrgApiClient = WordPressOrgApiClient(urlSession: .shared), @@ -153,12 +158,16 @@ public actor PluginRecommendationService { } public func recommendPlugin(for feature: Feature) async throws -> RecommendedPlugin { + if let cachedPlugin = try await fetchCachedPlugin(for: feature.recommendedPlugin.slug) { + return cachedPlugin + } + let plugin = try await dotOrgClient.pluginInformation(slug: feature.recommendedPlugin) return RecommendedPlugin( name: plugin.name, slug: plugin.slug.slug, - usageTitle: "Install \(plugin.name)", + usageTitle: "Install \(plugin.name.removingPercentEncoding ?? plugin.slug.slug)", usageDescription: feature.explanation, successMessage: feature.successMessage, imageUrl: try await cachePluginHeader(for: plugin), @@ -180,7 +189,7 @@ public actor PluginRecommendationService { return earliestFeatureDate > featureTimestamp && earliestGlobalDate > globalTimestamp } - public func didRecommendPlugin(for feature: Feature, at date: Date = Date()) { + 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") } @@ -191,17 +200,39 @@ public actor PluginRecommendationService { } self.userDefaults.removeObject(forKey: "plugin-last-recommended") } +} + +// MARK: - RecommendedPlugin Cache +private extension PluginRecommendationService { + private func cachedPluginData(for plugin: RecommendedPlugin) async throws { + let cacheKey = "plugin-recommendation-\(plugin.slug)" + try await self.diskCache.store(object: plugin, for: cacheKey) + } - private func cachePluginHeader(for plugin: PluginInformation) async throws -> URL? { + private func fetchCachedPlugin(for slug: String) async throws -> RecommendedPlugin? { + let cacheKey = "plugin-recommendation-\(slug)" + return try await self.diskCache.retrieve(for: cacheKey, notOlderThan: Date().addingTimeInterval(-86_400)) + } +} + +// 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) } - private func cacheAsset(_ url: URL, at path: URL) async throws -> URL { + func cacheAsset(_ url: URL, at path: URL) async throws -> URL { if FileManager.default.fileExists(at: path) { return path } @@ -212,7 +243,7 @@ public actor PluginRecommendationService { return path } - private func storagePath(for plugin: PluginInformation, filename: String) -> URL { + func storagePath(for plugin: PluginInformation, filename: String) -> URL { URL.cachesDirectory .appendingPathComponent("plugin-assets") .appendingPathComponent(plugin.slug.slug) diff --git a/Modules/Sources/WordPressCore/Plugins/PluginService.swift b/Modules/Sources/WordPressCore/Plugins/PluginService.swift index 4161877e2bc2..be3fa16a90fb 100644 --- a/Modules/Sources/WordPressCore/Plugins/PluginService.swift +++ b/Modules/Sources/WordPressCore/Plugins/PluginService.swift @@ -44,6 +44,10 @@ 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 } diff --git a/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index 23d31f4628f8..d898d7554619 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -1,13 +1,58 @@ import Foundation import WordPressAPI +import WordPressAPIInternal public actor WordPressClient { + public enum Feature { + case themeStyles + case applicationPasswordExtras + case managePlugins + } + public let api: WordPressAPI public let rootUrl: String + private var apiRoot: WpApiDetails? + public init(api: WordPressAPI, rootUrl: ParsedUrl) { self.api = api self.rootUrl = rootUrl.url() } + + public func refreshCachedSiteInfo() async throws { + let apiRoot = try await self.api.apiRoot.get() + self.apiRoot = apiRoot.data + } + + public func currentUserCan(_ capability: String) async throws -> Bool { + false + } + + 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 .managePlugins: 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 .managePlugins: apiRoot.hasRoute(route: "/wp/v2/plugins") + case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax") + } + } + + private func fetchApiRoot() async throws -> WpApiDetails { + if let apiRoot = self.apiRoot { + return apiRoot + } + let apiRoot = try await self.api.apiRoot.get() + self.apiRoot = apiRoot.data + return apiRoot.data + } } diff --git a/WordPress/Classes/Networking/WordPressClient.swift b/WordPress/Classes/Networking/WordPressClient.swift index 6fa599adcc8b..0452a82174a1 100644 --- a/WordPress/Classes/Networking/WordPressClient.swift +++ b/WordPress/Classes/Networking/WordPressClient.swift @@ -138,6 +138,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/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index 1ddd84ef5d9d..2fb963425d71 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -135,7 +135,14 @@ 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? @@ -156,6 +163,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor func getHTML() -> String { post.content ?? "" } private let blockEditorSettingsService: RawBlockEditorSettingsService + private let pluginRecommendationService = PluginRecommendationService() // MARK: - Initializers required convenience init( @@ -275,7 +283,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor 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 { @@ -361,7 +369,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() { @@ -374,6 +390,18 @@ 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: @@ -396,7 +424,9 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor do { try await fetchEditorDependencies() } catch { - self.editorState = .dependencyError(error) + await MainActor.run { + self.editorState = .dependencyError(error) + } } }) } @@ -422,7 +452,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } @MainActor - func startEditor(settings: String?) async throws { + func startEditor(settings: String?) { guard case .dependenciesReady = self.editorState else { preconditionFailure("`startEditor` should only be called when the editor is in the `.dependenciesReady` state.") } @@ -508,39 +538,60 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor // MARK: - Editor Setup private func fetchEditorDependencies() async throws { - let site = try await ContextManager.shared.performQuery { context in + let (site, dotComID) = try await ContextManager.shared.performQuery { context in let blog = try context.existingObject(with: self.blogID) - return try WordPressSite(blog: blog) + return (try WordPressSite(blog: blog), blog.dotComID?.intValue) } let client = WordPressClient(site: site) - let pluginService = PluginService(client: client, wordpressCoreVersion: nil) - let pluginRecommendationService = PluginRecommendationService() - let features: [PluginRecommendationService.Feature] = [.themeStyles, .editorCompatibility] + if let plugin = try await self.fetchPluginRecommendation(client: client) { + self.editorState = .suggestingPlugin(plugin) + return + } - // Don't make plugin recommendations for WordPress - if AppConfiguration.isJetpack { - for feature in features { - if pluginRecommendationService.shouldRecommendPlugin(for: feature, frequency: .weekly) { - let plugin = try await pluginRecommendationService.recommendPlugin(for: feature) - - guard try await pluginService.hasInstalledPlugin(slug: plugin.pluginSlug) else { - pluginRecommendationService.didRecommendPlugin(for: feature) - self.editorState = .suggestingPlugin(plugin) - return - } - } - } + let settings: String? + + if try await client.supports(.themeStyles, forSiteId: dotComID) { + settings = try await blockEditorSettingsService.getSettingsString(allowingCachedResponse: true) } - let settings = try await blockEditorSettingsService.getSettingsString(allowingCachedResponse: true) let loaded = await loadAuthenticationCookiesAsync() let dependencies = EditorDependencies(settings: settings, didLoadCookies: loaded) self.editorState = .dependenciesReady(dependencies) } + private func fetchPluginRecommendation(client: WordPressClient) async throws -> RecommendedPlugin? { + + guard try await client.supports(.managePlugins) else { + return nil + } + + let pluginService = PluginService(client: client, wordpressCoreVersion: nil) + try await pluginService.fetchInstalledPlugins() + + // Don't make plugin recommendations for WordPress – that app only supports features available in Core + guard AppConfiguration.isJetpack 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) + + guard try await pluginService.hasInstalledPlugin(slug: plugin.pluginSlug) else { + await pluginRecommendationService.displayedRecommendation(for: feature) + return plugin + } + } + } + + return nil + } + private func loadAuthenticationCookiesAsync() async -> Bool { guard post.blog.isPrivate() else { return true From 814d0186b29727b36e22ba2f6e7f2bb347d489aa Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:40:24 -0600 Subject: [PATCH 04/12] align with latest wprs --- .../Sources/WordPressCore/Users/DisplayUser.swift | 12 ++++++++++++ .../Classes/Users/ViewModel/UserListViewModel.swift | 2 +- WordPress/Classes/Users/Views/UserDetailsView.swift | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/WordPressCore/Users/DisplayUser.swift b/Modules/Sources/WordPressCore/Users/DisplayUser.swift index b1ab6300616a..a7a4d52d86c4 100644 --- a/Modules/Sources/WordPressCore/Users/DisplayUser.swift +++ b/Modules/Sources/WordPressCore/Users/DisplayUser.swift @@ -69,3 +69,15 @@ extension DisplayUser { .joined(separator: " ") } } + +extension UserRole: @retroactive Codable { + public init(from decoder: any Decoder) throws { + let role = try decoder.singleValueContainer().decode(String.self) + self.init(role) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.rawValue) + } +} diff --git a/WordPress/Classes/Users/ViewModel/UserListViewModel.swift b/WordPress/Classes/Users/ViewModel/UserListViewModel.swift index d3e023c1cb38..cd6112769056 100644 --- a/WordPress/Classes/Users/ViewModel/UserListViewModel.swift +++ b/WordPress/Classes/Users/ViewModel/UserListViewModel.swift @@ -126,7 +126,7 @@ class UserListViewModel: ObservableObject { } private func sortUsers(_ users: [DisplayUser]) -> [Section] { - Dictionary(grouping: users) { $0.id == currentUserId ? RoleSection.me : RoleSection.role($0.role) } + Dictionary(grouping: users) { $0.id == currentUserId ? RoleSection.me : RoleSection.role($0.role.rawValue) } .map { Section(id: $0.key, users: $0.value.sorted(by: { $0.username < $1.username })) } .sorted { $0.id < $1.id } } 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)) From 8254192bba18fe8aa2f5331f8212c2513ee34140 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 25 Sep 2025 16:06:47 -0600 Subject: [PATCH 05/12] Check if the current user can manage plugins --- .../WordPressCore/WordPressClient.swift | 24 +++++++++++++++---- .../CommentServiceRemoteFactory.swift | 3 ++- .../NewGutenbergViewController.swift | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index d898d7554619..9557908d7f00 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -14,6 +14,7 @@ public actor WordPressClient { public let rootUrl: String private var apiRoot: WpApiDetails? + private var currentUser: UserWithEditContext? public init(api: WordPressAPI, rootUrl: ParsedUrl) { self.api = api @@ -21,12 +22,27 @@ public actor WordPressClient { } public func refreshCachedSiteInfo() async throws { - let apiRoot = try await self.api.apiRoot.get() - self.apiRoot = apiRoot.data + async let apiRootTask = try await self.api.apiRoot.get().data + async let currentUserTask = try await self.api.users.retrieveMeWithEditContext().data + + let (apiRoot, currentUser) = try await (apiRootTask, currentUserTask) + + self.apiRoot = apiRoot + self.currentUser = currentUser } - public func currentUserCan(_ capability: String) async throws -> Bool { - false + public func currentUserCan(_ capability: UserCapability) async throws -> Bool { + try await fetchCurrentUser().capabilities.keys.contains(capability) + } + + private func fetchCurrentUser() async throws -> UserWithEditContext { + if let currentUser = self.currentUser { + return currentUser + } + + let currentUser = try await self.api.users.retrieveMeWithEditContext().data + self.currentUser = currentUser + return currentUser } public func supports(_ feature: Feature, forSiteId siteId: Int? = nil) async throws -> Bool { diff --git a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift index 495e7035febe..1c80e882c18d 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(site: site)) } if let api = blog.xmlrpcApi, diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index 2fb963425d71..9719fb80a0d1 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -564,7 +564,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor private func fetchPluginRecommendation(client: WordPressClient) async throws -> RecommendedPlugin? { - guard try await client.supports(.managePlugins) else { + guard try await client.supports(.managePlugins), try await client.currentUserCan(.installPlugins) else { return nil } From 986588cabe43d27886f94f4780427414ad4f558e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:47:41 -0600 Subject: [PATCH 06/12] Re-sync with upstream --- .../WordPressCore/DataStore/DiskCache.swift | 52 ------------------- Modules/Sources/WordPressCore/DiskCache.swift | 19 ++++++- .../Plugins/PluginRecommendationService.swift | 4 +- .../WordPressCore/Users/DisplayUser.swift | 14 +---- .../WordPressCore/Users/User+Extensions.swift | 13 ----- WordPress/Classes/Users/UserProvider.swift | 4 +- .../Users/ViewModel/UserListViewModel.swift | 2 +- .../BlogDashboardViewController.swift | 16 +++++- 8 files changed, 38 insertions(+), 86 deletions(-) delete mode 100644 Modules/Sources/WordPressCore/DataStore/DiskCache.swift diff --git a/Modules/Sources/WordPressCore/DataStore/DiskCache.swift b/Modules/Sources/WordPressCore/DataStore/DiskCache.swift deleted file mode 100644 index 92b1b2c5ba52..000000000000 --- a/Modules/Sources/WordPressCore/DataStore/DiskCache.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation - -public actor DiskCache { - - struct Wrapper: Codable where T: Codable { - let date: Date - let data: T - } - - public func store(object: T, for key: String) throws where T: Codable { - try self.ensureCacheDirectoryExists() - - let wrapper = Wrapper(date: Date(), data: object) - let data = try JSONEncoder().encode(wrapper) - - FileManager.default.createFile(atPath: self.cacheURL(for: key).path(), contents: data) - } - - public func retrieve(for key: String, notOlderThan date: Date? = nil) throws -> T? where T: Codable { - try self.ensureCacheDirectoryExists() - - let path = self.cacheURL(for: key) - guard FileManager.default.fileExists(at: path) else { - return nil - } - - let data = try Data(contentsOf: path) - let wrapper = try JSONDecoder().decode(Wrapper.self, from: data) - - if let date { - if wrapper.date > date { - return nil - } - } - - return wrapper.data - } - - private func ensureCacheDirectoryExists() throws { - try FileManager.default.createDirectory( - at: cacheURL(for: "").deletingLastPathComponent(), - withIntermediateDirectories: true - ) - } - - private func cacheURL(for key: String) -> URL { - URL.cachesDirectory - .appendingPathComponent("object-cache") - .appendingPathComponent(key) - .appendingPathExtension("json") - } -} diff --git a/Modules/Sources/WordPressCore/DiskCache.swift b/Modules/Sources/WordPressCore/DiskCache.swift index 61ddab5d23b0..070711f872a6 100644 --- a/Modules/Sources/WordPressCore/DiskCache.swift +++ b/Modules/Sources/WordPressCore/DiskCache.swift @@ -25,13 +25,30 @@ public actor DiskCache { 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 diff --git a/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift index d30c067e479e..ccee36306680 100644 --- a/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift +++ b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift @@ -206,12 +206,12 @@ public actor PluginRecommendationService { private extension PluginRecommendationService { private func cachedPluginData(for plugin: RecommendedPlugin) async throws { let cacheKey = "plugin-recommendation-\(plugin.slug)" - try await self.diskCache.store(object: plugin, for: cacheKey) + try await self.diskCache.store(plugin, forKey: cacheKey) } private func fetchCachedPlugin(for slug: String) async throws -> RecommendedPlugin? { let cacheKey = "plugin-recommendation-\(slug)" - return try await self.diskCache.retrieve(for: cacheKey, notOlderThan: Date().addingTimeInterval(-86_400)) + return try await self.diskCache.read(RecommendedPlugin.self, forKey: cacheKey) } } diff --git a/Modules/Sources/WordPressCore/Users/DisplayUser.swift b/Modules/Sources/WordPressCore/Users/DisplayUser.swift index a7a4d52d86c4..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 @@ -69,15 +69,3 @@ extension DisplayUser { .joined(separator: " ") } } - -extension UserRole: @retroactive Codable { - public init(from decoder: any Decoder) throws { - let role = try decoder.singleValueContainer().decode(String.self) - self.init(role) - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(self.rawValue) - } -} 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/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/ViewModel/UserListViewModel.swift b/WordPress/Classes/Users/ViewModel/UserListViewModel.swift index cd6112769056..d3e023c1cb38 100644 --- a/WordPress/Classes/Users/ViewModel/UserListViewModel.swift +++ b/WordPress/Classes/Users/ViewModel/UserListViewModel.swift @@ -126,7 +126,7 @@ class UserListViewModel: ObservableObject { } private func sortUsers(_ users: [DisplayUser]) -> [Section] { - Dictionary(grouping: users) { $0.id == currentUserId ? RoleSection.me : RoleSection.role($0.role.rawValue) } + Dictionary(grouping: users) { $0.id == currentUserId ? RoleSection.me : RoleSection.role($0.role) } .map { Section(id: $0.key, users: $0.value.sorted(by: { $0.username < $1.username })) } .sorted { $0.id < $1.id } } 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) } From f540dcf22623e1366757b8c79d78137cb32b638f Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:48:08 -0600 Subject: [PATCH 07/12] Faster loading by passing down `WordPressClient` --- .../WordPressCore/WordPressClient.swift | 49 ++++++++++--------- .../WordPressData/Swift/Blog+SelfHosted.swift | 21 ++++++++ .../ApplicationPasswordRequiredView.swift | 2 +- .../Classes/Models/Blog/Blog+Clients.swift | 18 +++++++ .../Classes/Networking/WordPressClient.swift | 29 ++++++++++- .../ApplicationPasswordRepository.swift | 2 +- .../CommentServiceRemoteFactory.swift | 2 +- .../Classes/Services/MediaRepository.swift | 2 +- .../TaxonomyServiceRemoteCoreREST.swift | 2 +- .../Utility/Analytics/WPAnalyticsEvent.swift | 4 ++ .../Utility/Editor/EditorFactory.swift | 11 +++-- .../Pages/DashboardPagesListCardCell.swift | 20 ++++++-- .../Cards/Pages/PagesCardViewModel.swift | 18 ++++++- .../Posts/DashboardPostsListCardCell.swift | 10 +++- .../Prompts/DashboardPromptsCardCell.swift | 12 ++++- .../DashboardQuickActionsCardCell.swift | 13 ++++- .../ViewModel/BlogDashboardViewModel.swift | 17 ++----- .../BloggingPromptsViewController.swift | 12 +++-- .../SiteSettingsViewController+Swift.swift | 2 +- .../Login/JetpackConnectionViewModel.swift | 2 +- .../Views/MediaStorageDetailsView.swift | 2 +- .../NewGutenbergViewController.swift | 42 ++++++++-------- .../Controllers/EditPageViewController.swift | 24 ++++++--- .../PageListViewController+Menu.swift | 4 +- .../Controllers/PageListViewController.swift | 12 ++--- .../Pages/PageEditorPresenter.swift | 10 +++- .../AbstractPostListViewController.swift | 9 ++++ .../Controllers/PostListViewController.swift | 24 +++++---- .../Post/EditPostViewController.swift | 41 +++++++++++++--- .../Post/PostListEditorPresenter.swift | 29 ++++++++--- 30 files changed, 319 insertions(+), 126 deletions(-) create mode 100644 WordPress/Classes/Models/Blog/Blog+Clients.swift diff --git a/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index 9557908d7f00..46524b98c936 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -13,22 +13,21 @@ public actor WordPressClient { public let api: WordPressAPI public let rootUrl: String - private var apiRoot: WpApiDetails? - private var currentUser: UserWithEditContext? + 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() - } - - public func refreshCachedSiteInfo() async throws { - async let apiRootTask = try await self.api.apiRoot.get().data - async let currentUserTask = try await self.api.users.retrieveMeWithEditContext().data - - let (apiRoot, currentUser) = try await (apiRootTask, currentUserTask) + self.loadSiteInfoTask = Task { [api] in + debugPrint("🚚 Fetching Site Info") + async let apiRootTask = try await api.apiRoot.get().data + async let currentUserTask = try await api.users.retrieveMeWithEditContext().data - self.apiRoot = apiRoot - self.currentUser = currentUser + return try await (apiRootTask, currentUserTask) + } } public func currentUserCan(_ capability: UserCapability) async throws -> Bool { @@ -36,17 +35,15 @@ public actor WordPressClient { } private func fetchCurrentUser() async throws -> UserWithEditContext { - if let currentUser = self.currentUser { - return currentUser - } - - let currentUser = try await self.api.users.retrieveMeWithEditContext().data - self.currentUser = currentUser - return currentUser + // 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 start = Date().timeIntervalSince1970 + let apiRoot = try await fetchApiRoot() + debugPrint(" ⏱ Fetched API root in \(Date().timeIntervalSince1970 - start)") if let siteId { return switch feature { @@ -64,11 +61,15 @@ public actor WordPressClient { } private func fetchApiRoot() async throws -> WpApiDetails { - if let apiRoot = self.apiRoot { - return apiRoot - } - let apiRoot = try await self.api.apiRoot.get() - self.apiRoot = apiRoot.data - return apiRoot.data + // 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/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 0452a82174a1..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, 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 1c80e882c18d..d5c42ff947bf 100644 --- a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift +++ b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift @@ -19,7 +19,7 @@ import WordPressCore // 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: WordPressClient(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/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 77ef8acc5bd7..1b6dc44024bc 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -631,6 +631,7 @@ import WordPressShared case applicationPasswordLogin case wpcomWebSignIn + case applicationPasswordClientInitializationFailed // MARK: - Jetpack Stats @@ -1761,6 +1762,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/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 9719fb80a0d1..0056f331a530 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -10,6 +10,7 @@ import WordPressData import WordPressShared import WebKit import CocoaLumberjackSwift +import OSLog class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor { @@ -146,6 +147,8 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor 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) @@ -169,6 +172,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor required convenience init( post: AbstractPost, replaceEditor: @escaping ReplaceEditorCallback, + wordPressClient: WordPressClient, editorSession: PostEditorAnalyticsSession? ) { self.init( @@ -181,7 +185,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 ) } @@ -189,11 +194,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) @@ -433,15 +440,11 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor @MainActor func recommendPlugin(_ plugin: RecommendedPlugin) { - guard let site = try? WordPressSite(blog: self.post.blog) else { - return - } - let controller = PluginInstallationPromptViewController( plugin: plugin, - installer: WordPressClient(site: site)) { _ in - self.startLoadingDependencies() - } + installer: self.wordPressClient + ) { _ in self.startLoadingDependencies() } + if let sheet = controller.sheetPresentationController { sheet.detents = [.medium(), .large()] sheet.prefersGrabberVisible = true @@ -538,21 +541,19 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor // MARK: - Editor Setup private func fetchEditorDependencies() async throws { - let (site, dotComID) = try await ContextManager.shared.performQuery { context in + let dotComId = try await ContextManager.shared.performQuery { context in let blog = try context.existingObject(with: self.blogID) - return (try WordPressSite(blog: blog), blog.dotComID?.intValue) + return blog.dotComID?.intValue } - let client = WordPressClient(site: site) - - if let plugin = try await self.fetchPluginRecommendation(client: client) { + if let plugin = try await self.fetchPluginRecommendation(client: self.wordPressClient) { self.editorState = .suggestingPlugin(plugin) return } - let settings: String? + var settings: String? = nil - if try await client.supports(.themeStyles, forSiteId: dotComID) { + if try await self.wordPressClient.supports(.themeStyles, forSiteId: dotComId) { settings = try await blockEditorSettingsService.getSettingsString(allowingCachedResponse: true) } @@ -563,6 +564,10 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } 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 client.supports(.managePlugins), try await client.currentUserCan(.installPlugins) else { return nil @@ -571,11 +576,6 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor let pluginService = PluginService(client: client, wordpressCoreVersion: nil) try await pluginService.fetchInstalledPlugins() - // Don't make plugin recommendations for WordPress – that app only supports features available in Core - guard AppConfiguration.isJetpack else { - return nil - } - let features: [PluginRecommendationService.Feature] = [.themeStyles, .editorCompatibility] for feature in features { 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..d574fb90b554 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,12 @@ 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?) -> 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?) { + 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/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..d2386ac0bf4c 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?) -> 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?, + 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 = { From 2e24bce619b78a04581da7b5c3e0092ce4bc4eef Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:15:39 -0600 Subject: [PATCH 08/12] Improved plugin service caching --- .../DataStore/InMemoryDataStore.swift | 4 ++ .../WordPressCore/Plugins/PluginService.swift | 37 +++++++++++++------ .../WordPressCore/WordPressClient.swift | 13 ++++--- .../NewGutenbergViewController.swift | 10 +++-- .../Controllers/PageListViewController.swift | 8 +++- .../Controllers/PostListViewController.swift | 4 +- 6 files changed, 52 insertions(+), 24 deletions(-) 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/Plugins/PluginService.swift b/Modules/Sources/WordPressCore/Plugins/PluginService.swift index be3fa16a90fb..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 { @@ -49,7 +60,11 @@ public actor PluginService: PluginServiceProtocol { } 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/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index 46524b98c936..8414551314ad 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -5,9 +5,14 @@ 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 - case managePlugins + + /// WordPress.com sites don't all support plugins + case plugins } public let api: WordPressAPI @@ -22,7 +27,6 @@ public actor WordPressClient { self.api = api self.rootUrl = rootUrl.url() self.loadSiteInfoTask = Task { [api] in - debugPrint("🚚 Fetching Site Info") async let apiRootTask = try await api.apiRoot.get().data async let currentUserTask = try await api.users.retrieveMeWithEditContext().data @@ -43,19 +47,18 @@ public actor WordPressClient { let start = Date().timeIntervalSince1970 let apiRoot = try await fetchApiRoot() - debugPrint(" ⏱ Fetched API root in \(Date().timeIntervalSince1970 - start)") if let siteId { return switch feature { case .themeStyles: apiRoot.hasRoute(route: "/wp-block-editor/v1/sites/\(siteId)/settings") - case .managePlugins: apiRoot.hasRoute(route: "/wp/v2/plugins") + 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 .managePlugins: apiRoot.hasRoute(route: "/wp/v2/plugins") + case .plugins: apiRoot.hasRoute(route: "/wp/v2/plugins") case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax") } } diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index 0056f331a530..250397b13d44 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -167,6 +167,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor private let blockEditorSettingsService: RawBlockEditorSettingsService private let pluginRecommendationService = PluginRecommendationService() + private let pluginService: PluginService // MARK: - Initializers required convenience init( @@ -210,6 +211,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) @@ -569,13 +571,13 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor return nil } - guard try await client.supports(.managePlugins), try await client.currentUserCan(.installPlugins) else { + guard + try await wordPressClient.supports(.plugins), + try await wordPressClient.currentUserCan(.installPlugins) + else { return nil } - let pluginService = PluginService(client: client, wordpressCoreVersion: nil) - try await pluginService.fetchInstalledPlugins() - let features: [PluginRecommendationService.Feature] = [.themeStyles, .editorCompatibility] for feature in features { diff --git a/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift b/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift index d574fb90b554..b08f26789031 100644 --- a/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift +++ b/WordPress/Classes/ViewRelated/Pages/Controllers/PageListViewController.swift @@ -54,11 +54,15 @@ final class PageListViewController: AbstractPostListViewController { private var fetchAllPagesTask: Task<[TaggedManagedObjectID], Error>? // MARK: - Convenience constructors - class func controllerWithBlog(_ blog: Blog, wordPressClient: WordPressClient?) -> PageListViewController { + 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, wordPressClient: WordPressClient?) { + 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/Post/Controllers/PostListViewController.swift b/WordPress/Classes/ViewRelated/Post/Controllers/PostListViewController.swift index d2386ac0bf4c..05fd9f5be563 100644 --- a/WordPress/Classes/ViewRelated/Post/Controllers/PostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Controllers/PostListViewController.swift @@ -12,14 +12,14 @@ final class PostListViewController: AbstractPostListViewController, InteractiveP // MARK: - Convenience constructors - class func controllerWithBlog(_ blog: Blog, wordPressClient: WordPressClient?) -> PostListViewController { + 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, - wordPressClient: WordPressClient?, + wordPressClient: WordPressClient? = nil, withPostStatus postStatus: BasePost.Status? = nil ) { let controller = PostListViewController.controllerWithBlog(blog, wordPressClient: wordPressClient) From bb488bf5e66da74897acb51dd26d6457b0ed6ca6 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:55:28 -0600 Subject: [PATCH 09/12] Clean up recommendations --- .../Sources/WordPressCore/Extensions/Foundation.swift | 8 ++++++++ .../Plugins/PluginRecommendationService.swift | 6 +++--- .../NewGutenberg/NewGutenbergViewController.swift | 10 +++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 Modules/Sources/WordPressCore/Extensions/Foundation.swift 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/PluginRecommendationService.swift b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift index ccee36306680..0acb3a5687ca 100644 --- a/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift +++ b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift @@ -183,10 +183,10 @@ public actor PluginRecommendationService { return true } - let earliestFeatureDate = Date().timeIntervalSince1970 - frequency.timeInterval - let earliestGlobalDate = Date().timeIntervalSince1970 - 86_400 + let earliestFeatureDate = Date(timeIntervalSince1970: featureTimestamp + frequency.timeInterval) + let earliestGlobalDate = Date(timeIntervalSince1970: globalTimestamp + frequency.timeInterval) - return earliestFeatureDate > featureTimestamp && earliestGlobalDate > globalTimestamp + return earliestFeatureDate.hasPast && earliestGlobalDate.hasPast } public func displayedRecommendation(for feature: Feature, at date: Date = Date()) { diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index 250397b13d44..09a4b9506771 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -584,10 +584,14 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor if await pluginRecommendationService.shouldRecommendPlugin(for: feature, frequency: .weekly) { let plugin = try await pluginRecommendationService.recommendPlugin(for: feature) - guard try await pluginService.hasInstalledPlugin(slug: plugin.pluginSlug) else { - await pluginRecommendationService.displayedRecommendation(for: feature) - return plugin + let pluginIsAlreadyInstalled = try await pluginService.hasInstalledPlugin(slug: plugin.pluginSlug) + + if pluginIsAlreadyInstalled { + continue } + + await pluginRecommendationService.displayedRecommendation(for: feature) + return plugin } } From 02d98e4a5ed6f329dce36b1198c8f46e0cd01cf2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 23 Oct 2025 11:01:24 -0600 Subject: [PATCH 10/12] Fix previews --- Modules/Package.swift | 10 +++- .../Support/Extensions/Foundation.swift | 16 +++++ .../Support/InternalDataProvider.swift | 33 ++++++++++- .../Sources/Support/SupportDataProvider.swift | 27 ++++++++- .../UI/Diagnostics/EmptyDiskCacheView.swift | 28 ++++----- .../CachedAndFetchedResult.swift | 22 +------ Modules/Sources/WordPressCore/DiskCache.swift | 27 ++------- .../WordPressCore/WordPressClient.swift | 2 - .../CachedAndFetchedResult.swift | 21 +++++++ .../DiskCacheProtocol.swift | 59 +++++++++++++++++++ 10 files changed, 178 insertions(+), 67 deletions(-) create mode 100644 Modules/Sources/WordPressCoreProtocols/CachedAndFetchedResult.swift create mode 100644 Modules/Sources/WordPressCoreProtocols/DiskCacheProtocol.swift diff --git a/Modules/Package.swift b/Modules/Package.swift index d286f43f98df..ce52c9e78da7 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -22,6 +22,7 @@ let package = Package( .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"), @@ -138,7 +139,7 @@ let package = Package( name: "Support", dependencies: [ "AsyncImageKit", - "WordPressCore", + "WordPressCoreProtocols", ] ), .target(name: "TextBundle"), @@ -153,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..7bdcd56c10c6 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..47eb3be7afd9 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 (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 (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/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/WordPressCore/CachedAndFetchedResult.swift b/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift index 062c74295296..0bf7cd6812e4 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). /// @@ -95,3 +76,4 @@ public struct UserDefaultsCachedAndFetchedResult: CachedAndFetchedResult wher return try? PropertyListDecoder().decode(T.self, from: data) } } + diff --git a/Modules/Sources/WordPressCore/DiskCache.swift b/Modules/Sources/WordPressCore/DiskCache.swift index 070711f872a6..499547267450 100644 --- a/Modules/Sources/WordPressCore/DiskCache.swift +++ b/Modules/Sources/WordPressCore/DiskCache.swift @@ -1,26 +1,9 @@ import Foundation +import WordPressCoreProtocols /// A super-basic on-disk cache for `Codable` objects. /// -public actor DiskCache { - - 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 actor DiskCache: DiskCacheProtocol { private let cacheRoot: URL = URL.cachesDirectory public init() {} @@ -69,16 +52,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/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index 8414551314ad..7e2a4a85d014 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -44,8 +44,6 @@ public actor WordPressClient { } public func supports(_ feature: Feature, forSiteId siteId: Int? = nil) async throws -> Bool { - let start = Date().timeIntervalSince1970 - let apiRoot = try await fetchApiRoot() if let siteId { 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 + } +} From bf063e0d87b13550f54578dc3d115af434941881 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 23 Oct 2025 11:01:30 -0600 Subject: [PATCH 11/12] Add ResetPluginRecommendationsView --- .../Support/Extensions/Foundation.swift | 2 +- .../Sources/Support/SupportDataProvider.swift | 4 +- .../UI/Diagnostics/DiagnosticsView.swift | 2 +- .../ResetPluginRecommendations.swift | 153 ++++++++++++++++++ .../CachedAndFetchedResult.swift | 5 +- Modules/Sources/WordPressCore/DiskCache.swift | 5 +- .../Plugins/PluginRecommendationService.swift | 5 +- WordPress/Classes/Utility/AccountHelper.swift | 2 +- .../NewSupport/SupportDataProvider.swift | 20 +++ 9 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 Modules/Sources/Support/UI/Diagnostics/ResetPluginRecommendations.swift diff --git a/Modules/Sources/Support/Extensions/Foundation.swift b/Modules/Sources/Support/Extensions/Foundation.swift index 7bdcd56c10c6..c30fd3ad62b7 100644 --- a/Modules/Sources/Support/Extensions/Foundation.swift +++ b/Modules/Sources/Support/Extensions/Foundation.swift @@ -103,7 +103,7 @@ extension Task where Failure == Error { _ duration: C.Instant.Duration, operation: @escaping @Sendable () async throws -> Success, clock: C = .continuous - ) async throws -> Success where C: Clock { + ) async throws -> Success where C: Clock { async let waitResult: () = try await clock.sleep(for: duration) async let performTask = try await operation() diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index 47eb3be7afd9..7723747dfcab 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -171,7 +171,7 @@ public final class SupportDataProvider: ObservableObject, Sendable { try await self.diagnosticsDataProvider.fetchDiskCacheUsage() } - public func clearDiskCache(progress: (@Sendable (CacheDeletionProgress) async throws -> Void)) async throws { + public func clearDiskCache(progress: (@Sendable @escaping (CacheDeletionProgress) async throws -> Void)) async throws { try await self.diagnosticsDataProvider.clearDiskCache(progress: progress) } @@ -231,7 +231,7 @@ public protocol CurrentUserDataProvider: Actor { public protocol DiagnosticsDataProvider: Actor { func fetchDiskCacheUsage() async throws -> DiskCacheUsage - func clearDiskCache(progress: (@Sendable (CacheDeletionProgress) async throws -> Void)) async throws + func clearDiskCache(progress: (@Sendable @escaping (CacheDeletionProgress) async throws -> Void)) async throws func resetPluginRecommendations() async throws } 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/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 0bf7cd6812e4..6a2d0e8cb707 100644 --- a/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift +++ b/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift @@ -28,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) } } @@ -76,4 +76,3 @@ public struct UserDefaultsCachedAndFetchedResult: CachedAndFetchedResult wher return try? PropertyListDecoder().decode(T.self, from: data) } } - diff --git a/Modules/Sources/WordPressCore/DiskCache.swift b/Modules/Sources/WordPressCore/DiskCache.swift index 499547267450..2b1fcb066f11 100644 --- a/Modules/Sources/WordPressCore/DiskCache.swift +++ b/Modules/Sources/WordPressCore/DiskCache.swift @@ -4,9 +4,10 @@ import WordPressCoreProtocols /// A super-basic on-disk cache for `Codable` objects. /// public actor DiskCache: DiskCacheProtocol { - private let cacheRoot: URL = URL.cachesDirectory - public init() {} + public static let shared = DiskCache() + + private let cacheRoot: URL = URL.cachesDirectory public func read( _ type: T.Type, diff --git a/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift index 0acb3a5687ca..818617de539c 100644 --- a/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift +++ b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift @@ -143,7 +143,6 @@ public actor PluginRecommendationService { private let dotOrgClient: WordPressOrgApiClient private let userDefaults: UserDefaults - private let diskCache = DiskCache() public init( dotOrgClient: WordPressOrgApiClient = WordPressOrgApiClient(urlSession: .shared), @@ -206,12 +205,12 @@ public actor PluginRecommendationService { private extension PluginRecommendationService { private func cachedPluginData(for plugin: RecommendedPlugin) async throws { let cacheKey = "plugin-recommendation-\(plugin.slug)" - try await self.diskCache.store(plugin, forKey: cacheKey) + 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 self.diskCache.read(RecommendedPlugin.self, forKey: cacheKey) + return try await DiskCache.shared.read(RecommendedPlugin.self, forKey: cacheKey) } } 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/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 { From 35fadd0d2e69a0a788a724a0fbecd67b4c3e4447 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 23 Oct 2025 13:33:35 -0600 Subject: [PATCH 12/12] Fix previews for PluginInstallationPrompt --- .../{Plugins => }/PluginServiceProtocol.swift | 0 .../Plugins/PluginRecommendationService.swift | 168 +++--------------- .../PluginRecommendationServiceProtocol.swift | 92 ++++++++++ .../Plugins/RecommendedPlugin.swift | 49 +++++ .../Utility/Analytics/WPAnalyticsEvent.swift | 4 + .../NewGutenbergViewController.swift | 35 +++- .../PluginInstallationPrompt+UIKit.swift | 4 +- .../Plugins/PluginInstallationPrompt.swift | 58 ++++-- 8 files changed, 248 insertions(+), 162 deletions(-) rename Modules/Sources/WordPressCore/{Plugins => }/PluginServiceProtocol.swift (100%) create mode 100644 Modules/Sources/WordPressCoreProtocols/Plugins/PluginRecommendationServiceProtocol.swift create mode 100644 Modules/Sources/WordPressCoreProtocols/Plugins/RecommendedPlugin.swift 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 index 818617de539c..391896e3049e 100644 --- a/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift +++ b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift @@ -1,145 +1,12 @@ import Foundation import WordPressAPI import WordPressAPIInternal +import WordPressCoreProtocols -public struct RecommendedPlugin: Codable, Sendable { +public actor PluginRecommendationService: PluginRecommendationServiceProtocol { - /// 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 - } - - public var pluginSlug: PluginWpOrgDirectorySlug { - PluginWpOrgDirectorySlug(slug: self.slug) - } -} - -public actor PluginRecommendationService { - - public enum Feature: CaseIterable { - case themeStyles - case postPreviews - case editorCompatibility - - 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" - ) - } - } - - 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" - ) - } - } - - 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)! - } - - var recommendedPlugin: PluginWpOrgDirectorySlug { - let slug = switch self { - case .themeStyles: "gutenberg" - case .postPreviews: "jetpack" - case .editorCompatibility: "jetpack" - } - - return PluginWpOrgDirectorySlug(slug: slug) - } - - fileprivate var cacheKey: String { - return "plugin-recommendation-\(self)-\(recommendedPlugin.slug)" - } - } - - public enum Frequency { - case daily - case weekly - case monthly - - var timeInterval: TimeInterval { - return switch self { - case .daily: 86_400 - case .weekly: 604_800 - case .monthly: 14_515_200 - } - } - } + public typealias Feature = WordPressCoreProtocols.PluginRecommendationFeature + public typealias Frequency = WordPressCoreProtocols.PluginRecommendationFrequency private let dotOrgClient: WordPressOrgApiClient private let userDefaults: UserDefaults @@ -152,21 +19,21 @@ public actor PluginRecommendationService { self.userDefaults = userDefaults } - public func recommendedPluginSlug(for feature: Feature) async throws -> PluginWpOrgDirectorySlug { + 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.slug) { + if let cachedPlugin = try await fetchCachedPlugin(for: feature.recommendedPlugin) { return cachedPlugin } - let plugin = try await dotOrgClient.pluginInformation(slug: feature.recommendedPlugin) + let plugin = try await dotOrgClient.pluginInformation(slug: .init(slug: feature.recommendedPlugin)) return RecommendedPlugin( name: plugin.name, slug: plugin.slug.slug, - usageTitle: "Install \(plugin.name.removingPercentEncoding ?? plugin.slug.slug)", + usageTitle: "Install \(unescapePluginTitle(plugin.name) ?? plugin.slug.slug)", usageDescription: feature.explanation, successMessage: feature.successMessage, imageUrl: try await cachePluginHeader(for: plugin), @@ -199,6 +66,25 @@ public actor PluginRecommendationService { } 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 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/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 1b6dc44024bc..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 @@ -782,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" diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index 09a4b9506771..ffcba00d6ceb 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -6,6 +6,7 @@ import GutenbergKit import SafariServices import WordPressAPI import WordPressCore +import WordPressCoreProtocols import WordPressData import WordPressShared import WebKit @@ -445,7 +446,10 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor let controller = PluginInstallationPromptViewController( plugin: plugin, installer: self.wordPressClient - ) { _ in self.startLoadingDependencies() } + ) { result in + self.recordPluginPromptResult(result) + self.startLoadingDependencies() + } if let sheet = controller.sheetPresentationController { sheet.detents = [.medium(), .large()] @@ -1210,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/Plugins/PluginInstallationPrompt+UIKit.swift b/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt+UIKit.swift index f6c4ff2f920e..b031386a6f72 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt+UIKit.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt+UIKit.swift @@ -1,10 +1,10 @@ import UIKit import SwiftUI -import WordPressCore +import WordPressCoreProtocols class PluginInstallationPromptViewController: UIHostingController { - typealias ActionCallback = (PluginInstallationState) -> Void + typealias ActionCallback = (PluginInstallationResult) -> Void @MainActor public init(plugin: RecommendedPlugin, installer: any PluginInstallerProtocol, wasDismissed: ActionCallback? = nil) { diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt.swift b/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt.swift index cbc6f341311a..d10affebfdb8 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginInstallationPrompt.swift @@ -1,5 +1,10 @@ import SwiftUI -import WordPressCore +import WordPressCoreProtocols + +struct PluginInstallationResult { + let pluginDetails: RecommendedPlugin + let installationState: PluginInstallationState +} enum PluginInstallationState: Equatable { case start @@ -30,7 +35,7 @@ struct PluginInstallationPrompt: View { let pluginDetails: RecommendedPlugin let installer: PluginInstallerProtocol - let wasDismissed: ((PluginInstallationState) -> Void)? + let wasDismissed: ((PluginInstallationResult) -> Void)? @State private var state: PluginInstallationState = .start @@ -47,7 +52,7 @@ struct PluginInstallationPrompt: View { public init( plugin: RecommendedPlugin, installer: PluginInstallerProtocol, - wasDismissed: ((PluginInstallationState) -> Void)? = nil + wasDismissed: ((PluginInstallationResult) -> Void)? = nil ) { self.pluginDetails = plugin self.installer = installer @@ -228,7 +233,12 @@ struct PluginInstallationPrompt: View { } func dismiss() { - self.wasDismissed?(self.state) + let result = PluginInstallationResult( + pluginDetails: self.pluginDetails, + installationState: self.state + ) + + self.wasDismissed?(result) self._dismiss() } @@ -286,12 +296,12 @@ fileprivate let gutenbergDetails = RecommendedPlugin( ) fileprivate let jetpackDetails = RecommendedPlugin( - name: "Jetpack", + 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?rev=2653649"), + imageUrl: URL(string: "https://ps.w.org/jetpack/assets/banner-1544x500.png"), helpUrl: URL(string: "https://wordpress.org/support/article/managing-plugins/#installing-plugins")! ) @@ -306,22 +316,34 @@ fileprivate let noBannerDetails = RecommendedPlugin( ) #Preview("Gutenberg") { - PluginInstallationPrompt( - plugin: gutenbergDetails, - installer: DummyInstaller() - ) + NavigationStack { + Text("") + }.sheet(isPresented: .constant(true)) { + PluginInstallationPrompt( + plugin: gutenbergDetails, + installer: DummyInstaller() + ).presentationDetents([.medium, .large]) + } } #Preview("Jetpack") { - PluginInstallationPrompt( - plugin: jetpackDetails, - installer: DummyInstaller() - ) + NavigationStack { + Text("") + }.sheet(isPresented: .constant(true)) { + PluginInstallationPrompt( + plugin: jetpackDetails, + installer: DummyInstaller() + ).presentationDetents([.medium, .large]) + } } #Preview("No Banner") { - PluginInstallationPrompt( - plugin: noBannerDetails, - installer: DummyInstaller() - ) + NavigationStack { + Text("") + }.sheet(isPresented: .constant(true)) { + PluginInstallationPrompt( + plugin: noBannerDetails, + installer: DummyInstaller() + ) + } }