diff --git a/Modules/Package.swift b/Modules/Package.swift
index abb826d3cf70..c1c17c608093 100644
--- a/Modules/Package.swift
+++ b/Modules/Package.swift
@@ -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"]),
@@ -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")]
+ ),
.target(
name: "ShareExtensionCore",
dependencies: [
@@ -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")])
]
)
@@ -403,6 +418,7 @@ enum XcodeSupport {
"DesignSystem",
"BuildSettingsKit",
"FormattableContentKit",
+ "JetpackSocial",
"JetpackStats",
"JetpackStatsWidgetsCore",
"NotificationServiceExtensionCore",
diff --git a/Modules/Sources/JetpackSocial/Models/AdditionalExternalUser.swift b/Modules/Sources/JetpackSocial/Models/AdditionalExternalUser.swift
new file mode 100644
index 000000000000..06211bd84b52
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Models/AdditionalExternalUser.swift
@@ -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:))
+ )
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Models/ConnectionStatus.swift b/Modules/Sources/JetpackSocial/Models/ConnectionStatus.swift
new file mode 100644
index 000000000000..d18df6964d16
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Models/ConnectionStatus.swift
@@ -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
+ }
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Models/SocialConnection.swift b/Modules/Sources/JetpackSocial/Models/SocialConnection.swift
new file mode 100644
index 000000000000..777cc5844896
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Models/SocialConnection.swift
@@ -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 }
+}
diff --git a/Modules/Sources/JetpackSocial/Models/SocialKeyringAccount.swift b/Modules/Sources/JetpackSocial/Models/SocialKeyringAccount.swift
new file mode 100644
index 000000000000..446855b5b548
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Models/SocialKeyringAccount.swift
@@ -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
+ }
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Models/SocialKeyringConnection.swift b/Modules/Sources/JetpackSocial/Models/SocialKeyringConnection.swift
new file mode 100644
index 000000000000..fa413eef4ec8
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Models/SocialKeyringConnection.swift
@@ -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)
+ )
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Models/SocialService.swift b/Modules/Sources/JetpackSocial/Models/SocialService.swift
new file mode 100644
index 000000000000..91eaebf0b0e9
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Models/SocialService.swift
@@ -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,
+ supportsAdditionalUsers: wire.supports.additionalUsers,
+ additionalUsersOnly: wire.supports.additionalUsersOnly,
+ isActive: wire.status == "ok",
+ connectURL: wire.url.isEmpty ? nil : URL(string: wire.url)
+ )
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Models/SocialSharingError.swift b/Modules/Sources/JetpackSocial/Models/SocialSharingError.swift
new file mode 100644
index 000000000000..9cfa3402ae45
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Models/SocialSharingError.swift
@@ -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)
+}
+
+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
+ }
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/Contents.json
new file mode 100644
index 000000000000..73c00596a7fc
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-bluesky.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-bluesky.imageset/Contents.json
new file mode 100644
index 000000000000..8cfd16ede205
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-bluesky.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "publicize-bluesky.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-bluesky.imageset/publicize-bluesky.svg b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-bluesky.imageset/publicize-bluesky.svg
new file mode 100644
index 000000000000..5e704bfef82d
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-bluesky.imageset/publicize-bluesky.svg
@@ -0,0 +1 @@
+
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-default.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-default.imageset/Contents.json
new file mode 100644
index 000000000000..4a520819e66e
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-default.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "publicize-default.pdf"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-default.imageset/publicize-default.pdf b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-default.imageset/publicize-default.pdf
new file mode 100644
index 000000000000..facf4b10b80a
Binary files /dev/null and b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-default.imageset/publicize-default.pdf differ
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-facebook.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-facebook.imageset/Contents.json
new file mode 100644
index 000000000000..ed289d9b5004
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-facebook.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "publicize-facebook.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-facebook.imageset/publicize-facebook.svg b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-facebook.imageset/publicize-facebook.svg
new file mode 100644
index 000000000000..c090044d83e9
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-facebook.imageset/publicize-facebook.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-google-plus.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-google-plus.imageset/Contents.json
new file mode 100644
index 000000000000..74fe3a403414
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-google-plus.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "publicize-google-plus.pdf"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-google-plus.imageset/publicize-google-plus.pdf b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-google-plus.imageset/publicize-google-plus.pdf
new file mode 100644
index 000000000000..6837a88e99aa
Binary files /dev/null and b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-google-plus.imageset/publicize-google-plus.pdf differ
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-instagram-business.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-instagram-business.imageset/Contents.json
new file mode 100644
index 000000000000..827d957dfbe6
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-instagram-business.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "publicize-instagram-business.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-instagram-business.imageset/publicize-instagram-business.svg b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-instagram-business.imageset/publicize-instagram-business.svg
new file mode 100644
index 000000000000..69c10588109f
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-instagram-business.imageset/publicize-instagram-business.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-linkedin.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-linkedin.imageset/Contents.json
new file mode 100644
index 000000000000..a66f0c0485bb
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-linkedin.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "publicize-linkedin.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-linkedin.imageset/publicize-linkedin.svg b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-linkedin.imageset/publicize-linkedin.svg
new file mode 100644
index 000000000000..7d6932bfb7c0
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-linkedin.imageset/publicize-linkedin.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-mastodon.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-mastodon.imageset/Contents.json
new file mode 100644
index 000000000000..274091255eca
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-mastodon.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "publicize-mastodon.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-mastodon.imageset/publicize-mastodon.svg b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-mastodon.imageset/publicize-mastodon.svg
new file mode 100644
index 000000000000..fcea8ed22762
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-mastodon.imageset/publicize-mastodon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-nextdoor.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-nextdoor.imageset/Contents.json
new file mode 100644
index 000000000000..8356662875a7
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-nextdoor.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "publicize-nextdoor.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-nextdoor.imageset/publicize-nextdoor.svg b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-nextdoor.imageset/publicize-nextdoor.svg
new file mode 100644
index 000000000000..99c6999aed85
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-nextdoor.imageset/publicize-nextdoor.svg
@@ -0,0 +1,4 @@
+
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-threads.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-threads.imageset/Contents.json
new file mode 100644
index 000000000000..61a953bbf880
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-threads.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "publicize-threads.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-threads.imageset/publicize-threads.png b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-threads.imageset/publicize-threads.png
new file mode 100644
index 000000000000..e75640c24a02
Binary files /dev/null and b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-threads.imageset/publicize-threads.png differ
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/Contents.json
new file mode 100644
index 000000000000..0c6461e25c91
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/Contents.json
@@ -0,0 +1,22 @@
+{
+ "images" : [
+ {
+ "filename" : "publicize-tumblr.svg",
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "publicize-tumblr-dark.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/publicize-tumblr-dark.svg b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/publicize-tumblr-dark.svg
new file mode 100644
index 000000000000..57292a204027
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/publicize-tumblr-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/publicize-tumblr.svg b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/publicize-tumblr.svg
new file mode 100644
index 000000000000..630ae4a02aef
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/publicize-tumblr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-twitter.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-twitter.imageset/Contents.json
new file mode 100644
index 000000000000..283779224387
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-twitter.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "publicize-twitter.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-twitter.imageset/publicize-twitter.svg b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-twitter.imageset/publicize-twitter.svg
new file mode 100644
index 000000000000..3d454fe928d3
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-twitter.imageset/publicize-twitter.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-wordpress.imageset/Contents.json b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-wordpress.imageset/Contents.json
new file mode 100644
index 000000000000..ef64d4dd24f9
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-wordpress.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "publicize-wordpress.pdf"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-wordpress.imageset/publicize-wordpress.pdf b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-wordpress.imageset/publicize-wordpress.pdf
new file mode 100644
index 000000000000..2ef1263917cc
Binary files /dev/null and b/Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-wordpress.imageset/publicize-wordpress.pdf differ
diff --git a/Modules/Sources/JetpackSocial/Strings/Strings.swift b/Modules/Sources/JetpackSocial/Strings/Strings.swift
new file mode 100644
index 000000000000..e24750eefb1b
--- /dev/null
+++ b/Modules/Sources/JetpackSocial/Strings/Strings.swift
@@ -0,0 +1,249 @@
+import Foundation
+
+public enum Strings {
+ public enum Errors {
+ public static let network = NSLocalizedString(
+ "jetpackSocial.error.network",
+ value: "Network error. Please check your connection and try again.",
+ comment: "Error shown when a social sharing network call fails."
+ )
+
+ public static let notAuthenticated = NSLocalizedString(
+ "jetpackSocial.error.notAuthenticated",
+ value: "You need to sign in again to manage social accounts.",
+ comment: "Error shown when the WP.com auth token is missing or invalid."
+ )
+
+ public static let connectionNotFoundFormat = NSLocalizedString(
+ "jetpackSocial.error.connectionNotFound",
+ value: "Connection %1$@ was not found.",
+ comment: "Error when a publicize connection ID can't be found. %1$@ is the connection ID."
+ )
+
+ public static let keyringNotFoundFormat = NSLocalizedString(
+ "jetpackSocial.error.keyringNotFound",
+ value: "Keyring connection %1$@ was not found.",
+ comment: "Error when a keyring token ID can't be found. %1$@ is the token ID."
+ )
+
+ public static let noKeyringForServiceFormat = NSLocalizedString(
+ "jetpackSocial.error.noKeyringForService",
+ value: "No connected accounts for %1$@.",
+ comment:
+ "Error when the WP.com account has no authorized keyring for a given service. %1$@ is the service label (e.g. 'Mastodon')."
+ )
+
+ public static let decoding = NSLocalizedString(
+ "jetpackSocial.error.decoding",
+ value: "Received an unexpected response from the server.",
+ comment: "Error shown when decoding a social sharing response fails."
+ )
+
+ public static let unknown = NSLocalizedString(
+ "jetpackSocial.error.unknown",
+ value: "Something went wrong. Please try again.",
+ comment: "Generic fallback error for social sharing."
+ )
+ }
+
+ public enum ManageConnections {
+ public static let navigationTitle = NSLocalizedString(
+ "jetpackSocial.manageConnections.title",
+ value: "Social",
+ comment: "Title of the Social Sharing settings screen."
+ )
+
+ public static let connectedHeader = NSLocalizedString(
+ "jetpackSocial.manageConnections.connectedHeader",
+ value: "Connected Accounts",
+ comment: "Section header listing currently connected social accounts."
+ )
+
+ public static let footer = NSLocalizedString(
+ "jetpackSocial.manageConnections.footer",
+ value: "Connect your favorite social media services to automatically share new posts with friends.",
+ comment: "Footer caption under the list of services in the Connect Account picker modal."
+ )
+
+ public static let sharedBadge = NSLocalizedString(
+ "jetpackSocial.manageConnections.sharedBadge",
+ value: "Shared",
+ comment: "Badge shown on connections that are shared with other site users."
+ )
+
+ public static let brokenStatus = NSLocalizedString(
+ "jetpackSocial.manageConnections.brokenStatus",
+ value: "Needs attention",
+ comment: "Status text for a broken / invalid / refresh-failed connection."
+ )
+
+ public static let deleteButton = NSLocalizedString(
+ "jetpackSocial.manageConnections.delete",
+ value: "Disconnect",
+ comment: "Button that removes a social sharing connection."
+ )
+
+ public static let deleteConfirmTitleFormat = NSLocalizedString(
+ "jetpackSocial.manageConnections.deleteConfirmTitle",
+ value: "Are you sure you want to disconnect %1$@?",
+ comment: "Confirmation alert title. %1$@ is the connected account's display name."
+ )
+
+ public static let connectNewAccount = NSLocalizedString(
+ "jetpackSocial.manageConnections.connectNewAccount",
+ value: "Connect a New Account",
+ comment: "Button on the Social screen that opens the add-connection modal."
+ )
+
+ public static let connectedFooter = NSLocalizedString(
+ "jetpackSocial.manageConnections.connectedFooter",
+ value:
+ "Connect your social media accounts and send a post's featured image and content to the selected channels when the post is published.",
+ comment: "Footer caption under the Connected Accounts section on the Social screen."
+ )
+
+ public static let cancelButton = NSLocalizedString(
+ "jetpackSocial.manageConnections.cancel",
+ value: "Cancel",
+ comment: "Cancel button in the disconnect confirmation alert."
+ )
+
+ public static let yesButton = NSLocalizedString(
+ "jetpackSocial.manageConnections.yes",
+ value: "Yes",
+ comment: "Confirm button in the disconnect confirmation alert."
+ )
+
+ public static let retry = NSLocalizedString(
+ "jetpackSocial.manageConnections.retry",
+ value: "Retry",
+ comment: "Button to retry a failed load."
+ )
+
+ public static let deleteFailedTitle = NSLocalizedString(
+ "jetpackSocial.manageConnections.deleteFailedTitle",
+ value: "Couldn't Disconnect",
+ comment: "Title of the alert shown when disconnecting a social connection fails."
+ )
+
+ public static let deleteFailedDismiss = NSLocalizedString(
+ "jetpackSocial.manageConnections.deleteFailedDismiss",
+ value: "OK",
+ comment: "Dismiss button in the disconnect failure alert."
+ )
+ }
+
+ public enum AccountConfirmation {
+ public static let title = NSLocalizedString(
+ "jetpackSocial.accountConfirmation.title",
+ value: "Connection confirmation",
+ comment: "Navigation title of the account confirmation screen shown after OAuth."
+ )
+
+ public static let description = NSLocalizedString(
+ "jetpackSocial.accountConfirmation.description",
+ value:
+ "You're connecting this account. New posts will automatically be shared to it. You can change this when writing a post.",
+ comment: "Explanation text shown at the top of the account confirmation screen."
+ )
+
+ public static let markAsSharedLabel = NSLocalizedString(
+ "jetpackSocial.accountConfirmation.markAsSharedLabel",
+ value: "Mark the connection as shared",
+ comment: "Toggle label controlling whether the new connection is shared with other site users."
+ )
+
+ public static let markAsSharedFooter = NSLocalizedString(
+ "jetpackSocial.accountConfirmation.markAsSharedFooter",
+ value:
+ "If enabled, the connection will be available to all administrators, editors, and authors. You can change this later.",
+ comment: "Footer caption below the 'Mark the connection as shared' toggle."
+ )
+
+ public static let confirm = NSLocalizedString(
+ "jetpackSocial.accountConfirmation.confirm",
+ value: "Confirm",
+ comment: "Nav-bar button that finalizes the social connection after choosing an account."
+ )
+
+ public static let connectedSectionTitle = NSLocalizedString(
+ "jetpackSocial.accountConfirmation.connectedSection",
+ value: "Connected",
+ comment: "Section header listing accounts already connected to this site."
+ )
+
+ public static let loadingMessage = NSLocalizedString(
+ "jetpackSocial.accountConfirmation.loading",
+ value: "Loading accounts…",
+ comment: "Loading caption while fetching accounts for the confirmation screen."
+ )
+
+ public static let retry = NSLocalizedString(
+ "jetpackSocial.accountConfirmation.retry",
+ value: "Retry",
+ comment: "Button to retry a failed account fetch."
+ )
+ }
+
+ public enum ServiceDetail {
+ public static let connectedNoticeFormat = NSLocalizedString(
+ "jetpackSocial.serviceDetail.connectedNotice",
+ value: "%1$@ connected",
+ comment: "Notice shown after a social connection is successfully created. %1$@ is the service label."
+ )
+
+ public static let failureAlertTitle = NSLocalizedString(
+ "jetpackSocial.serviceDetail.failureTitle",
+ value: "Connection Failed",
+ comment: "Title of the alert shown when adding a social connection fails."
+ )
+
+ public static let failureAlertRetry = NSLocalizedString(
+ "jetpackSocial.serviceDetail.failureRetry",
+ value: "Retry",
+ comment: "Retry button in the add-connection failure alert."
+ )
+
+ public static let failureAlertCancel = NSLocalizedString(
+ "jetpackSocial.serviceDetail.failureCancel",
+ value: "Cancel",
+ comment: "Cancel button in the add-connection failure alert."
+ )
+ }
+
+ public enum ConnectionDetail {
+ public static let settingsHeader = NSLocalizedString(
+ "jetpackSocial.connectionDetail.settingsHeader",
+ value: "Settings",
+ comment: "Section header on the connection detail screen."
+ )
+
+ public static let availableToAllUsers = NSLocalizedString(
+ "jetpackSocial.connectionDetail.availableToAllUsers",
+ value: "Available to all users",
+ comment: "Toggle label controlling whether a connection is shared with all site users."
+ )
+
+ public static let availableToAllUsersFooter = NSLocalizedString(
+ "jetpackSocial.connectionDetail.availableToAllUsersFooter",
+ value: "Allow this connection to be used by all admins and users of your site.",
+ comment: "Footer caption below the 'Available to all users' toggle."
+ )
+ }
+
+ public enum OAuthWebView {
+ public static let connectTitleFormat = NSLocalizedString(
+ "jetpackSocial.oauthWebView.connectTitle",
+ value: "Connect to %1$@",
+ comment: "Navigation bar title of the OAuth webview. %1$@ is the service label (e.g. 'Mastodon')."
+ )
+ }
+
+ public enum ServicePicker {
+ public static let navigationTitle = NSLocalizedString(
+ "jetpackSocial.servicePicker.title",
+ value: "Connect Account",
+ comment: "Navigation bar title of the service picker modal shown when adding a new social connection."
+ )
+ }
+}
diff --git a/Modules/Tests/JetpackSocialTests/ConnectionStatusTests.swift b/Modules/Tests/JetpackSocialTests/ConnectionStatusTests.swift
new file mode 100644
index 000000000000..8667de15bbb2
--- /dev/null
+++ b/Modules/Tests/JetpackSocialTests/ConnectionStatusTests.swift
@@ -0,0 +1,33 @@
+import Testing
+@testable import JetpackSocial
+
+@Suite("ConnectionStatus")
+struct ConnectionStatusTests {
+ @Test("maps known wire values")
+ func mapsKnownValues() {
+ #expect(ConnectionStatus(wireString: "ok") == .ok)
+ #expect(ConnectionStatus(wireString: "broken") == .broken)
+ #expect(ConnectionStatus(wireString: "invalid") == .invalid)
+ #expect(ConnectionStatus(wireString: "refresh-failed") == .refreshFailed)
+ }
+
+ @Test("unknown strings map to .unknown")
+ func mapsUnknownToUnknown() {
+ #expect(ConnectionStatus(wireString: "gibberish") == .unknown)
+ #expect(ConnectionStatus(wireString: "") == .unknown)
+ }
+
+ @Test("nil wire value maps to .unknown")
+ func mapsNilToUnknown() {
+ #expect(ConnectionStatus(wireString: nil) == .unknown)
+ }
+
+ @Test("isBroken is true only for server-confirmed bad states")
+ func isBrokenOnlyForBadStates() {
+ #expect(!ConnectionStatus.ok.isBroken)
+ #expect(!ConnectionStatus.unknown.isBroken)
+ #expect(ConnectionStatus.broken.isBroken)
+ #expect(ConnectionStatus.invalid.isBroken)
+ #expect(ConnectionStatus.refreshFailed.isBroken)
+ }
+}
diff --git a/Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift b/Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift
new file mode 100644
index 000000000000..e96c74373293
--- /dev/null
+++ b/Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift
@@ -0,0 +1,120 @@
+import Foundation
+import Testing
+import WordPressAPI
+import WordPressAPIInternal
+@testable import JetpackSocial
+
+@Suite("SocialConnection mapping")
+struct SocialConnectionTests {
+ @Test("maps required wire fields")
+ func mapsRequiredFields() {
+ let wire = PublicizeConnectionResponse(
+ connectionId: "123",
+ displayName: "Tony Li",
+ externalHandle: "@tony",
+ externalId: "ext-42",
+ profileLink: "https://example.com/tony",
+ profilePicture: "https://example.com/tony.jpg",
+ serviceLabel: "Mastodon",
+ serviceName: "mastodon",
+ shared: true,
+ wpcomUserId: 0,
+ id: "deprecated",
+ username: "",
+ profileDisplayName: "",
+ global: false,
+ status: "ok"
+ )
+
+ let model = SocialConnection(from: wire)
+
+ #expect(model.id == "123")
+ #expect(model.externalID == "ext-42")
+ #expect(model.serviceName == "mastodon")
+ #expect(model.serviceLabel == "Mastodon")
+ #expect(model.displayName == "Tony Li")
+ #expect(model.externalHandle == "@tony")
+ #expect(model.profileLink == URL(string: "https://example.com/tony"))
+ #expect(model.profilePictureURL == URL(string: "https://example.com/tony.jpg"))
+ #expect(model.isShared)
+ #expect(model.status == .ok)
+ }
+
+ @Test("empty display_name falls back to external_handle")
+ func emptyDisplayNameFallsBackToHandle() {
+ let wire = PublicizeConnectionResponse(
+ connectionId: "1",
+ displayName: "",
+ externalHandle: "@tony@mastodon.social",
+ externalId: "",
+ profileLink: "",
+ profilePicture: "",
+ serviceLabel: "Mastodon",
+ serviceName: "mastodon",
+ shared: false,
+ wpcomUserId: 0,
+ id: "",
+ username: "",
+ profileDisplayName: "",
+ global: false,
+ status: nil
+ )
+
+ let model = SocialConnection(from: wire)
+ #expect(model.displayName == "@tony@mastodon.social")
+ #expect(model.externalHandle == "@tony@mastodon.social")
+ }
+
+ @Test("empty display_name and empty handle stays empty")
+ func emptyDisplayNameAndHandleStaysEmpty() {
+ let wire = PublicizeConnectionResponse(
+ connectionId: "1",
+ displayName: "",
+ externalHandle: "",
+ externalId: "",
+ profileLink: "",
+ profilePicture: "",
+ serviceLabel: "x",
+ serviceName: "x",
+ shared: false,
+ wpcomUserId: 0,
+ id: "",
+ username: "",
+ profileDisplayName: "",
+ global: false,
+ status: nil
+ )
+
+ let model = SocialConnection(from: wire)
+ #expect(model.displayName.isEmpty)
+ #expect(model.externalHandle == nil)
+ }
+
+ @Test("empty external_handle becomes nil")
+ func emptyExternalHandleBecomesNil() {
+ let wire = PublicizeConnectionResponse(
+ connectionId: "1",
+ displayName: "x",
+ externalHandle: "",
+ externalId: "",
+ profileLink: "",
+ profilePicture: "",
+ serviceLabel: "x",
+ serviceName: "x",
+ shared: false,
+ wpcomUserId: 0,
+ id: "",
+ username: "",
+ profileDisplayName: "",
+ global: false,
+ status: nil
+ )
+
+ let model = SocialConnection(from: wire)
+ #expect(model.externalID.isEmpty)
+ #expect(model.externalHandle == nil)
+ #expect(model.profileLink == nil)
+ #expect(model.profilePictureURL == nil)
+ #expect(model.status == .unknown)
+ }
+}
diff --git a/Modules/Tests/JetpackSocialTests/SocialKeyringAccountTests.swift b/Modules/Tests/JetpackSocialTests/SocialKeyringAccountTests.swift
new file mode 100644
index 000000000000..8efe1bf3bb85
--- /dev/null
+++ b/Modules/Tests/JetpackSocialTests/SocialKeyringAccountTests.swift
@@ -0,0 +1,69 @@
+import Foundation
+import Testing
+@testable import JetpackSocial
+
+@Suite("SocialKeyringAccount")
+struct SocialKeyringAccountTests {
+ private func makeKeyring(
+ id: Int64 = 1,
+ service: String = "mastodon",
+ externalID: String = "primary-ext",
+ externalDisplay: String = "@primary",
+ additional: [AdditionalExternalUser] = []
+ ) -> SocialKeyringConnection {
+ SocialKeyringConnection(
+ id: id,
+ service: service,
+ externalID: externalID,
+ externalName: "primary",
+ externalDisplay: externalDisplay,
+ externalProfilePictureURL: nil,
+ additionalExternalUsers: additional,
+ status: .ok
+ )
+ }
+
+ @Test("flatten produces one account for a keyring with no additional users")
+ func flattenSingleAccount() {
+ let keyring = makeKeyring()
+ let accounts = SocialKeyringAccount.flatten([keyring])
+ #expect(accounts.count == 1)
+ let account = try! #require(accounts.first)
+ #expect(account.externalUserID == nil)
+ #expect(account.name == "@primary")
+ #expect(account.id == "1:primary:primary-ext")
+ #expect(account.externalIDForMatching == "primary-ext")
+ }
+
+ @Test("flatten produces primary + additional user rows")
+ func flattenMultipleAccounts() {
+ let page = AdditionalExternalUser(id: "page-1", name: "My Page", description: nil, profilePictureURL: nil)
+ let keyring = makeKeyring(additional: [page])
+ let accounts = SocialKeyringAccount.flatten([keyring])
+ #expect(accounts.count == 2)
+ #expect(accounts[0].externalUserID == nil)
+ #expect(accounts[0].name == "@primary")
+ #expect(accounts[0].externalIDForMatching == "primary-ext")
+ #expect(accounts[1].externalUserID == "page-1")
+ #expect(accounts[1].name == "My Page")
+ #expect(accounts[1].id == "1:user:page-1")
+ #expect(accounts[1].externalIDForMatching == "page-1")
+ }
+
+ @Test("flatten preserves order across multiple keyrings")
+ func flattenMultipleKeyrings() {
+ let a = makeKeyring(id: 10, service: "bluesky", externalID: "bs-a", externalDisplay: "@a")
+ let b = makeKeyring(id: 20, service: "bluesky", externalID: "bs-b", externalDisplay: "@b")
+ let accounts = SocialKeyringAccount.flatten([a, b])
+ #expect(accounts.map(\.id) == ["10:primary:bs-a", "20:primary:bs-b"])
+ }
+
+ @Test("id is composite of keyring id and external user id for primary vs additional")
+ func compositeIDFormat() {
+ let additional = AdditionalExternalUser(id: "x-1", name: "X", description: nil, profilePictureURL: nil)
+ let keyring = makeKeyring(id: 42, externalID: "prim", additional: [additional])
+ let accounts = SocialKeyringAccount.flatten([keyring])
+ #expect(accounts[0].id == "42:primary:prim")
+ #expect(accounts[1].id == "42:user:x-1")
+ }
+}
diff --git a/Modules/Tests/JetpackSocialTests/SocialKeyringConnectionTests.swift b/Modules/Tests/JetpackSocialTests/SocialKeyringConnectionTests.swift
new file mode 100644
index 000000000000..1a32b142e007
--- /dev/null
+++ b/Modules/Tests/JetpackSocialTests/SocialKeyringConnectionTests.swift
@@ -0,0 +1,83 @@
+import Foundation
+import Testing
+import WordPressAPI
+@testable import JetpackSocial
+
+@Suite("SocialKeyringConnection mapping")
+struct SocialKeyringConnectionTests {
+ @Test("maps keyring with additional users")
+ func mapsKeyringWithAdditionalUsers() {
+ let additional = KeyringExternalUser(
+ externalId: "page-1",
+ externalName: "My Page",
+ externalProfilePicture: "https://example.com/page.jpg",
+ externalDescription: "A description",
+ externalCategory: nil
+ )
+
+ let wire = KeyringConnectionResponse(
+ id: 42,
+ userId: 1,
+ service: "facebook",
+ label: "Facebook",
+ externalId: "fb-user",
+ externalName: "Tony",
+ externalDisplay: "Tony Li",
+ externalProfilePicture: "https://example.com/me.jpg",
+ status: "ok",
+ refreshUrl: "",
+ additionalExternalUsers: [additional]
+ )
+
+ let model = SocialKeyringConnection(from: wire)
+
+ #expect(model.id == 42)
+ #expect(model.service == "facebook")
+ #expect(model.externalID == "fb-user")
+ #expect(model.externalDisplay == "Tony Li")
+ #expect(model.additionalExternalUsers.count == 1)
+ #expect(model.additionalExternalUsers.first?.id == "page-1")
+ #expect(model.additionalExternalUsers.first?.name == "My Page")
+ #expect(model.status == .ok)
+ }
+
+ @Test("empty external_display falls back to external_name")
+ func emptyExternalDisplayFallsBackToExternalName() {
+ let wire = KeyringConnectionResponse(
+ id: 7,
+ userId: 1,
+ service: "mastodon",
+ label: nil,
+ externalId: "ext",
+ externalName: "tony",
+ externalDisplay: "",
+ externalProfilePicture: nil,
+ status: "ok",
+ refreshUrl: "",
+ additionalExternalUsers: []
+ )
+ let model = SocialKeyringConnection(from: wire)
+ #expect(model.externalDisplay == "tony")
+ }
+
+ @Test("handles missing optional fields")
+ func handlesMissingOptionals() {
+ let wire = KeyringConnectionResponse(
+ id: 1,
+ userId: 1,
+ service: "x",
+ label: nil,
+ externalId: "",
+ externalName: "",
+ externalDisplay: "",
+ externalProfilePicture: nil,
+ status: "",
+ refreshUrl: "",
+ additionalExternalUsers: []
+ )
+ let model = SocialKeyringConnection(from: wire)
+ #expect(model.externalProfilePictureURL == nil)
+ #expect(model.additionalExternalUsers.isEmpty)
+ #expect(model.status == .unknown)
+ }
+}
diff --git a/Modules/Tests/JetpackSocialTests/SocialServiceTests.swift b/Modules/Tests/JetpackSocialTests/SocialServiceTests.swift
new file mode 100644
index 000000000000..9bc2b313c1a3
--- /dev/null
+++ b/Modules/Tests/JetpackSocialTests/SocialServiceTests.swift
@@ -0,0 +1,73 @@
+import Foundation
+import Testing
+import WordPressAPI
+@testable import JetpackSocial
+
+@Suite("SocialService mapping")
+struct SocialServiceTests {
+ @Test("maps wire fields")
+ func mapsFields() {
+ let wire = PublicizeServiceResponse(
+ id: "mastodon",
+ description: "Share to your Mastodon timeline",
+ label: "Mastodon",
+ status: "ok",
+ supports: PublicizeServiceSupports(additionalUsers: false, additionalUsersOnly: false),
+ url: "https://mastodon.example"
+ )
+
+ let model = SocialService(from: wire)
+
+ #expect(model.id == "mastodon")
+ #expect(model.label == "Mastodon")
+ #expect(model.description == "Share to your Mastodon timeline")
+ #expect(!model.supportsAdditionalUsers)
+ #expect(!model.additionalUsersOnly)
+ #expect(model.isActive)
+ #expect(model.connectURL == URL(string: "https://mastodon.example"))
+ }
+
+ @Test("maps additionalUsersOnly from supports")
+ func mapsAdditionalUsersOnly() {
+ let wire = PublicizeServiceResponse(
+ id: "facebook",
+ description: "",
+ label: "Facebook",
+ status: "ok",
+ supports: PublicizeServiceSupports(additionalUsers: true, additionalUsersOnly: true),
+ url: ""
+ )
+ let model = SocialService(from: wire)
+ #expect(model.additionalUsersOnly)
+ #expect(model.supportsAdditionalUsers)
+ }
+
+ @Test("empty url maps to nil connectURL")
+ func emptyURLMapsToNil() {
+ let wire = PublicizeServiceResponse(
+ id: "s",
+ description: "",
+ label: "s",
+ status: "ok",
+ supports: PublicizeServiceSupports(additionalUsers: false, additionalUsersOnly: false),
+ url: ""
+ )
+ let model = SocialService(from: wire)
+ #expect(model.connectURL == nil)
+ }
+
+ @Test("non-ok status maps to inactive")
+ func nonOkStatusIsInactive() {
+ let wire = PublicizeServiceResponse(
+ id: "s",
+ description: "",
+ label: "s",
+ status: "deprecated",
+ supports: PublicizeServiceSupports(additionalUsers: true, additionalUsersOnly: false),
+ url: ""
+ )
+ let model = SocialService(from: wire)
+ #expect(!model.isActive)
+ #expect(model.supportsAdditionalUsers)
+ }
+}
diff --git a/Modules/Tests/JetpackSocialTests/SocialSharingErrorTests.swift b/Modules/Tests/JetpackSocialTests/SocialSharingErrorTests.swift
new file mode 100644
index 000000000000..1c4276fd59c0
--- /dev/null
+++ b/Modules/Tests/JetpackSocialTests/SocialSharingErrorTests.swift
@@ -0,0 +1,24 @@
+import Foundation
+import Testing
+@testable import JetpackSocial
+
+@Suite("SocialSharingError")
+struct SocialSharingErrorTests {
+ @Test("every case produces a non-empty localized description")
+ func everyCaseHasDescription() {
+ let cases: [SocialSharingError] = [
+ .network(NSError(domain: "t", code: 1)),
+ .notAuthenticated,
+ .connectionNotFound(id: "42"),
+ .keyringNotFound(id: 99),
+ .noKeyringForService(serviceLabel: "Mastodon"),
+ .decoding(NSError(domain: "t", code: 2)),
+ .unknown(NSError(domain: "t", code: 3))
+ ]
+
+ for error in cases {
+ let description = error.errorDescription ?? ""
+ #expect(!description.isEmpty, "\(error) produced empty description")
+ }
+ }
+}
diff --git a/Tests/KeystoneTests/WordPressUnitTests.xctestplan b/Tests/KeystoneTests/WordPressUnitTests.xctestplan
index f61cbd8bffac..897d693fb166 100644
--- a/Tests/KeystoneTests/WordPressUnitTests.xctestplan
+++ b/Tests/KeystoneTests/WordPressUnitTests.xctestplan
@@ -80,6 +80,13 @@
"name" : "WordPressTest"
}
},
+ {
+ "target" : {
+ "containerPath" : "container:..\/Modules",
+ "identifier" : "JetpackSocialTests",
+ "name" : "JetpackSocialTests"
+ }
+ },
{
"target" : {
"containerPath" : "container:..\/Modules",