Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ let package = Package(
.library(name: "DesignSystem", targets: ["DesignSystem"]),
.library(name: "FormattableContentKit", targets: ["FormattableContentKit"]),
.library(name: "JetpackStats", targets: ["JetpackStats"]),
.library(name: "JetpackSocial", targets: ["JetpackSocial"]),
.library(name: "JetpackStatsWidgetsCore", targets: ["JetpackStatsWidgetsCore"]),
.library(name: "NotificationServiceExtensionCore", targets: ["NotificationServiceExtensionCore"]),
.library(name: "ShareExtensionCore", targets: ["ShareExtensionCore"]),
Expand Down Expand Up @@ -119,6 +120,19 @@ let package = Package(
resources: [.process("Resources")]
),
.target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]),
.target(
name: "JetpackSocial",
dependencies: [
"AsyncImageKit",
"DesignSystem",
"WordPressShared",
"WordPressUI",
"WordPressCore",
.product(name: "WordPressAPI", package: "wordpress-rs"),
.product(name: "Logging", package: "swift-log")
],
resources: [.process("Resources")]
Comment thread
crazytonyli marked this conversation as resolved.
),
.target(
name: "ShareExtensionCore",
dependencies: [
Expand Down Expand Up @@ -306,7 +320,8 @@ let package = Package(
),
.testTarget(name: "WordPressCoreTests", dependencies: [.target(name: "WordPressCore")]),
.testTarget(name: "WordPressIntelligenceTests", dependencies: [.target(name: "WordPressIntelligence")]),
.testTarget(name: "WordPressReaderTests", dependencies: [.target(name: "WordPressReader")])
.testTarget(name: "WordPressReaderTests", dependencies: [.target(name: "WordPressReader")]),
.testTarget(name: "JetpackSocialTests", dependencies: [.target(name: "JetpackSocial")])
]
)

Expand Down Expand Up @@ -403,6 +418,7 @@ enum XcodeSupport {
"DesignSystem",
"BuildSettingsKit",
"FormattableContentKit",
"JetpackSocial",
"JetpackStats",
"JetpackStatsWidgetsCore",
"NotificationServiceExtensionCore",
Expand Down
25 changes: 25 additions & 0 deletions Modules/Sources/JetpackSocial/Models/AdditionalExternalUser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation
import WordPressAPI

public struct AdditionalExternalUser: Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let description: String?
public let profilePictureURL: URL?

public init(id: String, name: String, description: String?, profilePictureURL: URL?) {
self.id = id
self.name = name
self.description = description
self.profilePictureURL = profilePictureURL
}

init(from wire: KeyringExternalUser) {
self.init(
id: wire.externalId,
name: wire.externalName,
description: wire.externalDescription,
profilePictureURL: wire.externalProfilePicture.flatMap(URL.init(string:))
)
}
}
32 changes: 32 additions & 0 deletions Modules/Sources/JetpackSocial/Models/ConnectionStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

public enum ConnectionStatus: Hashable, Sendable {
case ok
case broken
case invalid
case refreshFailed
/// The server has not recently tested this connection. Not an error
/// state: a healthy connection between tests reports `nil` on the wire.
case unknown

public init(wireString: String?) {
switch wireString {
case "ok": self = .ok
case "broken": self = .broken
case "invalid": self = .invalid
case "refresh-failed": self = .refreshFailed
default: self = .unknown
}
}

/// `true` only for states the server has actively confirmed are broken.
/// `.unknown` (no recent test result) is treated as healthy-by-default.
public var isBroken: Bool {
switch self {
case .broken, .invalid, .refreshFailed:
return true
case .ok, .unknown:
return false
}
}
}
65 changes: 65 additions & 0 deletions Modules/Sources/JetpackSocial/Models/SocialConnection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation
import WordPressAPI

