From d52e1ac2765394dced9123aa2e35438588384479 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 27 May 2026 14:46:02 +1200 Subject: [PATCH 1/8] Format planned Swift edit files --- Sources/WordPressData/Swift/Post.swift | 46 +++++++++------- .../Services/PostHelper+JetpackSocial.swift | 21 +++++--- .../RemotePostCreateParameters+Helpers.swift | 16 +++--- .../PostSettings/PostSettingsViewModel.swift | 54 ++++++++++++------- ...blishingSocialAccountsViewController.swift | 47 +++++++++------- 5 files changed, 115 insertions(+), 69 deletions(-) diff --git a/Sources/WordPressData/Swift/Post.swift b/Sources/WordPressData/Swift/Post.swift index c211ca3468ec..5bd217b2fa1b 100644 --- a/Sources/WordPressData/Swift/Post.swift +++ b/Sources/WordPressData/Swift/Post.swift @@ -35,13 +35,13 @@ public class Post: AbstractPost { // MARK: - NSManagedObject public override class func entityName() -> String { - return "Post" + "Post" } // MARK: - Format @objc public func postFormatText() -> String? { - return blog.postFormatText(fromSlug: postFormat) + blog.postFormatText(fromSlug: postFormat) } @objc public func setPostFormatText(_ postFormatText: String) { @@ -81,7 +81,7 @@ public class Post: AbstractPost { return } - let matchingCategories = blogCategories.filter({ return $0.categoryName == categoryName }) + let matchingCategories = blogCategories.filter({ $0.categoryName == categoryName }) if !matchingCategories.isEmpty { newCategories = newCategories.union(matchingCategories) @@ -94,18 +94,20 @@ public class Post: AbstractPost { // MARK: - Sharing @objc public func canEditPublicizeSettings() -> Bool { - return !self.hasRemote() || self.status != .publish + !self.hasRemote() || self.status != .publish } // MARK: - PublicizeConnections @objc public func publicizeConnectionDisabledForKeyringID(_ keyringID: NSNumber) -> Bool { - let isKeyringEntryDisabled = disabledPublicizeConnections?[keyringID]?[Constants.publicizeValueKey] == Constants.publicizeDisabledValue + let isKeyringEntryDisabled = + disabledPublicizeConnections?[keyringID]?[Constants.publicizeValueKey] == Constants.publicizeDisabledValue // try to check in case there's an entry for the PublicizeConnection that's keyed by the connectionID. guard let connections = blog.connections, - let connection = connections.first(where: { $0.keyringConnectionID == keyringID }), - let existingValue = disabledPublicizeConnections?[connection.connectionID]?[Constants.publicizeValueKey] else { + let connection = connections.first(where: { $0.keyringConnectionID == keyringID }), + let existingValue = disabledPublicizeConnections?[connection.connectionID]?[Constants.publicizeValueKey] + else { // fall back to keyringID if there is no such entry with the connectionID. return isKeyringEntryDisabled } @@ -118,8 +120,9 @@ public class Post: AbstractPost { // if there's another entry keyed by connectionID references to the same connection, // we need to make sure that the values are kept in sync. if let connections = blog.connections, - let connection = connections.first(where: { $0.keyringConnectionID == keyringID }), - let _ = disabledPublicizeConnections?[connection.connectionID] { + let connection = connections.first(where: { $0.keyringConnectionID == keyringID }), + let _ = disabledPublicizeConnections?[connection.connectionID] + { enablePublicizeConnection(keyedBy: connection.connectionID) } @@ -130,14 +133,16 @@ public class Post: AbstractPost { // if there's another entry keyed by connectionID references to the same connection, // we need to make sure that the values are kept in sync. if let connections = blog.connections, - let connectionID = connections.first(where: { $0.keyringConnectionID == keyringID })?.connectionID, - let _ = disabledPublicizeConnections?[connectionID] { + let connectionID = connections.first(where: { $0.keyringConnectionID == keyringID })?.connectionID, + let _ = disabledPublicizeConnections?[connectionID] + { disablePublicizeConnection(keyedBy: connectionID) // additionally, if the keyring entry doesn't exist, there's no need create both formats. // we can just update the dictionary's key from connectionID to keyringID instead. if disabledPublicizeConnections?[keyringID] == nil, - let updatedEntry = disabledPublicizeConnections?[connectionID] { + let updatedEntry = disabledPublicizeConnections?[connectionID] + { disabledPublicizeConnections?.removeValue(forKey: connectionID) disabledPublicizeConnections?[keyringID] = updatedEntry return @@ -185,13 +190,13 @@ public class Post: AbstractPost { // MARK: - Comments @objc public func numberOfComments() -> Int { - return commentCount?.intValue ?? 0 + commentCount?.intValue ?? 0 } // MARK: - Likes @objc public func numberOfLikes() -> Int { - return likeCount?.intValue ?? 0 + likeCount?.intValue ?? 0 } // MARK: - AbstractPost @@ -209,7 +214,7 @@ public class Post: AbstractPost { } public func dateForDisplay() -> Date? { - return dateCreated + dateCreated } // MARK: - BasePost @@ -226,7 +231,8 @@ public class Post: AbstractPost { if let preview = PostPreviewCache.shared.content[content] { return preview } - let preview = GutenbergExcerptGenerator.firstParagraph(from: content, maxLength: 200).withCollapsedNewlines().trimmedForPreview() + let preview = GutenbergExcerptGenerator.firstParagraph(from: content, maxLength: 200) + .withCollapsedNewlines().trimmedForPreview() PostPreviewCache.shared.content[content] = preview return preview } else { @@ -236,12 +242,16 @@ public class Post: AbstractPost { override public func titleForDisplay() -> String { var title = postTitle?.trimmingCharacters(in: CharacterSet.whitespaces) ?? "" - title = title + title = + title .stringByDecodingXMLCharacters() .strippingHTML() if title.isEmpty && !hasRemote() && contentPreviewForDisplay().isEmpty { - title = NSLocalizedString("(no title)", comment: "Lets a user know that a local draft does not have a title.") + title = NSLocalizedString( + "(no title)", + comment: "Lets a user know that a local draft does not have a title." + ) } return title diff --git a/WordPress/Classes/Services/PostHelper+JetpackSocial.swift b/WordPress/Classes/Services/PostHelper+JetpackSocial.swift index be74ae7913e3..46e299ee52dd 100644 --- a/WordPress/Classes/Services/PostHelper+JetpackSocial.swift +++ b/WordPress/Classes/Services/PostHelper+JetpackSocial.swift @@ -17,12 +17,16 @@ extension PostHelper { /// - metadata: The metadata dictionary for the post. Optional because Obj-C shouldn't be trusted. /// - Returns: A dictionary for the `Post`'s `disabledPublicizeConnections` property. @objc(disabledPublicizeConnectionsForPost:andMetadata:) - static func disabledPublicizeConnections(for post: AbstractPost?, metadata: [[String: Any]]?) -> [NSNumber: StringDictionary] { + static func disabledPublicizeConnections( + for post: AbstractPost?, + metadata: [[String: Any]]? + ) -> [NSNumber: StringDictionary] { guard let post, let metadata else { return [:] } - return metadata + return + metadata .compactMap { $0 as? [String: String] } .filter { $0[Keys.publicizeKeyKey]?.hasPrefix(SkipPrefix.keyring.rawValue) ?? false } .reduce(into: [NSNumber: StringDictionary]()) { partialResult, entry in @@ -46,8 +50,9 @@ extension PostHelper { let entryConnectionID = Int(key.removingPrefix(SkipPrefix.connection.rawValue)) guard let connections = post.blog.connections, - let connectionID = entryConnectionID, - let connection = connections.first(where: { $0.connectionID.intValue == connectionID }) else { + let connectionID = entryConnectionID, + let connection = connections.first(where: { $0.connectionID.intValue == connectionID }) + else { /// Otherwise, fall back to the connectionID extracted from the metadata key. /// Note that entries with `connectionID` won't be detected by the Post's /// `publicizeConnectionDisabledForKeyringID` method. @@ -74,7 +79,8 @@ extension PostHelper { @objc(publicizeMetadataEntriesForPost:) static func publicizeMetadataEntries(for post: Post?) -> [StringDictionary] { guard let post, - let disabledConnectionsDictionary = post.disabledPublicizeConnections else { + let disabledConnectionsDictionary = post.disabledPublicizeConnections + else { return [] } @@ -96,8 +102,9 @@ extension PostHelper { // Try to add a key with the new format ONLY if the metadata hasn't been synced to the remote. let metadataKeyValue: String = { guard entry[Keys.publicizeIdKey] == nil, - let connections = post.blog.connections, - let connection = connections.first(where: { $0.keyringConnectionID == keyringID }) else { + let connections = post.blog.connections, + let connection = connections.first(where: { $0.keyringConnectionID == keyringID }) + else { // Fall back to the old keyring format. return "\(SkipPrefix.keyring.rawValue)\(keyringID)" } diff --git a/WordPress/Classes/Services/RemotePostCreateParameters+Helpers.swift b/WordPress/Classes/Services/RemotePostCreateParameters+Helpers.swift index 39f58baee396..bac8beb0e676 100644 --- a/WordPress/Classes/Services/RemotePostCreateParameters+Helpers.swift +++ b/WordPress/Classes/Services/RemotePostCreateParameters+Helpers.swift @@ -29,12 +29,16 @@ extension RemotePostCreateParameters { format = post.postFormat isSticky = post.isStickyPost tags = AbstractPost.makeTags(from: post.tags ?? "") - categoryIDs = (post.categories ?? []).map { - $0.categoryID.intValue - } - metadata = Set(Self.generateRemoteMetadata(for: post).compactMap { dictionary -> RemotePostMetadataItem? in - return Self.mapDictionaryToMetadataItems(dictionary) - }) + categoryIDs = (post.categories ?? []) + .map { + $0.categoryID.intValue + } + metadata = Set( + Self.generateRemoteMetadata(for: post) + .compactMap { dictionary -> RemotePostMetadataItem? in + Self.mapDictionaryToMetadataItems(dictionary) + } + ) discussion = RemotePostDiscussionSettings( allowComments: post.allowComments, allowPings: post.allowPings 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( diff --git a/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift b/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift index e1e936c7a259..baaa9898d534 100644 --- a/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift @@ -69,11 +69,13 @@ class PrepublishingSocialAccountsViewController: UITableViewController { fatalError("init(coder:) has not been implemented") } - init(blogID: Int, - model: PostSocialSharingSettings, - delegate: PrepublishingSocialAccountsDelegate?, - coreDataStack: CoreDataStackSwift = ContextManager.shared, - blogService: BlogService? = nil) { + init( + blogID: Int, + model: PostSocialSharingSettings, + delegate: PrepublishingSocialAccountsDelegate?, + coreDataStack: CoreDataStackSwift = ContextManager.shared, + blogService: BlogService? = nil + ) { self.blogID = blogID self.connections = model.services.flatMap { service in service.connections.map { @@ -116,7 +118,7 @@ class PrepublishingSocialAccountsViewController: UITableViewController { extension PrepublishingSocialAccountsViewController { override func numberOfSections(in tableView: UITableView) -> Int { - return 2 + 2 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -165,11 +167,13 @@ extension PrepublishingSocialAccountsViewController { return nil } - return PrepublishingSocialAccountsTableFooterView(remaining: sharingLimit.remaining, - showsWarning: shouldDisplayWarning, - onButtonTap: { [weak self] in - self?.subscribeButtonTapped() - }) + return PrepublishingSocialAccountsTableFooterView( + remaining: sharingLimit.remaining, + showsWarning: shouldDisplayWarning, + onButtonTap: { [weak self] in + self?.subscribeButtonTapped() + } + ) } } @@ -198,7 +202,9 @@ private extension PrepublishingSocialAccountsViewController { func accountCell(for indexPath: IndexPath) -> UITableViewCell { guard var connection = connections[safe: indexPath.row], - let cell = tableView.dequeueReusableCell(withIdentifier: Constants.accountCellIdentifier) as? SwitchTableViewCell else { + let cell = tableView.dequeueReusableCell(withIdentifier: Constants.accountCellIdentifier) + as? SwitchTableViewCell + else { return UITableViewCell() } @@ -243,7 +249,10 @@ private extension PrepublishingSocialAccountsViewController { lastToggledRow = index toggleInteractivityIfNeeded() - WPAnalytics.track(.jetpackSocialConnectionToggled, properties: ["source": Constants.trackingSource, "value": value]) + WPAnalytics.track( + .jetpackSocialConnectionToggled, + properties: ["source": Constants.trackingSource, "value": value] + ) } func toggleInteractivityIfNeeded() { @@ -278,11 +287,12 @@ private extension PrepublishingSocialAccountsViewController { } func makeCheckoutViewController() -> UIViewController? { - return coreDataStack.performQuery { [weak self] context in + coreDataStack.performQuery { [weak self] context in guard let self, - let blog = try? Blog.lookup(withID: self.blogID, in: context), - let host = blog.hostname, - let url = URL(string: "https://wordpress.com/checkout/\(host)/jetpack_social_basic_yearly") else { + let blog = try? Blog.lookup(withID: self.blogID, in: context), + let host = blog.hostname, + let url = URL(string: "https://wordpress.com/checkout/\(host)/jetpack_social_basic_yearly") + else { return nil } @@ -299,7 +309,8 @@ private extension PrepublishingSocialAccountsViewController { assert(Thread.isMainThread, "\(#function) must be called from the main thread") guard let blog = try? Blog.lookup(withID: blogID, in: coreDataStack.mainContext), - ReachabilityUtils.isInternetReachable() else { + ReachabilityUtils.isInternetReachable() + else { return } From 972b574f5a3181ae871e60727026c8185bd01613 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 27 May 2026 22:45:32 +1200 Subject: [PATCH 2/8] Add PostSocialSharingDraft post-metadata bridge Bridge the JetpackSocial v2 connection model to the WP.com REST v1.x post-editing path by reading and writing per-connection sharing state as `_wpas_skip_publicize_` and `_wpas_mess` post metadata. --- .../PostSocialSharingDraftMetadataTests.swift | 102 ++++++++++++++++++ .../PostSocialSharingDraft+PostMetadata.swift | 84 +++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 Tests/KeystoneTests/Tests/ViewRelated/Post/PostSocialSharingDraftMetadataTests.swift create mode 100644 WordPress/Classes/ViewRelated/Post/Social/PostSocialSharingDraft+PostMetadata.swift diff --git a/Tests/KeystoneTests/Tests/ViewRelated/Post/PostSocialSharingDraftMetadataTests.swift b/Tests/KeystoneTests/Tests/ViewRelated/Post/PostSocialSharingDraftMetadataTests.swift new file mode 100644 index 000000000000..9f7b48c9c369 --- /dev/null +++ b/Tests/KeystoneTests/Tests/ViewRelated/Post/PostSocialSharingDraftMetadataTests.swift @@ -0,0 +1,102 @@ +import Foundation +import JetpackSocial +import Testing +@testable import WordPress +@testable import WordPressData + +@Suite("PostSocialSharingDraft metadata bridge") +struct PostSocialSharingDraftMetadataTests { + @Test("seed reads disabled connections and message") + func seedReadsDisabledConnectionsAndMessage() { + let container = PostMetadataContainer(metadata: [ + ["key": "_wpas_mess", "value": "Hello"], + ["key": "_wpas_skip_publicize_111", "value": "1"], + ["key": "_wpas_skip_publicize_222", "value": "0"], + ["key": "_wpas_skip_333", "value": "1"], + ["key": "unrelated", "value": "value"] + ]) + + let draft = PostSocialSharingDraft(socialMetadata: container) + + #expect(draft.customMessage == "Hello") + #expect(!draft.isEnabled(connectionID: "111")) + #expect(draft.isEnabled(connectionID: "222")) + #expect(draft.isEnabled(connectionID: "999")) + } + + @Test("seed treats empty message as nil") + func seedTreatsEmptyMessageAsNil() { + let container = PostMetadataContainer(metadata: [ + ["key": "_wpas_mess", "value": ""] + ]) + + let draft = PostSocialSharingDraft(socialMetadata: container) + + #expect(draft.customMessage == nil) + } + + @Test("serialize writes connection scheme and message") + func serializeWritesConnectionSchemeAndMessage() { + var container = PostMetadataContainer(metadata: [ + ["id": "11", "key": "_wpas_skip_publicize_111", "value": "1"] + ]) + let draft = PostSocialSharingDraft( + customMessage: "Hi", + connectionsByID: [ + "111": .init(id: "111", enabled: true), + "222": .init(id: "222", enabled: false) + ] + ) + + draft.applySocialMetadata(to: &container) + + #expect(container.entry(forKey: "_wpas_skip_publicize_111")?["id"] as? String == "11") + #expect(container.getString(for: "_wpas_skip_publicize_111") == "0") + #expect(container.getString(for: "_wpas_skip_publicize_222") == "1") + #expect(container.getString(for: "_wpas_mess") == "Hi") + } + + @Test("serialize clears message only when it previously existed") + func serializeClearsMessageOnlyWhenItPreviouslyExisted() { + var containerWithMessage = PostMetadataContainer(metadata: [ + ["key": "_wpas_mess", "value": "Previous"] + ]) + let draft = PostSocialSharingDraft(customMessage: nil) + + draft.applySocialMetadata(to: &containerWithMessage) + + #expect(containerWithMessage.getString(for: "_wpas_mess")?.isEmpty == true) + + var containerWithoutMessage = PostMetadataContainer() + + draft.applySocialMetadata(to: &containerWithoutMessage) + + #expect(containerWithoutMessage.entry(forKey: "_wpas_mess") == nil) + } + + @Test("upload entries include only publicize keys") + func uploadEntriesIncludeOnlyPublicizeKeys() { + let container = PostMetadataContainer(metadata: [ + ["key": "_wpas_mess", "value": "Hello"], + ["key": "_wpas_skip_publicize_111", "value": "1"], + ["key": "_wpas_skip_222", "value": "1"], + ["key": "_jetpack_newsletter_access", "value": "subscribers"], + ["key": "unrelated", "value": "value"] + ]) + + let entries = SocialSharingMetadata.publicizeEntries(in: container) + let keys = Set(entries.compactMap { $0["key"] as? String }) + + #expect(keys == ["_wpas_mess", "_wpas_skip_publicize_111"]) + } + + @Test("isDisabled handles supported metadata value shapes") + func isDisabledHandlesSupportedValueShapes() { + #expect(SocialSharingMetadata.isDisabled("1")) + #expect(SocialSharingMetadata.isDisabled(true)) + #expect(!SocialSharingMetadata.isDisabled(false)) + #expect(SocialSharingMetadata.isDisabled(NSNumber(value: true))) + #expect(!SocialSharingMetadata.isDisabled(NSNumber(value: false))) + #expect(!SocialSharingMetadata.isDisabled(nil)) + } +} diff --git a/WordPress/Classes/ViewRelated/Post/Social/PostSocialSharingDraft+PostMetadata.swift b/WordPress/Classes/ViewRelated/Post/Social/PostSocialSharingDraft+PostMetadata.swift new file mode 100644 index 000000000000..3447f6a7f8a4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Social/PostSocialSharingDraft+PostMetadata.swift @@ -0,0 +1,84 @@ +import Foundation +import JetpackSocial +import WordPressData + +enum SocialSharingMetadata { + static let skipPrefix = "_wpas_skip_publicize_" + static let messageKey: PostMetadataContainer.Key = "_wpas_mess" + + static func publicizeEntries(in container: PostMetadataContainer) -> [[String: Any]] { + container.values.filter { entry in + guard let key = entry["key"] as? String else { + return false + } + return key == messageKey.rawValue || key.hasPrefix(skipPrefix) + } + } + + static func isDisabled(_ value: Any?) -> Bool { + switch value { + case let value as Bool: + return value + case let value as NSNumber: + return value.boolValue + case let value as String: + return value == "1" + default: + return false + } + } +} + +extension PostSocialSharingDraft { + init(socialMetadata container: PostMetadataContainer) { + let message = container.getString(for: SocialSharingMetadata.messageKey) + let connectionsByID = SocialSharingMetadata.publicizeEntries(in: container) + .reduce( + into: [String: Connection]() + ) { connectionsByID, entry in + guard let key = entry["key"] as? String, + key.hasPrefix(SocialSharingMetadata.skipPrefix) + else { + return + } + + let connectionID = String(key.dropFirst(SocialSharingMetadata.skipPrefix.count)) + guard !connectionID.isEmpty else { + return + } + + connectionsByID[connectionID] = Connection( + id: connectionID, + enabled: !SocialSharingMetadata.isDisabled(entry["value"]) + ) + } + + self.init( + customMessage: message?.isEmpty == false ? message : nil, + connectionsByID: connectionsByID.isEmpty ? nil : connectionsByID + ) + } + + func applySocialMetadata(to container: inout PostMetadataContainer) { + if let customMessage, !customMessage.isEmpty { + container.setValue(customMessage, for: SocialSharingMetadata.messageKey) + } else if container.entry(forKey: SocialSharingMetadata.messageKey) != nil { + container.setValue("", for: SocialSharingMetadata.messageKey) + } + + if let connectionsByID { + for connection in connectionsByID.values { + container.setValue( + connection.enabled ? "0" : "1", + for: PostMetadataContainer.Key(rawValue: "\(SocialSharingMetadata.skipPrefix)\(connection.id)") + ) + } + } + } +} + +enum PostSocialSharing { + static func isEligible(for post: AbstractPost) -> Bool { + post is Post && post.blog.dotComID != nil && post.blog.supports(.publicize) + } +} From 82b49c054a2aa4f34cb9ceffc601f56145b6ae95 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 27 May 2026 22:45:32 +1200 Subject: [PATCH 3/8] Emit Publicize upload metadata from post metadata Source the `_wpas_skip_publicize_*` and `_wpas_mess` upload entries from the post's metadata container and stop emitting them from the keyring-keyed PostHelper builder, so the v1.x create/update requests carry the connection scheme written by the new bridge. --- .../Models/RemotePostCreateParametersTests.swift | 15 +++++++++++++++ .../Tests/Services/PostCoordinatorTests.swift | 5 ++++- WordPress/Classes/Services/PostHelper.m | 12 ------------ .../RemotePostCreateParameters+Helpers.swift | 6 +++--- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Models/RemotePostCreateParametersTests.swift b/Tests/KeystoneTests/Tests/Models/RemotePostCreateParametersTests.swift index a801f2755183..c0110e097b1e 100644 --- a/Tests/KeystoneTests/Tests/Models/RemotePostCreateParametersTests.swift +++ b/Tests/KeystoneTests/Tests/Models/RemotePostCreateParametersTests.swift @@ -1,5 +1,6 @@ import Testing @testable import WordPress +@testable import WordPressData @Suite("RemotePostCreateParameters Tests") struct RemotePostCreateParametersTests { @@ -40,6 +41,20 @@ struct RemotePostCreateParametersTests { #expect(parameters.isSticky == true) } + @Test("Initialization from Post includes social sharing metadata from raw metadata") + func initializationFromPostIncludesSocialSharingMetadataFromRawMetadata() throws { + let post = Post(context: mainContext) + var metadata = PostMetadataContainer() + metadata.setValue("message-a", for: "_wpas_mess") + metadata.setValue("1", for: "_wpas_skip_publicize_123") + post.rawMetadata = try metadata.encode() + + let parameters = RemotePostCreateParameters(post: post) + + #expect(parameters.metadata.contains(RemotePostMetadataItem(id: nil, key: "_wpas_mess", value: "message-a"))) + #expect(parameters.metadata.contains(RemotePostMetadataItem(id: nil, key: "_wpas_skip_publicize_123", value: "1"))) + } + @Test("Direct metadata manipulation") func directMetadataManipulation() { var parameters = RemotePostCreateParameters(type: "post", status: "draft") diff --git a/Tests/KeystoneTests/Tests/Services/PostCoordinatorTests.swift b/Tests/KeystoneTests/Tests/Services/PostCoordinatorTests.swift index acdafbf4da32..abf3d4849018 100644 --- a/Tests/KeystoneTests/Tests/Services/PostCoordinatorTests.swift +++ b/Tests/KeystoneTests/Tests/Services/PostCoordinatorTests.swift @@ -4,6 +4,7 @@ import OHHTTPStubs import OHHTTPStubsSwift @testable import WordPress +@testable import WordPressData @MainActor class PostCoordinatorTests: CoreDataTestCase { @@ -435,7 +436,9 @@ class PostCoordinatorTests: CoreDataTestCase { // GIVEN an editor revision let revision = post.createRevision() as! Post - revision.publicizeMessage = "message-a" + var metadata = PostMetadataContainer() + metadata.setValue("message-a", for: "_wpas_mess") + revision.rawMetadata = try metadata.encode() // GIVEN stub(condition: isPath("/rest/v1.2/sites/80511/posts/974")) { request in diff --git a/WordPress/Classes/Services/PostHelper.m b/WordPress/Classes/Services/PostHelper.m index 4b3ab7d30425..6e8d9fd0813a 100644 --- a/WordPress/Classes/Services/PostHelper.m +++ b/WordPress/Classes/Services/PostHelper.m @@ -157,18 +157,6 @@ + (NSArray *)remoteMetadataForPost:(Post *)post [metadata addObject:publicDictionary]; } - if (post.publicizeMessageID || post.publicizeMessage.length) { - NSMutableDictionary *publicizeMessageDictionary = [NSMutableDictionary dictionaryWithCapacity:3]; - if (post.publicizeMessageID) { - publicizeMessageDictionary[@"id"] = post.publicizeMessageID; - } - publicizeMessageDictionary[@"key"] = @"_wpas_mess"; - publicizeMessageDictionary[@"value"] = post.publicizeMessage.length ? post.publicizeMessage : @""; - [metadata addObject:publicizeMessageDictionary]; - } - - [metadata addObjectsFromArray:[PostHelper publicizeMetadataEntriesForPost:post]]; - if (post.bloggingPromptID) { NSMutableDictionary *promptDictionary = [NSMutableDictionary dictionaryWithCapacity:3]; promptDictionary[@"key"] = @"_jetpack_blogging_prompt_key"; diff --git a/WordPress/Classes/Services/RemotePostCreateParameters+Helpers.swift b/WordPress/Classes/Services/RemotePostCreateParameters+Helpers.swift index bac8beb0e676..8fa95cdf16ac 100644 --- a/WordPress/Classes/Services/RemotePostCreateParameters+Helpers.swift +++ b/WordPress/Classes/Services/RemotePostCreateParameters+Helpers.swift @@ -55,10 +55,10 @@ private extension RemotePostCreateParameters { /// - note: It includes _only_ the keys known to the app and that you as a /// user can change from the app. static func generateRemoteMetadata(for post: Post) -> [[String: Any]] { - // Start with existing metadata from PostHelper var output = PostHelper.remoteMetadata(for: post) as? [[String: Any]] ?? [] - // Add metadata mananged using `PostMetadata` - output += PostMetadata.entries(in: PostMetadataContainer(post)) + let container = PostMetadataContainer(post) + output += PostMetadata.entries(in: container) + output += SocialSharingMetadata.publicizeEntries(in: container) return output } From 4e6829c284b087871df63bd25a23dd0ad3c41e9f Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 27 May 2026 22:45:32 +1200 Subject: [PATCH 4/8] Seed and persist the social sharing draft in PostSettings Seed the connection_id-keyed draft from the post metadata when the post is Publicize-eligible, and write it back through the metadata container on save. When there is no draft, leave the existing publicize metadata untouched so a user's disabled connections are not silently re-enabled. --- .../Features/Posts/PostSettingsTests.swift | 26 ++++++++++++++++ .../Post/PostSettings/PostSettings.swift | 31 ++++++++++--------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift index aa55c4312613..9e87ae42515e 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift @@ -152,6 +152,32 @@ struct PostSettingsTests { #expect(post.status == .publish) // Changed } + @Test("apply preserves stored publicize metadata when social draft is unavailable") + func applyPreservesStoredPublicizeMetadataWhenSocialDraftIsUnavailable() throws { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).build() + post.rawMetadata = try PostMetadataContainer(metadata: [ + ["key": "_wpas_mess", "value": "Hello", "id": "1"], + ["key": "_wpas_skip_publicize_111", "value": "1", "id": "2"], + ["key": "_jetpack_newsletter_access", "value": "everybody", "id": "3"] + ]) + .encode() + + var settings = PostSettings(from: post) + settings.socialSharingDraft = nil + + settings.apply(to: post) + + // With no draft to apply, the existing publicize metadata is left untouched + // (the user's per-connection choices are preserved, not neutralized). + let container = PostMetadataContainer(post) + #expect(container.getString(for: "_wpas_mess") == "Hello") + #expect(container.getString(for: "_wpas_skip_publicize_111") == "1") + #expect(container.entry(forKey: "_wpas_skip_publicize_111")?["id"] as? String == "2") + #expect(container.getString(for: "_jetpack_newsletter_access") == "everybody") + } + // MARK: - makeUpdateParameters Tests @Test("Creates update parameters for changed properties") diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift index 5ca33275468c..4044f9e1bbad 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift @@ -122,7 +122,10 @@ struct PostSettings: Hashable { $0.categoryID.intValue } ) - sharing = PostSocialSharingSettings.make(for: post) + if PostSocialSharing.isEligible(for: post) { + socialSharingDraft = PostSocialSharingDraft(socialMetadata: PostMetadataContainer(post)) + } + // `sharing` (the legacy keyring-keyed model) is intentionally no longer populated. allowComments = post.allowComments allowPings = post.allowPings case let page as Page: @@ -301,19 +304,17 @@ struct PostSettings: Hashable { post.allowPings = allowPings } - if let sharing { - for connection in sharing.services.flatMap(\.connections) { - let keyringID = NSNumber(value: connection.keyringID) - if !post.publicizeConnectionDisabledForKeyringID(keyringID) != connection.enabled { - if connection.enabled { - post.enablePublicizeConnectionWithKeyringID(keyringID) - } else { - post.disablePublicizeConnectionWithKeyringID(keyringID) - } - } - } - if post.publicizeMessage != sharing.message { - post.publicizeMessage = sharing.message + // Only write when there is a draft to apply. When the draft is absent + // (the connections service is unavailable, so the view model stripped it), + // leave the existing publicize metadata untouched so the user's + // per-connection choices are preserved rather than silently re-enabled. + if let socialSharingDraft { + var container = PostMetadataContainer(post) + socialSharingDraft.applySocialMetadata(to: &container) + do { + post.rawMetadata = try container.encode() + } catch { + wpAssertionFailure("failed to encode social sharing metadata") } } case let page as Page: @@ -623,6 +624,8 @@ extension PostStatus { } /// A value-type representation of `PublicizeService` for the current blog that's simplified for the auto-sharing flow. +// Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. +// Kept for remaining legacy references. struct PostSocialSharingSettings: Hashable { var services: [Service] var message: String From a5f194e28e363049b40349b2da2459c34eb0781c Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 27 May 2026 22:45:32 +1200 Subject: [PATCH 5/8] Surface the social sharing section in the legacy post settings Resolve a SiteSocialConnectionsService for eligible blogs and return a v2SocialSharing binding so the shared SwiftUI section renders in the AbstractPost editor, replacing the keyring-keyed pre-publish UI. Strip the draft when no connections service is available; keep it for private posts. --- .../Posts/PostSettingsViewModelTests.swift | 55 +++++++++++ .../PostSettings/PostSettingsViewModel.swift | 95 ++++++++++++++++++- .../PostSettingsViewModelProtocol.swift | 4 +- 3 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 Tests/KeystoneTests/Tests/Features/Posts/PostSettingsViewModelTests.swift diff --git a/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsViewModelTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsViewModelTests.swift new file mode 100644 index 000000000000..bae25233e199 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsViewModelTests.swift @@ -0,0 +1,55 @@ +import Foundation +import JetpackSocial +import Testing +@testable import WordPress +@testable import WordPressData + +@MainActor +@Suite("PostSettingsViewModel Tests") +struct PostSettingsViewModelTests { + @Test("publish settings preserve publishing fields and strip the social draft when no connections service") + func publishSettingsPreservePublishingFieldsAndStripDraftWithoutConnectionsService() { + let context = ContextManager.forTesting().mainContext + // A plain blog has no WP.com account, so it isn't Publicize-eligible and + // the view model resolves no connections service. The draft is therefore + // stripped (the strip is driven by the missing service, not the status). + let blog = BlogBuilder(context).build() + let post = PostBuilder(context, blog: blog).drafted().build() + let viewModel = PostSettingsViewModel(post: post, context: .publishing) + let publishDate = Date(timeIntervalSince1970: 2_000) + + viewModel.settings.status = .publishPrivate + viewModel.settings.password = "secret" + viewModel.settings.publishDate = publishDate + viewModel.settings.socialSharingDraft = PostSocialSharingDraft(customMessage: "Message") + + let settings = viewModel.getSettingsToPublish(for: viewModel.settings) + + #expect(settings.status == .publishPrivate) + #expect(settings.password == "secret") + #expect(settings.publishDate == publishDate) + #expect(settings.socialSharingDraft == nil) + } + + @Test("a private post keeps its social draft so disabled connections survive being made public") + func privateEligiblePostKeepsSocialDraft() { + let context = ContextManager.forTesting().mainContext + // Publicize-eligible blog: WP.com-hosted, with an account and publish + // capability, so the view model resolves a connections service. + let blog = BlogBuilder(context) + .isHostedAtWPcom() + .withAnAccount() + .with(capabilities: [.publishPosts]) + .build() + let post = PostBuilder(context, blog: blog).drafted().build() + let viewModel = PostSettingsViewModel(post: post, context: .publishing) + + let draft = PostSocialSharingDraft(connectionsByID: ["123": .init(id: "123", enabled: false)]) + viewModel.settings.status = .publishPrivate + viewModel.settings.socialSharingDraft = draft + + // The draft is retained for a private post: private posts aren't publicized, + // but the disabled connection must survive in case the post is later made public. + #expect(viewModel.getSettingsToPublish(for: viewModel.settings).socialSharingDraft == draft) + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index 1fc5bd0eb576..58e0fb444797 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -1,5 +1,6 @@ import Foundation import BuildSettingsKit +import JetpackSocial import SwiftUI import WordPressAPI import WordPressData @@ -39,6 +40,9 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM @Published var isShowingDeletedAlert = false + private let socialConnectionsService: SiteSocialConnectionsService? + private var addConnectionCoordinator: AddConnectionCoordinator? + /// The content of the post, used for AI excerpt generation. var postContent: String { post.content ?? "" @@ -193,6 +197,7 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM self.context = context self.preferences = preferences self.client = try? WordPressClientFactory.shared.instance(for: .init(blog: post.blog)) + self.socialConnectionsService = Self.resolveSocialConnectionsService(for: post) // Initialize settings from the post let initialSettings = PostSettings(from: post) @@ -328,6 +333,9 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM if old.parentPageID != new.parentPageID { refreshParentPageText() } + if old.status != new.status { + refreshSocialSharingState() + } } private func refreshDisplayedCategories() { @@ -366,7 +374,8 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM guard isStandalone else { // Apply settings and return to the editor (editor-specific) - settings.apply(to: post) + let settingsToSave = getSettingsToSave(for: settings) + settingsToSave.apply(to: post) didSaveChanges() wpAssert(onEditorPostSaved != nil, "configuration missing") onEditorPostSaved?() @@ -405,9 +414,25 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM settings.password = originalSettings.password settings.publishDate = originalSettings.publishDate } + stripUnavailableSocialSharing(from: &settings) + return settings + } + + func getSettingsToPublish(for settings: PostSettings) -> PostSettings { + var settings = settings + stripUnavailableSocialSharing(from: &settings) return settings } + private func stripUnavailableSocialSharing(from settings: inout PostSettings) { + // Only drop the draft when there is no connections service to back it. A + // private post keeps its draft: private posts aren't publicized, but the + // per-connection choices must survive in case the post is later made public. + if socialConnectionsService == nil { + settings.socialSharingDraft = nil + } + } + func buttonPublishTapped() { // Check if the post still exists guard let context = post.managedObjectContext, @@ -421,6 +446,7 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM Task { do { let coordinator = PostCoordinator.shared + let settings = getSettingsToPublish(for: self.settings) let changes = settings.makeUpdateParameters(from: post) try await coordinator.publish(post.getOriginal(), parameters: changes) onPostPublished?() @@ -468,8 +494,63 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM // MARK: - Social Sharing + var v2SocialSharing: V2SocialSharingBinding? { + guard let service = socialConnectionsService, + settings.status != .publishPrivate + else { + return nil + } + let binding = Binding( + get: { self.settings.socialSharingDraft ?? PostSocialSharingDraft() }, + set: { self.settings.socialSharingDraft = $0 } + ) + return V2SocialSharingBinding( + connections: service, + draft: binding, + isPostPublished: post.getOriginal().status == .publish, + onAddConnection: { [weak self] in + self?.presentAddSocialConnection() + } + ) + } + + private static func resolveSocialConnectionsService(for post: AbstractPost) -> SiteSocialConnectionsService? { + guard PostSocialSharing.isEligible(for: post) else { + return nil + } + return JetpackSocialFactory.shared.connectionsService(for: post.blog) + } + + private func presentAddSocialConnection() { + guard let service = socialConnectionsService, + let presenter = viewController + else { + return + } + let coordinator = AddConnectionCoordinator( + connectionsService: service, + authenticator: BlogSocialOAuthAuthenticator(blog: blog), + presenter: presenter, + onConnectionCreated: { [weak self, weak service] connection in + guard let self else { return } + var draft = self.settings.socialSharingDraft ?? PostSocialSharingDraft() + draft.addConnection( + connection, + availableConnections: service?.connections.value ?? [connection] + ) + self.settings.socialSharingDraft = draft + } + ) + addConnectionCoordinator = coordinator + coordinator.start() + } + private func refreshSocialSharingState() { - guard let post = post as? Post, isPostEligibleForSocialSharing(post) else { + guard settings.status != .publishPrivate, + socialConnectionsService != nil, + let post = post as? Post, + isPostEligibleForSocialSharing(post) + else { socialSharingState = nil return } @@ -519,6 +600,8 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM } } + // Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. + // Kept for remaining legacy references. private func makeSocialSharingSetupViewModel() -> JetpackSocialNoConnectionViewModel { JetpackSocialNoConnectionViewModel( services: getPublicizeServices(), @@ -528,6 +611,8 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM ) } + // Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. + // Kept for remaining legacy references. private func showSocialSharingSetupScreen() { guard let sharingVC = SharingViewController(blog: blog, delegate: self) else { return wpAssertionFailure("failed to instantiate SharingVC") @@ -537,6 +622,8 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM viewController?.present(navigationVC, animated: true) } + // Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. + // Kept for remaining legacy references. private func didDismissSocialSharingSetupPrompt() { track(.jetpackSocialNoConnectionCardDismissed) isSocialConnectionSetupDismissed = true @@ -545,6 +632,8 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM } } + // Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. + // Kept for remaining legacy references. func showSocialSharingOptions() { guard let blogID = blog.dotComID?.intValue, let settings = settings.sharing @@ -639,6 +728,8 @@ extension PostSettingsViewModel: @MainActor SharingViewControllerDelegate { } } +// Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. +// Kept for remaining legacy references. extension PostSettingsViewModel: @MainActor PrepublishingSocialAccountsDelegate { func didUpdateSharingLimit(with newValue: PublicizeInfo.SharingLimit?) { settings.sharing?.sharingLimit = newValue diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModelProtocol.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModelProtocol.swift index 5c936f853311..af4d128646a8 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModelProtocol.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModelProtocol.swift @@ -72,8 +72,8 @@ protocol PostSettingsViewModelProtocol: ObservableObject { var socialSharingState: PostSettingsSocialSharingSectionState? { get } /// Non-nil when the host screen should render the v2 social sharing - /// section. Only `CustomPostSettingsViewModel` populates this; legacy - /// `AbstractPost` flows return `nil`. + /// section. Custom post settings and eligible `AbstractPost` settings + /// can populate this. var v2SocialSharing: V2SocialSharingBinding? { get } var isShowingDeletedAlert: Bool { get set } From 5a1fb6a02220fec8a1cf01ed2d50f4404263abac Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 27 May 2026 22:45:32 +1200 Subject: [PATCH 6/8] Deprecate the keyring-keyed publicize code Mark the superseded keyring-keyed publicize properties, methods, and UI as deprecated now that post editing keys per-connection state by connection_id in post metadata. Left in place to avoid a Core Data migration. Also retire the obsolete sharing-limit auto-disablement, which Jetpack Social no longer enforces. --- .../Swift/Post+CoreDataProperties.swift | 3 +++ Sources/WordPressData/Swift/Post.swift | 8 ++++++++ .../Classes/Services/PostHelper+JetpackSocial.swift | 4 ++++ .../ViewRelated/Post/PostEditor+JetpackSocial.swift | 12 ++---------- .../PrepublishingSocialAccountsViewController.swift | 4 ++++ 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/Sources/WordPressData/Swift/Post+CoreDataProperties.swift b/Sources/WordPressData/Swift/Post+CoreDataProperties.swift index 7a633d9c0ede..64ce57d29915 100644 --- a/Sources/WordPressData/Swift/Post+CoreDataProperties.swift +++ b/Sources/WordPressData/Swift/Post+CoreDataProperties.swift @@ -5,12 +5,15 @@ import WordPressKit public extension Post { @NSManaged var commentCount: NSNumber? + // Deprecated: superseded by the connection_id-keyed PostSocialSharingDraft stored in post metadata. @NSManaged var disabledPublicizeConnections: [NSNumber: [String: String]]? @NSManaged var likeCount: NSNumber? @NSManaged var postFormat: String? @NSManaged var postType: String? @NSManaged var publicID: String? + // Deprecated: superseded by the connection_id-keyed PostSocialSharingDraft stored in post metadata. @NSManaged var publicizeMessage: String? + // Deprecated: superseded by the connection_id-keyed PostSocialSharingDraft stored in post metadata. @NSManaged var publicizeMessageID: String? @NSManaged var tags: String? @NSManaged var categories: Set? diff --git a/Sources/WordPressData/Swift/Post.swift b/Sources/WordPressData/Swift/Post.swift index 5bd217b2fa1b..cbe4330e4b9d 100644 --- a/Sources/WordPressData/Swift/Post.swift +++ b/Sources/WordPressData/Swift/Post.swift @@ -99,6 +99,8 @@ public class Post: AbstractPost { // MARK: - PublicizeConnections + // Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. + // Kept to avoid a Core Data migration and for remaining legacy references. @objc public func publicizeConnectionDisabledForKeyringID(_ keyringID: NSNumber) -> Bool { let isKeyringEntryDisabled = disabledPublicizeConnections?[keyringID]?[Constants.publicizeValueKey] == Constants.publicizeDisabledValue @@ -116,6 +118,8 @@ public class Post: AbstractPost { return isConnectionEntryDisabled || isKeyringEntryDisabled } + // Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. + // Kept to avoid a Core Data migration and for remaining legacy references. public func enablePublicizeConnectionWithKeyringID(_ keyringID: NSNumber) { // if there's another entry keyed by connectionID references to the same connection, // we need to make sure that the values are kept in sync. @@ -129,6 +133,8 @@ public class Post: AbstractPost { enablePublicizeConnection(keyedBy: keyringID) } + // Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. + // Kept to avoid a Core Data migration and for remaining legacy references. public func disablePublicizeConnectionWithKeyringID(_ keyringID: NSNumber) { // if there's another entry keyed by connectionID references to the same connection, // we need to make sure that the values are kept in sync. @@ -155,6 +161,7 @@ public class Post: AbstractPost { /// Marks the Publicize connection with the given id as enabled. /// /// - Parameter id: The dictionary key for `disabledPublicizeConnections`. + // Deprecated: helper for keyring-keyed publicize code kept for remaining legacy references. private func enablePublicizeConnection(keyedBy id: NSNumber) { guard var connection = disabledPublicizeConnections?[id] else { return @@ -174,6 +181,7 @@ public class Post: AbstractPost { /// Marks the Publicize connection with the given id as disabled. /// /// - Parameter id: The dictionary key for `disabledPublicizeConnections`. + // Deprecated: helper for keyring-keyed publicize code kept for remaining legacy references. private func disablePublicizeConnection(keyedBy id: NSNumber) { if let _ = disabledPublicizeConnections?[id] { disabledPublicizeConnections?[id]?[Constants.publicizeValueKey] = Constants.publicizeDisabledValue diff --git a/WordPress/Classes/Services/PostHelper+JetpackSocial.swift b/WordPress/Classes/Services/PostHelper+JetpackSocial.swift index 46e299ee52dd..659be73c2122 100644 --- a/WordPress/Classes/Services/PostHelper+JetpackSocial.swift +++ b/WordPress/Classes/Services/PostHelper+JetpackSocial.swift @@ -16,6 +16,8 @@ extension PostHelper { /// - post: The associated `Post` object. Optional because Obj-C shouldn't be trusted. /// - metadata: The metadata dictionary for the post. Optional because Obj-C shouldn't be trusted. /// - Returns: A dictionary for the `Post`'s `disabledPublicizeConnections` property. + // Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. + // Kept to avoid a Core Data migration and for remaining legacy references. @objc(disabledPublicizeConnectionsForPost:andMetadata:) static func disabledPublicizeConnections( for post: AbstractPost?, @@ -76,6 +78,8 @@ extension PostHelper { /// /// - Parameter post: The associated `Post` object. /// - Returns: An array of metadata dictionaries representing the `Post`'s disabled connections. + // Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. + // Kept to avoid a Core Data migration and for remaining legacy references. @objc(publicizeMetadataEntriesForPost:) static func publicizeMetadataEntries(for post: Post?) -> [StringDictionary] { guard let post, diff --git a/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift b/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift index cbcf04e9a1fd..836e83064bc8 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift @@ -2,16 +2,8 @@ import WordPressData extension PostEditor { + // Deprecated: Jetpack Social no longer enforces per-post share limits, and post editing now uses + // connection_id-keyed PostSocialSharingDraft metadata instead of keyring publicize state. func disableSocialConnectionsIfNecessary() { - let connections = self.post.blog.sortedConnections - guard RemoteFeatureFlag.jetpackSocialImprovements.enabled(), - let post = self.post as? Post, - let remainingShares = self.post.blog.sharingLimit?.remaining, - remainingShares < connections.count else { - return - } - for connection in connections { - post.disablePublicizeConnectionWithKeyringID(connection.keyringConnectionID) - } } } diff --git a/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift b/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift index baaa9898d534..3cc7b3c13ec3 100644 --- a/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift @@ -4,6 +4,8 @@ import WordPressData import WordPressShared import WordPressUI +// Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. +// Kept for remaining legacy references. protocol PrepublishingSocialAccountsDelegate: NSObjectProtocol { func didUpdateSharingLimit(with newValue: PublicizeInfo.SharingLimit?) @@ -11,6 +13,8 @@ protocol PrepublishingSocialAccountsDelegate: NSObjectProtocol { func didFinish(with connectionChanges: [Int: Bool], message: String?) } +// Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata. +// Kept for remaining legacy references. class PrepublishingSocialAccountsViewController: UITableViewController { // MARK: Properties From f3f8cad85257b8647be33eafa0c69ead924bb49e Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 28 May 2026 12:36:01 +1200 Subject: [PATCH 7/8] Add a release note --- RELEASE-NOTES.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index a3ad91f08916..3d2bac4c61cc 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,6 +1,6 @@ 27.0 ----- - +* [*] [internal] Jetpack Social: use new publicize API to support Jetpack Social [#25587] 26.9 ----- From 43d08ad284b1b88ada945b74661a2526fc175086 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 12 Jun 2026 00:15:45 +1200 Subject: [PATCH 8/8] Honor legacy keyring-keyed Publicize skip metadata Posts written by older clients carry legacy skip rows that the backend still ORs into the publish-time skip decision, so ignoring them showed stale connections as enabled and made re-enabling impossible. Mirror the backend instead: probe each connection's keyring and service key shapes when reading, pin same-keyring siblings before clearing a shared legacy row, and zero cleared rows on save. The keyring ID comes from the v2 connections payload, so no Core Data publicize state is involved. --- .../Models/SocialConnection.swift | 9 ++ .../Services/PostSocialSharingDraft.swift | 51 +++++++- .../Views/PostSocialSharingDetailView.swift | 4 +- .../SocialConnectionTests.swift | 4 + .../PostSocialSharingDraftMetadataTests.swift | 123 +++++++++++++++++- .../PostSocialSharingDraft+PostMetadata.swift | 86 ++++++++++-- 6 files changed, 253 insertions(+), 24 deletions(-) diff --git a/Modules/Sources/JetpackSocial/Models/SocialConnection.swift b/Modules/Sources/JetpackSocial/Models/SocialConnection.swift index a07073ce2887..76b7217b7e5d 100644 --- a/Modules/Sources/JetpackSocial/Models/SocialConnection.swift +++ b/Modules/Sources/JetpackSocial/Models/SocialConnection.swift @@ -3,6 +3,12 @@ import WordPressAPI public struct SocialConnection: Identifiable, Hashable, Sendable { public var id: String + /// The keyring (OAuth token) ID, shared by every connection backed by the + /// same external login. Carried because legacy `_wpas_skip_` + /// post meta rows are keyed by it and the backend still honors them at + /// publish time. Maps from the v2 payload's deprecated `id` field, the + /// only place the v2 API exposes this identifier. + public var keyringConnectionID: String? public var externalID: String public var serviceName: String public var serviceLabel: String @@ -15,6 +21,7 @@ public struct SocialConnection: Identifiable, Hashable, Sendable { public init( id: String, + keyringConnectionID: String? = nil, externalID: String, serviceName: String, serviceLabel: String, @@ -26,6 +33,7 @@ public struct SocialConnection: Identifiable, Hashable, Sendable { status: ConnectionStatus ) { self.id = id + self.keyringConnectionID = keyringConnectionID self.externalID = externalID self.serviceName = serviceName self.serviceLabel = serviceLabel @@ -46,6 +54,7 @@ public struct SocialConnection: Identifiable, Hashable, Sendable { let displayName: String = wire.displayName.nonEmpty ?? externalHandle ?? wire.displayName self.init( id: wire.connectionId, + keyringConnectionID: wire.id.nonEmpty, externalID: wire.externalId, serviceName: wire.serviceName, serviceLabel: wire.serviceLabel, diff --git a/Modules/Sources/JetpackSocial/Services/PostSocialSharingDraft.swift b/Modules/Sources/JetpackSocial/Services/PostSocialSharingDraft.swift index 7219bb3d2a73..dcdc5d5c1be0 100644 --- a/Modules/Sources/JetpackSocial/Services/PostSocialSharingDraft.swift +++ b/Modules/Sources/JetpackSocial/Services/PostSocialSharingDraft.swift @@ -16,13 +16,26 @@ public struct PostSocialSharingDraft: Equatable, Hashable, Sendable { public var customMessage: String? public var connectionsByID: [String: Connection]? + /// Suffixes (keyring connection IDs or service names) of truthy legacy + /// `_wpas_skip_*` rows found in the post's metadata. The backend ORs every + /// skip scheme at publish time, so a connection must render as disabled + /// when any of its legacy rows is set, even if its connection-keyed row + /// says otherwise. Populated only by the v1.1 metadata bridge; stays empty + /// on the core REST path, where the server resolves legacy rows itself. + public var legacyDisabledKeys: Set + // TODO: per-connection customization (`_wpas_customize_per_network`) — // extend to include per-connection message / attached_media / media_source // once the backend paid feature lands. - public init(customMessage: String? = nil, connectionsByID: [String: Connection]? = nil) { + public init( + customMessage: String? = nil, + connectionsByID: [String: Connection]? = nil, + legacyDisabledKeys: Set = [] + ) { self.customMessage = customMessage self.connectionsByID = connectionsByID + self.legacyDisabledKeys = legacyDisabledKeys } } @@ -39,20 +52,54 @@ extension PostSocialSharingDraft { ) } - public func isEnabled(connectionID: String) -> Bool { + /// Only a building block for `isEnabled(connection:)`: legacy skip rows + /// are keyed by keyring ID or service name, which a bare connection ID + /// cannot be matched against. + private func isEnabled(connectionID: String) -> Bool { connectionsByID?[connectionID]?.enabled ?? true } + /// Mirrors the backend publish-time gate: a connection is disabled when + /// its explicit entry says so or when any of its legacy-format skip rows + /// (keyring-keyed or service-keyed) is set. + public func isEnabled(connection: SocialConnection) -> Bool { + !isLegacyDisabled(connection) && isEnabled(connectionID: connection.id) + } + public mutating func setEnabled( _ enabled: Bool, for connection: SocialConnection, availableConnections: [SocialConnection] ) { var connections = materializedConnectionsByID(availableConnections: availableConnections) + if enabled { + // A legacy key covers every connection on its keyring (or service), + // so before clearing the keys shared with this connection, pin the + // other legacy-disabled connections to explicit OFF entries. Without + // this, enabling one Facebook page would silently re-enable the + // other pages under the same login. + for other in availableConnections + where other.id != connection.id && isLegacyDisabled(other) { + connections[other.id] = Connection(id: other.id, enabled: false) + } + if let keyringID = connection.keyringConnectionID { + legacyDisabledKeys.remove(keyringID) + } + legacyDisabledKeys.remove(connection.serviceName) + } connections[connection.id] = Connection(id: connection.id, enabled: enabled) connectionsByID = connections } + private func isLegacyDisabled(_ connection: SocialConnection) -> Bool { + if let keyringID = connection.keyringConnectionID, + legacyDisabledKeys.contains(keyringID) + { + return true + } + return legacyDisabledKeys.contains(connection.serviceName) + } + public mutating func addConnection( _ connection: SocialConnection, availableConnections: [SocialConnection] diff --git a/Modules/Sources/JetpackSocial/Views/PostSocialSharingDetailView.swift b/Modules/Sources/JetpackSocial/Views/PostSocialSharingDetailView.swift index 41b0cb298e61..0e43590104c6 100644 --- a/Modules/Sources/JetpackSocial/Views/PostSocialSharingDetailView.swift +++ b/Modules/Sources/JetpackSocial/Views/PostSocialSharingDetailView.swift @@ -103,7 +103,7 @@ public struct PostSocialSharingDetailView: View { private func bindingForToggle(connection: SocialConnection) -> Binding { Binding( - get: { draft.isEnabled(connectionID: connection.id) }, + get: { draft.isEnabled(connection: connection) }, set: { isEnabled in draft.setEnabled( isEnabled, @@ -122,7 +122,7 @@ extension PostSocialSharingDraft { /// connections") or all/none of the connections are enabled. public func summary(for connections: [SocialConnection]) -> String? { guard !connections.isEmpty else { return nil } - let enabledCount = connections.filter { isEnabled(connectionID: $0.id) }.count + let enabledCount = connections.filter { isEnabled(connection: $0) }.count if enabledCount == 0 || enabledCount == connections.count { return nil } diff --git a/Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift b/Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift index e96c74373293..06aedabc916a 100644 --- a/Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift +++ b/Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift @@ -29,6 +29,9 @@ struct SocialConnectionTests { let model = SocialConnection(from: wire) #expect(model.id == "123") + // The deprecated wire `id` carries the keyring (token) ID, needed to + // resolve legacy `_wpas_skip_` post meta. + #expect(model.keyringConnectionID == "deprecated") #expect(model.externalID == "ext-42") #expect(model.serviceName == "mastodon") #expect(model.serviceLabel == "Mastodon") @@ -63,6 +66,7 @@ struct SocialConnectionTests { let model = SocialConnection(from: wire) #expect(model.displayName == "@tony@mastodon.social") #expect(model.externalHandle == "@tony@mastodon.social") + #expect(model.keyringConnectionID == nil) } @Test("empty display_name and empty handle stays empty") diff --git a/Tests/KeystoneTests/Tests/ViewRelated/Post/PostSocialSharingDraftMetadataTests.swift b/Tests/KeystoneTests/Tests/ViewRelated/Post/PostSocialSharingDraftMetadataTests.swift index 9f7b48c9c369..d414880b3c64 100644 --- a/Tests/KeystoneTests/Tests/ViewRelated/Post/PostSocialSharingDraftMetadataTests.swift +++ b/Tests/KeystoneTests/Tests/ViewRelated/Post/PostSocialSharingDraftMetadataTests.swift @@ -13,15 +13,73 @@ struct PostSocialSharingDraftMetadataTests { ["key": "_wpas_skip_publicize_111", "value": "1"], ["key": "_wpas_skip_publicize_222", "value": "0"], ["key": "_wpas_skip_333", "value": "1"], + ["key": "_wpas_skip_444", "value": "0"], ["key": "unrelated", "value": "value"] ]) let draft = PostSocialSharingDraft(socialMetadata: container) #expect(draft.customMessage == "Hello") - #expect(!draft.isEnabled(connectionID: "111")) - #expect(draft.isEnabled(connectionID: "222")) - #expect(draft.isEnabled(connectionID: "999")) + #expect(!draft.isEnabled(connection: makeConnection(id: "111", keyringID: "881"))) + #expect(draft.isEnabled(connection: makeConnection(id: "222", keyringID: "882"))) + #expect(draft.isEnabled(connection: makeConnection(id: "999", keyringID: "883"))) + // Only truthy legacy rows mark their suffix as disabled. + #expect(draft.legacyDisabledKeys == ["333"]) + } + + @Test("legacy rows disable connections by keyring ID and service name") + func legacyRowsDisableConnectionsByKeyringAndService() { + let container = PostMetadataContainer(metadata: [ + ["key": "_wpas_skip_333", "value": "1"], + ["key": "_wpas_skip_mastodon", "value": "1"] + ]) + let draft = PostSocialSharingDraft(socialMetadata: container) + + let byKeyring = makeConnection(id: "1", keyringID: "333") + let sameKeyring = makeConnection(id: "2", keyringID: "333") + let byService = makeConnection(id: "3", keyringID: "555", service: "mastodon") + let unaffected = makeConnection(id: "4", keyringID: "777") + + #expect(!draft.isEnabled(connection: byKeyring)) + // A keyring-keyed row covers every connection on that keyring. + #expect(!draft.isEnabled(connection: sameKeyring)) + #expect(!draft.isEnabled(connection: byService)) + #expect(draft.isEnabled(connection: unaffected)) + } + + @Test("legacy row wins over an enabled connection-keyed row") + func legacyRowWinsOverEnabledConnectionRow() { + // The backend ORs all skip schemes, so `_wpas_skip_publicize_1 = 0` + // cannot re-enable a connection whose legacy row is still truthy. + let container = PostMetadataContainer(metadata: [ + ["key": "_wpas_skip_publicize_1", "value": "0"], + ["key": "_wpas_skip_333", "value": "1"] + ]) + let draft = PostSocialSharingDraft(socialMetadata: container) + + #expect(!draft.isEnabled(connection: makeConnection(id: "1", keyringID: "333"))) + } + + @Test("enabling clears legacy keys and pins same-keyring siblings") + func enablingClearsLegacyKeysAndPinsSiblings() { + let container = PostMetadataContainer(metadata: [ + ["key": "_wpas_skip_333", "value": "1"] + ]) + var draft = PostSocialSharingDraft(socialMetadata: container) + + let football = makeConnection(id: "1", keyringID: "333") + let basketball = makeConnection(id: "2", keyringID: "333") + let mastodon = makeConnection(id: "3", keyringID: "555", service: "mastodon") + let all = [football, basketball, mastodon] + + draft.setEnabled(true, for: football, availableConnections: all) + + #expect(draft.isEnabled(connection: football)) + // The sibling sharing the keyring must stay off after the shared + // legacy key is cleared. + #expect(!draft.isEnabled(connection: basketball)) + #expect(draft.isEnabled(connection: mastodon)) + #expect(draft.legacyDisabledKeys.isEmpty) } @Test("seed treats empty message as nil") @@ -74,8 +132,41 @@ struct PostSocialSharingDraftMetadataTests { #expect(containerWithoutMessage.entry(forKey: "_wpas_mess") == nil) } - @Test("upload entries include only publicize keys") - func uploadEntriesIncludeOnlyPublicizeKeys() { + @Test("serialize zeroes legacy rows cleared by re-enabling") + func serializeZeroesClearedLegacyRows() { + var container = PostMetadataContainer(metadata: [ + ["key": "_wpas_skip_333", "value": "1"], + ["key": "_wpas_skip_777", "value": "1"] + ]) + var draft = PostSocialSharingDraft(socialMetadata: container) + let connection = makeConnection(id: "1", keyringID: "333") + + draft.setEnabled(true, for: connection, availableConnections: [connection]) + draft.applySocialMetadata(to: &container) + + #expect(container.getString(for: "_wpas_skip_publicize_1") == "0") + // The cleared legacy row must be zeroed; the backend ORs all skip + // schemes, so leaving it truthy would keep suppressing the share. + #expect(container.getString(for: "_wpas_skip_333") == "0") + // A legacy row the user did not clear stays untouched, even when its + // keyring matches no known connection. + #expect(container.getString(for: "_wpas_skip_777") == "1") + } + + @Test("serialize leaves legacy rows alone without user changes") + func serializeLeavesLegacyRowsAloneWithoutUserChanges() { + var container = PostMetadataContainer(metadata: [ + ["key": "_wpas_skip_333", "value": "1"] + ]) + let draft = PostSocialSharingDraft(socialMetadata: container) + + draft.applySocialMetadata(to: &container) + + #expect(container.getString(for: "_wpas_skip_333") == "1") + } + + @Test("upload entries include publicize and legacy skip keys") + func uploadEntriesIncludePublicizeAndLegacySkipKeys() { let container = PostMetadataContainer(metadata: [ ["key": "_wpas_mess", "value": "Hello"], ["key": "_wpas_skip_publicize_111", "value": "1"], @@ -87,7 +178,7 @@ struct PostSocialSharingDraftMetadataTests { let entries = SocialSharingMetadata.publicizeEntries(in: container) let keys = Set(entries.compactMap { $0["key"] as? String }) - #expect(keys == ["_wpas_mess", "_wpas_skip_publicize_111"]) + #expect(keys == ["_wpas_mess", "_wpas_skip_publicize_111", "_wpas_skip_222"]) } @Test("isDisabled handles supported metadata value shapes") @@ -99,4 +190,24 @@ struct PostSocialSharingDraftMetadataTests { #expect(!SocialSharingMetadata.isDisabled(NSNumber(value: false))) #expect(!SocialSharingMetadata.isDisabled(nil)) } + + private func makeConnection( + id: String, + keyringID: String?, + service: String = "facebook" + ) -> SocialConnection { + SocialConnection( + id: id, + keyringConnectionID: keyringID, + externalID: "external-\(id)", + serviceName: service, + serviceLabel: service.capitalized, + displayName: "Connection \(id)", + externalHandle: nil, + profileLink: nil, + profilePictureURL: nil, + isShared: true, + status: .ok + ) + } } diff --git a/WordPress/Classes/ViewRelated/Post/Social/PostSocialSharingDraft+PostMetadata.swift b/WordPress/Classes/ViewRelated/Post/Social/PostSocialSharingDraft+PostMetadata.swift index 3447f6a7f8a4..3bb13b36bd97 100644 --- a/WordPress/Classes/ViewRelated/Post/Social/PostSocialSharingDraft+PostMetadata.swift +++ b/WordPress/Classes/ViewRelated/Post/Social/PostSocialSharingDraft+PostMetadata.swift @@ -2,19 +2,58 @@ import Foundation import JetpackSocial import WordPressData +/// Jetpack Social (Publicize) decides where a post gets shared at publish +/// time by reading the post's meta rows, not a dedicated API field. A +/// connection shares by default; a truthy "skip" row suppresses it. Three +/// generations of skip keys exist and the backend ORs them all, so any one +/// truthy row wins: +/// +/// - `_wpas_skip_publicize_`: the current scheme, one row per +/// connection, written by every first-party client since mid-2023. +/// - `_wpas_skip_`: legacy. The keyring (OAuth token) ID is shared +/// by every connection under one external login, so one row covers them +/// all. Old posts still carry these, and they never expire or migrate. +/// - `_wpas_skip_`: the oldest scheme, one row per service. +/// +/// Because of the OR, writing `_wpas_skip_publicize_ = 0` cannot +/// re-enable a connection whose legacy row is still truthy; the legacy row +/// itself must be zeroed. That asymmetry drives both directions of this +/// bridge: reading probes every key shape a connection's IDs could have +/// produced (mirroring `Publicize::get_filtered_connection_data()` in the +/// Jetpack plugin), and writing zeroes the legacy rows the user cleared. +/// `_wpas_mess` carries the optional custom share message. enum SocialSharingMetadata { static let skipPrefix = "_wpas_skip_publicize_" + static let legacySkipPrefix = "_wpas_skip_" static let messageKey: PostMetadataContainer.Key = "_wpas_mess" + /// All social sharing entries: the message plus skip rows of every + /// generation. Legacy rows must ride along in the upload set so that + /// zeroed-out values actually reach the server. static func publicizeEntries(in container: PostMetadataContainer) -> [[String: Any]] { container.values.filter { entry in guard let key = entry["key"] as? String else { return false } - return key == messageKey.rawValue || key.hasPrefix(skipPrefix) + // The skipPrefix check is redundant (legacySkipPrefix is its + // prefix) but spelled out so the filter stays correct if the + // constants ever diverge. + return key == messageKey.rawValue + || key.hasPrefix(skipPrefix) + || key.hasPrefix(legacySkipPrefix) } } + /// Suffix (keyring connection ID or service name) of a legacy-format skip + /// key, or nil for connection-keyed and unrelated keys. + static func legacySkipSuffix(of key: String) -> String? { + guard key.hasPrefix(legacySkipPrefix), !key.hasPrefix(skipPrefix) else { + return nil + } + let suffix = String(key.dropFirst(legacySkipPrefix.count)) + return suffix.isEmpty ? nil : suffix + } + static func isDisabled(_ value: Any?) -> Bool { switch value { case let value as Bool: @@ -32,30 +71,32 @@ enum SocialSharingMetadata { extension PostSocialSharingDraft { init(socialMetadata container: PostMetadataContainer) { let message = container.getString(for: SocialSharingMetadata.messageKey) - let connectionsByID = SocialSharingMetadata.publicizeEntries(in: container) - .reduce( - into: [String: Connection]() - ) { connectionsByID, entry in - guard let key = entry["key"] as? String, - key.hasPrefix(SocialSharingMetadata.skipPrefix) - else { - return - } - + var connectionsByID: [String: Connection] = [:] + var legacyDisabledKeys: Set = [] + for entry in SocialSharingMetadata.publicizeEntries(in: container) { + guard let key = entry["key"] as? String else { + continue + } + if key.hasPrefix(SocialSharingMetadata.skipPrefix) { let connectionID = String(key.dropFirst(SocialSharingMetadata.skipPrefix.count)) guard !connectionID.isEmpty else { - return + continue } - connectionsByID[connectionID] = Connection( id: connectionID, enabled: !SocialSharingMetadata.isDisabled(entry["value"]) ) + } else if let suffix = SocialSharingMetadata.legacySkipSuffix(of: key), + SocialSharingMetadata.isDisabled(entry["value"]) + { + legacyDisabledKeys.insert(suffix) } + } self.init( customMessage: message?.isEmpty == false ? message : nil, - connectionsByID: connectionsByID.isEmpty ? nil : connectionsByID + connectionsByID: connectionsByID.isEmpty ? nil : connectionsByID, + legacyDisabledKeys: legacyDisabledKeys ) } @@ -74,6 +115,23 @@ extension PostSocialSharingDraft { ) } } + + // Writing the connection-keyed row alone cannot re-enable a connection: + // the backend ORs every skip scheme, so a stale truthy legacy row keeps + // suppressing the share. Zero out the legacy rows the user cleared this + // session (their suffix left `legacyDisabledKeys` when the connection + // was toggled on). Rows still in the set, including ones for unknown + // keyrings, stay untouched. + for entry in container.values { + guard let key = entry["key"] as? String, + let suffix = SocialSharingMetadata.legacySkipSuffix(of: key), + !legacyDisabledKeys.contains(suffix), + SocialSharingMetadata.isDisabled(entry["value"]) + else { + continue + } + container.setValue("0", for: PostMetadataContainer.Key(rawValue: key)) + } } }