Skip to content

Commit

Permalink
Implement a new site plugins list
Browse files Browse the repository at this point in the history
  • Loading branch information
crazytonyli committed Jan 29, 2025
1 parent a338611 commit 04dc5bf
Show file tree
Hide file tree
Showing 10 changed files with 428 additions and 3 deletions.
38 changes: 38 additions & 0 deletions Modules/Sources/WordPressCore/Plugins/InstalledPlugin.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
83 changes: 83 additions & 0 deletions Modules/Sources/WordPressCore/Plugins/PluginService.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
19 changes: 19 additions & 0 deletions Modules/Sources/WordPressCore/Plugins/PluginServiceProtocol.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
101 changes: 101 additions & 0 deletions WordPress/Classes/Plugins/Views/InstalledPluginsListView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
19 changes: 19 additions & 0 deletions WordPress/Classes/Plugins/Views/PluginIconResolver.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 04dc5bf

Please sign in to comment.