public struct SocialConnection: Identifiable, Hashable, Sendable {
public var id: String
public var externalID: String
public var serviceName: String
public var serviceLabel: String
public var displayName: String
public var externalHandle: String?
public var profileLink: URL?
public var profilePictureURL: URL?
public var isShared: Bool
public var status: ConnectionStatus

public init(
id: String,
externalID: String,
serviceName: String,
serviceLabel: String,
displayName: String,
externalHandle: String?,
profileLink: URL?,
profilePictureURL: URL?,
isShared: Bool,
status: ConnectionStatus
) {
self.id = id
self.externalID = externalID
self.serviceName = serviceName
self.serviceLabel = serviceLabel
self.displayName = displayName
self.externalHandle = externalHandle
self.profileLink = profileLink
self.profilePictureURL = profilePictureURL
self.isShared = isShared
self.status = status
}

init(from wire: PublicizeConnectionResponse) {
// Some social services return an empty `display_name` for connections
// that only carry a handle (e.g., a Mastodon profile without a set
// name). Mirror the legacy v1.1 fallback by surfacing the handle so
// the row isn't blank.
let externalHandle: String? = wire.externalHandle.flatMap { $0.nonEmpty }
let displayName: String = wire.displayName.nonEmpty ?? externalHandle ?? wire.displayName
self.init(
id: wire.connectionId,
externalID: wire.externalId,
serviceName: wire.serviceName,
serviceLabel: wire.serviceLabel,
displayName: displayName,
externalHandle: externalHandle,
profileLink: wire.profileLink.nonEmpty.flatMap(URL.init(string:)),
profilePictureURL: wire.profilePicture.nonEmpty.flatMap(URL.init(string:)),
isShared: wire.shared,
status: ConnectionStatus(wireString: wire.status)
)
}

}

private extension String {
var nonEmpty: String? { isEmpty ? nil : self }
}
60 changes: 60 additions & 0 deletions Modules/Sources/JetpackSocial/Models/SocialKeyringAccount.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation

public struct SocialKeyringAccount: Identifiable, Hashable, Sendable {
/// Composite ID combining the keyring id and the external user id (or the
/// keyring's primary external_ID when `externalUserID` is nil). Stable
/// across fetches as long as the backend identifiers don't change.
public let id: String
public let name: String
public let profilePictureURL: URL?
public let keyring: SocialKeyringConnection
/// Nil when the row represents the keyring's primary external account.
public let externalUserID: String?

public init(
id: String,
name: String,
profilePictureURL: URL?,
keyring: SocialKeyringConnection,
externalUserID: String?
) {
self.id = id
self.name = name
self.profilePictureURL = profilePictureURL
self.keyring = keyring
self.externalUserID = externalUserID
}

/// The external account ID used to match against existing
/// `SocialConnection.externalID` values.
public var externalIDForMatching: String {
externalUserID ?? keyring.externalID
}

/// Flattens a list of keyrings into one account row per (keyring,
/// external user) pair. Every keyring yields at least the primary row.
public static func flatten(_ keyrings: [SocialKeyringConnection]) -> [SocialKeyringAccount] {
keyrings.flatMap { keyring -> [SocialKeyringAccount] in
// The `primary:` and `user:` discriminators keep IDs unique even
// when a keyring's externalID happens to equal an additional
// user's id.
let primary = SocialKeyringAccount(
id: "\(keyring.id):primary:\(keyring.externalID)",
name: keyring.externalDisplay,
profilePictureURL: keyring.externalProfilePictureURL,
keyring: keyring,
externalUserID: nil
)
let additional = keyring.additionalExternalUsers.map { user in
SocialKeyringAccount(
id: "\(keyring.id):user:\(user.id)",
name: user.name,
profilePictureURL: user.profilePictureURL,
keyring: keyring,
externalUserID: user.id
)
}
return [primary] + additional
}
}
}
50 changes: 50 additions & 0 deletions Modules/Sources/JetpackSocial/Models/SocialKeyringConnection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation
import WordPressAPI

public struct SocialKeyringConnection: Identifiable, Hashable, Sendable {
public let id: Int64
public let service: String
public let externalID: String
public let externalName: String
public let externalDisplay: String
public let externalProfilePictureURL: URL?
public let additionalExternalUsers: [AdditionalExternalUser]
public let status: ConnectionStatus

public init(
id: Int64,
service: String,
externalID: String,
externalName: String,
externalDisplay: String,
externalProfilePictureURL: URL?,
additionalExternalUsers: [AdditionalExternalUser],
status: ConnectionStatus
) {
self.id = id
self.service = service
self.externalID = externalID
self.externalName = externalName
self.externalDisplay = externalDisplay
self.externalProfilePictureURL = externalProfilePictureURL
self.additionalExternalUsers = additionalExternalUsers
self.status = status
}

init(from wire: KeyringConnectionResponse) {
// Some keyrings come back with an empty `external_display`. Direct
// port of the legacy v1.1 fallback in SharingServiceRemote: use
// `external_name` so the picker doesn't show a blank account.
let externalDisplay = wire.externalDisplay.isEmpty ? wire.externalName : wire.externalDisplay
self.init(
id: wire.id,
service: wire.service,
externalID: wire.externalId,
externalName: wire.externalName,
externalDisplay: externalDisplay,
externalProfilePictureURL: wire.externalProfilePicture.flatMap(URL.init(string:)),
additionalExternalUsers: wire.additionalExternalUsers.map(AdditionalExternalUser.init(from:)),
status: ConnectionStatus(wireString: wire.status)
)
}
}
45 changes: 45 additions & 0 deletions Modules/Sources/JetpackSocial/Models/SocialService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation
import WordPressAPI

