Skip to content
9 changes: 9 additions & 0 deletions Modules/Sources/JetpackSocial/Models/SocialConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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_<keyringID>`
/// 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
Expand All @@ -15,6 +21,7 @@ public struct SocialConnection: Identifiable, Hashable, Sendable {

public init(
id: String,
keyringConnectionID: String? = nil,
externalID: String,
serviceName: String,
serviceLabel: String,
Expand All @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>

// 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<String> = []
) {
self.customMessage = customMessage
self.connectionsByID = connectionsByID
self.legacyDisabledKeys = legacyDisabledKeys
}
}

Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public struct PostSocialSharingDetailView: View {

private func bindingForToggle(connection: SocialConnection) -> Binding<Bool> {
Binding(
get: { draft.isEnabled(connectionID: connection.id) },
get: { draft.isEnabled(connection: connection) },
set: { isEnabled in
draft.setEnabled(
isEnabled,
Expand All @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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_<keyringID>` post meta.
#expect(model.keyringConnectionID == "deprecated")
#expect(model.externalID == "ext-42")
#expect(model.serviceName == "mastodon")
#expect(model.serviceLabel == "Mastodon")
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
27.0
-----

* [*] [internal] Jetpack Social: use new publicize API to support Jetpack Social [#25587]

26.9
-----
Expand Down
3 changes: 3 additions & 0 deletions Sources/WordPressData/Swift/Post+CoreDataProperties.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<PostCategory>?
Expand Down
54 changes: 36 additions & 18 deletions Sources/WordPressData/Swift/Post.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -209,7 +222,7 @@ public class Post: AbstractPost {
}

public func dateForDisplay() -> Date? {
return dateCreated
dateCreated
}

// MARK: - BasePost
Expand All @@ -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 {
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,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")
Expand Down
Loading