From 6331260bce94c252133fcbe425a6d3b4e7927c43 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 1/5] Add plugin installation prompt --- Modules/Package.resolved | 2 +- Modules/Package.swift | 1 + .../Plugins/PluginRecommendationService.swift | 231 +++++++++++++ .../Plugins/PluginRecommendationService.swift | 38 ++ .../WordPressClient+UIProtocols.swift | 13 + .../NewGutenbergViewController.swift | 70 +++- .../PluginInstallationPrompt+UIKit.swift | 21 ++ .../Plugins/PluginInstallationPrompt.swift | 327 ++++++++++++++++++ WordPress/WordPress.xcodeproj/project.pbxproj | 9 + 9 files changed, 706 insertions(+), 6 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.resolved b/Modules/Package.resolved index 5e2301e16077..a83d8f221b50 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "330ea0e8d41133864d0a9053a9eec49bff8fa6d3a19eabcff858b34f981843a2", + "originHash" : "6631a276a1ae704c7e94905300343d6bf44e137d95b88d444dffcdfb571a0f32", "pins" : [ { "identity" : "alamofire", diff --git a/Modules/Package.swift b/Modules/Package.swift index 06b5a62924d2..7247481f1625 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -20,6 +20,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 ac85630787cf..e228d7c5e28b 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 @@ -174,6 +184,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor ) { self.post = post + self.blogID = TaggedManagedObjectID(post.blog) self.replaceEditor = replaceEditor self.editorSession = PostEditorAnalyticsSession(editor: .gutenbergKit, post: post) @@ -224,7 +235,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor // DDLogError("Error syncing JETPACK: \(String(describing: error))") // }) - onViewDidLoad() +// onViewDidLoad() } override func viewWillAppear(_ animated: Bool) { @@ -256,6 +267,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") @@ -362,6 +374,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: @@ -374,14 +388,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 { @@ -468,11 +501,38 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } // MARK: - Editor Setup - private func fetchEditorDependencies() async throws -> EditorDependencies { + 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 6cbcdea94876..0f34fe4a6691 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -1162,6 +1162,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 = ( @@ -1355,6 +1363,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 989ddfc9940b33b7acfb00a2366b1a57864879c5 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 2/5] 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 e8cdb01f080945172dcbbe99f51bec8b6d35dca6 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 3/5] Plugin recommendations --- Modules/Package.resolved | 6 +- Modules/Package.swift | 2 +- .../Plugins/PluginRecommendationService.swift | 43 ++++++- .../WordPressCore/Plugins/PluginService.swift | 4 + .../WordPressCore/WordPressClient.swift | 44 +++++++ .../Classes/Networking/WordPressClient.swift | 3 +- .../NewGutenbergViewController.swift | 117 +++++++++++------- 7 files changed, 165 insertions(+), 54 deletions(-) diff --git a/Modules/Package.resolved b/Modules/Package.resolved index a83d8f221b50..1a34800a3d66 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6631a276a1ae704c7e94905300343d6bf44e137d95b88d444dffcdfb571a0f32", + "originHash" : "1d6343590c371b5022f68dee17efafc677ac885d99f56fac9bf74e077b2d2b16", "pins" : [ { "identity" : "alamofire", @@ -372,8 +372,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Automattic/wordpress-rs", "state" : { - "branch" : "alpha-20250901", - "revision" : "8fa6532ea087bbc7dca60e027da0a1e82ea5dee8" + "branch" : "alpha-20250923", + "revision" : "1a28d35e1475fbbe41b60bb1ff4bf643b7a96914" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 7247481f1625..9dad999b87e1 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -53,7 +53,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. - .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250901"), + .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250923"), .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.8.1-alpha.2"), .package( url: "https://github.com/Automattic/color-studio", 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 438177c6e08d..2f57114c64e3 100644 --- a/Modules/Sources/WordPressCore/Plugins/PluginService.swift +++ b/Modules/Sources/WordPressCore/Plugins/PluginService.swift @@ -45,6 +45,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 c7880f33fba5..d898d7554619 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -1,14 +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 aa2c86a64012..02ed54210ad5 100644 --- a/WordPress/Classes/Networking/WordPressClient.swift +++ b/WordPress/Classes/Networking/WordPressClient.swift @@ -133,10 +133,11 @@ private class AppNotifier: @unchecked Sendable, WpAppNotifier { self.coreDataStack = coreDataStack } - func requestedWithInvalidAuthentication() async { + func requestedWithInvalidAuthentication(requestUrl: String) async { 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 e228d7c5e28b..c06740f2ce12 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -134,7 +134,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? @@ -155,6 +162,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor func getHTML() -> String { post.content ?? "" } private let blockEditorSettingsService: RawBlockEditorSettingsService + private let pluginRecommendationService = PluginRecommendationService() // MARK: - Initializers required convenience init( @@ -256,26 +264,6 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor if case .loadingCancelled = self.editorState { preconditionFailure("Dependency loading should not be cancelled") } - - self.editorLoadingTask = Task { - do { - while case .loadingDependencies = self.editorState { - try await Task.sleep(nanoseconds: 1000) - } - - switch self.editorState { - 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") - } - } catch { - self.showEditorError(error) - } - } } override func viewWillDisappear(_ animated: Bool) { @@ -355,7 +343,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() { @@ -368,6 +364,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: @@ -390,7 +398,9 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor do { try await fetchEditorDependencies() } catch { - self.editorState = .dependencyError(error) + await MainActor.run { + self.editorState = .dependencyError(error) + } } }) } @@ -416,7 +426,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.") } @@ -502,39 +512,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 - } - } - } + var settings = "undefined" + + 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 0878c229bdc26344dc51257e07772d68412ca4e6 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 4/5] align with latest wprs --- .../WordPressCore/Users/DisplayUser.swift | 19 ++++++++++++++++--- .../WordPressCore/Users/UserService.swift | 2 +- .../Users/UserServiceProtocol.swift | 2 +- .../CommentServiceRemoteCoreRESTAPI.swift | 2 +- WordPress/Classes/Users/UserProvider.swift | 2 +- .../Users/ViewModel/UserListViewModel.swift | 2 +- .../Classes/Users/Views/UserDetailsView.swift | 2 +- 7 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Modules/Sources/WordPressCore/Users/DisplayUser.swift b/Modules/Sources/WordPressCore/Users/DisplayUser.swift index eec70b5a4d7e..a7a4d52d86c4 100644 --- a/Modules/Sources/WordPressCore/Users/DisplayUser.swift +++ b/Modules/Sources/WordPressCore/Users/DisplayUser.swift @@ -1,4 +1,5 @@ import Foundation +import WordPressAPI public struct DisplayUser: Identifiable, Codable, Hashable, Sendable { public let id: Int64 @@ -8,7 +9,7 @@ public struct DisplayUser: Identifiable, Codable, Hashable, Sendable { public let lastName: String public let displayName: String public let profilePhotoUrl: URL? - public let role: String + public let role: UserRole public let emailAddress: String public let websiteUrl: String? @@ -23,7 +24,7 @@ public struct DisplayUser: Identifiable, Codable, Hashable, Sendable { lastName: String, displayName: String, profilePhotoUrl: URL?, - role: String, + role: UserRole, emailAddress: String, websiteUrl: String?, biography: String? @@ -49,7 +50,7 @@ public struct DisplayUser: Identifiable, Codable, Hashable, Sendable { lastName: "Smith", displayName: "John Smith", profilePhotoUrl: URL(string: "https://gravatar.com/avatar/58fc51586c9a1f9895ac70e3ca60886e?size=256"), - role: "administrator", + role: .administrator, emailAddress: "john@example.com", websiteUrl: "https://example.com", biography: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." @@ -68,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/Modules/Sources/WordPressCore/Users/UserService.swift b/Modules/Sources/WordPressCore/Users/UserService.swift index e8653ca336cf..102e068fbf65 100644 --- a/Modules/Sources/WordPressCore/Users/UserService.swift +++ b/Modules/Sources/WordPressCore/Users/UserService.swift @@ -36,7 +36,7 @@ public actor UserService: UserServiceProtocol { } } - public func isCurrentUserCapableOf(_ capability: String) async -> Bool { + public func isCurrentUserCapableOf(_ capability: UserCapability) async -> Bool { await currentUser?.capabilities.keys.contains(capability) == true } diff --git a/Modules/Sources/WordPressCore/Users/UserServiceProtocol.swift b/Modules/Sources/WordPressCore/Users/UserServiceProtocol.swift index bc1fd7b1891e..95968e8fb8b8 100644 --- a/Modules/Sources/WordPressCore/Users/UserServiceProtocol.swift +++ b/Modules/Sources/WordPressCore/Users/UserServiceProtocol.swift @@ -4,7 +4,7 @@ import WordPressAPI public protocol UserServiceProtocol: Actor { func fetchUsers() async throws - func isCurrentUserCapableOf(_ capability: String) async -> Bool + func isCurrentUserCapableOf(_ capability: UserCapability) async -> Bool func setNewPassword(id: UserId, newPassword: String) async throws diff --git a/WordPress/Classes/Services/CommentServiceRemoteCoreRESTAPI.swift b/WordPress/Classes/Services/CommentServiceRemoteCoreRESTAPI.swift index 46846c6a48d5..2c40aaa75c3e 100644 --- a/WordPress/Classes/Services/CommentServiceRemoteCoreRESTAPI.swift +++ b/WordPress/Classes/Services/CommentServiceRemoteCoreRESTAPI.swift @@ -149,7 +149,7 @@ private extension RemoteComment { self.postID = NSNumber(value: comment.post) self.status = comment.status.commentStatusType?.description - self.type = comment.commentType.type + self.type = comment.commentType.rawValue if let ext = try? comment.additionalFields.parseWpcomCommentsExtension() { self.postTitle = ext.post?.title diff --git a/WordPress/Classes/Users/UserProvider.swift b/WordPress/Classes/Users/UserProvider.swift index 16288e3e1a2c..f5c0300b4218 100644 --- a/WordPress/Classes/Users/UserProvider.swift +++ b/WordPress/Classes/Users/UserProvider.swift @@ -58,7 +58,7 @@ actor MockUserProvider: UserServiceProtocol { await userDataStore.listStream(query: .all) } - func isCurrentUserCapableOf(_ capability: String) async -> Bool { + func isCurrentUserCapableOf(_ capability: UserCapability) async -> Bool { true } diff --git a/WordPress/Classes/Users/ViewModel/UserListViewModel.swift b/WordPress/Classes/Users/ViewModel/UserListViewModel.swift index 58b043d3d2d9..593f279d81eb 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 e50e50b17eb7..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) + 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 345d3484887253a9ddd056dd4bf7b94474387257 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 5/5] 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 c06740f2ce12..bcba6fe7eebe 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -538,7 +538,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 }