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 ----- 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 c211ca3468ec..cbe4330e4b9d 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,22 @@ public class Post: AbstractPost { // MARK: - Sharing @objc public func canEditPublicizeSettings() -> Bool { - return !self.hasRemote() || self.status != .publish + !self.hasRemote() || self.status != .publish } // 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 + 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 } @@ -114,30 +118,37 @@ 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. 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) } 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. 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 @@ -150,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 @@ -169,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 @@ -185,13 +198,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 +222,7 @@ public class Post: AbstractPost { } public func dateForDisplay() -> Date? { - return dateCreated + dateCreated } // MARK: - BasePost @@ -226,7 +239,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 +250,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/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/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/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/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/Services/PostHelper+JetpackSocial.swift b/WordPress/Classes/Services/PostHelper+JetpackSocial.swift index be74ae7913e3..659be73c2122 100644 --- a/WordPress/Classes/Services/PostHelper+JetpackSocial.swift +++ b/WordPress/Classes/Services/PostHelper+JetpackSocial.swift @@ -16,13 +16,19 @@ 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?, 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 +52,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. @@ -71,10 +78,13 @@ 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, - let disabledConnectionsDictionary = post.disabledPublicizeConnections else { + let disabledConnectionsDictionary = post.disabledPublicizeConnections + else { return [] } @@ -96,8 +106,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/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 39f58baee396..8fa95cdf16ac 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 @@ -51,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 } 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/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 diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index 92fd79b783f4..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 ?? "" @@ -104,7 +108,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 { @@ -192,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) @@ -204,9 +210,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 +262,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 @@ -319,6 +333,9 @@ final class PostSettingsViewModel: NSObject, ObservableObject, PostSettingsViewM if old.parentPageID != new.parentPageID { refreshParentPageText() } + if old.status != new.status { + refreshSocialSharingState() + } } private func refreshDisplayedCategories() { @@ -331,8 +348,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,14 +366,16 @@ 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 } 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?() @@ -394,13 +414,30 @@ 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, - let _ = try? context.existingObject(with: post.objectID) else { + let _ = try? context.existingObject(with: post.objectID) + else { isShowingDeletedAlert = true return } @@ -409,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?() @@ -456,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 } @@ -490,8 +583,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 @@ -506,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(), @@ -515,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") @@ -524,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 @@ -532,9 +632,12 @@ 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 else { + let settings = settings.sharing + else { return wpAssertionFailure("invalid context") } let optionsVC = PrepublishingSocialAccountsViewController( @@ -625,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 } diff --git a/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift b/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift index e1e936c7a259..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 @@ -69,11 +73,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 +122,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 +171,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 +206,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 +253,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 +291,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 +313,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 } 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) + } +}