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..28def2354bfe 100644 --- a/Modules/Sources/WordPressCore/Plugins/PluginService.swift +++ b/Modules/Sources/WordPressCore/Plugins/PluginService.swift @@ -181,9 +181,9 @@ 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.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 fdb76e324f64..51fd53ed638e 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -1,6 +1,7 @@ import Foundation import WordPressAPI import WordPressAPIInternal +import WordPressApiCache /// Protocol defining the WordPress API methods that WordPressClient needs. /// This abstraction allows for mocking in tests using the `NoHandle` constructors @@ -15,6 +16,9 @@ public protocol WordPressClientAPI: Sendable { var taxonomies: TaxonomiesRequestExecutor { get } var terms: TermsRequestExecutor { get } var applicationPasswords: ApplicationPasswordsRequestExecutor { get } + var posts: PostsRequestExecutor { get } + + func createSelfHostedService(cache: WordPressApiCache) throws -> WpSelfHostedService func uploadMedia( params: MediaCreateParams, @@ -70,11 +74,34 @@ public actor WordPressClient { case noActiveTheme } + public let siteURL: URL + /// The underlying API executor used for making network requests. public let api: any WordPressClientAPI - /// The root URL of the WordPress site this client is connected to. - public let rootUrl: String + 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 + } + } /// The cached task for fetching site API details. private var loadSiteInfoTask: Task @@ -93,10 +120,10 @@ public actor WordPressClient { /// /// - Parameters: /// - api: The API executor to use for network requests. - /// - rootUrl: The parsed root URL of the WordPress site. - public init(api: any WordPressClientAPI, rootUrl: ParsedUrl) { + /// - siteURL: The parsed root URL of the WordPress site. + public init(api: WordPressClientAPI, siteURL: URL) { self.api = api - self.rootUrl = rootUrl.url() + self.siteURL = siteURL // These tasks need to be manually restated here because we can't use the task constructors self.loadSiteInfoTask = Task { try await api.apiRoot.get().data } diff --git a/Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift b/Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift index ce645b8cf857..b0f08ec4556a 100644 --- a/Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift +++ b/Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift @@ -1,6 +1,7 @@ import Foundation import WordPressAPI import WordPressAPIInternal +import WordPressApiCache @testable import WordPressCore /// Tracks call counts for API methods to verify caching behavior. @@ -60,6 +61,11 @@ final class MockWordPressClientAPI: WordPressClientAPI, @unchecked Sendable { var taxonomies: TaxonomiesRequestExecutor { fatalError("Not implemented") } var terms: TermsRequestExecutor { fatalError("Not implemented") } var applicationPasswords: ApplicationPasswordsRequestExecutor { fatalError("Not implemented") } + var posts: PostsRequestExecutor { fatalError("Not implemented") } + + func createSelfHostedService(cache: WordPressApiCache) throws -> WpSelfHostedService { + fatalError("Not implemented") + } func uploadMedia(params: MediaCreateParams, fulfilling progress: Progress) async throws -> MediaRequestCreateResponse { fatalError("Not implemented") diff --git a/Modules/Tests/WordPressCoreTests/WordPressClientFeatureTests.swift b/Modules/Tests/WordPressCoreTests/WordPressClientFeatureTests.swift index 77b0d3aa5a7b..dd54cdc22617 100644 --- a/Modules/Tests/WordPressCoreTests/WordPressClientFeatureTests.swift +++ b/Modules/Tests/WordPressCoreTests/WordPressClientFeatureTests.swift @@ -26,7 +26,7 @@ struct WordPressClientCachingTests { mockAPI.mockRoutes = ["/wp-block-editor/v1/settings"] mockAPI.mockIsBlockTheme = true - let client = try WordPressClient(api: mockAPI, rootUrl: .parse(input: "https://example.com")) + let client = try WordPressClient(api: mockAPI, siteURL: URL(string: "https://example.com")!) // First call - should trigger API fetches let result1 = try await client.supports(.blockEditorSettings) @@ -61,7 +61,7 @@ struct WordPressClientCachingTests { let mockAPI = MockWordPressClientAPI() mockAPI.mockRoutes = ["/wp-block-editor/v1/sites/12345/settings"] - let client = try WordPressClient(api: mockAPI, rootUrl: .parse(input: "https://example.com")) + let client = try WordPressClient(api: mockAPI, siteURL: URL(string: "https://example.com")!) // Call with siteId let result = try await client.supports(.blockEditorSettings, forSiteId: 12345) @@ -81,7 +81,7 @@ struct WordPressClientCachingTests { mockAPI.mockRoutes = ["/wp-block-editor/v1/settings", "/wp/v2/plugins"] mockAPI.mockIsBlockTheme = true - let client = try WordPressClient(api: mockAPI, rootUrl: .parse(input: "https://example.com")) + let client = try WordPressClient(api: mockAPI, siteURL: URL(string: "https://example.com")!) // Make multiple concurrent calls async let result1 = client.supports(.blockEditorSettings) 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/Sources/WordPressData/Swift/Blog+SelfHosted.swift b/Sources/WordPressData/Swift/Blog+SelfHosted.swift index 15cd91f5684a..0e844d654cbf 100644 --- a/Sources/WordPressData/Swift/Blog+SelfHosted.swift +++ b/Sources/WordPressData/Swift/Blog+SelfHosted.swift @@ -189,57 +189,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/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift b/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift index 063db8584535..b592d28f1449 100644 --- a/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift @@ -17,7 +17,10 @@ class UserListViewModelTests: XCTestCase { try await super.setUp() let api = try WordPressAPI(urlSession: .shared, apiRootUrl: .parse(input: "https://example.com/wp-json"), authentication: .none) - let client = try WordPressClient(api: api, rootUrl: .parse(input: "https://example.com")) + let client = try WordPressClient( + api: api, + siteURL: URL(string: "https://example.com")! + ) service = UserService(client: client) viewModel = await UserListViewModel(userService: service, currentUserId: 0) } 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..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,12 +7,37 @@ import WordPressCore import WordPressData import WordPressShared +public final class WordPressClientFactory: Sendable { + public static let shared = WordPressClientFactory() + + private let instances = OSAllocatedUnfairLock<[WordPressSite: WordPressClient]>(initialState: [:]) + private init() {} + + public func instance(for site: WordPressSite) -> WordPressClient { + instances.withLock { dict in + if let client = dict[site] { + return client + } else { + let client = WordPressClient(site: site) + dict[site] = client + return client + } + } + } + + public func reset() { + instances.withLock { dict in + dict.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, @@ -26,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, @@ -43,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 { @@ -74,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) } @@ -144,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 49a413638959..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,8 @@ 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 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/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/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/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index d4f6c2e97977..bcb93435e0c7 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/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 } 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/Blog Details/BlogDetailsTableViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift index 004ce9e18aa2..fa394defb9a4 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift @@ -531,6 +531,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)) @@ -606,6 +610,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 @@ -871,6 +879,7 @@ enum BlogDetailsRowKind { case themes case media case pages + case customPostTypes case activity case backup case scan @@ -977,6 +986,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/Blog/Site Settings/SiteSettingsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift index 95fc08be558c..9d4e7cc3ed77 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/CustomPostTypes/CustomPostEditor.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift new file mode 100644 index 000000000000..a3aaeba8c901 --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditor.swift @@ -0,0 +1,139 @@ +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() + + do { + guard let (title, content) = try await coordinator.getContent() else { return } + + try await update(title: title, content: content) + SVProgressHUD.showSuccess(withStatus: nil) + + dismiss() + success() + } catch { + SVProgressHUD.showError(withStatus: error.localizedDescription) + } + } + } + + 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, + 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) { + } +} + +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" + ) +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift new file mode 100644 index 000000000000..80f56408090f --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -0,0 +1,293 @@ +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 + ? String.localizedStringWithFormat(Strings.emptyStateMessage, 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) + } +} + +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") { + 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 new file mode 100644 index 000000000000..60522cb9140e --- /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 { + CustomPostMainView(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/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], + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift index 07f8adc79df4..55f525cd02f7 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift @@ -188,13 +188,13 @@ 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 } 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/NewGutenberg/SimpleGBKViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift new file mode 100644 index 000000000000..ddd80ffb923e --- /dev/null +++ b/WordPress/Classes/ViewRelated/NewGutenberg/SimpleGBKViewController.swift @@ -0,0 +1,146 @@ +import UIKit +import GutenbergKit +import CocoaLumberjackSwift +import WordPressData +import BuildSettingsKit +import WebKit +import DesignSystem + +class SimpleGBKViewController: UIViewController { + + private let blog: Blog + + private var editorViewController: GutenbergKit.EditorViewController + + init( + postID: Int, + postTitle: String?, + content: String, + blog: Blog, + postType: String? + ) { + self.blog = blog + + EditorLocalization.localize = { $0.localized } + + let editorConfiguration = EditorConfiguration(blog: blog, postType: postType ?? "post") + .toBuilder() + .setPostID(postID) + .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) + ) + + super.init(nibName: nil, bundle: nil) + + self.editorViewController.delegate = self + } + + required init?(coder aDecoder: NSCoder) { + fatalError() + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + setupEditorView() + + // Load auth cookies if needed (for private sites) + Task { + await loadAuthenticationCookiesAsync() + } + } + + 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 + } + + private func loadAuthenticationCookiesAsync() async { + guard blog.isPrivate() else { + return + } + + guard let authenticator = RequestAuthenticator(blog: blog), + let blogURL = blog.url, + let authURL = URL(string: blogURL) else { + return + } + + let cookieJar = WKWebsiteDataStore.default().httpCookieStore + + await withCheckedContinuation { (continuation: CheckedContinuation) in + authenticator.request(url: authURL, cookieJar: cookieJar) { _ in + DDLogInfo("Authentication cookies loaded into shared cookie store for GutenbergKit") + continuation.resume() + } + } + } + + 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) { + // Editor loaded successfully - no loading indicator needed with new approach + } + + 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, didLogNetworkRequest request: GutenbergKit.RecordedNetworkRequest) { + } + + 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) { + } + + func editorDidRequestLatestContent(_ controller: GutenbergKit.EditorViewController) -> (title: String, content: String)? { + return nil + } +} 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)