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];