diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 64b05a652b85..e7eaf5ded3a0 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "70be48d0eb082238f9389749632f80f30c4c499b77ac89d84c8acab08c45509a", + "originHash" : "e9907822fa3e9d2679666c6b7d1447b8ae011d657029de690bb26e25c06166ca", "pins" : [ { "identity" : "alamofire", @@ -390,8 +390,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Automattic/wordpress-rs", "state" : { - "branch" : "alpha-20260114", - "revision" : "4895b1bf67136eeeca5eb792418749c23ec5726d" + "branch" : "alpha-20260122v2", + "revision" : "6a34b2b745022debb9b7ab8487068fe1eb7f58aa" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 11474b9270ee..9e4dbe3bf14e 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.12.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-20260122v2"), .package( url: "https://github.com/Automattic/color-studio", revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de" diff --git a/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index 23d31f4628f8..6f9dc1d6ef11 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -1,13 +1,112 @@ import Foundation import WordPressAPI +import WordPressAPIInternal + +/// Protocol defining the WordPress API methods that WordPressClient needs. +/// This abstraction allows for mocking in tests using the `NoHandle` constructors +/// available on the executor classes. +public protocol WordPressClientAPI: Sendable { + var apiRoot: ApiRootRequestExecutor { get } + var users: UsersRequestExecutor { get } + var themes: ThemesRequestExecutor { get } + var plugins: PluginsRequestExecutor { get } + var comments: CommentsRequestExecutor { get } + var media: MediaRequestExecutor { get } + var taxonomies: TaxonomiesRequestExecutor { get } + var terms: TermsRequestExecutor { get } + var applicationPasswords: ApplicationPasswordsRequestExecutor { get } + + func uploadMedia( + params: MediaCreateParams, + fulfilling progress: Progress + ) async throws -> MediaRequestCreateResponse +} + +/// WordPressAPI already has these properties with the correct types, +/// so conformance is automatic. +extension WordPressAPI: WordPressClientAPI {} public actor WordPressClient { - public let api: WordPressAPI + public enum Feature { + /// A block theme is required to style the editor. + case blockTheme + + /// The block editor settings API is required to style the editor. + case blockEditorSettings + + /// Application Password Extras grants additional capabilities using Application Passwords. + case applicationPasswordExtras + + /// WordPress.com sites don't all support plugins. + case plugins + + public var stringValue: String { + switch self { + case .blockTheme: "is-block-theme" + case .blockEditorSettings: "block-editor-settings" + case .applicationPasswordExtras: "application-password-extras" + case .plugins: "plugins" + } + } + } + + public let api: any WordPressClientAPI public let rootUrl: String - public init(api: WordPressAPI, rootUrl: ParsedUrl) { + private var loadSiteInfoTask: Task<(WpApiDetails, UserWithEditContext, ThemeWithEditContext?), Error> + + public init(api: any WordPressClientAPI, rootUrl: ParsedUrl) { self.api = api self.rootUrl = rootUrl.url() + + self.loadSiteInfoTask = Task { [api] in + async let apiRootTask = try await api.apiRoot.get().data + async let currentUserTask = try await api.users.retrieveMeWithEditContext().data + async let activeThemeTask = try await api.themes.listWithEditContext( + params: ThemeListParams(status: .active) + ).data.first(where: { $0.status == .active }) + + return try await (apiRootTask, currentUserTask, activeThemeTask) + } + } + + public func supports(_ feature: Feature, forSiteId siteId: Int? = nil) async throws -> Bool { + let apiRoot = try await fetchApiRoot() + let isBlockTheme: Bool = try await fetchActiveTheme()?.isBlockTheme ?? false + + if let siteId { + return switch feature { + case .blockEditorSettings: apiRoot.hasRoute(route: "/wp-block-editor/v1/sites/\(siteId)/settings") + case .blockTheme: isBlockTheme + case .plugins: apiRoot.hasRoute(route: "/wp/v2/plugins") + case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax") + } + } + + return switch feature { + case .blockEditorSettings: apiRoot.hasRoute(route: "/wp-block-editor/v1/settings") + case .blockTheme: isBlockTheme + case .plugins: apiRoot.hasRoute(route: "/wp/v2/plugins") + case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax") + } + } + + /// Asynchronously read the site's API details. This value is cached internally. + /// + private var apiRoot: WpApiDetails { + get async throws { + try await self.fetchApiRoot() + } + } + + private func fetchApiRoot() async throws -> WpApiDetails { + // Wait for the `loadSiteInfoTask` to finish the initial load then use that value + return try await loadSiteInfoTask.value.0 + } + + private func fetchActiveTheme() async throws -> ThemeWithEditContext? { + // Wait for the `loadSiteInfoTask` to finish the initial load then use that value + return try await loadSiteInfoTask.value.2 } } diff --git a/Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift b/Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift new file mode 100644 index 000000000000..ce645b8cf857 --- /dev/null +++ b/Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift @@ -0,0 +1,180 @@ +import Foundation +import WordPressAPI +import WordPressAPIInternal +@testable import WordPressCore + +/// Tracks call counts for API methods to verify caching behavior. +final class MockWordPressClientAPI: WordPressClientAPI, @unchecked Sendable { + private let lock = NSLock() + + private var _apiRootCallCount = 0 + private var _usersCallCount = 0 + private var _themesCallCount = 0 + + var apiRootCallCount: Int { + lock.lock() + defer { lock.unlock() } + return _apiRootCallCount + } + + var usersCallCount: Int { + lock.lock() + defer { lock.unlock() } + return _usersCallCount + } + + var themesCallCount: Int { + lock.lock() + defer { lock.unlock() } + return _themesCallCount + } + + var mockRoutes: Set = [] + var mockIsBlockTheme: Bool = false + + var apiRoot: ApiRootRequestExecutor { + lock.lock() + _apiRootCallCount += 1 + lock.unlock() + return MockApiRootRequestExecutor(routes: mockRoutes) + } + + var users: UsersRequestExecutor { + lock.lock() + _usersCallCount += 1 + lock.unlock() + return MockUsersRequestExecutor(noHandle: UsersRequestExecutor.NoHandle()) + } + + var themes: ThemesRequestExecutor { + lock.lock() + _themesCallCount += 1 + lock.unlock() + return MockThemesRequestExecutor(isBlockTheme: mockIsBlockTheme) + } + + // Unused in WordPressClient.supports() - provide minimal implementations + var plugins: PluginsRequestExecutor { fatalError("Not implemented") } + var comments: CommentsRequestExecutor { fatalError("Not implemented") } + var media: MediaRequestExecutor { fatalError("Not implemented") } + var taxonomies: TaxonomiesRequestExecutor { fatalError("Not implemented") } + var terms: TermsRequestExecutor { fatalError("Not implemented") } + var applicationPasswords: ApplicationPasswordsRequestExecutor { fatalError("Not implemented") } + + func uploadMedia(params: MediaCreateParams, fulfilling progress: Progress) async throws -> MediaRequestCreateResponse { + fatalError("Not implemented") + } +} + +// MARK: - Mock Executors + +final class MockApiRootRequestExecutor: ApiRootRequestExecutor { + private var routes: Set + + init(routes: Set) { + self.routes = routes + super.init(noHandle: ApiRootRequestExecutor.NoHandle()) + } + + required init(unsafeFromHandle handle: UInt64) { + self.routes = [] + super.init(unsafeFromHandle: handle) + } + + override func getCancellation(context: RequestContext?) async throws -> ApiRootRequestGetResponse { + let mockApiDetails = MockWpApiDetails(routes: routes) + let mockHeaderMap = WpNetworkHeaderMap(noHandle: WpNetworkHeaderMap.NoHandle()) + return ApiRootRequestGetResponse(data: mockApiDetails, headerMap: mockHeaderMap) + } +} + +final class MockUsersRequestExecutor: UsersRequestExecutor { + override init(noHandle: UsersRequestExecutor.NoHandle) { + super.init(noHandle: noHandle) + } + + required init(unsafeFromHandle handle: UInt64) { + super.init(unsafeFromHandle: handle) + } + + override func retrieveMeWithEditContextCancellation(context: RequestContext?) async throws -> UsersRequestRetrieveMeWithEditContextResponse { + let mockUser = UserWithEditContext( + id: UserId(1), + username: "testuser", + name: "Test User", + firstName: "Test", + lastName: "User", + email: "test@example.com", + url: "", + description: "", + link: "https://example.com/author/testuser", + locale: "en_US", + nickname: "testuser", + slug: "testuser", + registeredDate: "2024-01-01T00:00:00", + roles: [], + capabilities: [:], + extraCapabilities: [:], + avatarUrls: nil + ) + let mockHeaderMap = WpNetworkHeaderMap(noHandle: WpNetworkHeaderMap.NoHandle()) + return UsersRequestRetrieveMeWithEditContextResponse(data: mockUser, headerMap: mockHeaderMap) + } +} + +final class MockThemesRequestExecutor: ThemesRequestExecutor { + private var isBlockTheme: Bool + + init(isBlockTheme: Bool) { + self.isBlockTheme = isBlockTheme + super.init(noHandle: ThemesRequestExecutor.NoHandle()) + } + + required init(unsafeFromHandle handle: UInt64) { + self.isBlockTheme = false + super.init(unsafeFromHandle: handle) + } + + override func listWithEditContextCancellation(params: ThemeListParams, context: RequestContext?) async throws -> ThemesRequestListWithEditContextResponse { + let mockTheme = ThemeWithEditContext( + stylesheet: ThemeStylesheet(value: "twentytwentyfour"), + template: "twentytwentyfour", + requiresPhp: "7.0", + requiresWp: "6.4", + textdomain: "twentytwentyfour", + version: "1.0", + screenshot: "", + author: ThemeAuthor(raw: "WordPress", rendered: "WordPress"), + authorUri: ThemeAuthorUri(raw: "", rendered: ""), + description: ThemeDescription(raw: "", rendered: ""), + name: ThemeName(raw: "Twenty Twenty-Four", rendered: "Twenty Twenty-Four"), + tags: ThemeTags(raw: [], rendered: ""), + themeUri: ThemeUri(raw: "", rendered: ""), + status: .active, + isBlockTheme: isBlockTheme, + stylesheetUri: "", + templateUri: "", + themeSupports: nil + ) + let mockHeaderMap = WpNetworkHeaderMap(noHandle: WpNetworkHeaderMap.NoHandle()) + return ThemesRequestListWithEditContextResponse(data: [mockTheme], headerMap: mockHeaderMap) + } +} + +final class MockWpApiDetails: WpApiDetails { + private var routes: Set + + init(routes: Set) { + self.routes = routes + super.init(noHandle: WpApiDetails.NoHandle()) + } + + required init(unsafeFromHandle handle: UInt64) { + self.routes = [] + super.init(unsafeFromHandle: handle) + } + + override func hasRoute(route: String) -> Bool { + routes.contains(route) + } +} diff --git a/Modules/Tests/WordPressCoreTests/WordPressClientFeatureTests.swift b/Modules/Tests/WordPressCoreTests/WordPressClientFeatureTests.swift new file mode 100644 index 000000000000..77b0d3aa5a7b --- /dev/null +++ b/Modules/Tests/WordPressCoreTests/WordPressClientFeatureTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing +import WordPressAPI +import WordPressAPIInternal +@testable import WordPressCore + +@Suite +struct WordPressClientFeatureTests { + + @Test + func featureStringValues() { + // These should never change – doing so will cause settings data to be lost + #expect(WordPressClient.Feature.blockTheme.stringValue == "is-block-theme") + #expect(WordPressClient.Feature.blockEditorSettings.stringValue == "block-editor-settings") + #expect(WordPressClient.Feature.applicationPasswordExtras.stringValue == "application-password-extras") + #expect(WordPressClient.Feature.plugins.stringValue == "plugins") + } +} + +@Suite("API Caching Behavior") +struct WordPressClientCachingTests { + + @Test + func supports_cachesAPIResponses_doesNotRefetch() async throws { + let mockAPI = MockWordPressClientAPI() + mockAPI.mockRoutes = ["/wp-block-editor/v1/settings"] + mockAPI.mockIsBlockTheme = true + + let client = try WordPressClient(api: mockAPI, rootUrl: .parse(input: "https://example.com")) + + // First call - should trigger API fetches + let result1 = try await client.supports(.blockEditorSettings) + #expect(result1 == true) + + // Verify API was called once + #expect(mockAPI.apiRootCallCount == 1) + #expect(mockAPI.usersCallCount == 1) + #expect(mockAPI.themesCallCount == 1) + + // Second call - should use cached Task, not refetch + let result2 = try await client.supports(.blockTheme) + #expect(result2 == true) + + // Verify API was NOT called again + #expect(mockAPI.apiRootCallCount == 1) + #expect(mockAPI.usersCallCount == 1) + #expect(mockAPI.themesCallCount == 1) + + // Third call with different feature - still uses cache + let result3 = try await client.supports(.plugins) + #expect(result3 == false) // Route not in mockRoutes + + // Still no additional API calls + #expect(mockAPI.apiRootCallCount == 1) + #expect(mockAPI.usersCallCount == 1) + #expect(mockAPI.themesCallCount == 1) + } + + @Test + func supports_withSiteId_usesCachedData() async throws { + let mockAPI = MockWordPressClientAPI() + mockAPI.mockRoutes = ["/wp-block-editor/v1/sites/12345/settings"] + + let client = try WordPressClient(api: mockAPI, rootUrl: .parse(input: "https://example.com")) + + // Call with siteId + let result = try await client.supports(.blockEditorSettings, forSiteId: 12345) + #expect(result == true) + + // Second call with different siteId - uses same cached data + let result2 = try await client.supports(.blockEditorSettings, forSiteId: 99999) + #expect(result2 == false) // Different siteId, route not found + + // API was only called once total + #expect(mockAPI.apiRootCallCount == 1) + } + + @Test + func supports_concurrentCalls_onlyFetchesOnce() async throws { + let mockAPI = MockWordPressClientAPI() + 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")) + + // Make multiple concurrent calls + async let result1 = client.supports(.blockEditorSettings) + async let result2 = client.supports(.blockTheme) + async let result3 = client.supports(.plugins) + async let result4 = client.supports(.applicationPasswordExtras) + + let results = try await [result1, result2, result3, result4] + + #expect(results[0] == true) // blockEditorSettings + #expect(results[1] == true) // blockTheme + #expect(results[2] == true) // plugins + #expect(results[3] == false) // applicationPasswordExtras (not in routes) + + // Despite 4 concurrent calls, API should only be called once + #expect(mockAPI.apiRootCallCount == 1) + #expect(mockAPI.usersCallCount == 1) + #expect(mockAPI.themesCallCount == 1) + } +} diff --git a/Tests/KeystoneTests/Tests/Features/Gutenberg/GutenbergSettingsTests.swift b/Tests/KeystoneTests/Tests/Features/Gutenberg/GutenbergSettingsTests.swift index 9d977a8a3a0d..6fb033952bd2 100644 --- a/Tests/KeystoneTests/Tests/Features/Gutenberg/GutenbergSettingsTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Gutenberg/GutenbergSettingsTests.swift @@ -1,3 +1,4 @@ +import WordPressCore import WordPressShared import XCTest @testable import WordPress @@ -293,6 +294,68 @@ class GutenbergSettingsTests: CoreDataTestCase { } } + // MARK: - Feature Support Tests + + func testGetSupportsFalseByDefault() { + XCTAssertFalse(settings.getSupports(.blockTheme, for: blog)) + XCTAssertFalse(settings.getSupports(.blockEditorSettings, for: blog)) + XCTAssertFalse(settings.getSupports(.plugins, for: blog)) + } + + func testSetAndGetSupports() { + settings.setSupports(.blockTheme, true, for: blog) + XCTAssertTrue(settings.getSupports(.blockTheme, for: blog)) + + settings.setSupports(.blockTheme, false, for: blog) + XCTAssertFalse(settings.getSupports(.blockTheme, for: blog)) + } + + func testSupportsFeaturesStoredSeparately() { + settings.setSupports(.blockTheme, true, for: blog) + settings.setSupports(.blockEditorSettings, false, for: blog) + + XCTAssertTrue(settings.getSupports(.blockTheme, for: blog)) + XCTAssertFalse(settings.getSupports(.blockEditorSettings, for: blog)) + } + + func testSupportsSeparatePerBlog() { + let blog2 = newTestBlog() + + settings.setSupports(.blockTheme, true, for: blog) + settings.setSupports(.blockTheme, false, for: blog2) + + XCTAssertTrue(settings.getSupports(.blockTheme, for: blog)) + XCTAssertFalse(settings.getSupports(.blockTheme, for: blog2)) + } + + func testCanEnableThemeStyleSettingRequiresBothFeatures() { + // Helper that mirrors GutenbergSettingsBridge.canEnableThemeStyleSetting logic + func canEnableThemeStyleSetting() -> Bool { + settings.getSupports(.blockEditorSettings, for: blog) && settings.getSupports(.blockTheme, for: blog) + } + + // Neither supported + XCTAssertFalse(canEnableThemeStyleSetting()) + + // Only blockTheme + settings.setSupports(.blockTheme, true, for: blog) + XCTAssertFalse(canEnableThemeStyleSetting()) + + // Both supported + settings.setSupports(.blockEditorSettings, true, for: blog) + XCTAssertTrue(canEnableThemeStyleSetting()) + } + + func testIsThemeStylesEnabledDefaultsToFalse() { + // Before capabilities are fetched, should return false + XCTAssertFalse(settings.isThemeStylesEnabled(for: blog)) + } + + func testIsThemeStylesEnabledReflectsBlockEditorSettingsSupport() { + settings.setSupports(.blockEditorSettings, true, for: blog) + XCTAssertTrue(settings.isThemeStylesEnabled(for: blog)) + } + // mark - Phase 2 func testPhase2RolloutMigration() { diff --git a/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift b/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift index 1d8413bb3e4b..063db8584535 100644 --- a/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift @@ -16,7 +16,8 @@ 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 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")) service = UserService(client: client) viewModel = await UserListViewModel(userService: service, currentUserId: 0) } diff --git a/WordPress/Classes/Utility/Editor/EditorDependencyManager.swift b/WordPress/Classes/Utility/Editor/EditorDependencyManager.swift index 21b3c2616814..de80152328ac 100644 --- a/WordPress/Classes/Utility/Editor/EditorDependencyManager.swift +++ b/WordPress/Classes/Utility/Editor/EditorDependencyManager.swift @@ -149,6 +149,52 @@ final class EditorDependencyManager: Sendable { // No need to use `removeAll` for the `invalidationTasks` } + /// Query the server for its editor capabilities, and update the local editor settings store with the result. + /// + @MainActor + public func fetchEditorCapabilities(for blog: Blog) async throws { + let site = try WordPressSite(blog: blog) + let client = WordPressClient(site: site) + + var siteId: Int? = nil + + if case .dotCom(let _siteId, _) = site { + siteId = _siteId + } + + let hasBlockTheme = try await client.supports(.blockTheme, forSiteId: siteId) + let hasBlockSettings = try await client.supports(.blockEditorSettings, forSiteId: siteId) + let supportsPlugins = try await client.supports(.plugins, forSiteId: siteId) + + GutenbergSettings() + .setSupports(.blockEditorSettings, hasBlockSettings, for: blog) + .setSupports(.blockTheme, hasBlockTheme, for: blog) + .setSupports(.plugins, supportsPlugins, for: blog) + } + + /// Query the server for its editor capabilities, and update the local editor settings store with the result. + /// + /// Returns immediately and ignores errors – prefer the `async` version of this method. + /// + public func fetchEditorCapabilities(for blog: Blog) { + let key = blog.locallyUniqueId + "-capabilities" + + // Don't allow more than one concurrent invalidation + if self.prefetchTasks[key] != nil { + return + } + + self.prefetchTasks[key] = Task { + do { + try await self.fetchEditorCapabilities(for: blog) + } catch { + DDLogError("EditorDependencyManager: Failed to fetch editor capabilities: \(error)") + } + + self.prefetchTasks[key] = nil + } + } + private func cacheKey(for blog: Blog) -> String { blog.objectID.uriRepresentation().absoluteString } diff --git a/WordPress/Classes/Utility/Editor/GutenbergSettings.swift b/WordPress/Classes/Utility/Editor/GutenbergSettings.swift index 317cad269e22..f0b917e8fc95 100644 --- a/WordPress/Classes/Utility/Editor/GutenbergSettings.swift +++ b/WordPress/Classes/Utility/Editor/GutenbergSettings.swift @@ -2,6 +2,8 @@ import Foundation import WordPressData import WordPressShared +import WordPressCore + /// Takes care of storing and accessing Gutenberg settings. /// class GutenbergSettings { @@ -226,15 +228,7 @@ class GutenbergSettings { /// - Parameter blog: The blog to check theme styles setting for /// - Returns: true if theme styles are enabled (default: true), false if explicitly disabled func isThemeStylesEnabled(for blog: Blog) -> Bool { - let key = Key.themeStylesEnabled(forBlogURL: blog.url) - - // If the preference has been explicitly set, return its value - if database.object(forKey: key) != nil { - return database.bool(forKey: key) - } - - // Default to enabled for sites that haven't set a preference - return true + return getSupports(.blockEditorSettings, for: blog) } /// Sets whether theme styles should be enabled for the given blog. @@ -245,6 +239,32 @@ class GutenbergSettings { func setThemeStylesEnabled(_ isEnabled: Bool, for blog: Blog) { database.set(isEnabled, forKey: Key.themeStylesEnabled(forBlogURL: blog.url)) } + + /// Sets whether the given API feature is available for the given blog + /// + /// - Parameters: + /// - isEnabled: Whether to enable theme styles + /// - blog: The blog to set theme styles setting for + @discardableResult + func setSupports(_ feature: WordPressClient.Feature, _ newValue: Bool, for blog: Blog) -> Self { + let key = "org.wordpress.gutenberg-supports-" + feature.stringValue + "-" + blog.locallyUniqueId + database.set(newValue, forKey: key) + return self + } + + /// Returns whether the given API feature is available for the given blog. + /// + /// - Parameter blog: The blog to check the given API feature for + /// - Returns: true if the feature is available, false if the server hasn't been queried for support yet, or if the server doesn't support it. + func getSupports(_ feature: WordPressClient.Feature, for blog: Blog) -> Bool { + let key = "org.wordpress.gutenberg-supports-" + feature.stringValue + "-" + blog.locallyUniqueId + + if database.object(forKey: key) != nil { + return database.bool(forKey: key) + } + + return false + } } @objc(GutenbergSettings) @@ -273,6 +293,12 @@ public class GutenbergSettingsBridge: NSObject { public static func setThemeStylesEnabled(_ isEnabled: Bool, for blog: Blog) { GutenbergSettings().setThemeStylesEnabled(isEnabled, for: blog) } + + @objc(isThemeStylesSupportedForBlog:) + public static func canEnableThemeStyleSetting(for blog: Blog) -> Bool { + let settings = GutenbergSettings() + return settings.getSupports(.blockEditorSettings, for: blog) && settings.getSupports(.blockTheme, for: blog) + } } private extension String { diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift index 28c049b09160..6d9bc6efda5c 100644 --- a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift @@ -405,6 +405,9 @@ final class MySiteViewController: UIViewController, UIScrollViewDelegate, NoSite if RemoteFeatureFlag.newGutenberg.enabled() { warmUpEditorIfNeeded(for: blog) + + // Refresh editor capabilities + EditorDependencyManager.shared.fetchEditorCapabilities(for: blog) } } @@ -430,6 +433,10 @@ final class MySiteViewController: UIViewController, UIScrollViewDelegate, NoSite guard let blog else { return } + + // Refresh editor capabilities + EditorDependencyManager.shared.fetchEditorCapabilities(for: blog) + switch currentSection { case .siteMenu: diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift index 60a1fc770978..c011289a02d2 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift @@ -75,6 +75,11 @@ extension SiteSettingsViewController { self.navigationController?.pushViewController(viewController, animated: true) } + @objc public func swiftRefreshSettings() { + // Refresh editor capabilities + EditorDependencyManager.shared.fetchEditorCapabilities(for: self.blog) + } + // MARK: - Timezone func formattedTimezoneValue() -> String? { @@ -200,7 +205,15 @@ extension SiteSettingsViewController { @objc(getThemeStylesSectionFooterView) public func themeStylesSectionFooterView() -> UIView { let footer = makeFooterView() - footer.textLabel?.text = NSLocalizedString("Make the block editor look like your theme.", comment: "Explanation for the option to enable theme styles") + let settings = GutenbergSettings() + if !settings.getSupports(.blockTheme, for: self.blog) { + footer.textLabel?.text = Strings.themeStylesFooterBlockThemeRequired + } else if !settings.getSupports(.blockEditorSettings, for: self.blog) { + footer.textLabel?.text = Strings.themeStylesFooterGutenbergRequired + } else { + footer.textLabel?.text = Strings.themeStylesFooterEnabled + } + return footer } @@ -445,6 +458,24 @@ extension SiteSettingsViewController { private extension SiteSettingsViewController { enum Strings { static let privacyTitle = NSLocalizedString("siteSettings.privacy.title", value: "Privacy", comment: "Title for screen to select the privacy options for a blog") + + static let themeStylesFooterBlockThemeRequired = NSLocalizedString( + "siteSettings.themeStyles.footer.blockThemeRequired", + value: "Switch to a theme that supports blocks to use theme styles.", + comment: "Explanation for why the 'Use theme styles' toggle is disabled when the site doesn't have a block theme" + ) + + static let themeStylesFooterGutenbergRequired = NSLocalizedString( + "siteSettings.themeStyles.footer.gutenbergRequired", + value: "Install the Gutenberg Plugin on your site to activate theme style support.", + comment: "Explanation for why the 'Use theme styles' toggle is disabled when the site doesn't have the Gutenberg plugin" + ) + + static let themeStylesFooterEnabled = NSLocalizedString( + "siteSettings.themeStyles.footer.enabled", + value: "Make the block editor look like your theme.", + comment: "Explanation for the option to enable theme styles when the feature is available" + ) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m index 0c04236760d3..78fdc3c045d6 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m @@ -362,6 +362,11 @@ - (SwitchTableViewCell *)themeStylesSelectorCell _themeStylesSelectorCell.onChange = ^(BOOL value){ [GutenbergSettings setThemeStylesEnabled:value forBlog:blog]; }; + + // Default to greyed-out, only make the control interactive if theme styles are supported + _themeStylesSelectorCell.flipSwitch.enabled = false; + _themeStylesSelectorCell.textLabel.textColor = UIColor.lightGrayColor; + [self.themeStylesSelectorCell setOn:false]; } return _themeStylesSelectorCell; } @@ -529,7 +534,11 @@ - (void)configureEditorSelectorCell - (void)configureThemeStylesSelectorCell { - [self.themeStylesSelectorCell setOn:[GutenbergSettings isThemeStylesEnabledForBlog:self.blog]]; + if([GutenbergSettings isThemeStylesSupportedForBlog: self.blog]){ + _themeStylesSelectorCell.flipSwitch.enabled = true; + _themeStylesSelectorCell.textLabel.textColor = UIColor.labelColor; + [self.themeStylesSelectorCell setOn:[GutenbergSettings isThemeStylesEnabledForBlog:self.blog]]; + } } - (void)configureDefaultCategoryCell @@ -1025,6 +1034,7 @@ - (void)refreshData [weakSelf.refreshControl endRefreshing]; }]; + [self swiftRefreshSettings]; } #pragma mark - Authentication methods @@ -1224,3 +1234,4 @@ - (void)handleAccountChange:(NSNotification *)notification } @end +