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",