From 04dc5bfc9d7b088cf7e2f2dc857c08deed0fb7de Mon Sep 17 00:00:00 2001 From: Tony Li <tony.li@automattic.com> Date: Wed, 29 Jan 2025 13:44:35 +1300 Subject: [PATCH] Implement a new site plugins list --- .../Plugins/InstalledPlugin.swift | 38 ++++++ .../Plugins/InstalledPluginDataStore.swift | 31 +++++ .../WordPressCore/Plugins/PluginService.swift | 83 +++++++++++++ .../Plugins/PluginServiceProtocol.swift | 19 +++ .../Views/InstalledPluginsListView.swift | 101 ++++++++++++++++ .../Plugins/Views/PluginIconResolver.swift | 19 +++ .../Plugins/Views/PluginListItemView.swift | 112 ++++++++++++++++++ .../BuildInformation/FeatureFlag.swift | 4 + ...DetailsViewController+SectionHelpers.swift | 18 ++- .../Blog Details/BlogDetailsViewController.m | 6 + 10 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 Modules/Sources/WordPressCore/Plugins/InstalledPlugin.swift create mode 100644 Modules/Sources/WordPressCore/Plugins/InstalledPluginDataStore.swift create mode 100644 Modules/Sources/WordPressCore/Plugins/PluginService.swift create mode 100644 Modules/Sources/WordPressCore/Plugins/PluginServiceProtocol.swift create mode 100644 WordPress/Classes/Plugins/Views/InstalledPluginsListView.swift create mode 100644 WordPress/Classes/Plugins/Views/PluginIconResolver.swift create mode 100644 WordPress/Classes/Plugins/Views/PluginListItemView.swift diff --git a/Modules/Sources/WordPressCore/Plugins/InstalledPlugin.swift b/Modules/Sources/WordPressCore/Plugins/InstalledPlugin.swift new file mode 100644 index 000000000000..7a6a71492b82 --- /dev/null +++ b/Modules/Sources/WordPressCore/Plugins/InstalledPlugin.swift @@ -0,0 +1,38 @@ +import Foundation +import WordPressAPI + +public struct InstalledPlugin: Equatable, Hashable, Identifiable, Sendable { + public var slug: PluginSlug + public var iconURL: URL? + public var name: String + public var version: String + public var author: String + public var shortDescription: String + + public init(slug: PluginSlug, iconURL: URL?, name: String, version: String, author: String, shortDescription: String) { + self.slug = slug + self.iconURL = iconURL + self.name = name + self.version = version + self.author = author + self.shortDescription = shortDescription + } + + public init(plugin: PluginWithViewContext) { + self.slug = plugin.plugin + iconURL = nil + name = plugin.name + version = plugin.version + author = plugin.author + shortDescription = plugin.description.raw + } + + public var id: String { + slug.slug + } + + public var possibleWpOrgDirectorySlug: PluginWpOrgDirectorySlug? { + guard let maybeWpOrgSlug = slug.slug.split(separator: "/").first else { return nil } + return .init(slug: String(maybeWpOrgSlug)) + } +} diff --git a/Modules/Sources/WordPressCore/Plugins/InstalledPluginDataStore.swift b/Modules/Sources/WordPressCore/Plugins/InstalledPluginDataStore.swift new file mode 100644 index 000000000000..6b5a68ebede8 --- /dev/null +++ b/Modules/Sources/WordPressCore/Plugins/InstalledPluginDataStore.swift @@ -0,0 +1,31 @@ +import Foundation +@preconcurrency import Combine +import WordPressShared +import WordPressAPI + +public protocol InstalledPluginDataStore: DataStore where T == InstalledPlugin, Query == PluginDataStoreQuery { +} + +public enum PluginDataStoreQuery: Equatable, Sendable { + case all +} + +public actor InMemoryInstalledPluginDataStore: InstalledPluginDataStore, InMemoryDataStore { + public typealias T = InstalledPlugin + + public var storage: [T.ID: T] = [:] + public let updates: PassthroughSubject<Set<T.ID>, Never> = .init() + + deinit { + updates.send(completion: .finished) + } + + public init() {} + + public func list(query: Query) throws -> [T] { + switch query { + case .all: + return storage.values.sorted(using: KeyPathComparator(\.slug.slug)) + } + } +} diff --git a/Modules/Sources/WordPressCore/Plugins/PluginService.swift b/Modules/Sources/WordPressCore/Plugins/PluginService.swift new file mode 100644 index 000000000000..1a7079474f96 --- /dev/null +++ b/Modules/Sources/WordPressCore/Plugins/PluginService.swift @@ -0,0 +1,83 @@ +import Foundation +import UIKit +import WordPressAPI + +public actor PluginService: PluginServiceProtocol { + private let client: WordPressClient + private let wpOrgClient: WordPressOrgApiClient + private let installedPluginDataStore = InMemoryInstalledPluginDataStore() + private let urlSession: URLSession + + public init(client: WordPressClient) { + self.client = client + self.urlSession = URLSession(configuration: .ephemeral) + wpOrgClient = WordPressOrgApiClient(requestExecutor: urlSession) + } + + public func fetchInstalledPlugins() async throws { + let response = try await self.client.api.plugins.listWithViewContext(params: .init()) + let plugins = response.data.map(InstalledPlugin.init(plugin:)) + try await installedPluginDataStore.store(plugins) + } + + public func streamInstalledPlugins() async -> AsyncStream<Result<[InstalledPlugin], Error>> { + await installedPluginDataStore.listStream(query: .all) + } + + public func resolveIconURL(of slug: PluginWpOrgDirectorySlug) async -> URL? { + // TODO: Cache the icon URL + + if let url = await findIconFromPluginDirectory(slug: slug) { + return url + } + + if let url = await findIconFromSVNServer(slug: slug) { + return url + } + + return nil + } + +} + +private extension PluginService { + func findIconFromPluginDirectory(slug: PluginWpOrgDirectorySlug) async -> URL? { + guard let pluginInfo = try? await wpOrgClient.pluginInformation(slug: slug) else { return nil } + guard let icons = pluginInfo.icons else { return nil } + + let supportedFormat: Set<String> = ["png", "jpg", "jpeg", "gif"] + let urls: [String?] = [icons.default, icons.high, icons.low] + for string in urls { + guard let string, let url = URL(string: string) else { continue } + + if supportedFormat.contains(url.pathExtension) { + return url + } + } + + return nil + } + + func findIconFromSVNServer(slug: PluginWpOrgDirectorySlug) async -> URL? { + let url = URL(string: "https://ps.w.org")! + .appending(path: slug.slug) + .appending(path: "assets") + let size = [256, 128] + let supportedFormat = ["png", "jpg", "jpeg", "gif"] + let candidates = zip(size, supportedFormat).map { size, format in + url.appending(path: "icon-\(size)x\(size).\(format)") + } + + for url in candidates { + var request = URLRequest(url: url) + request.httpMethod = "HEAD" + + if let (_, response) = try? await urlSession.data(for: request), + (response as? HTTPURLResponse)?.statusCode == 200 { + return url + } + } + + return nil + } +} diff --git a/Modules/Sources/WordPressCore/Plugins/PluginServiceProtocol.swift b/Modules/Sources/WordPressCore/Plugins/PluginServiceProtocol.swift new file mode 100644 index 000000000000..cb567c1c927d --- /dev/null +++ b/Modules/Sources/WordPressCore/Plugins/PluginServiceProtocol.swift @@ -0,0 +1,19 @@ +import Foundation +import WordPressAPI + +public protocol PluginServiceProtocol: Actor { + + func fetchInstalledPlugins() async throws + + func streamInstalledPlugins() async -> AsyncStream<Result<[InstalledPlugin], Error>> + + func resolveIconURL(of slug: PluginWpOrgDirectorySlug) async -> URL? + +} + +extension PluginServiceProtocol { + public func resolveIconURL(of plugin: InstalledPlugin) async -> URL? { + guard let slug = plugin.possibleWpOrgDirectorySlug else { return nil } + return await resolveIconURL(of: slug) + } +} diff --git a/WordPress/Classes/Plugins/Views/InstalledPluginsListView.swift b/WordPress/Classes/Plugins/Views/InstalledPluginsListView.swift new file mode 100644 index 000000000000..d49a4f3eb124 --- /dev/null +++ b/WordPress/Classes/Plugins/Views/InstalledPluginsListView.swift @@ -0,0 +1,101 @@ +import SwiftUI +import AsyncImageKit +import WordPressUI +import WordPressAPI +import WordPressCore + +struct InstalledPluginsListView: View { + @StateObject private var viewModel: InstalledPluginsListViewModel + + init(client: WordPressClient) { + self.init(service: PluginService(client: client)) + } + + init(service: PluginServiceProtocol) { + _viewModel = StateObject(wrappedValue: .init(service: service)) + } + + var body: some View { + ZStack { + if let error = viewModel.error { + EmptyStateView(error, systemImage: "exclamationmark.triangle.fill") + } else if viewModel.isRefreshing && viewModel.plugins.isEmpty { + Label { Text(Strings.loading) } icon: { ProgressView() } + } else { + List { + Section { + ForEach(viewModel.plugins, id: \.self) { plugin in + PluginListItemView( + plugin: plugin, + iconResolver: PluginIconResolver( + slug: plugin.possibleWpOrgDirectorySlug, + service: viewModel.service + ) + ) + } + } + .listSectionSeparator(.hidden, edges: .top) + } + .listStyle(.plain) + .refreshable(action: viewModel.refreshItems) + } + } + .navigationTitle(Strings.title) + .task(id: 0) { + await viewModel.onAppear() + } + .task(id: 1) { + await viewModel.performQuery() + } + } + + private enum Strings { + static let title: String = NSLocalizedString("site.plugins.title", value: "Plugins", comment: "Installed plugins list title") + static let loading: String = NSLocalizedString("site.plugins.loading", value: "Loading installed plugins…", comment: "Message displayed when fetching installed plugins from the site") + static let noPluginInstalled: String = NSLocalizedString("site.plugins.noInstalledPlugins", value: "You haven't installed any plugins yet", comment: "No installed plugins message") + } +} + +@MainActor +final class InstalledPluginsListViewModel: ObservableObject { + let service: PluginServiceProtocol + private var initialLoad = false + + @Published var isRefreshing: Bool = false + @Published var plugins: [InstalledPlugin] = [] + @Published var error: String? = nil + + init(service: PluginServiceProtocol) { + self.service = service + } + + func onAppear() async { + if !initialLoad { + initialLoad = true + await refreshItems() + } + } + + @Sendable + func refreshItems() async { + isRefreshing = true + defer { isRefreshing = false } + + do { + try await self.service.fetchInstalledPlugins() + } catch { + self.error = (error as? WpApiError)?.errorMessage ?? error.localizedDescription + } + } + + func performQuery() async { + for await update in await self.service.streamInstalledPlugins() { + switch update { + case let .success(plugins): + self.plugins = plugins + case let .failure(error): + self.error = (error as? WpApiError)?.errorMessage ?? error.localizedDescription + } + } + } +} diff --git a/WordPress/Classes/Plugins/Views/PluginIconResolver.swift b/WordPress/Classes/Plugins/Views/PluginIconResolver.swift new file mode 100644 index 000000000000..869d2ef0ce2c --- /dev/null +++ b/WordPress/Classes/Plugins/Views/PluginIconResolver.swift @@ -0,0 +1,19 @@ +import Foundation +import AsyncImageKit +import WordPressAPI +import WordPressCore + +struct PluginIconResolver: ImageURLResolver { + let slug: PluginWpOrgDirectorySlug? + weak var service: PluginServiceProtocol? + + var id: String? { + slug?.slug + } + + func imageURL() async -> URL? { + guard let slug else { return nil } + + return await service?.resolveIconURL(of: slug) + } +} diff --git a/WordPress/Classes/Plugins/Views/PluginListItemView.swift b/WordPress/Classes/Plugins/Views/PluginListItemView.swift new file mode 100644 index 000000000000..7a0bb9bac3e2 --- /dev/null +++ b/WordPress/Classes/Plugins/Views/PluginListItemView.swift @@ -0,0 +1,112 @@ +import Foundation +import SwiftUI +import AsyncImageKit +import WordPressCore + +struct PluginListItemView: View { + + private static let iconSize: CGFloat = 44 + + @ScaledMetric(relativeTo: .body) var descriptionFontSize: CGFloat = 14 + + private var iconURL: URL? + private var name: String + private var version: String + private var author: String + private var shortDescription: String + private var iconResolver: PluginIconResolver + + init(plugin: InstalledPlugin, iconResolver: PluginIconResolver) { + self.iconURL = plugin.iconURL + self.name = plugin.name + self.version = plugin.version + self.author = plugin.author + self.shortDescription = plugin.shortDescription + self.iconResolver = iconResolver + } + + var body: some View { + HStack(alignment: .top) { + CachedAsyncImage(urlResolver: iconResolver) { image in + image.resizable() + } placeholder: { + Image("site-menu-plugins") + .resizable() + } + .frame(width: Self.iconSize, height: Self.iconSize) + .padding(.all, 4) + + VStack(alignment: .leading, spacing: 0) { + Text(name) + .lineLimit(1) + .font(.headline) + .foregroundStyle(.primary) + + if !author.isEmpty { + Text(Strings.author(author)) + .lineLimit(1) + .font(.caption) + .foregroundStyle(.secondary) + } + + Group { + if shortDescription.isEmpty { + Text(Strings.noDescriptionAvailable) + .font(.system(size: descriptionFontSize).italic()) + } else if let html = renderedDescription() { + Text(html) + } else { + Text(shortDescription) + .font(.system(size: descriptionFontSize)) + } + } + .padding(.vertical, 4) + + Text(Strings.version(version)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + func renderedDescription() -> AttributedString? { + guard var data = shortDescription.data(using: .utf8) else { + return nil + } + + // We want to use the system font, instead of the default "Times New Roman" font in the rendered HTML. + // Using `.defaultAttributes: [.font: systemFont(...)]` in the `NSAttributedString` initialiser below doesn't + // work. Using a CSS style here as a workaround. + data.append(contentsOf: "<style> body { font-family: -apple-system; font-size: \(descriptionFontSize)px; } </style>".data(using: .utf8)!) + + do { + let string = try NSAttributedString( + data: data, + options: [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue, + .sourceTextScaling: NSTextScalingType.iOS, + ], + documentAttributes: nil + ) + return try AttributedString(string, including: \.uiKit) + } catch { + DDLogError("Failed to parse HTML: \(error)") + return nil + } + } + + private enum Strings { + static func author(_ author: String) -> String { + let format = NSLocalizedString("site.plugins.list.item.author", value: "By %@", comment: "The plugin author displayed in the plugins list. The first argument is plugin author name") + return String(format: format, author) + } + + static func version(_ version: String) -> String { + let format = NSLocalizedString("site.plugins.list.item.author", value: "Version: %@", comment: "The plugin version displayed in the plugins list. The first argument is plugin version") + return String(format: format, version) + } + + static let noDescriptionAvailable: String = NSLocalizedString("site.plugins.list.item.noDescriptionAvailable", value: "The plugin author did not provide a description for this plugin.", comment: "The message displayed when a plugin has no description") + } +} diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index ce04de452338..7317f1ce2db9 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -15,6 +15,7 @@ enum FeatureFlag: Int, CaseIterable { case newGutenbergThemeStyles case newGutenbergPlugins case selfHostedSiteUserManagement + case pluginManagementOverhaul /// Returns a boolean indicating if the feature is enabled var enabled: Bool { @@ -49,6 +50,8 @@ enum FeatureFlag: Int, CaseIterable { return false case .selfHostedSiteUserManagement: return false + case .pluginManagementOverhaul: + return false } } @@ -84,6 +87,7 @@ extension FeatureFlag { case .newGutenbergThemeStyles: "Experimental Block Editor Styles" case .newGutenbergPlugins: "Experimental Block Editor Plugins" case .selfHostedSiteUserManagement: "Self-hosted Site User Management" + case .pluginManagementOverhaul: "Plugin Management Overhaul" } } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift index 25bed1e26195..b10085d8e7b0 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift @@ -145,9 +145,21 @@ extension BlogDetailsViewController { let site = JetpackSiteRef(blog: blog) else { return } - let query = PluginQuery.all(site: site) - let listViewController = PluginListViewController(site: site, query: query) - presentationDelegate?.presentBlogDetailsViewController(listViewController) + + let viewController: UIViewController + if Feature.enabled(.pluginManagementOverhaul) { + let feature = NSLocalizedString("applicationPasswordRequired.feature.plugins", value: "Plugin Management", comment: "Feature name for managing plugins in the app") + let rootView = ApplicationPasswordRequiredView(blog: self.blog, localizedFeatureName: feature) { client in + let service = PluginService(client: client) + InstalledPluginsListView(service: service) + } + viewController = UIHostingController(rootView: rootView) + } else { + let query = PluginQuery.all(site: site) + viewController = PluginListViewController(site: site, query: query) + } + + presentationDelegate?.presentBlogDetailsViewController(viewController) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m index 33b8dc9d72e0..ea304e283273 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m @@ -1777,6 +1777,12 @@ - (void)showPeople - (void)showPlugins { [WPAppAnalytics track:WPAnalyticsStatOpenedPluginDirectory withBlog:self.blog]; + + if ([Feature enabled:FeatureFlagPluginManagementOverhaul]) { + [self showManagePluginsScreen]; + return; + } + PluginDirectoryViewController *controller = [self makePluginDirectoryViewControllerWithBlog:self.blog]; controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; [self.presentationDelegate presentBlogDetailsViewController:controller];