Skip to content

Commit bb2fd35

Browse files
authored
Use the new Jetpack Social connection on AbstractPost (#25587)
* Format planned Swift edit files * 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_<connection_id>` and `_wpas_mess` post metadata. * 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. * 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. * 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. * 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. * Add a release note * 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.
1 parent 236a51c commit bb2fd35

21 files changed

Lines changed: 771 additions & 118 deletions

Modules/Sources/JetpackSocial/Models/SocialConnection.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import WordPressAPI
33

44
public struct SocialConnection: Identifiable, Hashable, Sendable {
55
public var id: String
6+
/// The keyring (OAuth token) ID, shared by every connection backed by the
7+
/// same external login. Carried because legacy `_wpas_skip_<keyringID>`
8+
/// post meta rows are keyed by it and the backend still honors them at
9+
/// publish time. Maps from the v2 payload's deprecated `id` field, the
10+
/// only place the v2 API exposes this identifier.
11+
public var keyringConnectionID: String?
612
public var externalID: String
713
public var serviceName: String
814
public var serviceLabel: String
@@ -15,6 +21,7 @@ public struct SocialConnection: Identifiable, Hashable, Sendable {
1521

1622
public init(
1723
id: String,
24+
keyringConnectionID: String? = nil,
1825
externalID: String,
1926
serviceName: String,
2027
serviceLabel: String,
@@ -26,6 +33,7 @@ public struct SocialConnection: Identifiable, Hashable, Sendable {
2633
status: ConnectionStatus
2734
) {
2835
self.id = id
36+
self.keyringConnectionID = keyringConnectionID
2937
self.externalID = externalID
3038
self.serviceName = serviceName
3139
self.serviceLabel = serviceLabel
@@ -46,6 +54,7 @@ public struct SocialConnection: Identifiable, Hashable, Sendable {
4654
let displayName: String = wire.displayName.nonEmpty ?? externalHandle ?? wire.displayName
4755
self.init(
4856
id: wire.connectionId,
57+
keyringConnectionID: wire.id.nonEmpty,
4958
externalID: wire.externalId,
5059
serviceName: wire.serviceName,
5160
serviceLabel: wire.serviceLabel,

Modules/Sources/JetpackSocial/Services/PostSocialSharingDraft.swift

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,26 @@ public struct PostSocialSharingDraft: Equatable, Hashable, Sendable {
1616
public var customMessage: String?
1717
public var connectionsByID: [String: Connection]?
1818

19+
/// Suffixes (keyring connection IDs or service names) of truthy legacy
20+
/// `_wpas_skip_*` rows found in the post's metadata. The backend ORs every
21+
/// skip scheme at publish time, so a connection must render as disabled
22+
/// when any of its legacy rows is set, even if its connection-keyed row
23+
/// says otherwise. Populated only by the v1.1 metadata bridge; stays empty
24+
/// on the core REST path, where the server resolves legacy rows itself.
25+
public var legacyDisabledKeys: Set<String>
26+
1927
// TODO: per-connection customization (`_wpas_customize_per_network`) —
2028
// extend to include per-connection message / attached_media / media_source
2129
// once the backend paid feature lands.
2230

23-
public init(customMessage: String? = nil, connectionsByID: [String: Connection]? = nil) {
31+
public init(
32+
customMessage: String? = nil,
33+
connectionsByID: [String: Connection]? = nil,
34+
legacyDisabledKeys: Set<String> = []
35+
) {
2436
self.customMessage = customMessage
2537
self.connectionsByID = connectionsByID
38+
self.legacyDisabledKeys = legacyDisabledKeys
2639
}
2740
}
2841

@@ -39,20 +52,54 @@ extension PostSocialSharingDraft {
3952
)
4053
}
4154

42-
public func isEnabled(connectionID: String) -> Bool {
55+
/// Only a building block for `isEnabled(connection:)`: legacy skip rows
56+
/// are keyed by keyring ID or service name, which a bare connection ID
57+
/// cannot be matched against.
58+
private func isEnabled(connectionID: String) -> Bool {
4359
connectionsByID?[connectionID]?.enabled ?? true
4460
}
4561

62+
/// Mirrors the backend publish-time gate: a connection is disabled when
63+
/// its explicit entry says so or when any of its legacy-format skip rows
64+
/// (keyring-keyed or service-keyed) is set.
65+
public func isEnabled(connection: SocialConnection) -> Bool {
66+
!isLegacyDisabled(connection) && isEnabled(connectionID: connection.id)
67+
}
68+
4669
public mutating func setEnabled(
4770
_ enabled: Bool,
4871
for connection: SocialConnection,
4972
availableConnections: [SocialConnection]
5073
) {
5174
var connections = materializedConnectionsByID(availableConnections: availableConnections)
75+
if enabled {
76+
// A legacy key covers every connection on its keyring (or service),
77+
// so before clearing the keys shared with this connection, pin the
78+
// other legacy-disabled connections to explicit OFF entries. Without
79+
// this, enabling one Facebook page would silently re-enable the
80+
// other pages under the same login.
81+
for other in availableConnections
82+
where other.id != connection.id && isLegacyDisabled(other) {
83+
connections[other.id] = Connection(id: other.id, enabled: false)
84+
}
85+
if let keyringID = connection.keyringConnectionID {
86+
legacyDisabledKeys.remove(keyringID)
87+
}
88+
legacyDisabledKeys.remove(connection.serviceName)
89+
}
5290
connections[connection.id] = Connection(id: connection.id, enabled: enabled)
5391
connectionsByID = connections
5492
}
5593

94+
private func isLegacyDisabled(_ connection: SocialConnection) -> Bool {
95+
if let keyringID = connection.keyringConnectionID,
96+
legacyDisabledKeys.contains(keyringID)
97+
{
98+
return true
99+
}
100+
return legacyDisabledKeys.contains(connection.serviceName)
101+
}
102+
56103
public mutating func addConnection(
57104
_ connection: SocialConnection,
58105
availableConnections: [SocialConnection]

Modules/Sources/JetpackSocial/Views/PostSocialSharingDetailView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public struct PostSocialSharingDetailView: View {
103103

104104
private func bindingForToggle(connection: SocialConnection) -> Binding<Bool> {
105105
Binding(
106-
get: { draft.isEnabled(connectionID: connection.id) },
106+
get: { draft.isEnabled(connection: connection) },
107107
set: { isEnabled in
108108
draft.setEnabled(
109109
isEnabled,
@@ -122,7 +122,7 @@ extension PostSocialSharingDraft {
122122
/// connections") or all/none of the connections are enabled.
123123
public func summary(for connections: [SocialConnection]) -> String? {
124124
guard !connections.isEmpty else { return nil }
125-
let enabledCount = connections.filter { isEnabled(connectionID: $0.id) }.count
125+
let enabledCount = connections.filter { isEnabled(connection: $0) }.count
126126
if enabledCount == 0 || enabledCount == connections.count {
127127
return nil
128128
}

Modules/Sources/WordPressData/Swift/Post+CoreDataProperties.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@ import WordPressKit
55
public extension Post {
66

77
@NSManaged var commentCount: NSNumber?
8+
// Deprecated: superseded by the connection_id-keyed PostSocialSharingDraft stored in post metadata.
89
@NSManaged var disabledPublicizeConnections: [NSNumber: [String: String]]?
910
@NSManaged var likeCount: NSNumber?
1011
@NSManaged var postFormat: String?
1112
@NSManaged var postType: String?
1213
@NSManaged var publicID: String?
14+
// Deprecated: superseded by the connection_id-keyed PostSocialSharingDraft stored in post metadata.
1315
@NSManaged var publicizeMessage: String?
16+
// Deprecated: superseded by the connection_id-keyed PostSocialSharingDraft stored in post metadata.
1417
@NSManaged var publicizeMessageID: String?
1518
@NSManaged var tags: String?
1619
@NSManaged var categories: Set<PostCategory>?

Modules/Sources/WordPressData/Swift/Post.swift

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ public class Post: AbstractPost {
3535
// MARK: - NSManagedObject
3636

3737
public override class func entityName() -> String {
38-
return "Post"
38+
"Post"
3939
}
4040

4141
// MARK: - Format
4242

4343
@objc public func postFormatText() -> String? {
44-
return blog.postFormatText(fromSlug: postFormat)
44+
blog.postFormatText(fromSlug: postFormat)
4545
}
4646

4747
@objc public func setPostFormatText(_ postFormatText: String) {
@@ -81,7 +81,7 @@ public class Post: AbstractPost {
8181
return
8282
}
8383

84-
let matchingCategories = blogCategories.filter({ return $0.categoryName == categoryName })
84+
let matchingCategories = blogCategories.filter({ $0.categoryName == categoryName })
8585

8686
if !matchingCategories.isEmpty {
8787
newCategories = newCategories.union(matchingCategories)
@@ -94,18 +94,22 @@ public class Post: AbstractPost {
9494
// MARK: - Sharing
9595

9696
@objc public func canEditPublicizeSettings() -> Bool {
97-
return !self.hasRemote() || self.status != .publish
97+
!self.hasRemote() || self.status != .publish
9898
}
9999

100100
// MARK: - PublicizeConnections
101101

102+
// Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata.
103+
// Kept to avoid a Core Data migration and for remaining legacy references.
102104
@objc public func publicizeConnectionDisabledForKeyringID(_ keyringID: NSNumber) -> Bool {
103-
let isKeyringEntryDisabled = disabledPublicizeConnections?[keyringID]?[Constants.publicizeValueKey] == Constants.publicizeDisabledValue
105+
let isKeyringEntryDisabled =
106+
disabledPublicizeConnections?[keyringID]?[Constants.publicizeValueKey] == Constants.publicizeDisabledValue
104107

105108
// try to check in case there's an entry for the PublicizeConnection that's keyed by the connectionID.
106109
guard let connections = blog.connections,
107-
let connection = connections.first(where: { $0.keyringConnectionID == keyringID }),
108-
let existingValue = disabledPublicizeConnections?[connection.connectionID]?[Constants.publicizeValueKey] else {
110+
let connection = connections.first(where: { $0.keyringConnectionID == keyringID }),
111+
let existingValue = disabledPublicizeConnections?[connection.connectionID]?[Constants.publicizeValueKey]
112+
else {
109113
// fall back to keyringID if there is no such entry with the connectionID.
110114
return isKeyringEntryDisabled
111115
}
@@ -114,30 +118,37 @@ public class Post: AbstractPost {
114118
return isConnectionEntryDisabled || isKeyringEntryDisabled
115119
}
116120

121+
// Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata.
122+
// Kept to avoid a Core Data migration and for remaining legacy references.
117123
public func enablePublicizeConnectionWithKeyringID(_ keyringID: NSNumber) {
118124
// if there's another entry keyed by connectionID references to the same connection,
119125
// we need to make sure that the values are kept in sync.
120126
if let connections = blog.connections,
121-
let connection = connections.first(where: { $0.keyringConnectionID == keyringID }),
122-
let _ = disabledPublicizeConnections?[connection.connectionID] {
127+
let connection = connections.first(where: { $0.keyringConnectionID == keyringID }),
128+
let _ = disabledPublicizeConnections?[connection.connectionID]
129+
{
123130
enablePublicizeConnection(keyedBy: connection.connectionID)
124131
}
125132

126133
enablePublicizeConnection(keyedBy: keyringID)
127134
}
128135

136+
// Deprecated: superseded for post editing by connection_id-keyed PostSocialSharingDraft stored in post metadata.
137+
// Kept to avoid a Core Data migration and for remaining legacy references.
129138
public func disablePublicizeConnectionWithKeyringID(_ keyringID: NSNumber) {
130139
// if there's another entry keyed by connectionID references to the same connection,
131140
// we need to make sure that the values are kept in sync.
132141
if let connections = blog.connections,
133-
let connectionID = connections.first(where: { $0.keyringConnectionID == keyringID })?.connectionID,
134-
let _ = disabledPublicizeConnections?[connectionID] {
142+
let connectionID = connections.first(where: { $0.keyringConnectionID == keyringID })?.connectionID,
143+
let _ = disabledPublicizeConnections?[connectionID]
144+
{
135145
disablePublicizeConnection(keyedBy: connectionID)
136146

137147
// additionally, if the keyring entry doesn't exist, there's no need create both formats.
138148
// we can just update the dictionary's key from connectionID to keyringID instead.
139149
if disabledPublicizeConnections?[keyringID] == nil,
140-
let updatedEntry = disabledPublicizeConnections?[connectionID] {
150+
let updatedEntry = disabledPublicizeConnections?[connectionID]
151+
{
141152
disabledPublicizeConnections?.removeValue(forKey: connectionID)
142153
disabledPublicizeConnections?[keyringID] = updatedEntry
143154
return
@@ -150,6 +161,7 @@ public class Post: AbstractPost {
150161
/// Marks the Publicize connection with the given id as enabled.
151162
///
152163
/// - Parameter id: The dictionary key for `disabledPublicizeConnections`.
164+
// Deprecated: helper for keyring-keyed publicize code kept for remaining legacy references.
153165
private func enablePublicizeConnection(keyedBy id: NSNumber) {
154166
guard var connection = disabledPublicizeConnections?[id] else {
155167
return
@@ -169,6 +181,7 @@ public class Post: AbstractPost {
169181
/// Marks the Publicize connection with the given id as disabled.
170182
///
171183
/// - Parameter id: The dictionary key for `disabledPublicizeConnections`.
184+
// Deprecated: helper for keyring-keyed publicize code kept for remaining legacy references.
172185
private func disablePublicizeConnection(keyedBy id: NSNumber) {
173186
if let _ = disabledPublicizeConnections?[id] {
174187
disabledPublicizeConnections?[id]?[Constants.publicizeValueKey] = Constants.publicizeDisabledValue
@@ -185,13 +198,13 @@ public class Post: AbstractPost {
185198
// MARK: - Comments
186199

187200
@objc public func numberOfComments() -> Int {
188-
return commentCount?.intValue ?? 0
201+
commentCount?.intValue ?? 0
189202
}
190203

191204
// MARK: - Likes
192205

193206
@objc public func numberOfLikes() -> Int {
194-
return likeCount?.intValue ?? 0
207+
likeCount?.intValue ?? 0
195208
}
196209

197210
// MARK: - AbstractPost
@@ -209,7 +222,7 @@ public class Post: AbstractPost {
209222
}
210223

211224
public func dateForDisplay() -> Date? {
212-
return dateCreated
225+
dateCreated
213226
}
214227

215228
// MARK: - BasePost
@@ -226,7 +239,8 @@ public class Post: AbstractPost {
226239
if let preview = PostPreviewCache.shared.content[content] {
227240
return preview
228241
}
229-
let preview = GutenbergExcerptGenerator.firstParagraph(from: content, maxLength: 200).withCollapsedNewlines().trimmedForPreview()
242+
let preview = GutenbergExcerptGenerator.firstParagraph(from: content, maxLength: 200)
243+
.withCollapsedNewlines().trimmedForPreview()
230244
PostPreviewCache.shared.content[content] = preview
231245
return preview
232246
} else {
@@ -236,12 +250,16 @@ public class Post: AbstractPost {
236250

237251
override public func titleForDisplay() -> String {
238252
var title = postTitle?.trimmingCharacters(in: CharacterSet.whitespaces) ?? ""
239-
title = title
253+
title =
254+
title
240255
.stringByDecodingXMLCharacters()
241256
.strippingHTML()
242257

243258
if title.isEmpty && !hasRemote() && contentPreviewForDisplay().isEmpty {
244-
title = NSLocalizedString("(no title)", comment: "Lets a user know that a local draft does not have a title.")
259+
title = NSLocalizedString(
260+
"(no title)",
261+
comment: "Lets a user know that a local draft does not have a title."
262+
)
245263
}
246264

247265
return title

Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ struct SocialConnectionTests {
2929
let model = SocialConnection(from: wire)
3030

3131
#expect(model.id == "123")
32+
// The deprecated wire `id` carries the keyring (token) ID, needed to
33+
// resolve legacy `_wpas_skip_<keyringID>` post meta.
34+
#expect(model.keyringConnectionID == "deprecated")
3235
#expect(model.externalID == "ext-42")
3336
#expect(model.serviceName == "mastodon")
3437
#expect(model.serviceLabel == "Mastodon")
@@ -63,6 +66,7 @@ struct SocialConnectionTests {
6366
let model = SocialConnection(from: wire)
6467
#expect(model.displayName == "@tony@mastodon.social")
6568
#expect(model.externalHandle == "@tony@mastodon.social")
69+
#expect(model.keyringConnectionID == nil)
6670
}
6771

6872
@Test("empty display_name and empty handle stays empty")

RELEASE-NOTES.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
27.0
22
-----
33
* [*] Remove the Free GIF Library media source. The Tenor service it relied on shuts down on June 30, 2026 [#25634]
4-
4+
* [*] [internal] Jetpack Social: use new publicize API to support Jetpack Social [#25587]
55

66
26.9
77
-----

Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,32 @@ struct PostSettingsTests {
153153
#expect(post.status == .publish) // Changed
154154
}
155155

156+
@Test("apply preserves stored publicize metadata when social draft is unavailable")
157+
func applyPreservesStoredPublicizeMetadataWhenSocialDraftIsUnavailable() throws {
158+
let context = ContextManager.forTesting().mainContext
159+
let blog = BlogBuilder(context).build()
160+
let post = PostBuilder(context, blog: blog).build()
161+
post.rawMetadata = try PostMetadataContainer(metadata: [
162+
["key": "_wpas_mess", "value": "Hello", "id": "1"],
163+
["key": "_wpas_skip_publicize_111", "value": "1", "id": "2"],
164+
["key": "_jetpack_newsletter_access", "value": "everybody", "id": "3"]
165+
])
166+
.encode()
167+
168+
var settings = PostSettings(from: post)
169+
settings.socialSharingDraft = nil
170+
171+
settings.apply(to: post)
172+
173+
// With no draft to apply, the existing publicize metadata is left untouched
174+
// (the user's per-connection choices are preserved, not neutralized).
175+
let container = PostMetadataContainer(post)
176+
#expect(container.getString(for: "_wpas_mess") == "Hello")
177+
#expect(container.getString(for: "_wpas_skip_publicize_111") == "1")
178+
#expect(container.entry(forKey: "_wpas_skip_publicize_111")?["id"] as? String == "2")
179+
#expect(container.getString(for: "_jetpack_newsletter_access") == "everybody")
180+
}
181+
156182
// MARK: - makeUpdateParameters Tests
157183

158184
@Test("Creates update parameters for changed properties")

0 commit comments

Comments
 (0)