From ca8f552f2a37b5b306b19b4971640381cce534bf Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 4 Dec 2025 21:27:56 +1300 Subject: [PATCH 01/20] Create WordPressClientFactory --- .../ApplicationPasswordRequiredView.swift | 2 +- .../Classes/Networking/WordPressClient.swift | 29 ++++++++++++++++++- .../ApplicationPasswordRepository.swift | 1 + .../Classes/Services/BlogService+Swift.swift | 2 +- .../CommentServiceRemoteFactory.swift | 2 +- .../Classes/Services/MediaRepository.swift | 2 +- .../TaxonomyServiceRemoteCoreREST.swift | 2 +- WordPress/Classes/Utility/AccountHelper.swift | 2 ++ .../ViewModel/BlogDashboardViewModel.swift | 2 +- .../SiteSettingsViewController+Swift.swift | 2 +- .../Login/JetpackConnectionViewModel.swift | 2 +- .../Views/MediaStorageDetailsView.swift | 2 +- .../PostSettings/PostSettingsViewModel.swift | 2 +- 13 files changed, 41 insertions(+), 11 deletions(-) diff --git a/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift b/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift index 5096c9eac650..097022ac802f 100644 --- a/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift +++ b/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift @@ -28,7 +28,7 @@ struct ApplicationPasswordRequiredView: View { } else if showLoading { ProgressView() } else if let site { - builder(WordPressClient(site: site)) + builder(WordPressClientFactory.shared.instance(for: site)) } else { RestApiUpgradePrompt(localizedFeatureName: localizedFeatureName) { Task { diff --git a/WordPress/Classes/Networking/WordPressClient.swift b/WordPress/Classes/Networking/WordPressClient.swift index a9a243f1717f..3c878b241278 100644 --- a/WordPress/Classes/Networking/WordPressClient.swift +++ b/WordPress/Classes/Networking/WordPressClient.swift @@ -6,12 +6,39 @@ import WordPressCore import WordPressData import WordPressShared +public final class WordPressClientFactory: @unchecked Sendable { + public static let shared = WordPressClientFactory() + + private let lock = NSLock() + private var instanes = [WordPressSite: WordPressClient]() + private init() {} + + public func instance(for site: WordPressSite) -> WordPressClient { + lock.withLock { + let client: WordPressClient + if let existingClient = instanes[site] { + client = existingClient + } else { + client = WordPressClient(site: site) + instanes[site] = client + } + return client + } + } + + public func reset() { + lock.withLock { + instanes.removeAll() + } + } +} + extension WordPressClient { static var requestedWithInvalidAuthenticationNotification: Foundation.Notification.Name { .init("WordPressClient.requestedWithInvalidAuthenticationNotification") } - init(site: WordPressSite) { + fileprivate convenience init(site: WordPressSite) { // Currently, the app supports both account passwords and application passwords. // When a site is initially signed in with an account password, WordPress login cookies are stored // in `URLSession.shared`. After switching the site to application password authentication, diff --git a/WordPress/Classes/Services/ApplicationPasswordRepository.swift b/WordPress/Classes/Services/ApplicationPasswordRepository.swift index 49a413638959..8d78c7b69510 100644 --- a/WordPress/Classes/Services/ApplicationPasswordRepository.swift +++ b/WordPress/Classes/Services/ApplicationPasswordRepository.swift @@ -345,6 +345,7 @@ private extension ApplicationPasswordRepository { } else if let dotComId, let dotComAuthToken { let site = WordPressSite.dotCom(siteId: dotComId.intValue, authToken: dotComAuthToken) let client = WordPressClient(site: site) + let client = WordPressClientFactory.shared.instance(for: site) siteUsername = try await client.api.users.retrieveMeWithEditContext().data.username try await coreDataStack.performAndSave { context in let blog = try context.existingObject(with: blogId) diff --git a/WordPress/Classes/Services/BlogService+Swift.swift b/WordPress/Classes/Services/BlogService+Swift.swift index ac248c13f19e..6178c97c05ce 100644 --- a/WordPress/Classes/Services/BlogService+Swift.swift +++ b/WordPress/Classes/Services/BlogService+Swift.swift @@ -78,7 +78,7 @@ extension BlogService { public func syncTaxnomies(for blogId: TaggedManagedObjectID) async throws { let client = try await self.coreDataStack.performQuery { context in let blog = try context.existingObject(with: blogId) - return try WordPressClient(site: .init(blog: blog)) + return try WordPressClientFactory.shared.instance(for: .init(blog: blog)) } let result = try await client.api.taxonomies.listWithEditContext(params: .init()).data.taxonomyTypes.values diff --git a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift index 495e7035febe..5be2cc2896b2 100644 --- a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift +++ b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift @@ -18,7 +18,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: WordPressClientFactory.shared.instance(for: site)) } if let api = blog.xmlrpcApi, diff --git a/WordPress/Classes/Services/MediaRepository.swift b/WordPress/Classes/Services/MediaRepository.swift index 13bfcea37784..f631d7a85d71 100644 --- a/WordPress/Classes/Services/MediaRepository.swift +++ b/WordPress/Classes/Services/MediaRepository.swift @@ -99,7 +99,7 @@ private extension MediaRepository { // compatibility with WordPress.com-specific features such as video upload restrictions // and storage limits based on the site's plan. if let site = try? WordPressSite(blog: blog) { - return MediaServiceRemoteCoreREST(client: .init(site: site)) + return MediaServiceRemoteCoreREST(client: WordPressClientFactory.shared.instance(for: site)) } if let username = blog.username, let password = blog.password, let api = blog.xmlrpcApi { diff --git a/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift b/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift index b1f74c4f41c3..868796e36aa6 100644 --- a/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift +++ b/WordPress/Classes/Services/TaxonomyServiceRemoteCoreREST.swift @@ -10,7 +10,7 @@ import WordPressAPI @objc public convenience init?(blog: Blog) { guard let site = try? WordPressSite(blog: blog) else { return nil } - self.init(client: .init(site: site)) + self.init(client: WordPressClientFactory.shared.instance(for: site)) } init(client: WordPressClient) { diff --git a/WordPress/Classes/Utility/AccountHelper.swift b/WordPress/Classes/Utility/AccountHelper.swift index 1a968ef95ce4..62660c72665c 100644 --- a/WordPress/Classes/Utility/AccountHelper.swift +++ b/WordPress/Classes/Utility/AccountHelper.swift @@ -90,6 +90,8 @@ import WordPressData service.removeDefaultWordPressComAccount() deleteAccountData() + + WordPressClientFactory.shared.reset() } static func deleteAccountData() { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift index 00b64cfb7bea..5dc18d4db83d 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift @@ -115,7 +115,7 @@ final class BlogDashboardViewModel { var _error: Error? do { - self.wordpressClient = try WordPressClient(site: .init(blog: self.blog)) + self.wordpressClient = try WordPressClientFactory.shared.instance(for: .init(blog: self.blog)) } catch { _error = error self.wordpressClient = nil diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift index 60a1fc770978..02af1240f7f6 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift @@ -57,7 +57,7 @@ extension SiteSettingsViewController { @objc public func showCustomTaxonomies() { let viewController: UIViewController - if let client = try? WordPressClient(site: .init(blog: blog)) { + if let client = try? WordPressClientFactory.shared.instance(for: .init(blog: blog)) { let rootView = SiteCustomTaxonomiesView(blog: self.blog, client: client) viewController = UIHostingController(rootView: rootView) } else { diff --git a/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift index 07f8adc79df4..8c1b4cbf3368 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift @@ -194,7 +194,7 @@ class JetpackConnectionService { } self.blogId = TaggedManagedObjectID(blog) - self.client = .init(site: site) + self.client = WordPressClientFactory.shared.instance(for: site) self.jetpackConnectionClient = .init( apiRootUrl: apiRootURL, urlSession: .init(configuration: .ephemeral), diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaStorageDetailsView.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaStorageDetailsView.swift index a8c010098831..0268c67adf98 100644 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaStorageDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaStorageDetailsView.swift @@ -488,7 +488,7 @@ final class MediaStorageDetailsViewModel: ObservableObject { assert(blog.dotComID != nil) self.blog = blog - client = try WordPressClient(site: WordPressSite(blog: blog)) + client = try WordPressClientFactory.shared.instance(for: WordPressSite(blog: blog)) service = MediaServiceRemoteCoreREST(client: client) updateUsage() diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index 28cda4f81813..7adeac26a55d 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -164,7 +164,7 @@ final class PostSettingsViewModel: NSObject, ObservableObject { self.isStandalone = isStandalone self.context = context self.preferences = preferences - self.client = try? WordPressClient(site: .init(blog: post.blog)) + self.client = try? WordPressClientFactory.shared.instance(for: .init(blog: post.blog)) // Initialize settings from the post let initialSettings = PostSettings(from: post) From cb99793907ea27ac4dbc0bb263226ad37754486c Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 4 Dec 2025 21:50:19 +1300 Subject: [PATCH 02/20] Instantiate WordPressApiCache and service in WordPressClient --- Modules/Sources/WordPressCore/ApiCache.swift | 72 +++++++++++++++++++ .../WordPressCore/Plugins/PluginService.swift | 2 +- .../WordPressCore/WordPressClient.swift | 35 +++++++-- .../WordPressData/Swift/Blog+SelfHosted.swift | 37 ++++++---- .../Classes/Networking/WordPressClient.swift | 44 ++++++------ .../ApplicationPasswordRepository.swift | 8 +-- .../Login/JetpackConnectionViewModel.swift | 2 +- 7 files changed, 155 insertions(+), 45 deletions(-) create mode 100644 Modules/Sources/WordPressCore/ApiCache.swift diff --git a/Modules/Sources/WordPressCore/ApiCache.swift b/Modules/Sources/WordPressCore/ApiCache.swift new file mode 100644 index 000000000000..856eafdaeff9 --- /dev/null +++ b/Modules/Sources/WordPressCore/ApiCache.swift @@ -0,0 +1,72 @@ +import Foundation +import WordPressAPI +import WordPressAPIInternal +import WordPressApiCache + +extension WordPressApiCache { + static func bootstrap() -> WordPressApiCache? { + let instance: WordPressApiCache? = .onDiskCache() ?? .memoryCache() + instance?.startListeningForUpdates() + return instance + } + + private static func onDiskCache() -> WordPressApiCache? { + let cacheURL: URL + do { + cacheURL = try FileManager.default + .url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + .appending(path: "app.sqlite") + } catch { + NSLog("Failed to create api cache file: \(error)") + return nil + } + + if let cache = WordPressApiCache.onDiskCache(file: cacheURL) { + return cache + } + + if FileManager.default.fileExists(at: cacheURL) { + do { + try FileManager.default.removeItem(at: cacheURL) + + if let cache = WordPressApiCache.onDiskCache(file: cacheURL) { + return cache + } + } catch { + NSLog("Failed to delete sqlite database: \(error)") + } + } + + return nil + } + + private static func onDiskCache(file: URL) -> WordPressApiCache? { + let cache: WordPressApiCache + do { + cache = try WordPressApiCache(url: file) + } catch { + NSLog("Failed to create an instance: \(error)") + return nil + } + + do { + _ = try cache.performMigrations() + } catch { + NSLog("Failed to migrate database: \(error)") + return nil + } + + return cache + } + + private static func memoryCache() -> WordPressApiCache? { + do { + let cache = try WordPressApiCache() + _ = try cache.performMigrations() + return cache + } catch { + NSLog("Failed to create memory cache: \(error)") + return nil + } + } +} diff --git a/Modules/Sources/WordPressCore/Plugins/PluginService.swift b/Modules/Sources/WordPressCore/Plugins/PluginService.swift index 4161877e2bc2..4032f96162ab 100644 --- a/Modules/Sources/WordPressCore/Plugins/PluginService.swift +++ b/Modules/Sources/WordPressCore/Plugins/PluginService.swift @@ -183,7 +183,7 @@ private extension PluginService { let updateCheck = try await wpOrgClient.checkPluginUpdates( // Use a fairely recent version if the actual version is unknown. wordpressCoreVersion: wordpressCoreVersion ?? "6.6", - siteUrl: ParsedUrl.parse(input: client.rootUrl), + siteUrl: ParsedUrl.parse(input: client.siteURL.absoluteString), plugins: plugins ) let updateAvailable = updateCheck.plugins diff --git a/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index 23d31f4628f8..93cd6eb40504 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -1,13 +1,38 @@ import Foundation import WordPressAPI +import WordPressAPIInternal +import WordPressApiCache -public actor WordPressClient { - +public final actor WordPressClient: Sendable { + public let siteURL: URL public let api: WordPressAPI - public let rootUrl: String - public init(api: WordPressAPI, rootUrl: ParsedUrl) { + private var _cache: WordPressApiCache? + public var cache: WordPressApiCache? { + get { + if _cache == nil { + _cache = WordPressApiCache.bootstrap() + } + return _cache + } + } + + private var _service: WpSelfHostedService? + public var service: WpSelfHostedService? { + get { + if _service == nil, let cache { + do { + _service = try api.createSelfHostedService(cache: cache) + } catch { + NSLog("Failed to create service: \(error)") + } + } + return _service + } + } + + public init(api: WordPressAPI, siteURL: URL) { self.api = api - self.rootUrl = rootUrl.url() + self.siteURL = siteURL } } diff --git a/Sources/WordPressData/Swift/Blog+SelfHosted.swift b/Sources/WordPressData/Swift/Blog+SelfHosted.swift index c7122c85750c..59129588a371 100644 --- a/Sources/WordPressData/Swift/Blog+SelfHosted.swift +++ b/Sources/WordPressData/Swift/Blog+SelfHosted.swift @@ -184,57 +184,68 @@ public extension WpApiApplicationPasswordDetails { } } -public enum WordPressSite { - case dotCom(siteId: Int, authToken: String) - case selfHosted(blogId: TaggedManagedObjectID, apiRootURL: ParsedUrl, username: String, authToken: String) +public enum WordPressSite: Hashable { + case dotCom(siteURL: URL, siteId: Int, authToken: String) + case selfHosted(blogId: TaggedManagedObjectID, siteURL: URL, apiRootURL: ParsedUrl, username: String, authToken: String) public init(blog: Blog) throws { + let siteURL = try blog.getUrl() // Directly access the site content when available. if let restApiRootURL = blog.restApiRootURL, let restApiRootURL = try? ParsedUrl.parse(input: restApiRootURL), let username = blog.username, let authToken = try? blog.getApplicationToken() { - self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: restApiRootURL, username: username, authToken: authToken) + self = .selfHosted(blogId: TaggedManagedObjectID(blog), siteURL: siteURL, apiRootURL: restApiRootURL, username: username, authToken: authToken) } else if let account = blog.account, let siteId = blog.dotComID?.intValue { // When the site is added via a WP.com account, access the site via WP.com let authToken = try account.authToken ?? WPAccount.token(forUsername: account.username) - self = .dotCom(siteId: siteId, authToken: authToken) + self = .dotCom(siteURL: siteURL, siteId: siteId, authToken: authToken) } else { // In theory, this branch should never run, because the two if statements above should have covered all paths. // But we'll keep it here as the fallback. - let url = try blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString - let apiRootURL = try ParsedUrl.parse(input: url) - self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken()) + let url = try blog.getUrl() + let apiRootURL = try ParsedUrl.parse(input: blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString) + self = .selfHosted(blogId: TaggedManagedObjectID(blog), siteURL: url, apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken()) + } + } + + public var siteURL: URL { + switch self { + case let .dotCom(siteURL, _, _): + return siteURL + case let .selfHosted(_, siteURL, _, _, _): + return siteURL } } public static func throughDotCom(blog: Blog) -> Self? { guard + let siteURL = try? blog.getUrl(), let account = blog.account, let siteId = blog.dotComID?.intValue, let authToken = try? account.authToken ?? WPAccount.token(forUsername: account.username) else { return nil } - return .dotCom(siteId: siteId, authToken: authToken) + return .dotCom(siteURL: siteURL, siteId: siteId, authToken: authToken) } public func blog(in context: NSManagedObjectContext) throws -> Blog? { switch self { - case let .dotCom(siteId, _): + case let .dotCom(_, siteId, _): return try Blog.lookup(withID: siteId, in: context) - case let .selfHosted(blogId, _, _, _): + case let .selfHosted(blogId, _, _, _, _): return try context.existingObject(with: blogId) } } public func blogId(in coreDataStack: CoreDataStack) -> TaggedManagedObjectID? { switch self { - case let .dotCom(siteId, _): + case let .dotCom(_, siteId, _): return coreDataStack.performQuery { context in guard let blog = try? Blog.lookup(withID: siteId, in: context) else { return nil } return TaggedManagedObjectID(blog) } - case let .selfHosted(id, _, _, _): + case let .selfHosted(id, _, _, _, _): return id } } diff --git a/WordPress/Classes/Networking/WordPressClient.swift b/WordPress/Classes/Networking/WordPressClient.swift index 3c878b241278..abd9a2bf7eeb 100644 --- a/WordPress/Classes/Networking/WordPressClient.swift +++ b/WordPress/Classes/Networking/WordPressClient.swift @@ -1,3 +1,4 @@ +import os import Foundation import Combine import WordPressAPI @@ -6,29 +7,27 @@ import WordPressCore import WordPressData import WordPressShared -public final class WordPressClientFactory: @unchecked Sendable { +public final class WordPressClientFactory: Sendable { public static let shared = WordPressClientFactory() - private let lock = NSLock() - private var instanes = [WordPressSite: WordPressClient]() + private let instances = OSAllocatedUnfairLock<[WordPressSite: WordPressClient]>(initialState: [:]) private init() {} public func instance(for site: WordPressSite) -> WordPressClient { - lock.withLock { - let client: WordPressClient - if let existingClient = instanes[site] { - client = existingClient + instances.withLock { dict in + if let client = dict[site] { + return client } else { - client = WordPressClient(site: site) - instanes[site] = client + let client = WordPressClient(site: site) + dict[site] = client + return client } - return client } } public func reset() { - lock.withLock { - instanes.removeAll() + instances.withLock { dict in + dict.removeAll() } } } @@ -53,15 +52,18 @@ extension WordPressClient { let provider = WpAuthenticationProvider.dynamic( dynamicAuthenticationProvider: AutoUpdateAuthenticationProvider(site: site, coreDataStack: ContextManager.shared) ) + let siteURL: URL let apiRootURL: ParsedUrl let resolver: ApiUrlResolver switch site { - case let .dotCom(siteId, _): + case let .dotCom(url, siteId, _): + siteURL = url apiRootURL = try! ParsedUrl.parse(input: AppEnvironment.current.wordPressComApiBase.absoluteString) resolver = WpComDotOrgApiUrlResolver(siteId: "\(siteId)", baseUrl: .custom(apiRootURL)) - case let .selfHosted(_, url, _, _): - apiRootURL = url - resolver = WpOrgSiteApiUrlResolver(apiRootUrl: url) + case let .selfHosted(_, url, apiRoot, _, _): + siteURL = url + apiRootURL = apiRoot + resolver = WpOrgSiteApiUrlResolver(apiRootUrl: apiRoot) } let api = WordPressAPI( urlSession: session, @@ -70,7 +72,7 @@ extension WordPressClient { authenticationProvider: provider, appNotifier: notifier, ) - self.init(api: api, rootUrl: apiRootURL) + self.init(api: api, siteURL: siteURL) } func installJetpack() async throws -> PluginWithEditContext { @@ -101,9 +103,9 @@ private final class AutoUpdateAuthenticationProvider: @unchecked Sendable, WpDyn self.site = site self.coreDataStack = coreDataStack self.authentication = switch site { - case let .dotCom(_, authToken): + case let .dotCom(_, _, authToken): .bearer(token: authToken) - case let .selfHosted(_, _, username, authToken): + case let .selfHosted(_, _, _, username, authToken): .init(username: username, password: authToken) } @@ -171,13 +173,13 @@ private class AppNotifier: @unchecked Sendable, WpAppNotifier { private extension WordPressSite { func authentication(in context: NSManagedObjectContext) -> WpAuthentication { switch self { - case let .dotCom(siteId, _): + case let .dotCom(_, siteId, _): guard let blog = try? Blog.lookup(withID: siteId, in: context), let token = blog.authToken else { return WpAuthentication.none } return WpAuthentication.bearer(token: token) - case let .selfHosted(blogId, _, _, _): + case let .selfHosted(blogId, _, _, _, _): guard let blog = try? context.existingObject(with: blogId), let username = try? blog.getUsername(), let password = try? blog.getApplicationToken() diff --git a/WordPress/Classes/Services/ApplicationPasswordRepository.swift b/WordPress/Classes/Services/ApplicationPasswordRepository.swift index 8d78c7b69510..bc1b360b4221 100644 --- a/WordPress/Classes/Services/ApplicationPasswordRepository.swift +++ b/WordPress/Classes/Services/ApplicationPasswordRepository.swift @@ -53,7 +53,7 @@ actor ApplicationPasswordRepository { return (blog.asApplicationPasswordOwners(), try? WordPressSite(blog: blog)) } - guard case let .selfHosted(_, apiRootURL, username, authToken) = site else { + guard case let .selfHosted(_, _, apiRootURL, username, authToken) = site else { return } @@ -330,12 +330,13 @@ private extension ApplicationPasswordRepository { } func updateSiteUsernameIfNeeded(_ blogId: TaggedManagedObjectID) async throws -> String { - let (username, dotComId, dotComAuthToken) = try await coreDataStack.performQuery { context in + let (username, dotComId, dotComAuthToken, siteURL) = try await coreDataStack.performQuery { context in let blog = try context.existingObject(with: blogId) return ( blog.username, blog.dotComID, blog.account?.authToken, + try blog.getUrl(), ) } @@ -343,8 +344,7 @@ private extension ApplicationPasswordRepository { if let username { siteUsername = username } else if let dotComId, let dotComAuthToken { - let site = WordPressSite.dotCom(siteId: dotComId.intValue, authToken: dotComAuthToken) - let client = WordPressClient(site: site) + let site = WordPressSite.dotCom(siteURL: siteURL, siteId: dotComId.intValue, authToken: dotComAuthToken) let client = WordPressClientFactory.shared.instance(for: site) siteUsername = try await client.api.users.retrieveMeWithEditContext().data.username try await coreDataStack.performAndSave { context in diff --git a/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift index 8c1b4cbf3368..55f525cd02f7 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift @@ -188,7 +188,7 @@ class JetpackConnectionService { } guard let site = try? WordPressSite(blog: blog), - case let .selfHosted(_, apiRootURL, username, password) = site + case let .selfHosted(_, _, apiRootURL, username, password) = site else { return nil } From a8ed0cb3ea2cdba53636bd30dfe468c1d2c21ae6 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 20 Jan 2026 20:18:34 +1300 Subject: [PATCH 03/20] Show custom post types --- Sources/WordPressData/Swift/Blog+Plans.swift | 7 + .../Classes/System/WordPressAppDelegate.swift | 2 + .../BuildInformation/FeatureFlag.swift | 4 + .../BlogDetailsTableViewModel.swift | 20 + .../BlogDetailsViewController+Swift.swift | 25 + .../CustomPostTypes/CustomPostEditor.swift | 109 ++++ .../CustomPostTypes/CustomPostList.swift | 558 ++++++++++++++++++ .../CustomPostTypes/CustomPostTypesView.swift | 103 ++++ .../SimpleGBKViewController.swift | 307 ++++++++++ 9 files changed, 1135 insertions(+) create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift create mode 100644 WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift diff --git a/Sources/WordPressData/Swift/Blog+Plans.swift b/Sources/WordPressData/Swift/Blog+Plans.swift index 09e830c7b9e0..2b25dcd9e6a3 100644 --- a/Sources/WordPressData/Swift/Blog+Plans.swift +++ b/Sources/WordPressData/Swift/Blog+Plans.swift @@ -17,4 +17,11 @@ 1031] // 2y Ecommerce Plan .contains(planID?.intValue) } + + public var supportsCoreRESTAPI: Bool { + if isHostedAtWPcom { + return isAtomic() + } + return true + } } diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 7578c31dec18..fda2917b5aa5 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -20,6 +20,7 @@ import WordPressShared import WordPressUI import ZendeskCoreSDK import Support +import WordPressAPIInternal public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { @@ -80,6 +81,7 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { let window = UIWindow(frame: UIScreen.main.bounds) self.window = window + WordPressAPIInternal.setupLogger(appId: Bundle.main.bundleIdentifier!) DesignSystem.FontManager.registerCustomFonts() AssertionLoggerDependencyContainer.logger = AssertionLogger() UITestConfigurator.prepareApplicationForUITests(in: application, window: window) diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 5e808b3478bf..9cbd63529fea 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -28,6 +28,7 @@ public enum FeatureFlag: Int, CaseIterable { case newSupport case nativeBlockInserter case statsAds + case customPostTypes /// Returns a boolean indicating if the feature is enabled. /// @@ -89,6 +90,8 @@ public enum FeatureFlag: Int, CaseIterable { return true case .statsAds: return BuildConfiguration.current == .debug + case .customPostTypes: + return BuildConfiguration.current == .debug } } @@ -133,6 +136,7 @@ extension FeatureFlag { case .newSupport: "New Support" case .nativeBlockInserter: "Native Block Inserter" case .statsAds: "Stats Ads Tab" + case .customPostTypes: "Custom Post Types" } } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift index 5457d4e4383c..04aabd57c3db 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift @@ -512,6 +512,10 @@ private extension BlogDetailsTableViewModel { rows.append(Row.pages(viewController: viewController)) } + if FeatureFlag.customPostTypes.enabled && blog.supportsCoreRESTAPI { + rows.append(Row.customPostTypes(viewController: viewController)) + } + rows.append(Row.media(viewController: viewController)) rows.append(Row.comments(viewController: viewController)) @@ -587,6 +591,10 @@ private extension BlogDetailsTableViewModel { rows.append(Row.pages(viewController: viewController)) } + if blog.isSelfHosted { + rows.append(Row.customPostTypes(viewController: viewController)) + } + rows.append(Row.comments(viewController: viewController)) let title = Strings.publishSection @@ -851,6 +859,7 @@ enum BlogDetailsRowKind { case themes case media case pages + case customPostTypes case activity case backup case scan @@ -957,6 +966,17 @@ extension Row { ) } + static func customPostTypes(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .customPostTypes, + title: "Custom Post Types", + image: UIImage(systemName: "square.3.layers.3d"), + action: { [weak viewController] _ in + viewController?.showCustomPostTypes() + } + ) + } + static func media(viewController: BlogDetailsViewController?) -> Row { Row( kind: .media, diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 02fb439d33b3..373e5d653787 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -86,6 +86,31 @@ extension BlogDetailsViewController { presentationDelegate?.presentBlogDetailsViewController(controller) } + public func showCustomPostTypes() { + Task { + guard let client = try? WordPressClientFactory.shared.instance(for: .init(blog: blog)), + let service = await client.service + else { + return + } + + let feature = NSLocalizedString( + "applicationPasswordRequired.feature.customPosts", + value: "Custom Post Types", + comment: "Feature name for managing custom post types in the app" + ) + let rootView = ApplicationPasswordRequiredView( + blog: blog, + localizedFeatureName: feature, + presentingViewController: self) { [blog] client in + CustomPostTypesView(client: client, service: service, blog: blog) + } + let controller = UIHostingController(rootView: rootView) + controller.navigationItem.largeTitleDisplayMode = .never + presentationDelegate?.presentBlogDetailsViewController(controller) + } + } + public func showMediaLibrary(from source: BlogDetailsNavigationSource) { showMediaLibrary(from: source, showPicker: false) } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift new file mode 100644 index 000000000000..7442d1fa16f3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift @@ -0,0 +1,109 @@ +import SwiftUI +import SVProgressHUD +import WordPressCore +import WordPressData +import WordPressAPI +import WordPressAPIInternal + +struct CustomPostEditor: View { + let client: WordPressClient + let post: AnyPostWithEditContext + let details: PostTypeDetailsWithEditContext + let blog: Blog + let success: () -> Void + + private let coordinator = SimpleGBKEditor.EditorCoordinator() + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + SimpleGBKEditor(post: post, blog: blog, coordinator: coordinator) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(role: .cancel) { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + ToolbarItem(placement: .primaryAction) { + Button(SharedStrings.Button.save) { + save() + } + } + } + } + } + + private func save() { + Task { + SVProgressHUD.show() + defer { SVProgressHUD.dismiss(withDelay: 0.3) } + + do { + guard let (title, content) = try await coordinator.getContent() else { return } + + try await update(post: post, title: title, content: content) + + if let image = UIImage(systemName: "checkmark") { + SVProgressHUD.show(image, status: nil) + } + + dismiss() + success() + } catch { + SVProgressHUD.showError(withStatus: error.localizedDescription) + } + } + } + + private func update(post: AnyPostWithEditContext, title: String, content: String) async throws { + let hasTitle = details.supports.map[.title] == .bool(true) + let params = PostUpdateParams( + title: hasTitle ? title : nil, + content: content, + meta: nil + ) + _ = try await client.api + .posts + .update( + postEndpointType: postTypeDetailsToPostEndpointType(postTypeDetails: details), + postId: post.id, + params: params + ) + } +} + +private struct SimpleGBKEditor: UIViewControllerRepresentable { + class EditorCoordinator { + weak var editor: SimpleGBKViewController? + + func getContent() async throws -> (title: String, content: String)? { + try await editor?.getCurrentContent() + } + } + + let post: AnyPostWithEditContext + let blog: Blog + let coordinator: EditorCoordinator + + func makeCoordinator() -> EditorCoordinator { + coordinator + } + + func makeUIViewController(context: Context) -> UIViewController { + let editor = SimpleGBKViewController( + postID: Int(post.id), + postTitle: post.title?.raw, + content: post.content.raw ?? "", + blog: blog, + postType: post.postType + ) + context.coordinator.editor = editor + return editor + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + } +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift new file mode 100644 index 000000000000..e213d5bd2750 --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift @@ -0,0 +1,558 @@ +import Foundation +import SwiftUI +import WordPressCore +import WordPressAPI +import WordPressAPIInternal +import WordPressApiCache +import WordPressUI +import WordPressData + +private struct DisplayPost { + let date: Date + let title: String? + let excerpt: String? + + init(date: Date, title: String?, excerpt: String?) { + self.date = date + self.title = title + self.excerpt = excerpt + } + + init(_ entity: AnyPostWithEditContext, excerptLimit: Int = 100) { + self.date = entity.dateGmt + self.title = entity.title?.raw + self.excerpt = entity.excerpt?.raw + ?? String((entity.content.raw ?? entity.content.rendered).prefix(excerptLimit)) + } + + static let placeholder = DisplayPost( + date: .now, + title: "Lorem ipsum dolor sit amet", + excerpt: "Lorem ipsum dolor sit amet consectetur adipiscing elit" + ) +} + +private enum ListItem: Identifiable { + case ready(id: Int64, post: DisplayPost, fullPost: AnyPostWithEditContext) + case stale(id: Int64, post: DisplayPost) + case refreshing(id: Int64, post: DisplayPost) + case fetching(id: Int64) + case missing(id: Int64) + case error(id: Int64, message: String) + case errorWithData(id: Int64, message: String, post: DisplayPost) + + var id: Int64 { + switch self { + case .ready(let id, _, _), + .stale(let id, _), + .refreshing(let id, _), + .fetching(let id), + .missing(let id), + .error(let id, _), + .errorWithData(let id, _, _): + return id + } + } + + init(item: PostMetadataCollectionItem) { + let id = item.id + + switch item.state { + case .fresh(let entity): + self = .ready(id: id, post: DisplayPost(entity.data), fullPost: entity.data) + + case .stale(let entity): + self = .stale(id: id, post: DisplayPost(entity.data)) + + case .fetchingWithData(let entity): + self = .refreshing(id: id, post: DisplayPost(entity.data)) + + case .fetching: + self = .fetching(id: id) + + case .missing: + self = .missing(id: id) + + case .failed(let error): + self = .error(id: id, message: error) + + case .failedWithData(let error, let entity): + self = .errorWithData(id: id, message: error, post: DisplayPost(entity.data, excerptLimit: 50)) + } + } +} + +private struct PostList: View { + let items: [ListItem] + let showSyncingState: Bool + let onLoadNextPage: () async -> Void + let onSelectPost: (AnyPostWithEditContext) -> Void + + var body: some View { + List { + Section { + ForEach(items) { item in + listRow(item) + } + } footer: { + if showSyncingState { + ProgressView() + .progressViewStyle(.circular) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .center) + .task { + await onLoadNextPage() + } + } + } + + } + .listStyle(.plain) + } + + @ViewBuilder + private func listRow(_ item: ListItem) -> some View { + switch item { + case .error(_, let message): + ErrorRow(message: message) + + case .errorWithData(_, let message, let post): + VStack(spacing: 4) { + PostRowView(post: post) + ErrorRow(message: message) + } + + case .fetching, .missing, .refreshing: + PostRowView( + post: DisplayPost( + date: Date(), + title: "Lorem ipsum dolor sit amet", + excerpt: "Lorem ipsum dolor sit amet consectetur adipiscing elit" + ) + ) + .redacted(reason: .placeholder) + + case .ready(_, let displayPost, let post): + Button { + onSelectPost(post) + } label: { + PostRowView(post: displayPost) + } + .buttonStyle(.plain) + + case .stale(_, let post): + PostRowView(post: post) + } + } +} + +private struct PostRowView: View { + let post: DisplayPost + + init(post: DisplayPost) { + self.post = post + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(post.date, format: .dateTime.day().month().year()) + .font(.caption) + .foregroundStyle(.secondary) + + if let title = post.title { + Text(verbatim: title) + .font(.headline) + .foregroundStyle(.primary) + } + + if let excerpt = post.excerpt { + Text(verbatim: excerpt) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } +} + +struct CustomPostList: View { + let client: WordPressClient + let service: WpSelfHostedService + let endpoint: PostEndpointType + let details: PostTypeDetailsWithEditContext + let blog: Blog + + @State var filter: WordPressAPIInternal.PostListFilter = .default + @State var collection: PostMetadataCollectionWithEditContext + @State private var items: [ListItem] = [] + @State private var listInfo: ListInfo? + @State private var selectedPost: AnyPostWithEditContext? + + private var isFiltered: Bool { + filter.status != [.custom("any")] + } + + init(client: WordPressClient, service: WpSelfHostedService, endpoint: PostEndpointType, details: PostTypeDetailsWithEditContext, blog: Blog) { + self.client = client + self.service = service + self.endpoint = endpoint + self.details = details + self.blog = blog + self.collection = self.service.posts().createPostMetadataCollectionWithEditContext(endpointType: endpoint, filter: .default, perPage: 20) + } + + var body: some View { + PostList( + items: items, + showSyncingState: loadingIndicatorPosition == .footer, + onLoadNextPage: { await loadNextPage() }, + onSelectPost: { selectedPost = $0 } + ) + .overlay { + if items.isEmpty, listInfo?.isSyncing == false { + let emptyText = details.labels.notFound.isEmpty + ? "No \(details.name)" + : details.labels.notFound + EmptyStateView(emptyText, systemImage: "doc.text") + } + } + .refreshable { + do { + _ = try await collection.refresh() + } catch { + DDLogError("Pull to refresh failed: \(error)") + } + } + .fullScreenCover(item: $selectedPost) { post in + CustomPostEditor(client: client, post: post, details: details, blog: blog) { + Task { + _ = try await collection.refreshPost(postId: post.id) + } + } + } + .toolbar { + ToolbarItem(placement: .principal) { + makeTitleView() + } + ToolbarItem(placement: .topBarTrailing) { + makeFilterMenu() + } + } + .task(id: filter) { + // Reset when filter changes. + if self.collection.filter() != filter { + self.collection = service + .posts() + .createPostMetadataCollectionWithEditContext( + endpointType: endpoint, + filter: filter, + perPage: 20 + ) + self.listInfo = nil + self.items = [] + } + + do { + _ = try await collection.refresh() + } catch { + DDLogError("Failed to refresh: \(error)") + } + } + .task(id: filter) { + await handleDataChanges() + } + } + + private var loadingIndicatorPosition: LoadingIndicatorPosition { + guard let listInfo else { return .none } + + switch listInfo.state { + case .idle: + // If the list is displaying data, and there are more pages to be loaded, + // we need to show a loading indicator to indicate that there are more items. + if let currentPage = listInfo.currentPage, + let totalPages = listInfo.totalPages, + currentPage > 0, + currentPage < totalPages { + return .footer + } else { + return .none + } + case .fetchingFirstPage: + return .title + case .fetchingNextPage: + return .footer + case .error: + return .none + } + } + + @ViewBuilder + private func makeTitleView() -> some View { + Text(details.labels.itemsList) + .overlay(alignment: .leading) { + if loadingIndicatorPosition == .title { + ProgressView() + .offset(x: -24) + } + } + } + + @ViewBuilder + private func makeFilterMenu() -> some View { + Menu { + FilterMenuItem(filter: $filter, status: .custom("any"), title: Strings.filterAll) + FilterMenuItem(filter: $filter, status: .publish, title: Strings.filterPublished) + FilterMenuItem(filter: $filter, status: .draft, title: Strings.filterDraft) + FilterMenuItem(filter: $filter, status: .future, title: Strings.filterScheduled) + } label: { + Image(systemName: "line.3.horizontal.decrease") + } + .foregroundStyle(isFiltered ? Color.white : .primary) + .background { + if isFiltered { + Circle() + .fill(Color.accentColor) + } + } + } + + func loadNextPage() async { + if let listInfo, listInfo.isSyncing || !listInfo.hasMorePages { + return + } + + do { + if listInfo?.currentPage == nil { + _ = try await collection.refresh() + } else { + _ = try await collection.loadNextPage() + } + } catch { + DDLogError("Failed to fetch items: \(error)") + } + } + + private func handleDataChanges() async { + guard let cache = await client.cache else { return } + + let updates = cache.databaseUpdatesPublisher() + .debounce(for: .milliseconds(50), scheduler: DispatchQueue.main) + .values + for await hook in updates { + guard collection.isRelevantUpdate(hook: hook) else { continue } + + DDLogInfo("WpApiCache update: \(hook.action) to \(hook.table) at row \(hook.rowId)") + + let listInfo = collection.listInfo() + + NSLog("List info: \(String(describing: listInfo))") + + do { + let items = try await collection.loadItems().map(ListItem.init) + withAnimation { + self.listInfo = listInfo + self.items = items + } + } catch { + DDLogError("Failed to get collection items: \(error)") + } + } + } +} + +private struct ErrorRow: View { + let message: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "info.circle") + + Text(message) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + .foregroundStyle(.red) + .padding(.vertical, 4) + } +} + +private enum LoadingIndicatorPosition: Equatable { + case none + case title + case footer +} + +private extension ListInfo { + var isSyncing: Bool { + state == .fetchingFirstPage || state == .fetchingNextPage + } + + var hasMorePages: Bool { + guard let currentPage, let totalPages else { return true } + return currentPage < totalPages + } +} + +private extension WordPressAPIInternal.PostListFilter { + static var `default`: Self { + Self(status: [.custom("any")]) + } +} + +private enum Strings { + static let sortByDateCreated = NSLocalizedString( + "postList.menu.sortByDateCreated", + value: "Sort by Date Created", + comment: "Menu item to sort posts by creation date" + ) + static let sortByDateModified = NSLocalizedString( + "postList.menu.sortByDateModified", + value: "Sort by Date Modified", + comment: "Menu item to sort posts by modification date" + ) + static let filter = NSLocalizedString( + "postList.menu.filter", + value: "Filter", + comment: "Menu item to access filter options" + ) + static let filterAll = NSLocalizedString( + "postList.menu.filter.all", + value: "All", + comment: "Filter option to show all posts" + ) + static let filterPublished = NSLocalizedString( + "postList.menu.filter.published", + value: "Published", + comment: "Filter option to show only published posts" + ) + static let filterDraft = NSLocalizedString( + "postList.menu.filter.draft", + value: "Draft", + comment: "Filter option to show only draft posts" + ) + static let filterScheduled = NSLocalizedString( + "postList.menu.filter.scheduled", + value: "Scheduled", + comment: "Filter option to show only scheduled posts" + ) +} + +private struct FilterMenuItem: View { + @Binding var filter: WordPressAPIInternal.PostListFilter + let status: PostStatus + let title: String + + var body: some View { + Button { + filter.status = [status] + } label: { + Label { + Text(title) + } icon: { + if filter.status == [status] { + Image(systemName: "checkmark") + } + } + } + } +} + +// MARK: - Previews + +#Preview("Fetching Placeholders") { + PostList( + items: [ + .fetching(id: 1), + .fetching(id: 2), + .fetching(id: 3) + ], + showSyncingState: false, + onLoadNextPage: {}, + onSelectPost: { _ in } + ) +} + +#Preview("Error State") { + PostList( + items: [ + .error(id: 1, message: "Failed to load post"), + .error(id: 2, message: "Network connection lost") + ], + showSyncingState: false, + onLoadNextPage: {}, + onSelectPost: { _ in } + ) +} + +#Preview("Stale Content") { + PostList( + items: [ + .stale( + id: 1, + post: DisplayPost( + date: .now, + title: "First Draft Post", + excerpt: "This is a preview of the first post that might be outdated." + ) + ), + .stale( + id: 2, + post: DisplayPost( + date: .now.addingTimeInterval(-86400), + title: "Second Post", + excerpt: "Another post with stale data showing in the list." + ) + ), + .stale( + id: 3, + post: DisplayPost( + date: .now.addingTimeInterval(-86400 * 7), + title: nil, + excerpt: "Post without a title" + ) + ) + ], + showSyncingState: false, + onLoadNextPage: {}, + onSelectPost: { _ in } + ) +} + +#Preview("Mixed States") { + PostList( + items: [ + .stale( + id: 1, + post: DisplayPost( + date: .now, + title: "Published Post", + excerpt: "This post has stale data and is being refreshed." + ) + ), + .refreshing( + id: 2, + post: DisplayPost( + date: .now.addingTimeInterval(-86400), + title: "Refreshing Post", + excerpt: "Currently being refreshed in the background." + ) + ), + .fetching(id: 3), + .error(id: 4, message: "Failed to sync"), + .errorWithData( + id: 5, + message: "Sync failed, showing cached data", + post: DisplayPost( + date: .now.addingTimeInterval(-86400 * 3), + title: "Cached Post", + excerpt: "This post failed to sync but we have old data." + ) + ), + ], + showSyncingState: true, + onLoadNextPage: {}, + onSelectPost: { _ in } + ) +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift new file mode 100644 index 000000000000..e55829c03f38 --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift @@ -0,0 +1,103 @@ +import Foundation +import SwiftUI +import WordPressCore +import WordPressData +import WordPressAPI +import WordPressAPIInternal +import WordPressUI + +struct CustomPostTypesView: View { + let client: WordPressClient + let service: WpSelfHostedService + let blog: Blog + + let collection: PostTypeCollectionWithEditContext + + @State private var types: [(PostEndpointType, PostTypeDetailsWithEditContext)] = [] + @State private var isLoading: Bool = true + @State private var error: Error? + + init(client: WordPressClient, service: WpSelfHostedService, blog: Blog) { + self.client = client + self.service = service + self.blog = blog + self.collection = service.postTypes().createPostTypeCollectionWithEditContext() + } + + var body: some View { + List { + ForEach(types, id: \.1.slug) { (type, details) in + NavigationLink { + CustomPostList(client: client, service: service, endpoint: type, details: details, blog: blog) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(details.name) + + if !details.description.isEmpty { + Text(details.description) + .foregroundColor(.secondary) + } + } + } + } + } + .listStyle(.plain) + .navigationTitle(Strings.title) + .overlay { + if isLoading { + ProgressView() + .progressViewStyle(.circular) + } else if let error { + EmptyStateView.failure(error: error) + } else if types.isEmpty { + EmptyStateView(Strings.emptyState, systemImage: "doc.text") + } + } + .task { + await refresh() + + isLoading = self.types.isEmpty + defer { isLoading = false } + + do { + _ = try await self.collection.fetch() + await refresh() + } catch { + self.error = error + } + } + } + + private func refresh() async { + do { + self.types = try await self.collection.loadData() + .compactMap { + let details = $0.data + let endpoint = postTypeDetailsToPostEndpointType(postTypeDetails: details) + if case .custom = endpoint, details.slug != "attachment" { + return (endpoint, details) + } + return nil + } + .sorted { + $0.1.slug < $1.1.slug + } + } catch { + self.error = error + } + } +} + +private enum Strings { + static let title = NSLocalizedString( + "customPostTypes.title", + value: "Custom Post Types", + comment: "Title for the Custom Post Types screen" + ) + + static let emptyState = NSLocalizedString( + "customPostTypes.emptyState.message", + value: "No Custom Post Types", + comment: "Empty state message when there are no custom post types to display" + ) +} diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift new file mode 100644 index 000000000000..6c3385dee05d --- /dev/null +++ b/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift @@ -0,0 +1,307 @@ +import UIKit +import GutenbergKit +import CocoaLumberjackSwift +import WordPressData +import BuildSettingsKit +import WebKit +import DesignSystem + +class SimpleGBKViewController: UIViewController { + + enum EditorLoadingState { + case uninitialized + case loadingDependencies(_ task: Task) + case loadingCancelled + case dependencyError(Error) + case dependenciesReady(EditorDependencies) + case started + } + + struct EditorDependencies { + let settings: String? + let didLoadCookies: Bool + } + + private let blog: Blog + + private var editorViewController: GutenbergKit.EditorViewController + private var editorState: EditorLoadingState = .uninitialized + private var activityIndicator: UIActivityIndicatorView? + private var editorLoadingTask: Task? + + private let blockEditorSettingsService: RawBlockEditorSettingsService + + init( + postID: Int, + postTitle: String?, + content: String, + blog: Blog, + postType: String? + ) { + self.blog = blog + + EditorLocalization.localize = getLocalizedString + + let editorConfiguration = EditorConfiguration(blog: blog) + .toBuilder() + .setPostID(postID) + .setPostType(postType) + .setTitle(postTitle ?? "") + .setShouldHideTitle(postTitle == nil) + .setContent(content) + .setNativeInserterEnabled(FeatureFlag.nativeBlockInserter.enabled) + .build() + + self.editorViewController = GutenbergKit.EditorViewController( + configuration: editorConfiguration, + mediaPicker: MediaPickerController(blog: blog) + ) + + self.blockEditorSettingsService = RawBlockEditorSettingsService(blog: blog) + + super.init(nibName: nil, bundle: nil) + + self.editorViewController.delegate = self + } + + required init?(coder aDecoder: NSCoder) { + fatalError() + } + + deinit { + editorLoadingTask?.cancel() + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + setupEditorView() + startLoadingDependencies() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if case .loadingDependencies = self.editorState { + self.showActivityIndicator() + } + + if case .loadingCancelled = self.editorState { + startLoadingDependencies() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if case .loadingCancelled = self.editorState { + preconditionFailure("Dependency loading should not be cancelled") + } + + self.editorLoadingTask = Task { [weak self] in + guard let self else { return } + 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) { + super.viewWillDisappear(animated) + if case .loadingDependencies(let task) = self.editorState { + task.cancel() + } + + self.editorLoadingTask?.cancel() + } + + private func setupEditorView() { + view.tintColor = UIAppColor.editorPrimary + + addChild(editorViewController) + view.addSubview(editorViewController.view) + view.pinSubviewToAllEdges(editorViewController.view) + editorViewController.didMove(toParent: self) + +#if DEBUG + editorViewController.webView.isInspectable = true +#endif + } + + func showEditorError(_ error: Error) { + DDLogError("Editor error: \(error)") + } + + func startLoadingDependencies() { + switch self.editorState { + case .uninitialized: + break + case .loadingDependencies: + preconditionFailure("`startLoadingDependencies` should not be called while in the `.loadingDependencies` state") + case .loadingCancelled: + break + case .dependencyError: + break + case .dependenciesReady: + preconditionFailure("`startLoadingDependencies` should not be called while in the `.dependenciesReady` state") + case .started: + preconditionFailure("`startLoadingDependencies` should not be called while in the `.started` state") + } + + self.editorState = .loadingDependencies(Task { + do { + let dependencies = try await fetchEditorDependencies() + self.editorState = .dependenciesReady(dependencies) + } catch { + self.editorState = .dependencyError(error) + } + }) + } + + @MainActor + func startEditor(settings: String?) async throws { + guard case .dependenciesReady = self.editorState else { + preconditionFailure("`startEditor` should only be called when the editor is in the `.dependenciesReady` state.") + } + + let updatedConfiguration = self.editorViewController.configuration.toBuilder() + .apply(settings) { $0.setEditorSettings($1) } + .setNativeInserterEnabled(FeatureFlag.nativeBlockInserter.enabled) + .build() + + self.editorViewController.updateConfiguration(updatedConfiguration) + self.editorViewController.startEditorSetup() + } + + private func showActivityIndicator() { + let indicator = UIActivityIndicatorView() + indicator.color = .gray + indicator.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(indicator) + + NSLayoutConstraint.activate([ + indicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + indicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + indicator.startAnimating() + self.activityIndicator = indicator + } + + private func hideActivityIndicator() { + activityIndicator?.stopAnimating() + activityIndicator?.removeFromSuperview() + activityIndicator = nil + } + + private func fetchEditorDependencies() async throws -> EditorDependencies { + let settings: String? + do { + settings = try await blockEditorSettingsService.getSettingsString(allowingCachedResponse: true) + } catch { + DDLogError("Failed to fetch editor settings: \(error)") + settings = nil + } + + let loaded = await loadAuthenticationCookiesAsync() + + return EditorDependencies(settings: settings, didLoadCookies: loaded) + } + + private func loadAuthenticationCookiesAsync() async -> Bool { + guard blog.isPrivate() else { + return true + } + + guard let authenticator = RequestAuthenticator(blog: blog), + let blogURL = blog.url, + let authURL = URL(string: blogURL) else { + return false + } + + let cookieJar = WKWebsiteDataStore.default().httpCookieStore + + return await withCheckedContinuation { continuation in + authenticator.request(url: authURL, cookieJar: cookieJar) { _ in + DDLogInfo("Authentication cookies loaded into shared cookie store for GutenbergKit") + continuation.resume(returning: true) + } + } + } + + func getCurrentContent() async throws -> (title: String, content: String) { + let editorData = try await editorViewController.getTitleAndContent() + return (editorData.title, editorData.content) + } +} + +extension SimpleGBKViewController: GutenbergKit.EditorViewControllerDelegate { + func editorDidLoad(_ viewContoller: GutenbergKit.EditorViewController) { + self.hideActivityIndicator() + } + + func editor(_ viewContoller: GutenbergKit.EditorViewController, didDisplayInitialContent content: String) { + } + + func editor(_ viewContoller: GutenbergKit.EditorViewController, didEncounterCriticalError error: any Error) { + DDLogError("Editor critical error: \(error)") + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateContentWithState state: GutenbergKit.EditorState) { + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateHistoryState state: GutenbergKit.EditorState) { + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateFeaturedImage mediaID: Int) { + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didLogException error: GutenbergKit.GutenbergJSException) { + DDLogError("Gutenberg exception: \(error)") + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didLogMessage message: String, level: GutenbergKit.LogLevel) { + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didRequestMediaFromSiteMediaLibrary config: GutenbergKit.OpenMediaLibraryAction) { + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didTriggerAutocompleter type: String) { + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didOpenModalDialog dialogType: String) { + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didCloseModalDialog dialogType: String) { + } +} + +private func getLocalizedString(for value: GutenbergKit.EditorLocalizableString) -> String { + switch value { + case .showMore: NSLocalizedString("editor.blockInserter.showMore", value: "Show More", comment: "Button title to expand and show more blocks") + case .showLess: NSLocalizedString("editor.blockInserter.showLess", value: "Show Less", comment: "Button title to collapse and show fewer blocks") + case .search: NSLocalizedString("editor.blockInserter.search", value: "Search", comment: "Placeholder text for block search field") + case .insertBlock: NSLocalizedString("editor.blockInserter.insertBlock", value: "Insert Block", comment: "Context menu action to insert a block") + case .failedToInsertMedia: NSLocalizedString("editor.media.failedToInsert", value: "Failed to insert media", comment: "Error message when media insertion fails") + case .patterns: NSLocalizedString("editor.patterns.title", value: "Patterns", comment: "Navigation title for patterns view") + case .noPatternsFound: NSLocalizedString("editor.patterns.noPatternsFound", value: "No Patterns Found", comment: "Title shown when no patterns match the search") + case .insertPattern: NSLocalizedString("editor.patterns.insertPattern", value: "Insert Pattern", comment: "Context menu action to insert a pattern") + case .patternsCategoryUncategorized: NSLocalizedString("editor.patterns.uncategorized", value: "Uncategorized", comment: "Category name for patterns without a category") + case .patternsCategoryAll: NSLocalizedString("editor.patterns.all", value: "All", comment: "Category name for section showing all patterns") + } +} From 17c55d257efbb1bf87afe4870b8b6b7eadbe182d Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 21 Jan 2026 20:45:04 +1300 Subject: [PATCH 04/20] Extract `CustomPostCollectionView` --- .../CustomPostTypes/CustomPostList.swift | 163 +++++++++--------- 1 file changed, 86 insertions(+), 77 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift index e213d5bd2750..49d21a835bae 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift @@ -184,30 +184,92 @@ struct CustomPostList: View { let blog: Blog @State var filter: WordPressAPIInternal.PostListFilter = .default - @State var collection: PostMetadataCollectionWithEditContext @State private var items: [ListItem] = [] @State private var listInfo: ListInfo? + @State private var selectedPost: AnyPostWithEditContext? private var isFiltered: Bool { filter.status != [.custom("any")] } - init(client: WordPressClient, service: WpSelfHostedService, endpoint: PostEndpointType, details: PostTypeDetailsWithEditContext, blog: Blog) { - self.client = client - self.service = service - self.endpoint = endpoint - self.details = details - self.blog = blog - self.collection = self.service.posts().createPostMetadataCollectionWithEditContext(endpointType: endpoint, filter: .default, perPage: 20) + var body: some View { + CustomPostCollectionView( + client: client, + service: service, + endpoint: endpoint, + details: details, + filter: $filter, + onSelectPost: { selectedPost = $0 } + ) + .fullScreenCover(item: $selectedPost) { post in + // TODO: Check if the post supports Gutenberg first? + CustomPostEditor(client: client, post: post, details: details, blog: blog) { + Task { + _ = try await service.posts().refreshPost(postId: post.id, endpointType: endpoint) + } + } + } + .toolbar { + ToolbarItem(placement: .principal) { + makeTitleView() + } + ToolbarItem(placement: .topBarTrailing) { + makeFilterMenu() + } + } } + @ViewBuilder + private func makeTitleView() -> some View { + Text(details.labels.itemsList) + .overlay(alignment: .leading) { + if listInfo?.state == .fetchingFirstPage { + ProgressView() + .offset(x: -24) + } + } + } + + @ViewBuilder + private func makeFilterMenu() -> some View { + Menu { + FilterMenuItem(filter: $filter, status: .custom("any"), title: Strings.filterAll) + FilterMenuItem(filter: $filter, status: .publish, title: Strings.filterPublished) + FilterMenuItem(filter: $filter, status: .draft, title: Strings.filterDraft) + FilterMenuItem(filter: $filter, status: .future, title: Strings.filterScheduled) + } label: { + Image(systemName: "line.3.horizontal.decrease") + } + .foregroundStyle(isFiltered ? Color.white : .primary) + .background { + if isFiltered { + Circle() + .fill(Color.accentColor) + } + } + } +} + +struct CustomPostCollectionView: View { + let client: WordPressClient + let service: WpSelfHostedService + let endpoint: PostEndpointType + let details: PostTypeDetailsWithEditContext + @Binding var filter: WordPressAPIInternal.PostListFilter + + let onSelectPost: (AnyPostWithEditContext) -> Void + + @State private var collection: PostMetadataCollectionWithEditContext? + @State private var items: [ListItem] = [] + @State private var listInfo: ListInfo? + var body: some View { PostList( items: items, - showSyncingState: loadingIndicatorPosition == .footer, + showSyncingState: isLoadingMore, onLoadNextPage: { await loadNextPage() }, - onSelectPost: { selectedPost = $0 } + onSelectPost: onSelectPost ) .overlay { if items.isEmpty, listInfo?.isSyncing == false { @@ -219,29 +281,14 @@ struct CustomPostList: View { } .refreshable { do { - _ = try await collection.refresh() + _ = try await collection?.refresh() } catch { DDLogError("Pull to refresh failed: \(error)") } } - .fullScreenCover(item: $selectedPost) { post in - CustomPostEditor(client: client, post: post, details: details, blog: blog) { - Task { - _ = try await collection.refreshPost(postId: post.id) - } - } - } - .toolbar { - ToolbarItem(placement: .principal) { - makeTitleView() - } - ToolbarItem(placement: .topBarTrailing) { - makeFilterMenu() - } - } .task(id: filter) { // Reset when filter changes. - if self.collection.filter() != filter { + if collection == nil || collection?.filter() != filter { self.collection = service .posts() .createPostMetadataCollectionWithEditContext( @@ -254,7 +301,7 @@ struct CustomPostList: View { } do { - _ = try await collection.refresh() + _ = try await collection?.refresh() } catch { DDLogError("Failed to refresh: \(error)") } @@ -264,8 +311,8 @@ struct CustomPostList: View { } } - private var loadingIndicatorPosition: LoadingIndicatorPosition { - guard let listInfo else { return .none } + var isLoadingMore: Bool { + guard let listInfo else { return false } switch listInfo.state { case .idle: @@ -275,59 +322,27 @@ struct CustomPostList: View { let totalPages = listInfo.totalPages, currentPage > 0, currentPage < totalPages { - return .footer + return true } else { - return .none + return false } - case .fetchingFirstPage: - return .title case .fetchingNextPage: - return .footer - case .error: - return .none - } - } - - @ViewBuilder - private func makeTitleView() -> some View { - Text(details.labels.itemsList) - .overlay(alignment: .leading) { - if loadingIndicatorPosition == .title { - ProgressView() - .offset(x: -24) - } - } - } - - @ViewBuilder - private func makeFilterMenu() -> some View { - Menu { - FilterMenuItem(filter: $filter, status: .custom("any"), title: Strings.filterAll) - FilterMenuItem(filter: $filter, status: .publish, title: Strings.filterPublished) - FilterMenuItem(filter: $filter, status: .draft, title: Strings.filterDraft) - FilterMenuItem(filter: $filter, status: .future, title: Strings.filterScheduled) - } label: { - Image(systemName: "line.3.horizontal.decrease") - } - .foregroundStyle(isFiltered ? Color.white : .primary) - .background { - if isFiltered { - Circle() - .fill(Color.accentColor) - } + return true + case .fetchingFirstPage, .error: + return false } } - func loadNextPage() async { + private func loadNextPage() async { if let listInfo, listInfo.isSyncing || !listInfo.hasMorePages { return } do { if listInfo?.currentPage == nil { - _ = try await collection.refresh() + _ = try await collection?.refresh() } else { - _ = try await collection.loadNextPage() + _ = try await collection?.loadNextPage() } } catch { DDLogError("Failed to fetch items: \(error)") @@ -341,7 +356,7 @@ struct CustomPostList: View { .debounce(for: .milliseconds(50), scheduler: DispatchQueue.main) .values for await hook in updates { - guard collection.isRelevantUpdate(hook: hook) else { continue } + guard let collection, collection.isRelevantUpdate(hook: hook) else { continue } DDLogInfo("WpApiCache update: \(hook.action) to \(hook.table) at row \(hook.rowId)") @@ -378,12 +393,6 @@ private struct ErrorRow: View { } } -private enum LoadingIndicatorPosition: Equatable { - case none - case title - case footer -} - private extension ListInfo { var isSyncing: Bool { state == .fetchingFirstPage || state == .fetchingNextPage From 8109fe7e9ff7529ae2e1fda0bdaf6f4413ecbf34 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 21 Jan 2026 22:07:57 +1300 Subject: [PATCH 05/20] Support searching custom posts --- .../CustomPostTypes/CustomPostList.swift | 87 +++++++++++++++---- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift index 49d21a835bae..da50b77fa87a 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift @@ -7,7 +7,7 @@ import WordPressApiCache import WordPressUI import WordPressData -private struct DisplayPost { +private struct DisplayPost: Equatable { let date: Date let title: String? let excerpt: String? @@ -32,7 +32,7 @@ private struct DisplayPost { ) } -private enum ListItem: Identifiable { +private enum ListItem: Identifiable, Equatable { case ready(id: Int64, post: DisplayPost, fullPost: AnyPostWithEditContext) case stale(id: Int64, post: DisplayPost) case refreshing(id: Int64, post: DisplayPost) @@ -184,9 +184,10 @@ struct CustomPostList: View { let blog: Blog @State var filter: WordPressAPIInternal.PostListFilter = .default - @State private var items: [ListItem] = [] @State private var listInfo: ListInfo? + @State var searchText = "" + @State private var selectedPost: AnyPostWithEditContext? private var isFiltered: Bool { @@ -194,14 +195,31 @@ struct CustomPostList: View { } var body: some View { - CustomPostCollectionView( - client: client, - service: service, - endpoint: endpoint, - details: details, - filter: $filter, - onSelectPost: { selectedPost = $0 } - ) + ZStack { + if searchText.isEmpty { + CustomPostCollectionView( + client: client, + service: service, + endpoint: endpoint, + details: details, + listInfo: $listInfo, + filter: filter, + showInitialLoading: false, + onSelectPost: { selectedPost = $0 } + ) + } else { + CustomPostSearchResultView( + client: client, + service: service, + endpoint: endpoint, + details: details, + baseFilter: .default, + searchText: $searchText, + onSelectPost: { selectedPost = $0 } + ) + } + } + .searchable(text: $searchText) .fullScreenCover(item: $selectedPost) { post in // TODO: Check if the post supports Gutenberg first? CustomPostEditor(client: client, post: post, details: details, blog: blog) { @@ -256,13 +274,13 @@ struct CustomPostCollectionView: View { let service: WpSelfHostedService let endpoint: PostEndpointType let details: PostTypeDetailsWithEditContext - @Binding var filter: WordPressAPIInternal.PostListFilter - + @Binding var listInfo: ListInfo? + let filter: WordPressAPIInternal.PostListFilter + let showInitialLoading: Bool let onSelectPost: (AnyPostWithEditContext) -> Void @State private var collection: PostMetadataCollectionWithEditContext? @State private var items: [ListItem] = [] - @State private var listInfo: ListInfo? var body: some View { PostList( @@ -277,6 +295,8 @@ struct CustomPostCollectionView: View { ? "No \(details.name)" : details.labels.notFound EmptyStateView(emptyText, systemImage: "doc.text") + } else if showInitialLoading, items.isEmpty, listInfo?.isSyncing == true { + ProgressView() } } .refreshable { @@ -367,8 +387,12 @@ struct CustomPostCollectionView: View { do { let items = try await collection.loadItems().map(ListItem.init) withAnimation { - self.listInfo = listInfo - self.items = items + if self.listInfo != listInfo { + self.listInfo = listInfo + } + if self.items != items { + self.items = items + } } } catch { DDLogError("Failed to get collection items: \(error)") @@ -377,6 +401,37 @@ struct CustomPostCollectionView: View { } } +struct CustomPostSearchResultView: View { + let client: WordPressClient + let service: WpSelfHostedService + let endpoint: PostEndpointType + let details: PostTypeDetailsWithEditContext + let baseFilter: WordPressAPIInternal.PostListFilter + @Binding var searchText: String + let onSelectPost: (AnyPostWithEditContext) -> Void + + @State var listInfo: ListInfo? = nil + + var body: some View { + CustomPostCollectionView( + client: client, + service: service, + endpoint: endpoint, + details: details, + listInfo: $listInfo, + filter: { + var search = baseFilter + // TODO: Support author? + search.searchColumns = [.postTitle, .postContent, .postExcerpt] + search.search = searchText + return search + }(), + showInitialLoading: true, + onSelectPost: onSelectPost + ) + } +} + private struct ErrorRow: View { let message: String From b0d13ced561e0c93a23681a19035b5f8463c85d2 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 21 Jan 2026 22:08:13 +1300 Subject: [PATCH 06/20] Re-implement showing loading more states --- .../CustomPostTypes/CustomPostList.swift | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift index da50b77fa87a..81785df8760c 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift @@ -84,31 +84,43 @@ private enum ListItem: Identifiable, Equatable { private struct PostList: View { let items: [ListItem] - let showSyncingState: Bool - let onLoadNextPage: () async -> Void + let onLoadNextPage: () async throws -> Void let onSelectPost: (AnyPostWithEditContext) -> Void + @State var isLoadingMore = false + @State var loadMoreError: Error? + var body: some View { List { - Section { - ForEach(items) { item in - listRow(item) - } - } footer: { - if showSyncingState { - ProgressView() - .progressViewStyle(.circular) - .frame(maxWidth: .infinity, minHeight: 44, alignment: .center) - .task { - await onLoadNextPage() + ForEach(items) { item in + listRow(item) + .task { + if !isLoadingMore, items.suffix(5).contains(where: { $0.id == item.id }) { + await loadNextPage() } - } + } } + makeFooterView() } .listStyle(.plain) } + private func loadNextPage() async { + guard !isLoadingMore else { return } + + isLoadingMore = true + defer { isLoadingMore = false } + + self.loadMoreError = nil + + do { + try await onLoadNextPage() + } catch { + self.loadMoreError = error + } + } + @ViewBuilder private func listRow(_ item: ListItem) -> some View { switch item { @@ -143,6 +155,25 @@ private struct PostList: View { PostRowView(post: post) } } + + @ViewBuilder + private func makeFooterView() -> some View { + if isLoadingMore { + ProgressView() + .progressViewStyle(.circular) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .center) + .id(UUID()) // A hack to show the ProgressView after cell reusing. + } else if loadMoreError != nil { + Button { + Task { await loadNextPage() } + } label: { + HStack { + Image(systemName: "exclamationmark.circle") + Text(SharedStrings.Button.retry) + } + } + } + } } private struct PostRowView: View { @@ -285,8 +316,7 @@ struct CustomPostCollectionView: View { var body: some View { PostList( items: items, - showSyncingState: isLoadingMore, - onLoadNextPage: { await loadNextPage() }, + onLoadNextPage: { try await loadNextPage() }, onSelectPost: onSelectPost ) .overlay { @@ -331,41 +361,15 @@ struct CustomPostCollectionView: View { } } - var isLoadingMore: Bool { - guard let listInfo else { return false } - - switch listInfo.state { - case .idle: - // If the list is displaying data, and there are more pages to be loaded, - // we need to show a loading indicator to indicate that there are more items. - if let currentPage = listInfo.currentPage, - let totalPages = listInfo.totalPages, - currentPage > 0, - currentPage < totalPages { - return true - } else { - return false - } - case .fetchingNextPage: - return true - case .fetchingFirstPage, .error: - return false - } - } - - private func loadNextPage() async { + private func loadNextPage() async throws { if let listInfo, listInfo.isSyncing || !listInfo.hasMorePages { return } - do { - if listInfo?.currentPage == nil { - _ = try await collection?.refresh() - } else { - _ = try await collection?.loadNextPage() - } - } catch { - DDLogError("Failed to fetch items: \(error)") + if listInfo?.currentPage == nil { + _ = try await collection?.refresh() + } else { + _ = try await collection?.loadNextPage() } } @@ -532,7 +536,6 @@ private struct FilterMenuItem: View { .fetching(id: 2), .fetching(id: 3) ], - showSyncingState: false, onLoadNextPage: {}, onSelectPost: { _ in } ) @@ -544,7 +547,6 @@ private struct FilterMenuItem: View { .error(id: 1, message: "Failed to load post"), .error(id: 2, message: "Network connection lost") ], - showSyncingState: false, onLoadNextPage: {}, onSelectPost: { _ in } ) @@ -578,7 +580,6 @@ private struct FilterMenuItem: View { ) ) ], - showSyncingState: false, onLoadNextPage: {}, onSelectPost: { _ in } ) @@ -615,7 +616,6 @@ private struct FilterMenuItem: View { ) ), ], - showSyncingState: true, onLoadNextPage: {}, onSelectPost: { _ in } ) From 01af67d285fdb1dfd8393fc5444c1879566f6bef Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 22 Jan 2026 13:54:31 +1300 Subject: [PATCH 07/20] Adopt new GBK API --- .../SimpleGBKViewController.swift | 195 ++---------------- 1 file changed, 15 insertions(+), 180 deletions(-) diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift index 6c3385dee05d..14b982cf5dd4 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift @@ -8,28 +8,9 @@ import DesignSystem class SimpleGBKViewController: UIViewController { - enum EditorLoadingState { - case uninitialized - case loadingDependencies(_ task: Task) - case loadingCancelled - case dependencyError(Error) - case dependenciesReady(EditorDependencies) - case started - } - - struct EditorDependencies { - let settings: String? - let didLoadCookies: Bool - } - private let blog: Blog private var editorViewController: GutenbergKit.EditorViewController - private var editorState: EditorLoadingState = .uninitialized - private var activityIndicator: UIActivityIndicatorView? - private var editorLoadingTask: Task? - - private let blockEditorSettingsService: RawBlockEditorSettingsService init( postID: Int, @@ -40,25 +21,25 @@ class SimpleGBKViewController: UIViewController { ) { self.blog = blog - EditorLocalization.localize = getLocalizedString + EditorLocalization.localize = { $0.localized } - let editorConfiguration = EditorConfiguration(blog: blog) + let editorConfiguration = EditorConfiguration(blog: blog, postType: postType ?? "post") .toBuilder() .setPostID(postID) - .setPostType(postType) .setTitle(postTitle ?? "") .setShouldHideTitle(postTitle == nil) .setContent(content) .setNativeInserterEnabled(FeatureFlag.nativeBlockInserter.enabled) .build() + let cachedDependencies = EditorDependencyManager.shared.dependencies(for: blog) + self.editorViewController = GutenbergKit.EditorViewController( configuration: editorConfiguration, + dependencies: cachedDependencies, mediaPicker: MediaPickerController(blog: blog) ) - self.blockEditorSettingsService = RawBlockEditorSettingsService(blog: blog) - super.init(nibName: nil, bundle: nil) self.editorViewController.delegate = self @@ -68,68 +49,17 @@ class SimpleGBKViewController: UIViewController { fatalError() } - deinit { - editorLoadingTask?.cancel() - } - override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .systemBackground - setupEditorView() - startLoadingDependencies() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - if case .loadingDependencies = self.editorState { - self.showActivityIndicator() - } - - if case .loadingCancelled = self.editorState { - startLoadingDependencies() + // Load auth cookies if needed (for private sites) + Task { + await loadAuthenticationCookiesAsync() } } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - if case .loadingCancelled = self.editorState { - preconditionFailure("Dependency loading should not be cancelled") - } - - self.editorLoadingTask = Task { [weak self] in - guard let self else { return } - 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) { - super.viewWillDisappear(animated) - if case .loadingDependencies(let task) = self.editorState { - task.cancel() - } - - self.editorLoadingTask?.cancel() - } - private func setupEditorView() { view.tintColor = UIAppColor.editorPrimary @@ -143,103 +73,23 @@ class SimpleGBKViewController: UIViewController { #endif } - func showEditorError(_ error: Error) { - DDLogError("Editor error: \(error)") - } - - func startLoadingDependencies() { - switch self.editorState { - case .uninitialized: - break - case .loadingDependencies: - preconditionFailure("`startLoadingDependencies` should not be called while in the `.loadingDependencies` state") - case .loadingCancelled: - break - case .dependencyError: - break - case .dependenciesReady: - preconditionFailure("`startLoadingDependencies` should not be called while in the `.dependenciesReady` state") - case .started: - preconditionFailure("`startLoadingDependencies` should not be called while in the `.started` state") - } - - self.editorState = .loadingDependencies(Task { - do { - let dependencies = try await fetchEditorDependencies() - self.editorState = .dependenciesReady(dependencies) - } catch { - self.editorState = .dependencyError(error) - } - }) - } - - @MainActor - func startEditor(settings: String?) async throws { - guard case .dependenciesReady = self.editorState else { - preconditionFailure("`startEditor` should only be called when the editor is in the `.dependenciesReady` state.") - } - - let updatedConfiguration = self.editorViewController.configuration.toBuilder() - .apply(settings) { $0.setEditorSettings($1) } - .setNativeInserterEnabled(FeatureFlag.nativeBlockInserter.enabled) - .build() - - self.editorViewController.updateConfiguration(updatedConfiguration) - self.editorViewController.startEditorSetup() - } - - private func showActivityIndicator() { - let indicator = UIActivityIndicatorView() - indicator.color = .gray - indicator.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(indicator) - - NSLayoutConstraint.activate([ - indicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), - indicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - - indicator.startAnimating() - self.activityIndicator = indicator - } - - private func hideActivityIndicator() { - activityIndicator?.stopAnimating() - activityIndicator?.removeFromSuperview() - activityIndicator = nil - } - - private func fetchEditorDependencies() async throws -> EditorDependencies { - let settings: String? - do { - settings = try await blockEditorSettingsService.getSettingsString(allowingCachedResponse: true) - } catch { - DDLogError("Failed to fetch editor settings: \(error)") - settings = nil - } - - let loaded = await loadAuthenticationCookiesAsync() - - return EditorDependencies(settings: settings, didLoadCookies: loaded) - } - - private func loadAuthenticationCookiesAsync() async -> Bool { + private func loadAuthenticationCookiesAsync() async { guard blog.isPrivate() else { - return true + return } guard let authenticator = RequestAuthenticator(blog: blog), let blogURL = blog.url, let authURL = URL(string: blogURL) else { - return false + return } let cookieJar = WKWebsiteDataStore.default().httpCookieStore - return await withCheckedContinuation { continuation in + await withCheckedContinuation { (continuation: CheckedContinuation) in authenticator.request(url: authURL, cookieJar: cookieJar) { _ in DDLogInfo("Authentication cookies loaded into shared cookie store for GutenbergKit") - continuation.resume(returning: true) + continuation.resume() } } } @@ -252,7 +102,7 @@ class SimpleGBKViewController: UIViewController { extension SimpleGBKViewController: GutenbergKit.EditorViewControllerDelegate { func editorDidLoad(_ viewContoller: GutenbergKit.EditorViewController) { - self.hideActivityIndicator() + // Editor loaded successfully - no loading indicator needed with new approach } func editor(_ viewContoller: GutenbergKit.EditorViewController, didDisplayInitialContent content: String) { @@ -275,7 +125,7 @@ extension SimpleGBKViewController: GutenbergKit.EditorViewControllerDelegate { DDLogError("Gutenberg exception: \(error)") } - func editor(_ viewController: GutenbergKit.EditorViewController, didLogMessage message: String, level: GutenbergKit.LogLevel) { + func editor(_ viewController: GutenbergKit.EditorViewController, didLogNetworkRequest request: GutenbergKit.RecordedNetworkRequest) { } func editor(_ viewController: GutenbergKit.EditorViewController, didRequestMediaFromSiteMediaLibrary config: GutenbergKit.OpenMediaLibraryAction) { @@ -290,18 +140,3 @@ extension SimpleGBKViewController: GutenbergKit.EditorViewControllerDelegate { func editor(_ viewController: GutenbergKit.EditorViewController, didCloseModalDialog dialogType: String) { } } - -private func getLocalizedString(for value: GutenbergKit.EditorLocalizableString) -> String { - switch value { - case .showMore: NSLocalizedString("editor.blockInserter.showMore", value: "Show More", comment: "Button title to expand and show more blocks") - case .showLess: NSLocalizedString("editor.blockInserter.showLess", value: "Show Less", comment: "Button title to collapse and show fewer blocks") - case .search: NSLocalizedString("editor.blockInserter.search", value: "Search", comment: "Placeholder text for block search field") - case .insertBlock: NSLocalizedString("editor.blockInserter.insertBlock", value: "Insert Block", comment: "Context menu action to insert a block") - case .failedToInsertMedia: NSLocalizedString("editor.media.failedToInsert", value: "Failed to insert media", comment: "Error message when media insertion fails") - case .patterns: NSLocalizedString("editor.patterns.title", value: "Patterns", comment: "Navigation title for patterns view") - case .noPatternsFound: NSLocalizedString("editor.patterns.noPatternsFound", value: "No Patterns Found", comment: "Title shown when no patterns match the search") - case .insertPattern: NSLocalizedString("editor.patterns.insertPattern", value: "Insert Pattern", comment: "Context menu action to insert a pattern") - case .patternsCategoryUncategorized: NSLocalizedString("editor.patterns.uncategorized", value: "Uncategorized", comment: "Category name for patterns without a category") - case .patternsCategoryAll: NSLocalizedString("editor.patterns.all", value: "All", comment: "Category name for section showing all patterns") - } -} From 0908d905d83cc489ecb81e33df5a7a51648386b3 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 22 Jan 2026 14:51:00 +1300 Subject: [PATCH 08/20] Simple post conflict detection --- .../CustomPostTypes/CustomPostEditor.swift | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift index 7442d1fa16f3..892ff4099ce8 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift @@ -39,16 +39,12 @@ struct CustomPostEditor: View { private func save() { Task { SVProgressHUD.show() - defer { SVProgressHUD.dismiss(withDelay: 0.3) } do { guard let (title, content) = try await coordinator.getContent() else { return } - try await update(post: post, title: title, content: content) - - if let image = UIImage(systemName: "checkmark") { - SVProgressHUD.show(image, status: nil) - } + try await update(title: title, content: content) + SVProgressHUD.showSuccess(withStatus: nil) dismiss() success() @@ -58,7 +54,25 @@ struct CustomPostEditor: View { } } - private func update(post: AnyPostWithEditContext, title: String, content: String) async throws { + private func hasBeenModified() async throws -> Bool { + let endpoint = postTypeDetailsToPostEndpointType(postTypeDetails: details) + let lastModified = try await client.api.posts + .filterRetrieveWithEditContext( + postEndpointType: endpoint, + postId: post.id, + params: .init(), + fields: [.modified] + ) + .data + .modified + return lastModified != post.modified + } + + private func update(title: String, content: String) async throws { + // This is a simple way to avoid overwriting others' changes. We can further improve it + // to align with the implementation in `PostRepository`. + guard try await !hasBeenModified() else { throw PostUpdateError.conflicts } + let hasTitle = details.supports.map[.title] == .bool(true) let params = PostUpdateParams( title: hasTitle ? title : nil, @@ -107,3 +121,20 @@ private struct SimpleGBKEditor: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: UIViewController, context: Context) { } } + +private enum PostUpdateError: LocalizedError { + case conflicts + + var errorDescription: String? { + Strings.conflictErrorMessage + } +} + +private enum Strings { + static let conflictErrorMessage = NSLocalizedString( + "customPostEditor.error.conflict.message", + value: "The post you are trying to save has been changed in the meantime.", + comment: "Error message shown when the post was modified by another user while editing" + ) +} + From ad5df26e8c1e6a0a712f61ca8ebde67090213f6b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 22 Jan 2026 22:19:45 +1300 Subject: [PATCH 09/20] Add a Filter type --- .../CustomPostTypes/CustomPostList.swift | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift index 81785df8760c..52e2cec66fc5 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift @@ -214,15 +214,15 @@ struct CustomPostList: View { let details: PostTypeDetailsWithEditContext let blog: Blog - @State var filter: WordPressAPIInternal.PostListFilter = .default + @State private var filter = Filter.default @State private var listInfo: ListInfo? - @State var searchText = "" + @State private var searchText = "" @State private var selectedPost: AnyPostWithEditContext? private var isFiltered: Bool { - filter.status != [.custom("any")] + filter.status != .custom("any") } var body: some View { @@ -300,13 +300,13 @@ struct CustomPostList: View { } } -struct CustomPostCollectionView: View { +private struct CustomPostCollectionView: View { let client: WordPressClient let service: WpSelfHostedService let endpoint: PostEndpointType let details: PostTypeDetailsWithEditContext @Binding var listInfo: ListInfo? - let filter: WordPressAPIInternal.PostListFilter + let filter: Filter let showInitialLoading: Bool let onSelectPost: (AnyPostWithEditContext) -> Void @@ -338,12 +338,12 @@ struct CustomPostCollectionView: View { } .task(id: filter) { // Reset when filter changes. - if collection == nil || collection?.filter() != filter { + if collection == nil || collection?.filter() != filter.asPostListFilter() { self.collection = service .posts() .createPostMetadataCollectionWithEditContext( endpointType: endpoint, - filter: filter, + filter: filter.asPostListFilter(), perPage: 20 ) self.listInfo = nil @@ -405,12 +405,12 @@ struct CustomPostCollectionView: View { } } -struct CustomPostSearchResultView: View { +private struct CustomPostSearchResultView: View { let client: WordPressClient let service: WpSelfHostedService let endpoint: PostEndpointType let details: PostTypeDetailsWithEditContext - let baseFilter: WordPressAPIInternal.PostListFilter + let baseFilter: Filter @Binding var searchText: String let onSelectPost: (AnyPostWithEditContext) -> Void @@ -425,8 +425,6 @@ struct CustomPostSearchResultView: View { listInfo: $listInfo, filter: { var search = baseFilter - // TODO: Support author? - search.searchColumns = [.postTitle, .postContent, .postExcerpt] search.search = searchText return search }(), @@ -436,6 +434,26 @@ struct CustomPostSearchResultView: View { } } +private struct Filter: Equatable { + var status: PostStatus + var search: String? + + static var `default`: Self { + get { + .init(status: .custom("any")) + } + } + + func asPostListFilter() -> WordPressAPIInternal.PostListFilter { + .init( + search: search, + // TODO: Support author? + searchColumns: search == nil ? [] : [.postTitle, .postContent, .postExcerpt], + status: [status], + ) + } +} + private struct ErrorRow: View { let message: String @@ -463,12 +481,6 @@ private extension ListInfo { } } -private extension WordPressAPIInternal.PostListFilter { - static var `default`: Self { - Self(status: [.custom("any")]) - } -} - private enum Strings { static let sortByDateCreated = NSLocalizedString( "postList.menu.sortByDateCreated", @@ -508,18 +520,18 @@ private enum Strings { } private struct FilterMenuItem: View { - @Binding var filter: WordPressAPIInternal.PostListFilter + @Binding var filter: Filter let status: PostStatus let title: String var body: some View { Button { - filter.status = [status] + filter.status = status } label: { Label { Text(title) } icon: { - if filter.status == [status] { + if filter.status == status { Image(systemName: "checkmark") } } From 7a54dcbb78875349276c6f51a668cfaddf53d56a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 22 Jan 2026 22:20:14 +1300 Subject: [PATCH 10/20] Update wordpress-rs --- Modules/Package.resolved | 6 +++--- Modules/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 26ae7cd9f9bb..ba53cee2dd14 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ab0556fecab7b57b8b994011f8d8349c6aa75d7a0e65d4c94bbc33045ebece90", + "originHash" : "ad79f4b2d652e0c3c44771b7e948d8213948729425b92eed1ccb62f22035d7c9", "pins" : [ { "identity" : "alamofire", @@ -390,8 +390,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Automattic/wordpress-rs", "state" : { - "branch" : "alpha-20260114", - "revision" : "4895b1bf67136eeeca5eb792418749c23ec5726d" + "branch" : "alpha-20260122", + "revision" : "6d1a8eec910655ed14f8759cc57fbf897e79b12b" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 647d977537c2..a5270c4d45c2 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -58,7 +58,7 @@ let package = Package( .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.13.1"), // We can't use wordpress-rs branches nor commits here. Only tags work. - .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20260114"), + .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20260122"), .package( url: "https://github.com/Automattic/color-studio", revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de" From 0341dc89ecb923050c4d6f60d8015f8505592b8c Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 23 Jan 2026 10:32:54 +1300 Subject: [PATCH 11/20] Fix a compilation issue in unit tests --- .../Tests/Services/UserListViewModelTests.swift | 5 ++++- .../ViewRelated/CustomPostTypes/CustomPostEditor.swift | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift b/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift index 1d8413bb3e4b..443450b72417 100644 --- a/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift @@ -16,7 +16,10 @@ class UserListViewModelTests: XCTestCase { override func setUp() async throws { try await super.setUp() - let client = try WordPressClient(api: .init(urlSession: .shared, apiRootUrl: .parse(input: "https://example.com/wp-json"), authentication: .none), rootUrl: .parse(input: "https://example.com")) + let client = try WordPressClient( + api: .init(urlSession: .shared, apiRootUrl: .parse(input: "https://example.com/wp-json"), authentication: .none), + siteURL: URL(string: "https://example.com")! + ) service = UserService(client: client) viewModel = await UserListViewModel(userService: service, currentUserId: 0) } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift index 892ff4099ce8..a3aaeba8c901 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift @@ -137,4 +137,3 @@ private enum Strings { comment: "Error message shown when the post was modified by another user while editing" ) } - From 9b44e33828e89c9b08ae9c6bf39d5a80b2573905 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 23 Jan 2026 23:10:54 +1300 Subject: [PATCH 12/20] Add a missing delegate function --- .../ViewRelated/NewGutenberg/SimpleGBKViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift index 14b982cf5dd4..ddd80ffb923e 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift @@ -139,4 +139,8 @@ extension SimpleGBKViewController: GutenbergKit.EditorViewControllerDelegate { func editor(_ viewController: GutenbergKit.EditorViewController, didCloseModalDialog dialogType: String) { } + + func editorDidRequestLatestContent(_ controller: GutenbergKit.EditorViewController) -> (title: String, content: String)? { + return nil + } } From bcf17ed52108e74618cf595274c6798ae293d820 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 28 Jan 2026 10:07:25 +1300 Subject: [PATCH 13/20] Improve excerpt rendering --- .../ViewRelated/CustomPostTypes/CustomPostList.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift index 52e2cec66fc5..a7787db2448c 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift @@ -22,7 +22,16 @@ private struct DisplayPost: Equatable { self.date = entity.dateGmt self.title = entity.title?.raw self.excerpt = entity.excerpt?.raw - ?? String((entity.content.raw ?? entity.content.rendered).prefix(excerptLimit)) + ?? GutenbergExcerptGenerator + .firstParagraph( + from: entity.content.rendered, + maxLength: excerptLimit + ) + .replacingOccurrences( + of: "[\n]{2,}", + with: "\n", + options: .regularExpression + ) } static let placeholder = DisplayPost( From 3e3d4a3814c6fa1e0a7bf62057119491d1901408 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 28 Jan 2026 10:07:42 +1300 Subject: [PATCH 14/20] Add a TODO --- .../Classes/ViewRelated/CustomPostTypes/CustomPostList.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift index a7787db2448c..7538d57801cf 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift @@ -41,6 +41,7 @@ private struct DisplayPost: Equatable { ) } +// TODO: Decouple the "display item" from the internall states of the `PostMetadataCollectionItem` private enum ListItem: Identifiable, Equatable { case ready(id: Int64, post: DisplayPost, fullPost: AnyPostWithEditContext) case stale(id: Int64, post: DisplayPost) From 6a8d9a29bd43b7c952afd4c4e756c9b581bbbac7 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 28 Jan 2026 10:17:15 +1300 Subject: [PATCH 15/20] Use Picker to present the status filter --- .../CustomPostTypes/CustomPostList.swift | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift index 7538d57801cf..ff272d3aed55 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift @@ -274,7 +274,7 @@ struct CustomPostList: View { makeTitleView() } ToolbarItem(placement: .topBarTrailing) { - makeFilterMenu() + filterMenu } } } @@ -290,13 +290,15 @@ struct CustomPostList: View { } } - @ViewBuilder - private func makeFilterMenu() -> some View { + private var filterMenu: some View { Menu { - FilterMenuItem(filter: $filter, status: .custom("any"), title: Strings.filterAll) - FilterMenuItem(filter: $filter, status: .publish, title: Strings.filterPublished) - FilterMenuItem(filter: $filter, status: .draft, title: Strings.filterDraft) - FilterMenuItem(filter: $filter, status: .future, title: Strings.filterScheduled) + Picker("", selection: $filter.status) { + Text(Strings.filterAll).tag(PostStatus.custom("any")) + Text(Strings.filterPublished).tag(PostStatus.publish) + Text(Strings.filterDraft).tag(PostStatus.draft) + Text(Strings.filterScheduled).tag(PostStatus.future) + } + .pickerStyle(.inline) } label: { Image(systemName: "line.3.horizontal.decrease") } @@ -529,26 +531,6 @@ private enum Strings { ) } -private struct FilterMenuItem: View { - @Binding var filter: Filter - let status: PostStatus - let title: String - - var body: some View { - Button { - filter.status = status - } label: { - Label { - Text(title) - } icon: { - if filter.status == status { - Image(systemName: "checkmark") - } - } - } - } -} - // MARK: - Previews #Preview("Fetching Placeholders") { From bef22c05f7328ccc455056b9f012619e440d31dd Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 28 Jan 2026 10:34:40 +1300 Subject: [PATCH 16/20] Use DDLog instead of NSLog --- .../Classes/ViewRelated/CustomPostTypes/CustomPostList.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift index ff272d3aed55..d3dc57460b2e 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift @@ -398,7 +398,7 @@ private struct CustomPostCollectionView: View { let listInfo = collection.listInfo() - NSLog("List info: \(String(describing: listInfo))") + DDLogInfo("List info: \(String(describing: listInfo))") do { let items = try await collection.loadItems().map(ListItem.init) From 5215f23ec9b6150f1804cc8ece102916de57b984 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 28 Jan 2026 14:31:27 +1300 Subject: [PATCH 17/20] Extract data loading code to a view model type --- .../CustomPostTypes/CustomPostList.swift | 626 ------------------ .../CustomPostTypes/CustomPostListView.swift | 285 ++++++++ .../CustomPostListViewModel.swift | 194 ++++++ .../CustomPostTypes/CustomPostMainView.swift | 184 +++++ .../CustomPostTypes/CustomPostTypesView.swift | 2 +- .../ViewRelated/CustomPostTypes/Filter.swift | 29 + 6 files changed, 693 insertions(+), 627 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostMainView.swift create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/Filter.swift diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift deleted file mode 100644 index d3dc57460b2e..000000000000 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift +++ /dev/null @@ -1,626 +0,0 @@ -import Foundation -import SwiftUI -import WordPressCore -import WordPressAPI -import WordPressAPIInternal -import WordPressApiCache -import WordPressUI -import WordPressData - -private struct DisplayPost: Equatable { - let date: Date - let title: String? - let excerpt: String? - - init(date: Date, title: String?, excerpt: String?) { - self.date = date - self.title = title - self.excerpt = excerpt - } - - init(_ entity: AnyPostWithEditContext, excerptLimit: Int = 100) { - self.date = entity.dateGmt - self.title = entity.title?.raw - self.excerpt = entity.excerpt?.raw - ?? GutenbergExcerptGenerator - .firstParagraph( - from: entity.content.rendered, - maxLength: excerptLimit - ) - .replacingOccurrences( - of: "[\n]{2,}", - with: "\n", - options: .regularExpression - ) - } - - static let placeholder = DisplayPost( - date: .now, - title: "Lorem ipsum dolor sit amet", - excerpt: "Lorem ipsum dolor sit amet consectetur adipiscing elit" - ) -} - -// TODO: Decouple the "display item" from the internall states of the `PostMetadataCollectionItem` -private enum ListItem: Identifiable, Equatable { - case ready(id: Int64, post: DisplayPost, fullPost: AnyPostWithEditContext) - case stale(id: Int64, post: DisplayPost) - case refreshing(id: Int64, post: DisplayPost) - case fetching(id: Int64) - case missing(id: Int64) - case error(id: Int64, message: String) - case errorWithData(id: Int64, message: String, post: DisplayPost) - - var id: Int64 { - switch self { - case .ready(let id, _, _), - .stale(let id, _), - .refreshing(let id, _), - .fetching(let id), - .missing(let id), - .error(let id, _), - .errorWithData(let id, _, _): - return id - } - } - - init(item: PostMetadataCollectionItem) { - let id = item.id - - switch item.state { - case .fresh(let entity): - self = .ready(id: id, post: DisplayPost(entity.data), fullPost: entity.data) - - case .stale(let entity): - self = .stale(id: id, post: DisplayPost(entity.data)) - - case .fetchingWithData(let entity): - self = .refreshing(id: id, post: DisplayPost(entity.data)) - - case .fetching: - self = .fetching(id: id) - - case .missing: - self = .missing(id: id) - - case .failed(let error): - self = .error(id: id, message: error) - - case .failedWithData(let error, let entity): - self = .errorWithData(id: id, message: error, post: DisplayPost(entity.data, excerptLimit: 50)) - } - } -} - -private struct PostList: View { - let items: [ListItem] - let onLoadNextPage: () async throws -> Void - let onSelectPost: (AnyPostWithEditContext) -> Void - - @State var isLoadingMore = false - @State var loadMoreError: Error? - - var body: some View { - List { - ForEach(items) { item in - listRow(item) - .task { - if !isLoadingMore, items.suffix(5).contains(where: { $0.id == item.id }) { - await loadNextPage() - } - } - } - - makeFooterView() - } - .listStyle(.plain) - } - - private func loadNextPage() async { - guard !isLoadingMore else { return } - - isLoadingMore = true - defer { isLoadingMore = false } - - self.loadMoreError = nil - - do { - try await onLoadNextPage() - } catch { - self.loadMoreError = error - } - } - - @ViewBuilder - private func listRow(_ item: ListItem) -> some View { - switch item { - case .error(_, let message): - ErrorRow(message: message) - - case .errorWithData(_, let message, let post): - VStack(spacing: 4) { - PostRowView(post: post) - ErrorRow(message: message) - } - - case .fetching, .missing, .refreshing: - PostRowView( - post: DisplayPost( - date: Date(), - title: "Lorem ipsum dolor sit amet", - excerpt: "Lorem ipsum dolor sit amet consectetur adipiscing elit" - ) - ) - .redacted(reason: .placeholder) - - case .ready(_, let displayPost, let post): - Button { - onSelectPost(post) - } label: { - PostRowView(post: displayPost) - } - .buttonStyle(.plain) - - case .stale(_, let post): - PostRowView(post: post) - } - } - - @ViewBuilder - private func makeFooterView() -> some View { - if isLoadingMore { - ProgressView() - .progressViewStyle(.circular) - .frame(maxWidth: .infinity, minHeight: 44, alignment: .center) - .id(UUID()) // A hack to show the ProgressView after cell reusing. - } else if loadMoreError != nil { - Button { - Task { await loadNextPage() } - } label: { - HStack { - Image(systemName: "exclamationmark.circle") - Text(SharedStrings.Button.retry) - } - } - } - } -} - -private struct PostRowView: View { - let post: DisplayPost - - init(post: DisplayPost) { - self.post = post - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(post.date, format: .dateTime.day().month().year()) - .font(.caption) - .foregroundStyle(.secondary) - - if let title = post.title { - Text(verbatim: title) - .font(.headline) - .foregroundStyle(.primary) - } - - if let excerpt = post.excerpt { - Text(verbatim: excerpt) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } -} - -struct CustomPostList: View { - let client: WordPressClient - let service: WpSelfHostedService - let endpoint: PostEndpointType - let details: PostTypeDetailsWithEditContext - let blog: Blog - - @State private var filter = Filter.default - @State private var listInfo: ListInfo? - - @State private var searchText = "" - - @State private var selectedPost: AnyPostWithEditContext? - - private var isFiltered: Bool { - filter.status != .custom("any") - } - - var body: some View { - ZStack { - if searchText.isEmpty { - CustomPostCollectionView( - client: client, - service: service, - endpoint: endpoint, - details: details, - listInfo: $listInfo, - filter: filter, - showInitialLoading: false, - onSelectPost: { selectedPost = $0 } - ) - } else { - CustomPostSearchResultView( - client: client, - service: service, - endpoint: endpoint, - details: details, - baseFilter: .default, - searchText: $searchText, - onSelectPost: { selectedPost = $0 } - ) - } - } - .searchable(text: $searchText) - .fullScreenCover(item: $selectedPost) { post in - // TODO: Check if the post supports Gutenberg first? - CustomPostEditor(client: client, post: post, details: details, blog: blog) { - Task { - _ = try await service.posts().refreshPost(postId: post.id, endpointType: endpoint) - } - } - } - .toolbar { - ToolbarItem(placement: .principal) { - makeTitleView() - } - ToolbarItem(placement: .topBarTrailing) { - filterMenu - } - } - } - - @ViewBuilder - private func makeTitleView() -> some View { - Text(details.labels.itemsList) - .overlay(alignment: .leading) { - if listInfo?.state == .fetchingFirstPage { - ProgressView() - .offset(x: -24) - } - } - } - - private var filterMenu: some View { - Menu { - Picker("", selection: $filter.status) { - Text(Strings.filterAll).tag(PostStatus.custom("any")) - Text(Strings.filterPublished).tag(PostStatus.publish) - Text(Strings.filterDraft).tag(PostStatus.draft) - Text(Strings.filterScheduled).tag(PostStatus.future) - } - .pickerStyle(.inline) - } label: { - Image(systemName: "line.3.horizontal.decrease") - } - .foregroundStyle(isFiltered ? Color.white : .primary) - .background { - if isFiltered { - Circle() - .fill(Color.accentColor) - } - } - } -} - -private struct CustomPostCollectionView: View { - let client: WordPressClient - let service: WpSelfHostedService - let endpoint: PostEndpointType - let details: PostTypeDetailsWithEditContext - @Binding var listInfo: ListInfo? - let filter: Filter - let showInitialLoading: Bool - let onSelectPost: (AnyPostWithEditContext) -> Void - - @State private var collection: PostMetadataCollectionWithEditContext? - @State private var items: [ListItem] = [] - - var body: some View { - PostList( - items: items, - onLoadNextPage: { try await loadNextPage() }, - onSelectPost: onSelectPost - ) - .overlay { - if items.isEmpty, listInfo?.isSyncing == false { - let emptyText = details.labels.notFound.isEmpty - ? "No \(details.name)" - : details.labels.notFound - EmptyStateView(emptyText, systemImage: "doc.text") - } else if showInitialLoading, items.isEmpty, listInfo?.isSyncing == true { - ProgressView() - } - } - .refreshable { - do { - _ = try await collection?.refresh() - } catch { - DDLogError("Pull to refresh failed: \(error)") - } - } - .task(id: filter) { - // Reset when filter changes. - if collection == nil || collection?.filter() != filter.asPostListFilter() { - self.collection = service - .posts() - .createPostMetadataCollectionWithEditContext( - endpointType: endpoint, - filter: filter.asPostListFilter(), - perPage: 20 - ) - self.listInfo = nil - self.items = [] - } - - do { - _ = try await collection?.refresh() - } catch { - DDLogError("Failed to refresh: \(error)") - } - } - .task(id: filter) { - await handleDataChanges() - } - } - - private func loadNextPage() async throws { - if let listInfo, listInfo.isSyncing || !listInfo.hasMorePages { - return - } - - if listInfo?.currentPage == nil { - _ = try await collection?.refresh() - } else { - _ = try await collection?.loadNextPage() - } - } - - private func handleDataChanges() async { - guard let cache = await client.cache else { return } - - let updates = cache.databaseUpdatesPublisher() - .debounce(for: .milliseconds(50), scheduler: DispatchQueue.main) - .values - for await hook in updates { - guard let collection, collection.isRelevantUpdate(hook: hook) else { continue } - - DDLogInfo("WpApiCache update: \(hook.action) to \(hook.table) at row \(hook.rowId)") - - let listInfo = collection.listInfo() - - DDLogInfo("List info: \(String(describing: listInfo))") - - do { - let items = try await collection.loadItems().map(ListItem.init) - withAnimation { - if self.listInfo != listInfo { - self.listInfo = listInfo - } - if self.items != items { - self.items = items - } - } - } catch { - DDLogError("Failed to get collection items: \(error)") - } - } - } -} - -private struct CustomPostSearchResultView: View { - let client: WordPressClient - let service: WpSelfHostedService - let endpoint: PostEndpointType - let details: PostTypeDetailsWithEditContext - let baseFilter: Filter - @Binding var searchText: String - let onSelectPost: (AnyPostWithEditContext) -> Void - - @State var listInfo: ListInfo? = nil - - var body: some View { - CustomPostCollectionView( - client: client, - service: service, - endpoint: endpoint, - details: details, - listInfo: $listInfo, - filter: { - var search = baseFilter - search.search = searchText - return search - }(), - showInitialLoading: true, - onSelectPost: onSelectPost - ) - } -} - -private struct Filter: Equatable { - var status: PostStatus - var search: String? - - static var `default`: Self { - get { - .init(status: .custom("any")) - } - } - - func asPostListFilter() -> WordPressAPIInternal.PostListFilter { - .init( - search: search, - // TODO: Support author? - searchColumns: search == nil ? [] : [.postTitle, .postContent, .postExcerpt], - status: [status], - ) - } -} - -private struct ErrorRow: View { - let message: String - - var body: some View { - HStack(spacing: 12) { - Image(systemName: "info.circle") - - Text(message) - .font(.subheadline) - .frame(maxWidth: .infinity, alignment: .leading) - } - .foregroundStyle(.red) - .padding(.vertical, 4) - } -} - -private extension ListInfo { - var isSyncing: Bool { - state == .fetchingFirstPage || state == .fetchingNextPage - } - - var hasMorePages: Bool { - guard let currentPage, let totalPages else { return true } - return currentPage < totalPages - } -} - -private enum Strings { - static let sortByDateCreated = NSLocalizedString( - "postList.menu.sortByDateCreated", - value: "Sort by Date Created", - comment: "Menu item to sort posts by creation date" - ) - static let sortByDateModified = NSLocalizedString( - "postList.menu.sortByDateModified", - value: "Sort by Date Modified", - comment: "Menu item to sort posts by modification date" - ) - static let filter = NSLocalizedString( - "postList.menu.filter", - value: "Filter", - comment: "Menu item to access filter options" - ) - static let filterAll = NSLocalizedString( - "postList.menu.filter.all", - value: "All", - comment: "Filter option to show all posts" - ) - static let filterPublished = NSLocalizedString( - "postList.menu.filter.published", - value: "Published", - comment: "Filter option to show only published posts" - ) - static let filterDraft = NSLocalizedString( - "postList.menu.filter.draft", - value: "Draft", - comment: "Filter option to show only draft posts" - ) - static let filterScheduled = NSLocalizedString( - "postList.menu.filter.scheduled", - value: "Scheduled", - comment: "Filter option to show only scheduled posts" - ) -} - -// MARK: - Previews - -#Preview("Fetching Placeholders") { - PostList( - items: [ - .fetching(id: 1), - .fetching(id: 2), - .fetching(id: 3) - ], - onLoadNextPage: {}, - onSelectPost: { _ in } - ) -} - -#Preview("Error State") { - PostList( - items: [ - .error(id: 1, message: "Failed to load post"), - .error(id: 2, message: "Network connection lost") - ], - onLoadNextPage: {}, - onSelectPost: { _ in } - ) -} - -#Preview("Stale Content") { - PostList( - items: [ - .stale( - id: 1, - post: DisplayPost( - date: .now, - title: "First Draft Post", - excerpt: "This is a preview of the first post that might be outdated." - ) - ), - .stale( - id: 2, - post: DisplayPost( - date: .now.addingTimeInterval(-86400), - title: "Second Post", - excerpt: "Another post with stale data showing in the list." - ) - ), - .stale( - id: 3, - post: DisplayPost( - date: .now.addingTimeInterval(-86400 * 7), - title: nil, - excerpt: "Post without a title" - ) - ) - ], - onLoadNextPage: {}, - onSelectPost: { _ in } - ) -} - -#Preview("Mixed States") { - PostList( - items: [ - .stale( - id: 1, - post: DisplayPost( - date: .now, - title: "Published Post", - excerpt: "This post has stale data and is being refreshed." - ) - ), - .refreshing( - id: 2, - post: DisplayPost( - date: .now.addingTimeInterval(-86400), - title: "Refreshing Post", - excerpt: "Currently being refreshed in the background." - ) - ), - .fetching(id: 3), - .error(id: 4, message: "Failed to sync"), - .errorWithData( - id: 5, - message: "Sync failed, showing cached data", - post: DisplayPost( - date: .now.addingTimeInterval(-86400 * 3), - title: "Cached Post", - excerpt: "This post failed to sync but we have old data." - ) - ), - ], - onLoadNextPage: {}, - onSelectPost: { _ in } - ) -} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift new file mode 100644 index 000000000000..dbbda161cc7b --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -0,0 +1,285 @@ +import Foundation +import SwiftUI +import WordPressAPI +import WordPressAPIInternal +import WordPressCore +import WordPressUI + +/// Displays a paginated list of custom posts. +/// +/// Used to show posts filtered by status or search results. +struct CustomPostListView: View { + @ObservedObject var viewModel: CustomPostListViewModel + let details: PostTypeDetailsWithEditContext + let onSelectPost: (AnyPostWithEditContext) -> Void + + var body: some View { + PaginatedList( + items: viewModel.items, + onLoadNextPage: { try await viewModel.loadNextPage() }, + onSelectPost: onSelectPost + ) + .overlay { + if viewModel.shouldDisplayEmptyView { + let emptyText = details.labels.notFound.isEmpty + ? "No \(details.name)" + : details.labels.notFound + EmptyStateView(emptyText, systemImage: "doc.text") + } else if viewModel.shouldDisplayInitialLoading { + ProgressView() + } + } + .refreshable { + await viewModel.refresh() + } + .task(id: viewModel.filter) { + await viewModel.refresh() + } + .task(id: viewModel.filter) { + await viewModel.handleDataChanges() + } + } +} + +private struct PaginatedList: View { + let items: [CustomPostCollectionItem] + let onLoadNextPage: () async throws -> Void + let onSelectPost: (AnyPostWithEditContext) -> Void + + @State var isLoadingMore = false + @State var loadMoreError: Error? + + var body: some View { + List { + ForEach(items) { item in + ForEachContent(item: item, onSelectPost: onSelectPost) + .task { + await onRowAppear(item: item) + } + } + + makeFooterView() + } + .listStyle(.plain) + } + + private func onRowAppear(item: CustomPostCollectionItem) async { + if !isLoadingMore, items.suffix(5).contains(where: { $0.id == item.id }) { + await loadNextPage() + } + } + + private func loadNextPage() async { + guard !isLoadingMore else { return } + + isLoadingMore = true + defer { isLoadingMore = false } + + self.loadMoreError = nil + + do { + try await onLoadNextPage() + } catch { + self.loadMoreError = error + } + } + + @ViewBuilder + private func makeFooterView() -> some View { + if isLoadingMore { + ProgressView() + .progressViewStyle(.circular) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .center) + .id(UUID()) // A hack to show the ProgressView after cell reusing. + } else if loadMoreError != nil { + Button { + Task { await loadNextPage() } + } label: { + HStack { + Image(systemName: "exclamationmark.circle") + Text(SharedStrings.Button.retry) + } + } + } + } +} + +private struct ForEachContent: View { + let item: CustomPostCollectionItem + let onSelectPost: (AnyPostWithEditContext) -> Void + + var body: some View { + switch item { + case .error(_, let message): + ErrorRow(message: message) + + case .errorWithData(_, let message, let post): + VStack(spacing: 4) { + PostContent(post: post) + ErrorRow(message: message) + } + + case .fetching, .missing, .refreshing: + PostContent( + post: CustomPostCollectionDisplayPost( + date: Date(), + title: "Lorem ipsum dolor sit amet", + excerpt: "Lorem ipsum dolor sit amet consectetur adipiscing elit" + ) + ) + .redacted(reason: .placeholder) + + case .ready(_, let displayPost, let post): + Button { + onSelectPost(post) + } label: { + PostContent(post: displayPost) + } + .buttonStyle(.plain) + + case .stale(_, let post): + PostContent(post: post) + } + } +} + +private struct PostContent: View { + let post: CustomPostCollectionDisplayPost + + init(post: CustomPostCollectionDisplayPost) { + self.post = post + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(post.date, format: .dateTime.day().month().year()) + .font(.caption) + .foregroundStyle(.secondary) + + if let title = post.title { + Text(verbatim: title) + .font(.headline) + .foregroundStyle(.primary) + } + + if let excerpt = post.excerpt { + Text(verbatim: excerpt) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } +} + +private struct ErrorRow: View { + let message: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "info.circle") + + Text(message) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + .foregroundStyle(.red) + .padding(.vertical, 4) + } +} + +// MARK: - Previews + +#Preview("Fetching Placeholders") { + PaginatedList( + items: [ + .fetching(id: 1), + .fetching(id: 2), + .fetching(id: 3) + ], + onLoadNextPage: {}, + onSelectPost: { _ in } + ) +} + +#Preview("Error State") { + PaginatedList( + items: [ + .error(id: 1, message: "Failed to load post"), + .error(id: 2, message: "Network connection lost") + ], + onLoadNextPage: {}, + onSelectPost: { _ in } + ) +} + +#Preview("Stale Content") { + PaginatedList( + items: [ + .stale( + id: 1, + post: CustomPostCollectionDisplayPost( + date: .now, + title: "First Draft Post", + excerpt: "This is a preview of the first post that might be outdated." + ) + ), + .stale( + id: 2, + post: CustomPostCollectionDisplayPost( + date: .now.addingTimeInterval(-86400), + title: "Second Post", + excerpt: "Another post with stale data showing in the list." + ) + ), + .stale( + id: 3, + post: CustomPostCollectionDisplayPost( + date: .now.addingTimeInterval(-86400 * 7), + title: nil, + excerpt: "Post without a title" + ) + ) + ], + onLoadNextPage: {}, + onSelectPost: { _ in } + ) +} + +#Preview("Mixed States") { + PaginatedList( + items: [ + .stale( + id: 1, + post: CustomPostCollectionDisplayPost( + date: .now, + title: "Published Post", + excerpt: "This post has stale data and is being refreshed." + ) + ), + .refreshing( + id: 2, + post: CustomPostCollectionDisplayPost( + date: .now.addingTimeInterval(-86400), + title: "Refreshing Post", + excerpt: "Currently being refreshed in the background." + ) + ), + .fetching(id: 3), + .error(id: 4, message: "Failed to sync"), + .errorWithData( + id: 5, + message: "Sync failed, showing cached data", + post: CustomPostCollectionDisplayPost( + date: .now.addingTimeInterval(-86400 * 3), + title: "Cached Post", + excerpt: "This post failed to sync but we have old data." + ) + ), + ], + onLoadNextPage: {}, + onSelectPost: { _ in } + ) +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift new file mode 100644 index 000000000000..ee935c70e50b --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -0,0 +1,194 @@ +import Foundation +import SwiftUI +import WordPressAPI +import WordPressAPIInternal +import WordPressCore +import WordPressShared + +@MainActor +final class CustomPostListViewModel: ObservableObject { + private let service: WpSelfHostedService + private let client: WordPressClient + private let endpoint: PostEndpointType + let filter: CustomPostListFilter + + private var collection: PostMetadataCollectionWithEditContext? + + @Published private(set) var items: [CustomPostCollectionItem] = [] + @Published private(set) var listInfo: ListInfo? + + var shouldDisplayEmptyView: Bool { + items.isEmpty && listInfo?.isSyncing == false + } + + var shouldDisplayInitialLoading: Bool { + items.isEmpty && listInfo?.isSyncing == true + } + + init( + client: WordPressClient, + service: WpSelfHostedService, + endpoint: PostEndpointType, + filter: CustomPostListFilter + ) { + self.client = client + self.service = service + self.endpoint = endpoint + self.filter = filter + + collection = service + .posts() + .createPostMetadataCollectionWithEditContext( + endpointType: endpoint, + filter: filter.asPostListFilter(), + perPage: 20 + ) + } + + func refresh() async { + do { + _ = try await collection?.refresh() + } catch { + DDLogError("Pull to refresh failed: \(error)") + } + } + + func loadNextPage() async throws { + if let listInfo, listInfo.isSyncing || !listInfo.hasMorePages { + return + } + + if listInfo?.currentPage == nil { + _ = try await collection?.refresh() + } else { + _ = try await collection?.loadNextPage() + } + } + + func handleDataChanges() async { + guard let cache = await client.cache else { return } + + let updates = cache.databaseUpdatesPublisher() + .debounce(for: .milliseconds(50), scheduler: DispatchQueue.main) + .values + for await hook in updates { + guard let collection, collection.isRelevantUpdate(hook: hook) else { continue } + + DDLogInfo("WpApiCache update: \(hook.action) to \(hook.table) at row \(hook.rowId)") + + let listInfo = collection.listInfo() + + DDLogInfo("List info: \(String(describing: listInfo))") + + do { + let items = try await collection.loadItems().map(CustomPostCollectionItem.init) + withAnimation { + if self.listInfo != listInfo { + self.listInfo = listInfo + } + if self.items != items { + self.items = items + } + } + } catch { + DDLogError("Failed to get collection items: \(error)") + } + } + } +} + +struct CustomPostCollectionDisplayPost: Equatable { + let date: Date + let title: String? + let excerpt: String? + + init(date: Date, title: String?, excerpt: String?) { + self.date = date + self.title = title + self.excerpt = excerpt + } + + init(_ entity: AnyPostWithEditContext, excerptLimit: Int = 100) { + self.date = entity.dateGmt + self.title = entity.title?.raw + self.excerpt = entity.excerpt?.raw + ?? GutenbergExcerptGenerator + .firstParagraph( + from: entity.content.rendered, + maxLength: excerptLimit + ) + .replacingOccurrences( + of: "[\n]{2,}", + with: "\n", + options: .regularExpression + ) + } + + static let placeholder = CustomPostCollectionDisplayPost( + date: .now, + title: "Lorem ipsum dolor sit amet", + excerpt: "Lorem ipsum dolor sit amet consectetur adipiscing elit" + ) +} + +// TODO: Decouple the "display item" from the internall states of the `PostMetadataCollectionItem` +enum CustomPostCollectionItem: Identifiable, Equatable { + case ready(id: Int64, post: CustomPostCollectionDisplayPost, fullPost: AnyPostWithEditContext) + case stale(id: Int64, post: CustomPostCollectionDisplayPost) + case refreshing(id: Int64, post: CustomPostCollectionDisplayPost) + case fetching(id: Int64) + case missing(id: Int64) + case error(id: Int64, message: String) + case errorWithData(id: Int64, message: String, post: CustomPostCollectionDisplayPost) + + var id: Int64 { + switch self { + case .ready(let id, _, _), + .stale(let id, _), + .refreshing(let id, _), + .fetching(let id), + .missing(let id), + .error(let id, _), + .errorWithData(let id, _, _): + return id + } + } + + init(item: PostMetadataCollectionItem) { + let id = item.id + + switch item.state { + case .fresh(let entity): + self = .ready(id: id, post: CustomPostCollectionDisplayPost(entity.data), fullPost: entity.data) + + case .stale(let entity): + self = .stale(id: id, post: CustomPostCollectionDisplayPost(entity.data)) + + case .fetchingWithData(let entity): + self = .refreshing(id: id, post: CustomPostCollectionDisplayPost(entity.data)) + + case .fetching: + self = .fetching(id: id) + + case .missing: + self = .missing(id: id) + + case .failed(let error): + self = .error(id: id, message: error) + + case .failedWithData(let error, let entity): + self = .errorWithData(id: id, message: error, post: CustomPostCollectionDisplayPost(entity.data, excerptLimit: 50)) + } + } +} + +private extension ListInfo { + var isSyncing: Bool { + state == .fetchingFirstPage || state == .fetchingNextPage + } + + var hasMorePages: Bool { + guard let currentPage, let totalPages else { return true } + return currentPage < totalPages + } +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostMainView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostMainView.swift new file mode 100644 index 000000000000..f5509f31c933 --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostMainView.swift @@ -0,0 +1,184 @@ +import Foundation +import SwiftUI +import WordPressCore +import WordPressAPI +import WordPressAPIInternal +import WordPressApiCache +import WordPressUI +import WordPressData + +/// The top-level screen for browsing custom posts of a specific type. +/// +/// Provides search, status filtering, and navigation to the post editor. +struct CustomPostMainView: View { + let client: WordPressClient + let service: WpSelfHostedService + let endpoint: PostEndpointType + let details: PostTypeDetailsWithEditContext + let blog: Blog + + @State private var filter = CustomPostListFilter.default + @State private var searchText = "" + @State private var selectedPost: AnyPostWithEditContext? + @State private var filteredViewModel: CustomPostListViewModel + + init( + client: WordPressClient, + service: WpSelfHostedService, + endpoint: PostEndpointType, + details: PostTypeDetailsWithEditContext, + blog: Blog + ) { + self.client = client + self.service = service + self.endpoint = endpoint + self.details = details + self.blog = blog + + _filteredViewModel = State(initialValue: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: .default + )) + } + + private var isFiltered: Bool { + filter.status != .custom("any") + } + + var body: some View { + ZStack { + if searchText.isEmpty { + CustomPostListView( + viewModel: filteredViewModel, + details: details, + onSelectPost: { selectedPost = $0 } + ) + } else { + CustomPostSearchResultView( + client: client, + service: service, + endpoint: endpoint, + details: details, + searchText: $searchText, + onSelectPost: { selectedPost = $0 } + ) + } + } + .onChange(of: filter) { + filteredViewModel = CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: filter + ) + } + .searchable(text: $searchText) + .fullScreenCover(item: $selectedPost) { post in + // TODO: Check if the post supports Gutenberg first? + CustomPostEditor(client: client, post: post, details: details, blog: blog) { + Task { + _ = try await service.posts().refreshPost(postId: post.id, endpointType: endpoint) + } + } + } + .navigationTitle(details.labels.itemsList) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + filterMenu + } + } + } + + private var filterMenu: some View { + Menu { + Picker("", selection: $filter.status) { + Text(Strings.filterAll).tag(PostStatus.custom("any")) + Text(Strings.filterPublished).tag(PostStatus.publish) + Text(Strings.filterDraft).tag(PostStatus.draft) + Text(Strings.filterScheduled).tag(PostStatus.future) + } + .pickerStyle(.inline) + } label: { + Image(systemName: "line.3.horizontal.decrease") + } + .foregroundStyle(isFiltered ? Color.white : .primary) + .background { + if isFiltered { + Circle() + .fill(Color.accentColor) + } + } + } +} + +private struct CustomPostSearchResultView: View { + let client: WordPressClient + let service: WpSelfHostedService + let endpoint: PostEndpointType + let details: PostTypeDetailsWithEditContext + @Binding var searchText: String + let onSelectPost: (AnyPostWithEditContext) -> Void + + @State private var finalSearchText = "" + + var body: some View { + CustomPostListView( + viewModel: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: CustomPostListFilter.default.with(search: finalSearchText) + ), + details: details, + onSelectPost: onSelectPost + ) + .task(id: searchText) { + do { + try await Task.sleep(for: .milliseconds(100)) + finalSearchText = searchText + } catch { + // Do nothing. + } + } + } +} + +private enum Strings { + static let sortByDateCreated = NSLocalizedString( + "postList.menu.sortByDateCreated", + value: "Sort by Date Created", + comment: "Menu item to sort posts by creation date" + ) + static let sortByDateModified = NSLocalizedString( + "postList.menu.sortByDateModified", + value: "Sort by Date Modified", + comment: "Menu item to sort posts by modification date" + ) + static let filter = NSLocalizedString( + "postList.menu.filter", + value: "Filter", + comment: "Menu item to access filter options" + ) + static let filterAll = NSLocalizedString( + "postList.menu.filter.all", + value: "All", + comment: "Filter option to show all posts" + ) + static let filterPublished = NSLocalizedString( + "postList.menu.filter.published", + value: "Published", + comment: "Filter option to show only published posts" + ) + static let filterDraft = NSLocalizedString( + "postList.menu.filter.draft", + value: "Draft", + comment: "Filter option to show only draft posts" + ) + static let filterScheduled = NSLocalizedString( + "postList.menu.filter.scheduled", + value: "Scheduled", + comment: "Filter option to show only scheduled posts" + ) +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift index e55829c03f38..60522cb9140e 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift @@ -28,7 +28,7 @@ struct CustomPostTypesView: View { List { ForEach(types, id: \.1.slug) { (type, details) in NavigationLink { - CustomPostList(client: client, service: service, endpoint: type, details: details, blog: blog) + CustomPostMainView(client: client, service: service, endpoint: type, details: details, blog: blog) } label: { VStack(alignment: .leading, spacing: 4) { Text(details.name) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/Filter.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/Filter.swift new file mode 100644 index 000000000000..eb34423ebf5d --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/Filter.swift @@ -0,0 +1,29 @@ +import Foundation +import WordPressAPI +import WordPressAPIInternal + +struct CustomPostListFilter: Equatable { + var status: PostStatus + var search: String? + + static var `default`: Self { + get { + .init(status: .custom("any")) + } + } + + func with(search: String) -> Self { + var copy = self + copy.search = search + return copy + } + + func asPostListFilter() -> WordPressAPIInternal.PostListFilter { + .init( + search: search, + // TODO: Support author? + searchColumns: search == nil ? [] : [.postTitle, .postContent, .postExcerpt], + status: [status], + ) + } +} From ca016e697e55df683b94340ae2177e3f57a46adb Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 28 Jan 2026 14:49:36 +1300 Subject: [PATCH 18/20] Add a localizable string --- .../CustomPostTypes/CustomPostListView.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index dbbda161cc7b..80f56408090f 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -22,7 +22,7 @@ struct CustomPostListView: View { .overlay { if viewModel.shouldDisplayEmptyView { let emptyText = details.labels.notFound.isEmpty - ? "No \(details.name)" + ? String.localizedStringWithFormat(Strings.emptyStateMessage, details.name) : details.labels.notFound EmptyStateView(emptyText, systemImage: "doc.text") } else if viewModel.shouldDisplayInitialLoading { @@ -190,6 +190,14 @@ private struct ErrorRow: View { } } +private enum Strings { + static let emptyStateMessage = NSLocalizedString( + "customPostList.emptyState.message", + value: "No %1$@", + comment: "Empty state message when no custom posts exist. %1$@ is the post type name (e.g., 'Podcasts', 'Products')." + ) +} + // MARK: - Previews #Preview("Fetching Placeholders") { From 4093ab4cd1582641ab3cd6d19ce056e84562656b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 9 Feb 2026 10:11:54 +1300 Subject: [PATCH 19/20] Fix compilation issues after merge --- .../Classes/Utility/Editor/EditorDependencyManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WordPress/Classes/Utility/Editor/EditorDependencyManager.swift b/WordPress/Classes/Utility/Editor/EditorDependencyManager.swift index d4fedabe7774..9797fda68568 100644 --- a/WordPress/Classes/Utility/Editor/EditorDependencyManager.swift +++ b/WordPress/Classes/Utility/Editor/EditorDependencyManager.swift @@ -171,11 +171,11 @@ final class EditorDependencyManager: Sendable { @MainActor public func fetchEditorCapabilities(for blog: Blog) async throws { let site = try WordPressSite(blog: blog) - let client = WordPressClient(site: site) + let client = WordPressClientFactory.shared.instance(for: site) var siteId: Int? = nil - if case .dotCom(let _siteId, _) = site { + if case .dotCom(_, let _siteId, _) = site { siteId = _siteId } From 2d0aec9604b861ceb225a08928340a56d40d9fd8 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 10 Feb 2026 16:01:33 +1300 Subject: [PATCH 20/20] Fix a typo Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> --- Modules/Sources/WordPressCore/Plugins/PluginService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/WordPressCore/Plugins/PluginService.swift b/Modules/Sources/WordPressCore/Plugins/PluginService.swift index 4032f96162ab..28def2354bfe 100644 --- a/Modules/Sources/WordPressCore/Plugins/PluginService.swift +++ b/Modules/Sources/WordPressCore/Plugins/PluginService.swift @@ -181,7 +181,7 @@ private extension PluginService { func checkPluginUpdates(plugins: [PluginWithViewContext]) async throws { let updateCheck = try await wpOrgClient.checkPluginUpdates( - // Use a fairely recent version if the actual version is unknown. + // Use a fairly recent version if the actual version is unknown. wordpressCoreVersion: wordpressCoreVersion ?? "6.6", siteUrl: ParsedUrl.parse(input: client.siteURL.absoluteString), plugins: plugins