diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 5e2301e16077..1a34800a3d66 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "330ea0e8d41133864d0a9053a9eec49bff8fa6d3a19eabcff858b34f981843a2", + "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 06b5a62924d2..9dad999b87e1 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"), @@ -52,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/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") + } +} diff --git a/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift new file mode 100644 index 000000000000..d30c067e479e --- /dev/null +++ b/Modules/Sources/WordPressCore/Plugins/PluginRecommendationService.swift @@ -0,0 +1,262 @@ +import Foundation +import WordPressAPI +import WordPressAPIInternal + +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 + } + + 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 + } + } + } + + private let dotOrgClient: WordPressOrgApiClient + private let userDefaults: UserDefaults + private let diskCache = DiskCache() + + 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 { + 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.removingPercentEncoding ?? plugin.slug.slug)", + usageDescription: feature.explanation, + successMessage: feature.successMessage, + imageUrl: try await cachePluginHeader(for: plugin), + helpUrl: feature.helpArticleUrl + ) + } + + public func shouldRecommendPlugin(for feature: Feature, frequency: Frequency) -> Bool { + let featureTimestamp = self.userDefaults.double(forKey: feature.cacheKey) + let globalTimestamp = self.userDefaults.double(forKey: "plugin-last-recommended") + + if featureTimestamp == 0 && globalTimestamp == 0 { + return true + } + + let earliestFeatureDate = Date().timeIntervalSince1970 - frequency.timeInterval + let earliestGlobalDate = Date().timeIntervalSince1970 - 86_400 + + return earliestFeatureDate > featureTimestamp && earliestGlobalDate > globalTimestamp + } + + public func displayedRecommendation(for feature: Feature, at date: Date = Date()) { + self.userDefaults.set(date.timeIntervalSince1970, forKey: feature.cacheKey) + self.userDefaults.set(date.timeIntervalSince1970, forKey: "plugin-last-recommended") + } + + public func resetRecommendations() { + for feature in Feature.allCases { + self.userDefaults.removeObject(forKey: feature.cacheKey) + } + self.userDefaults.removeObject(forKey: "plugin-last-recommended") + } +} + +// 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 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) + } + + func cacheAsset(_ url: URL, at path: URL) async throws -> URL { + if FileManager.default.fileExists(at: path) { + return path + } + + let (tempPath, _) = try await URLSession.shared.download(from: url) + try FileManager.default.moveItem(at: tempPath, to: path) + + return path + } + + func storagePath(for plugin: PluginInformation, filename: String) -> URL { + URL.cachesDirectory + .appendingPathComponent("plugin-assets") + .appendingPathComponent(plugin.slug.slug) + .appendingPathComponent(filename) + } +} + +fileprivate extension PluginInformation { + var bannerFileName: String? { + bannerUrl?.lastPathComponent + } + + var bannerUrl: URL? { + URL(string: self.banners.high) + } +} diff --git a/Modules/Sources/WordPressCore/Plugins/PluginService.swift b/Modules/Sources/WordPressCore/Plugins/PluginService.swift index 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/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/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index c7880f33fba5..9557908d7f00 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -1,14 +1,74 @@ 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? + private var currentUser: UserWithEditContext? + 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.apiRoot = apiRoot + self.currentUser = currentUser + } + + 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 { + 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/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/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/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/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/Services/WordPressClient+UIProtocols.swift b/WordPress/Classes/Services/WordPressClient+UIProtocols.swift new file mode 100644 index 000000000000..8cd036ed353a --- /dev/null +++ b/WordPress/Classes/Services/WordPressClient+UIProtocols.swift @@ -0,0 +1,13 @@ +import WordPressAPI +import WordPressCore + +extension WordPressClient: PluginInstallerProtocol { + func installAndActivatePlugin(slug: String) async throws { + let params = PluginCreateParams( + slug: PluginWpOrgDirectorySlug(slug: slug), + status: .active + ) + + _ = try await self.api.plugins.create(params: params) + } +} diff --git a/WordPress/Classes/Users/UserProvider.swift b/WordPress/Classes/Users/UserProvider.swift index 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)) diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index ac85630787cf..bcba6fe7eebe 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 @@ -124,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? @@ -145,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( @@ -174,6 +192,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 +243,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor // DDLogError("Error syncing JETPACK: \(String(describing: error))") // }) - onViewDidLoad() +// onViewDidLoad() } override func viewWillAppear(_ animated: Bool) { @@ -245,25 +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 .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) { @@ -343,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() { @@ -356,12 +364,26 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } } + func evaluateEditorState() { + switch self.editorState { + case .uninitialized: break + case .loadingDependencies: break + case .loadingCancelled: break + case .suggestingPlugin(let plugin): self.recommendPlugin(plugin) + case .dependencyError(let error): self.showEditorError(error) + case .dependenciesReady(let dependencies): self.startEditor(settings: dependencies.settings) + case .started: break + } + } + func startLoadingDependencies() { switch self.editorState { case .uninitialized: break // This is fine – we're loading for the first time case .loadingDependencies: preconditionFailure("`startLoadingDependencies` should not be called while in the `.loadingDependencies` state") + case .suggestingPlugin: + break // This is fine – we're loading after suggesting a plugin to the user case .loadingCancelled: break // This is fine – we're loading after quickly switching posts case .dependencyError: @@ -374,16 +396,37 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor self.editorState = .loadingDependencies(Task { do { - let dependencies = try await fetchEditorDependencies() - self.editorState = .dependenciesReady(dependencies) + try await fetchEditorDependencies() } catch { - self.editorState = .dependencyError(error) + await MainActor.run { + self.editorState = .dependencyError(error) + } } }) } @MainActor - func startEditor(settings: String) async throws { + func recommendPlugin(_ plugin: RecommendedPlugin) { + 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) { guard case .dependenciesReady = self.editorState else { preconditionFailure("`startEditor` should only be called when the editor is in the `.dependenciesReady` state.") } @@ -468,11 +511,59 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } // MARK: - Editor Setup - private func fetchEditorDependencies() async throws -> EditorDependencies { - let settings = try await blockEditorSettingsService.getSettingsString(allowingCachedResponse: true) + private func fetchEditorDependencies() async throws { + let (site, dotComID) = try await ContextManager.shared.performQuery { context in + let blog = try context.existingObject(with: self.blogID) + return (try WordPressSite(blog: blog), blog.dotComID?.intValue) + } + + let client = WordPressClient(site: site) + + if let plugin = try await self.fetchPluginRecommendation(client: client) { + self.editorState = .suggestingPlugin(plugin) + return + } + + var settings = "undefined" + + if try await client.supports(.themeStyles, forSiteId: dotComID) { + 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 fetchPluginRecommendation(client: WordPressClient) async throws -> RecommendedPlugin? { + + guard try await client.supports(.managePlugins), try await client.currentUserCan(.installPlugins) 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 { 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 = "";