public struct SocialService: Identifiable, Hashable, Sendable {
public let id: String
public let label: String
public let description: String
public let supportsAdditionalUsers: Bool
/// True for services like Facebook where Publicize can only target sub-accounts
/// (Pages) and not the keyring's primary external account (the user's profile).
/// `AccountConfirmationView` uses this to hide the primary row from the picker.
public let additionalUsersOnly: Bool
public let isActive: Bool
public let connectURL: URL?

public init(
id: String,
label: String,
description: String,
supportsAdditionalUsers: Bool,
additionalUsersOnly: Bool,
isActive: Bool,
connectURL: URL? = nil
) {
self.id = id
self.label = label
self.description = description
self.supportsAdditionalUsers = supportsAdditionalUsers
self.additionalUsersOnly = additionalUsersOnly
self.isActive = isActive
self.connectURL = connectURL
}

init(from wire: PublicizeServiceResponse) {
self.init(
id: wire.id,
label: wire.label,
description: wire.description,
Comment on lines +8 to +38
Copy link
Copy Markdown
Contributor

@mokagio mokagio May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Sent the PR to GPT first and Opus next to verify. Here's the only relevant comment. @crazytonyli was the slight change in domain model intentional?

The wire model exposes both wire.supports.additionalUsers and wire.supports.additionalUsersOnly (see PublicizeServiceSupports), but SocialService collapses them to a single supportsAdditionalUsers.

The pre-JetpackSocial sharing stack preserved that distinction:

The two bits aren't equivalent:

  • additionalUsers == true means “you can add more accounts under the parent keyring.”
  • additionalUsersOnly == true means “this service can only be reached as an additional user” (e.g. Facebook Pages, Instagram Business — there is no standalone primary connect path).

The service picker needs the second bit to suppress the primary “Connect” affordance for those services.
Dropping it here means downstream UI logic will either need a breaking model change later or will have to infer it from service IDs.
Worth keeping the field while the model layer is still new.


Posted by Claude (Opus 4.7) on behalf of @mokagio with approval.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great finding, actually. I happened to find an issue caused by this during review this afternoon. f636640 adds the missing property, which will be used in a future PR.

supportsAdditionalUsers: wire.supports.additionalUsers,
additionalUsersOnly: wire.supports.additionalUsersOnly,
isActive: wire.status == "ok",
connectURL: wire.url.isEmpty ? nil : URL(string: wire.url)
)
}
}
32 changes: 32 additions & 0 deletions Modules/Sources/JetpackSocial/Models/SocialSharingError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

public enum SocialSharingError: Error, Sendable {
case network(Error)
case notAuthenticated
case connectionNotFound(id: String)
case keyringNotFound(id: Int64)
case noKeyringForService(serviceLabel: String)
case decoding(Error)
case unknown(Error)
Comment thread
crazytonyli marked this conversation as resolved.
}

extension SocialSharingError: LocalizedError {
public var errorDescription: String? {
switch self {
case .network:
return Strings.Errors.network
case .notAuthenticated:
return Strings.Errors.notAuthenticated
case .connectionNotFound(let id):
return String.localizedStringWithFormat(Strings.Errors.connectionNotFoundFormat, id)
case .keyringNotFound(let id):
return String.localizedStringWithFormat(Strings.Errors.keyringNotFoundFormat, String(id))
case .noKeyringForService(let label):
return String.localizedStringWithFormat(Strings.Errors.noKeyringForServiceFormat, label)
case .decoding:
return Strings.Errors.decoding
case .unknown:
return Strings.Errors.unknown
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "publicize-bluesky.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "publicize-default.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.
Loading