From 56da6d2eb993380b66ce1aaaa3ed9ed9f0319bef Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 09:38:16 +1200 Subject: [PATCH 1/9] Format files in preparation for CMM-2069 --- .../WordPressData/Swift/PostMetadata.swift | 5 +- .../PostSettings/PostSettingsViewModel.swift | 54 ++++++++++++------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/Modules/Sources/WordPressData/Swift/PostMetadata.swift b/Modules/Sources/WordPressData/Swift/PostMetadata.swift index 27ce43fca15d..e0e9578ca22e 100644 --- a/Modules/Sources/WordPressData/Swift/PostMetadata.swift +++ b/Modules/Sources/WordPressData/Swift/PostMetadata.swift @@ -28,7 +28,10 @@ public struct PostMetadata: Hashable { container.accessLevel = accessLevel } if previous.isJetpackNewsletterEmailDisabled != isJetpackNewsletterEmailDisabled { - container.setValue(String(describing: isJetpackNewsletterEmailDisabled), for: .jetpackNewsletterEmailDisabled) + container.setValue( + String(describing: isJetpackNewsletterEmailDisabled), + for: .jetpackNewsletterEmailDisabled + ) } } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index 92fd79b783f4..1fc5bd0eb576 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -104,7 +104,8 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM var postFormatText: String { guard capabilities.supportsPostFormats else { return "" } - return blog.postFormatText(fromSlug: settings.postFormat) ?? NSLocalizedString("Standard", comment: "Default post format") + return blog.postFormatText(fromSlug: settings.postFormat) + ?? NSLocalizedString("Standard", comment: "Default post format") } var timeZone: TimeZone { @@ -204,9 +205,11 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM super.init() // Observe selection changes from featured image view model - featuredImageViewModel?.$selection.dropFirst().sink { [weak self] media in - self?.settings.featuredImageID = media?.mediaID?.intValue - }.store(in: &cancellables) + featuredImageViewModel?.$selection.dropFirst() + .sink { [weak self] media in + self?.settings.featuredImageID = media?.mediaID?.intValue + } + .store(in: &cancellables) // Initialize all cached properties refreshDisplayedCategories() @@ -254,20 +257,26 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM self.track(.intelligenceSuggestedTagsGenerated, properties: ["count": tags.count]) } catch { guard let self else { return } - self.track(.intelligenceGenerationFailed, properties: ["description": (error as NSError).debugDescription]) + self.track( + .intelligenceGenerationFailed, + properties: ["description": (error as NSError).debugDescription] + ) } } - cancellables.insert(AnyCancellable { - task.cancel() - }) + cancellables.insert( + AnyCancellable { + task.cancel() + } + ) } private func refreshCustomTaxonomies() { - let postType: String? = switch post { - case is Post: "post" - case is Page: "page" - default: nil - } + let postType: String? = + switch post { + case is Post: "post" + case is Page: "page" + default: nil + } guard let postType else { customTaxonomies = [] return @@ -331,8 +340,9 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM private func refreshParentPageText() { if let page = post as? Page, - let context = page.managedObjectContext, - let parentPageID = settings.parentPageID { + let context = page.managedObjectContext, + let parentPageID = settings.parentPageID + { parentPageText = Page.parentPageText(in: context, parentID: NSNumber(value: parentPageID)) } else { parentPageText = nil @@ -348,7 +358,8 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM func buttonSaveTapped() { // Check if the post still exists guard let context = post.managedObjectContext, - let _ = try? context.existingObject(with: post.objectID) else { + let _ = try? context.existingObject(with: post.objectID) + else { isShowingDeletedAlert = true return } @@ -400,7 +411,8 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM func buttonPublishTapped() { // Check if the post still exists guard let context = post.managedObjectContext, - let _ = try? context.existingObject(with: post.objectID) else { + let _ = try? context.existingObject(with: post.objectID) + else { isShowingDeletedAlert = true return } @@ -490,8 +502,9 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM private var isSocialConnectionSetupDismissed: Bool { get { guard let blogID = blog.dotComID?.intValue, - let dictionary = preferences.dictionary(forKey: Constants.noConnectionKey) as? [String: Bool], - let value = dictionary["\(blogID)"] else { + let dictionary = preferences.dictionary(forKey: Constants.noConnectionKey) as? [String: Bool], + let value = dictionary["\(blogID)"] + else { return false } return value @@ -534,7 +547,8 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM func showSocialSharingOptions() { guard let blogID = blog.dotComID?.intValue, - let settings = settings.sharing else { + let settings = settings.sharing + else { return wpAssertionFailure("invalid context") } let optionsVC = PrepublishingSocialAccountsViewController( From 79ce0d1efca02f9986c57fa941df960b1e4a39fc Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 09:52:25 +1200 Subject: [PATCH 2/9] Add PostMeta accessors for Jetpack newsletter meta keys --- .../PostMetaJetpackNewsletterTests.swift | 61 +++++++++++++++++++ .../PostMeta+JetpackNewsletter.swift | 42 +++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 Tests/KeystoneTests/Tests/Features/Posts/PostMetaJetpackNewsletterTests.swift create mode 100644 WordPress/Classes/ViewRelated/Post/PostSettings/Extensions/PostMeta+JetpackNewsletter.swift diff --git a/Tests/KeystoneTests/Tests/Features/Posts/PostMetaJetpackNewsletterTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/PostMetaJetpackNewsletterTests.swift new file mode 100644 index 000000000000..2f1bcb2f3061 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Posts/PostMetaJetpackNewsletterTests.swift @@ -0,0 +1,61 @@ +import Testing +import Foundation +import WordPressAPIInternal +import WordPressData + +@testable import WordPress + +struct PostMetaJetpackNewsletterTests { + + // MARK: - Access Level + + @Test("addingJetpackNewsletterAccess round-trips through jetpackNewsletterAccess") + func accessLevelRoundTrip() { + let meta = PostMeta().addingJetpackNewsletterAccess(.subscribers) + #expect(meta.jetpackNewsletterAccess == .subscribers) + } + + @Test("addingJetpackNewsletterAccess supports paid_subscribers") + func accessLevelPaidSubscribers() { + let meta = PostMeta().addingJetpackNewsletterAccess(.paidSubscribers) + #expect(meta.jetpackNewsletterAccess == .paidSubscribers) + } + + @Test("addingJetpackNewsletterAccess with nil clears the value") + func accessLevelClear() { + let meta = PostMeta() + .addingJetpackNewsletterAccess(.subscribers) + .addingJetpackNewsletterAccess(nil) + #expect(meta.jetpackNewsletterAccess == nil) + } + + @Test("jetpackNewsletterAccess returns nil when key is absent") + func accessLevelAbsent() { + #expect(PostMeta().jetpackNewsletterAccess == nil) + } + + @Test("jetpackNewsletterAccess returns nil for unknown raw values") + func accessLevelUnknownRawValue() { + let meta = PostMeta().withValue(key: "_jetpack_newsletter_access", value: .string("not_a_level")) + #expect(meta.jetpackNewsletterAccess == nil) + } + + // MARK: - Email Disabled + + @Test("addingJetpackNewsletterEmailDisabled true round-trips") + func emailDisabledTrueRoundTrip() { + let meta = PostMeta().addingJetpackNewsletterEmailDisabled(true) + #expect(meta.isJetpackNewsletterEmailDisabled) + } + + @Test("addingJetpackNewsletterEmailDisabled false round-trips") + func emailDisabledFalseRoundTrip() { + let meta = PostMeta().addingJetpackNewsletterEmailDisabled(false) + #expect(!meta.isJetpackNewsletterEmailDisabled) + } + + @Test("isJetpackNewsletterEmailDisabled returns false when key is absent") + func emailDisabledAbsent() { + #expect(!PostMeta().isJetpackNewsletterEmailDisabled) + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Extensions/PostMeta+JetpackNewsletter.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Extensions/PostMeta+JetpackNewsletter.swift new file mode 100644 index 000000000000..e17d6b529307 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Extensions/PostMeta+JetpackNewsletter.swift @@ -0,0 +1,42 @@ +import Foundation +import WordPressAPIInternal +import WordPressData + +extension PostMeta { + /// The Jetpack Newsletter access level stored in this meta, if any. + /// + /// Jetpack registers `_jetpack_newsletter_access` for the built-in `post` + /// type only (see `extensions/blocks/subscriptions/subscriptions.php` in + /// the Jetpack plugin). + var jetpackNewsletterAccess: JetpackPostAccessLevel? { + guard case let .string(raw)? = valueForKey(key: Self.newsletterAccessKey) else { + return nil + } + return JetpackPostAccessLevel(rawValue: raw) + } + + /// Returns a new `PostMeta` with the access level set. Pass `nil` to + /// clear any previously-saved value. + func addingJetpackNewsletterAccess(_ accessLevel: JetpackPostAccessLevel?) -> PostMeta { + let value: JsonValue = accessLevel.map { .string($0.rawValue) } ?? .null + return withValue(key: Self.newsletterAccessKey, value: value) + } + + /// Whether the post is configured to NOT be sent in an email to + /// subscribers. Defaults to `false` when the key is absent, matching + /// `PostMetadataContainer.getAdaptiveBool` semantics. + var isJetpackNewsletterEmailDisabled: Bool { + guard case let .bool(value)? = valueForKey(key: Self.dontEmailKey) else { + return false + } + return value + } + + /// Returns a new `PostMeta` with the "don't email" flag set. + func addingJetpackNewsletterEmailDisabled(_ disabled: Bool) -> PostMeta { + withValue(key: Self.dontEmailKey, value: .bool(disabled)) + } + + private static let newsletterAccessKey = "_jetpack_newsletter_access" + private static let dontEmailKey = "_jetpack_dont_email_post_to_subs" +} From d7ecc56288aa328d81bf6f9a5d968eefd2b78035 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 10:01:01 +1200 Subject: [PATCH 3/9] Add memberwise init to PostMetadata --- .../WordPressData/Swift/PostMetadata.swift | 5 +++++ .../WordPressDataTests/PostMetadataTests.swift | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/Modules/Sources/WordPressData/Swift/PostMetadata.swift b/Modules/Sources/WordPressData/Swift/PostMetadata.swift index e0e9578ca22e..1f1702ed0d51 100644 --- a/Modules/Sources/WordPressData/Swift/PostMetadata.swift +++ b/Modules/Sources/WordPressData/Swift/PostMetadata.swift @@ -20,6 +20,11 @@ public struct PostMetadata: Hashable { self.isJetpackNewsletterEmailDisabled = container.getAdaptiveBool(for: .jetpackNewsletterEmailDisabled) } + public init(accessLevel: JetpackPostAccessLevel?, isJetpackNewsletterEmailDisabled: Bool = false) { + self.accessLevel = accessLevel + self.isJetpackNewsletterEmailDisabled = isJetpackNewsletterEmailDisabled + } + /// Applies the metadata values to the container and returns them /// as metadata values. public func encode(in container: inout PostMetadataContainer) { diff --git a/Modules/Tests/WordPressDataTests/PostMetadataTests.swift b/Modules/Tests/WordPressDataTests/PostMetadataTests.swift index 05726c70d8e8..12f2f46078d0 100644 --- a/Modules/Tests/WordPressDataTests/PostMetadataTests.swift +++ b/Modules/Tests/WordPressDataTests/PostMetadataTests.swift @@ -81,6 +81,23 @@ struct PostMetadataTests { // THEN #expect(container.getString(for: .jetpackNewsletterEmailDisabled)?.isEmpty == true) } + + @Test("memberwise init populates accessLevel and isJetpackNewsletterEmailDisabled") + func memberwiseInit() { + let metadata = PostMetadata( + accessLevel: .paidSubscribers, + isJetpackNewsletterEmailDisabled: true + ) + #expect(metadata.accessLevel == .paidSubscribers) + #expect(metadata.isJetpackNewsletterEmailDisabled) + } + + @Test("memberwise init defaults isJetpackNewsletterEmailDisabled to false when omitted") + func memberwiseInitDefaults() { + let metadata = PostMetadata(accessLevel: nil) + #expect(metadata.accessLevel == nil) + #expect(!metadata.isJetpackNewsletterEmailDisabled) + } } private extension PostMetadata { From 28ed57952b685ee0ce3262c96fab33ce0d356bbb Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 11:23:02 +1200 Subject: [PATCH 4/9] Plumb Jetpack newsletter meta through REST post settings --- .../Features/Posts/PostSettingsTests.swift | 106 ++++++++++++++++++ .../Post/PostSettings/PostSettings.swift | 28 ++++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift index 3a76183a7b5a..58ebf2a9cbd4 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift @@ -720,6 +720,30 @@ struct PostSettingsTests { #expect(settings.socialSharingDraft == expectedDraft) } + @Test("init(from: AnyPostWithEditContext) populates jetpack newsletter access from meta") + func initFromRestPopulatesAccessLevel() throws { + let meta = PostMeta().addingJetpackNewsletterAccess(.paidSubscribers) + let post = makeRemotePost(meta: meta) + let settings = PostSettings(from: post) + #expect(settings.metadata.accessLevel == .paidSubscribers) + } + + @Test("init(from: AnyPostWithEditContext) populates email-disabled flag from meta") + func initFromRestPopulatesEmailDisabled() throws { + let meta = PostMeta().addingJetpackNewsletterEmailDisabled(true) + let post = makeRemotePost(meta: meta) + let settings = PostSettings(from: post) + #expect(settings.metadata.isJetpackNewsletterEmailDisabled) + } + + @Test("init(from: AnyPostWithEditContext) defaults metadata when meta is nil") + func initFromRestDefaultsMetadata() throws { + let post = makeRemotePost(meta: nil) + let settings = PostSettings(from: post) + #expect(settings.metadata.accessLevel == nil) + #expect(!settings.metadata.isJetpackNewsletterEmailDisabled) + } + @Test("apply(to:) converts terms back to name strings") func testApplyConvertsTermsToNameStrings() { // Given @@ -1048,6 +1072,88 @@ struct PostSettingsTests { #expect(flagsByID == ["1": true, "2": false]) } + // MARK: - makeUpdateParameters(from: AnyPostWithEditContext) Newsletter Meta Tests + + @Test("makeUpdateParameters(from: AnyPostWithEditContext) omits meta when newsletter settings unchanged") + func updateParamsOmitsMetaWhenUnchanged() throws { + let meta = PostMeta() + .addingJetpackNewsletterAccess(.subscribers) + .addingJetpackNewsletterEmailDisabled(true) + let post = makeRemotePost(meta: meta) + let settings = PostSettings(from: post) + // Sanity: metadata read back correctly. + #expect(settings.metadata.accessLevel == .subscribers) + + let params = settings.makeUpdateParameters(from: post) + #expect(params.meta?.jetpackNewsletterAccess == nil) + #expect(params.meta?.valueForKey(key: "_jetpack_dont_email_post_to_subs") == nil) + } + + @Test("makeUpdateParameters(from: AnyPostWithEditContext) writes access level when changed") + func updateParamsWritesAccessLevelChange() throws { + let post = makeRemotePost(meta: nil) + var settings = PostSettings(from: post) + settings.metadata.accessLevel = .paidSubscribers + + let params = settings.makeUpdateParameters(from: post) + #expect(params.meta?.jetpackNewsletterAccess == .paidSubscribers) + // Email-disabled key should NOT be written because it didn't change. + #expect(params.meta?.valueForKey(key: "_jetpack_dont_email_post_to_subs") == nil) + } + + @Test("makeUpdateParameters(from: AnyPostWithEditContext) writes email-disabled when changed") + func updateParamsWritesEmailDisabledChange() throws { + let post = makeRemotePost(meta: nil) + var settings = PostSettings(from: post) + settings.metadata.isJetpackNewsletterEmailDisabled = true + + let params = settings.makeUpdateParameters(from: post) + #expect(params.meta?.isJetpackNewsletterEmailDisabled == true) + #expect(params.meta?.valueForKey(key: "_jetpack_newsletter_access") == nil) + } + + @Test("makeUpdateParameters(from: AnyPostWithEditContext) clears access level when set to nil") + func updateParamsClearsAccessLevel() throws { + let meta = PostMeta().addingJetpackNewsletterAccess(.subscribers) + let post = makeRemotePost(meta: meta) + var settings = PostSettings(from: post) + settings.metadata.accessLevel = nil + + let params = settings.makeUpdateParameters(from: post) + // A nil access level writes `.null` so the server clears the meta. + #expect(params.meta?.valueForKey(key: "_jetpack_newsletter_access") == JsonValue.null) + } + + // MARK: - makeCreateParameters Newsletter Meta Tests + + @Test("makeCreateParameters emits access level when set") + func createParamsEmitsAccessLevel() throws { + var settings = PostSettings() + settings.metadata.accessLevel = .subscribers + + let params = settings.makeCreateParameters() + #expect(params.meta?.jetpackNewsletterAccess == .subscribers) + } + + @Test("makeCreateParameters emits email-disabled when true") + func createParamsEmitsEmailDisabled() throws { + var settings = PostSettings() + settings.metadata.isJetpackNewsletterEmailDisabled = true + + let params = settings.makeCreateParameters() + #expect(params.meta?.isJetpackNewsletterEmailDisabled == true) + } + + @Test("makeCreateParameters omits newsletter meta at defaults") + func createParamsOmitsDefaults() throws { + let settings = PostSettings() + // Defaults: accessLevel nil, isJetpackNewsletterEmailDisabled false. + + let params = settings.makeCreateParameters() + #expect(params.meta?.valueForKey(key: "_jetpack_newsletter_access") == nil) + #expect(params.meta?.valueForKey(key: "_jetpack_dont_email_post_to_subs") == nil) + } + // MARK: - defaults(from: Blog) Tests @Test("defaults inherits site discussion defaults (closed)") diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift index f38d8e11e21f..1d10080c56b2 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift @@ -165,8 +165,10 @@ struct PostSettings: Hashable { } self.otherTerms = otherTerms - // FIXME: Post metadata is not supported yet. Require wordpress-rs changes. - metadata = PostMetadata(from: .init()) + metadata = PostMetadata( + accessLevel: post.meta?.jetpackNewsletterAccess, + isJetpackNewsletterEmailDisabled: post.meta?.isJetpackNewsletterEmailDisabled ?? false + ) postFormat = post.format.map { $0.id } isStickyPost = post.sticky ?? false @@ -446,6 +448,19 @@ struct PostSettings: Hashable { } } + let originalMetadata = PostMetadata( + accessLevel: post.meta?.jetpackNewsletterAccess, + isJetpackNewsletterEmailDisabled: post.meta?.isJetpackNewsletterEmailDisabled ?? false + ) + if originalMetadata.accessLevel != self.metadata.accessLevel { + params.meta = (params.meta ?? PostMeta()) + .addingJetpackNewsletterAccess(self.metadata.accessLevel) + } + if originalMetadata.isJetpackNewsletterEmailDisabled != self.metadata.isJetpackNewsletterEmailDisabled { + params.meta = (params.meta ?? PostMeta()) + .addingJetpackNewsletterEmailDisabled(self.metadata.isJetpackNewsletterEmailDisabled) + } + let postParentPageID = post.parent.map { Int($0) } if postParentPageID != self.parentPageID { params.parent = self.parentPageID.map { PostId(Int64($0)) } ?? PostId(0) @@ -501,6 +516,15 @@ struct PostSettings: Hashable { params.meta = (params.meta ?? PostMeta()).addingPublicizeMessage(message) } + if metadata.accessLevel != nil { + params.meta = (params.meta ?? PostMeta()) + .addingJetpackNewsletterAccess(metadata.accessLevel) + } + if metadata.isJetpackNewsletterEmailDisabled { + params.meta = (params.meta ?? PostMeta()) + .addingJetpackNewsletterEmailDisabled(true) + } + return params } } From d193af25d7efc140fc9236c6aaedd4d445ca11da Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 10:39:56 +1200 Subject: [PATCH 5/9] Show Jetpack access level and newsletter rows for custom posts --- .../CustomPostSettingsViewModelTests.swift | 77 ++++++++++++++++++- .../CustomPostSettingsViewModel.swift | 8 +- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift index 2e5aa2358a7a..d2af891599c4 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift @@ -191,6 +191,41 @@ struct CustomPostSettingsViewModelTests { #expect(viewModel.v2SocialSharing == nil) #expect(viewModel.getSettingsToSave(for: viewModel.settings).socialSharingDraft == nil) } + + // MARK: - shouldShow Jetpack rows + + @Test("shouldShow .jetpackAccessLevel is true for post type on wpcom site") + func shouldShowAccessLevelTrue() throws { + let viewModel = try makeViewModel(postTypeSlug: "post", wpComRESTAPI: true) + #expect(viewModel.shouldShow(.jetpackAccessLevel)) + } + + @Test("shouldShow .jetpackAccessLevel is false for non-post type") + func shouldShowAccessLevelFalseForNonPost() throws { + let viewModel = try makeViewModel(postTypeSlug: "page", wpComRESTAPI: true) + #expect(!viewModel.shouldShow(.jetpackAccessLevel)) + } + + @Test("shouldShow .jetpackAccessLevel is false on non-wpcom site") + func shouldShowAccessLevelFalseForNonWpcom() throws { + let viewModel = try makeViewModel(postTypeSlug: "post", wpComRESTAPI: false) + #expect(!viewModel.shouldShow(.jetpackAccessLevel)) + } + + @Test("shouldShow .jetpackNewsletterEmailOptions is true only in publishing context") + func shouldShowNewsletterTrueOnlyInPublishing() throws { + let publishingVM = try makeViewModel(postTypeSlug: "post", wpComRESTAPI: true, context: .publishing) + #expect(publishingVM.shouldShow(.jetpackNewsletterEmailOptions)) + + let settingsVM = try makeViewModel(postTypeSlug: "post", wpComRESTAPI: true, context: .settings) + #expect(!settingsVM.shouldShow(.jetpackNewsletterEmailOptions)) + } + + @Test("shouldShow .jetpackNewsletterEmailOptions is false for non-post type") + func shouldShowNewsletterFalseForNonPost() throws { + let viewModel = try makeViewModel(postTypeSlug: "page", wpComRESTAPI: true, context: .publishing) + #expect(!viewModel.shouldShow(.jetpackNewsletterEmailOptions)) + } } // MARK: - Test Helpers @@ -295,7 +330,43 @@ private func makeConnectionsService() -> SiteSocialConnectionsService { ) } -private func makePostTypeDetails(supportsPublicize: Bool = true) -> PostTypeDetailsWithEditContext { +@MainActor +private func makeViewModel( + postTypeSlug: String, + wpComRESTAPI: Bool, + context: PostSettingsContext = .settings +) throws -> CustomPostSettingsViewModel { + let coreData = ContextManager.forTesting().mainContext + let builder = BlogBuilder(coreData) + let blog: Blog + if wpComRESTAPI { + blog = builder.withAnAccount().build() + } else { + blog = builder.build() + } + let post = try makePostWithDisabledConnection() + let details = makePostTypeDetails(supportsPublicize: true, slug: postTypeSlug) + let dependencies = try makeServiceDependencies() + let editorService = CustomPostEditorService( + blog: blog, + post: post, + details: details, + client: dependencies.client, + wpService: dependencies.wpService, + initialSettings: nil + ) + return CustomPostSettingsViewModel( + editorService: editorService, + blog: blog, + socialConnectionsService: nil, + context: context + ) +} + +private func makePostTypeDetails( + supportsPublicize: Bool = true, + slug: String = "test_post_type" +) -> PostTypeDetailsWithEditContext { var supports: [PostTypeSupports: JsonValue] = [ .title: .bool(true), .editor: .bool(true) @@ -311,11 +382,11 @@ private func makePostTypeDetails(supportsPublicize: Bool = true) -> PostTypeDeta viewable: true, labels: makePostTypeLabels(), name: "Test Post Type", - slug: "test_post_type", + slug: slug, supports: PostTypeSupportsMap(map: supports), hasArchive: .bool(false), taxonomies: [], - restBase: "test_post_type", + restBase: slug, restNamespace: "wp/v2", visibility: PostTypeVisibility(showInNavMenus: true, showUi: true), icon: nil diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/CustomPostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/CustomPostSettingsViewModel.swift index 847280dea9b1..ae4655569a70 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/CustomPostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/CustomPostSettingsViewModel.swift @@ -295,8 +295,12 @@ final class CustomPostSettingsViewModel: NSObject, ObservableObject, PostSetting func onAppear() {} func shouldShow(_ row: PostSettingsRow) -> Bool { - // FIXME: meta support missing in AnyPostWithEditContext - false + switch row { + case .jetpackAccessLevel: + return isPost && blog.supports(.wpComRESTAPI) + case .jetpackNewsletterEmailOptions: + return isPost && blog.supports(.wpComRESTAPI) && context == .publishing + } } func buttonCancelTapped() { From 4dc3cf0769b38c859689677eb494fe845e5c19c5 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 11:12:01 +1200 Subject: [PATCH 6/9] Remove stale FIXME from legacy PostSettingsViewModel.shouldShow --- .../ViewRelated/Post/PostSettings/PostSettingsViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index 1fc5bd0eb576..9662f408a068 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -227,7 +227,6 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM } func shouldShow(_ row: PostSettingsRow) -> Bool { - // FIXME: meta support missing in AnyPostWithEditContext switch row { case .jetpackAccessLevel: return blog.supports(.wpComRESTAPI) From f0453bd073bb56096f11b6b3d72f6fe5eff3a2d2 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 12:43:07 +1200 Subject: [PATCH 7/9] Gate Jetpack newsletter rows on subscriptions module activation --- .../WordPressData/Swift/Blog+Features.swift | 11 ++++++++ .../CustomPostSettingsViewModelTests.swift | 26 +++++++++---------- .../CustomPostSettingsViewModel.swift | 4 +-- .../PostSettings/PostSettingsViewModel.swift | 4 +-- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/Modules/Sources/WordPressData/Swift/Blog+Features.swift b/Modules/Sources/WordPressData/Swift/Blog+Features.swift index b7e88e332ca9..26ae02539bd5 100644 --- a/Modules/Sources/WordPressData/Swift/Blog+Features.swift +++ b/Modules/Sources/WordPressData/Swift/Blog+Features.swift @@ -53,6 +53,7 @@ import Foundation case siteMonitoring case publicize case shareButtons + case jetpackNewsletter } extension Blog { @@ -134,6 +135,8 @@ extension Blog { return supportsPublicize case .shareButtons: return supportsShareButtons + case .jetpackNewsletter: + return supportsJetpackNewsletter } } @@ -173,6 +176,14 @@ private extension Blog { } } + var supportsJetpackNewsletter: Bool { + guard supportsRestAPI else { return false } + if isHostedAtWPcom { + return true + } + return isJetpackModuleActive("subscriptions") + } + var supportsShareButtons: Bool { guard isAdmin, supportsRestAPI else { return false diff --git a/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift index d2af891599c4..b870e9dbb6b6 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift @@ -194,36 +194,36 @@ struct CustomPostSettingsViewModelTests { // MARK: - shouldShow Jetpack rows - @Test("shouldShow .jetpackAccessLevel is true for post type on wpcom site") + @Test("shouldShow .jetpackAccessLevel is true for post type when Jetpack newsletter is available") func shouldShowAccessLevelTrue() throws { - let viewModel = try makeViewModel(postTypeSlug: "post", wpComRESTAPI: true) + let viewModel = try makeViewModel(postTypeSlug: "post", jetpackNewsletter: true) #expect(viewModel.shouldShow(.jetpackAccessLevel)) } @Test("shouldShow .jetpackAccessLevel is false for non-post type") func shouldShowAccessLevelFalseForNonPost() throws { - let viewModel = try makeViewModel(postTypeSlug: "page", wpComRESTAPI: true) + let viewModel = try makeViewModel(postTypeSlug: "page", jetpackNewsletter: true) #expect(!viewModel.shouldShow(.jetpackAccessLevel)) } - @Test("shouldShow .jetpackAccessLevel is false on non-wpcom site") - func shouldShowAccessLevelFalseForNonWpcom() throws { - let viewModel = try makeViewModel(postTypeSlug: "post", wpComRESTAPI: false) + @Test("shouldShow .jetpackAccessLevel is false when Jetpack newsletter is unavailable") + func shouldShowAccessLevelFalseWithoutNewsletter() throws { + let viewModel = try makeViewModel(postTypeSlug: "post", jetpackNewsletter: false) #expect(!viewModel.shouldShow(.jetpackAccessLevel)) } @Test("shouldShow .jetpackNewsletterEmailOptions is true only in publishing context") func shouldShowNewsletterTrueOnlyInPublishing() throws { - let publishingVM = try makeViewModel(postTypeSlug: "post", wpComRESTAPI: true, context: .publishing) + let publishingVM = try makeViewModel(postTypeSlug: "post", jetpackNewsletter: true, context: .publishing) #expect(publishingVM.shouldShow(.jetpackNewsletterEmailOptions)) - let settingsVM = try makeViewModel(postTypeSlug: "post", wpComRESTAPI: true, context: .settings) + let settingsVM = try makeViewModel(postTypeSlug: "post", jetpackNewsletter: true, context: .settings) #expect(!settingsVM.shouldShow(.jetpackNewsletterEmailOptions)) } @Test("shouldShow .jetpackNewsletterEmailOptions is false for non-post type") func shouldShowNewsletterFalseForNonPost() throws { - let viewModel = try makeViewModel(postTypeSlug: "page", wpComRESTAPI: true, context: .publishing) + let viewModel = try makeViewModel(postTypeSlug: "page", jetpackNewsletter: true, context: .publishing) #expect(!viewModel.shouldShow(.jetpackNewsletterEmailOptions)) } } @@ -333,16 +333,16 @@ private func makeConnectionsService() -> SiteSocialConnectionsService { @MainActor private func makeViewModel( postTypeSlug: String, - wpComRESTAPI: Bool, + jetpackNewsletter: Bool, context: PostSettingsContext = .settings ) throws -> CustomPostSettingsViewModel { let coreData = ContextManager.forTesting().mainContext let builder = BlogBuilder(coreData) let blog: Blog - if wpComRESTAPI { - blog = builder.withAnAccount().build() + if jetpackNewsletter { + blog = builder.withAnAccount().with(modules: ["subscriptions"]).build() } else { - blog = builder.build() + blog = builder.withAnAccount().build() } let post = try makePostWithDisabledConnection() let details = makePostTypeDetails(supportsPublicize: true, slug: postTypeSlug) diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/CustomPostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/CustomPostSettingsViewModel.swift index ae4655569a70..e44bd5a3c52c 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/CustomPostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/CustomPostSettingsViewModel.swift @@ -297,9 +297,9 @@ final class CustomPostSettingsViewModel: NSObject, ObservableObject, PostSetting func shouldShow(_ row: PostSettingsRow) -> Bool { switch row { case .jetpackAccessLevel: - return isPost && blog.supports(.wpComRESTAPI) + return isPost && blog.supports(.jetpackNewsletter) case .jetpackNewsletterEmailOptions: - return isPost && blog.supports(.wpComRESTAPI) && context == .publishing + return isPost && blog.supports(.jetpackNewsletter) && context == .publishing } } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index 9662f408a068..dfe340668dfa 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -229,9 +229,9 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM func shouldShow(_ row: PostSettingsRow) -> Bool { switch row { case .jetpackAccessLevel: - return blog.supports(.wpComRESTAPI) + return blog.supports(.jetpackNewsletter) case .jetpackNewsletterEmailOptions: - return blog.supports(.wpComRESTAPI) && context == .publishing + return blog.supports(.jetpackNewsletter) && context == .publishing } } From 0989e98fda51f0e553bda7439e11952bb2e6d641 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 13:48:57 +1200 Subject: [PATCH 8/9] Persist BlogBuilder state so CustomPostSettings shouldShow tests see it --- .../Tests/Features/Posts/CustomPostSettingsViewModelTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift index b870e9dbb6b6..6d5841baf8b9 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift @@ -344,6 +344,7 @@ private func makeViewModel( } else { blog = builder.withAnAccount().build() } + try coreData.save() let post = try makePostWithDisabledConnection() let details = makePostTypeDetails(supportsPublicize: true, slug: postTypeSlug) let dependencies = try makeServiceDependencies() From adfeaece57f17531c0cd38c1799d9d82f325d257 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 11 Jun 2026 22:14:39 +1200 Subject: [PATCH 9/9] Gate legacy Jetpack newsletter rows on the post type The Jetpack plugin registers the newsletter access and email metas for the "post" type only, so pages should not show these rows. This matches the isPost check that CustomPostSettingsViewModel already applies. --- .../ViewRelated/Post/PostSettings/PostSettingsViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index dfe340668dfa..2e249bbe3721 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -229,9 +229,9 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM func shouldShow(_ row: PostSettingsRow) -> Bool { switch row { case .jetpackAccessLevel: - return blog.supports(.jetpackNewsletter) + return isPost && blog.supports(.jetpackNewsletter) case .jetpackNewsletterEmailOptions: - return blog.supports(.jetpackNewsletter) && context == .publishing + return isPost && blog.supports(.jetpackNewsletter) && context == .publishing } }