From cb7115085af7f509e39e5e1df12bdca612d5020a 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/Blog+Features.swift | 10 ++-- .../WordPressData/Swift/PostMetadata.swift | 5 +- .../CustomPostSettingsViewModelTests.swift | 8 +-- .../Features/Posts/PostSettingsTests.swift | 10 ++-- .../PostSettings/PostSettingsViewModel.swift | 54 ++++++++++++------- 5 files changed, 56 insertions(+), 31 deletions(-) diff --git a/Sources/WordPressData/Swift/Blog+Features.swift b/Sources/WordPressData/Swift/Blog+Features.swift index c95f482adf89..577bc896dfd2 100644 --- a/Sources/WordPressData/Swift/Blog+Features.swift +++ b/Sources/WordPressData/Swift/Blog+Features.swift @@ -82,7 +82,9 @@ extension Blog { case .customThemes: return supportsRestAPI && isAdmin && !isHostedAtWPcom case .premiumThemes: - return supports(.customThemes) && (planID?.intValue == Self.jetpackProfessionalYearlyPlanId || planID?.intValue == Self.jetpackProfessionalMonthlyPlanId) + return supports(.customThemes) + && (planID?.intValue == Self.jetpackProfessionalYearlyPlanId + || planID?.intValue == Self.jetpackProfessionalMonthlyPlanId) case .private: return isHostedAtWPcom case .sharing: @@ -192,7 +194,8 @@ private extension Blog { return true } if account == nil && !isHostedAtWPcom && selfHostedSiteRestApi != nil - && hasRequiredWordPressVersion("5.5") { + && hasRequiredWordPressVersion("5.5") + { return true } return false @@ -207,7 +210,8 @@ private extension Blog { func hasRequiredJetpackVersion(_ requiredVersion: String) -> Bool { guard supportsRestAPI, !isHostedAtWPcom, - let version = jetpack?.version else { + let version = jetpack?.version + else { return false } return version.compare(requiredVersion, options: .numeric) != .orderedAscending diff --git a/Sources/WordPressData/Swift/PostMetadata.swift b/Sources/WordPressData/Swift/PostMetadata.swift index 27ce43fca15d..e0e9578ca22e 100644 --- a/Sources/WordPressData/Swift/PostMetadata.swift +++ b/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/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift index c73d7b5cb4f4..cdfdf7324be6 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift @@ -28,9 +28,11 @@ struct CustomPostSettingsViewModelTests { ) // Sanity: the parsed draft has the disabled connection from the post. - #expect(viewModel.settings.socialSharingDraft?.connectionsByID == [ - "12345": .init(id: "12345", enabled: false) - ]) + #expect( + viewModel.settings.socialSharingDraft?.connectionsByID == [ + "12345": .init(id: "12345", enabled: false) + ] + ) // When: settings is reassigned to a value-equivalent copy (simulating // `resolveTermNames` writing back resolved-but-identical tags). diff --git a/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift index 971b765bc8d8..3d134d85f371 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift @@ -1046,10 +1046,12 @@ struct PostSettingsTests { let settings = PostSettings(from: params) #expect(settings.socialSharingDraft?.customMessage == "Stored message") - #expect(settings.socialSharingDraft?.connectionsByID == [ - "1": .init(id: "1", enabled: true), - "2": .init(id: "2", enabled: false) - ]) + #expect( + settings.socialSharingDraft?.connectionsByID == [ + "1": .init(id: "1", enabled: true), + "2": .init(id: "2", enabled: false) + ] + ) } @Test("makeCreateParameters clears stored publicize message from empty social draft") 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 17d195f7711a99810c3ea11035a5b2fab0036f69 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 fe3ee15df570c6f1eb6d2bc5f691013d7ef46315 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 --- Sources/WordPressData/Swift/PostMetadata.swift | 5 +++++ .../WordPressDataTests/PostMetadataTests.swift | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/Sources/WordPressData/Swift/PostMetadata.swift b/Sources/WordPressData/Swift/PostMetadata.swift index e0e9578ca22e..1f1702ed0d51 100644 --- a/Sources/WordPressData/Swift/PostMetadata.swift +++ b/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/Tests/WordPressDataTests/PostMetadataTests.swift b/Tests/WordPressDataTests/PostMetadataTests.swift index 05726c70d8e8..12f2f46078d0 100644 --- a/Tests/WordPressDataTests/PostMetadataTests.swift +++ b/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 611c00a9a5fe829729f8fa8616309c8557b21a5d 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 3d134d85f371..e347a72c33e1 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift @@ -719,6 +719,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 @@ -1138,6 +1162,88 @@ struct PostSettingsTests { #expect(settings.publishDate == nil) } + // 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(from: PostCreateParams()) + settings.metadata.accessLevel = .subscribers + + let params = settings.makeCreateParameters(from: PostCreateParams()) + #expect(params.meta?.jetpackNewsletterAccess == .subscribers) + } + + @Test("makeCreateParameters emits email-disabled when true") + func createParamsEmitsEmailDisabled() throws { + var settings = PostSettings(from: PostCreateParams()) + settings.metadata.isJetpackNewsletterEmailDisabled = true + + let params = settings.makeCreateParameters(from: PostCreateParams()) + #expect(params.meta?.isJetpackNewsletterEmailDisabled == true) + } + + @Test("makeCreateParameters omits newsletter meta at defaults") + func createParamsOmitsDefaults() throws { + let settings = PostSettings(from: PostCreateParams()) + // Defaults: accessLevel nil, isJetpackNewsletterEmailDisabled false. + + let params = settings.makeCreateParameters(from: PostCreateParams()) + #expect(params.meta?.valueForKey(key: "_jetpack_newsletter_access") == nil) + #expect(params.meta?.valueForKey(key: "_jetpack_dont_email_post_to_subs") == nil) + } + @Test("PostCreateParams status and date survive round-trip through PostSettings") func testStatusAndDateRoundTripThroughPostSettings() { // Given diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift index f454f3879b76..337dea4cbeb0 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift @@ -224,8 +224,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 @@ -488,6 +490,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) @@ -552,6 +567,15 @@ struct PostSettings: Hashable { } } + 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 6aa8d8e66772a9de114d678b8e6ba35239891b31 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 cdfdf7324be6..27982243a4f7 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift @@ -197,6 +197,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 @@ -297,7 +332,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, + initialParams: 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) @@ -313,11 +384,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 97e181824b2caa39c3cb1a278cf6fb1cc5e63b8b 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 896b85041c1de73ed8836bd0cb05c622ff059bed Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 11:37:21 +1200 Subject: [PATCH 7/9] Document newsletter metadata rebase caveat --- .../Classes/ViewRelated/Post/PostSettings/PostSettings.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift index 337dea4cbeb0..51dcee4b2695 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift @@ -567,6 +567,10 @@ struct PostSettings: Hashable { } } + // TODO: When PR 25543 moves new-post state to PostSettings, keep this + // newsletter serialization in makeCreateParameters(taxonomies:). If the + // PostCreateParams restore path remains, it must decode these meta + // fields or local new-post settings reopen with stale defaults. if metadata.accessLevel != nil { params.meta = (params.meta ?? PostMeta()) .addingJetpackNewsletterAccess(metadata.accessLevel) From f8ddf59d38e7429878d17bd12ee27036f2f45cef Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 12:43:07 +1200 Subject: [PATCH 8/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/Sources/WordPressData/Swift/Blog+Features.swift b/Sources/WordPressData/Swift/Blog+Features.swift index 577bc896dfd2..9349e3be0601 100644 --- a/Sources/WordPressData/Swift/Blog+Features.swift +++ b/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 27982243a4f7..37bdef96bb0f 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift @@ -200,36 +200,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)) } } @@ -335,16 +335,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 9805872e07670a760aa2fb2f9562ebd050cf6d37 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 13:48:57 +1200 Subject: [PATCH 9/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 37bdef96bb0f..626a0f083666 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/CustomPostSettingsViewModelTests.swift @@ -346,6 +346,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()