-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add JetpackSocial module skeleton with models, strings, and assets #25537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import Foundation | ||
| import WordPressAPI | ||
|
|
||
| public struct AdditionalExternalUser: Identifiable, Hashable, Sendable { | ||
| public let id: String | ||
| public let name: String | ||
| public let description: String? | ||
| public let profilePictureURL: URL? | ||
|
|
||
| public init(id: String, name: String, description: String?, profilePictureURL: URL?) { | ||
| self.id = id | ||
| self.name = name | ||
| self.description = description | ||
| self.profilePictureURL = profilePictureURL | ||
| } | ||
|
|
||
| init(from wire: KeyringExternalUser) { | ||
| self.init( | ||
| id: wire.externalId, | ||
| name: wire.externalName, | ||
| description: wire.externalDescription, | ||
| profilePictureURL: wire.externalProfilePicture.flatMap(URL.init(string:)) | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import Foundation | ||
|
|
||
| public enum ConnectionStatus: Hashable, Sendable { | ||
| case ok | ||
| case broken | ||
| case invalid | ||
| case refreshFailed | ||
| /// The server has not recently tested this connection. Not an error | ||
| /// state: a healthy connection between tests reports `nil` on the wire. | ||
| case unknown | ||
|
|
||
| public init(wireString: String?) { | ||
| switch wireString { | ||
| case "ok": self = .ok | ||
| case "broken": self = .broken | ||
| case "invalid": self = .invalid | ||
| case "refresh-failed": self = .refreshFailed | ||
| default: self = .unknown | ||
| } | ||
| } | ||
|
|
||
| /// `true` only for states the server has actively confirmed are broken. | ||
| /// `.unknown` (no recent test result) is treated as healthy-by-default. | ||
| public var isBroken: Bool { | ||
| switch self { | ||
| case .broken, .invalid, .refreshFailed: | ||
| return true | ||
| case .ok, .unknown: | ||
| return false | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import Foundation | ||
| import WordPressAPI | ||
|
|
||
| public struct SocialConnection: Identifiable, Hashable, Sendable { | ||
| public var id: String | ||
| public var externalID: String | ||
| public var serviceName: String | ||
| public var serviceLabel: String | ||
| public var displayName: String | ||
| public var externalHandle: String? | ||
| public var profileLink: URL? | ||
| public var profilePictureURL: URL? | ||
| public var isShared: Bool | ||
| public var status: ConnectionStatus | ||
|
|
||
| public init( | ||
| id: String, | ||
| externalID: String, | ||
| serviceName: String, | ||
| serviceLabel: String, | ||
| displayName: String, | ||
| externalHandle: String?, | ||
| profileLink: URL?, | ||
| profilePictureURL: URL?, | ||
| isShared: Bool, | ||
| status: ConnectionStatus | ||
| ) { | ||
| self.id = id | ||
| self.externalID = externalID | ||
| self.serviceName = serviceName | ||
| self.serviceLabel = serviceLabel | ||
| self.displayName = displayName | ||
| self.externalHandle = externalHandle | ||
| self.profileLink = profileLink | ||
| self.profilePictureURL = profilePictureURL | ||
| self.isShared = isShared | ||
| self.status = status | ||
| } | ||
|
|
||
| init(from wire: PublicizeConnectionResponse) { | ||
| // Some social services return an empty `display_name` for connections | ||
| // that only carry a handle (e.g., a Mastodon profile without a set | ||
| // name). Mirror the legacy v1.1 fallback by surfacing the handle so | ||
| // the row isn't blank. | ||
| let externalHandle: String? = wire.externalHandle.flatMap { $0.nonEmpty } | ||
| let displayName: String = wire.displayName.nonEmpty ?? externalHandle ?? wire.displayName | ||
| self.init( | ||
| id: wire.connectionId, | ||
| externalID: wire.externalId, | ||
| serviceName: wire.serviceName, | ||
| serviceLabel: wire.serviceLabel, | ||
| displayName: displayName, | ||
| externalHandle: externalHandle, | ||
| profileLink: wire.profileLink.nonEmpty.flatMap(URL.init(string:)), | ||
| profilePictureURL: wire.profilePicture.nonEmpty.flatMap(URL.init(string:)), | ||
| isShared: wire.shared, | ||
| status: ConnectionStatus(wireString: wire.status) | ||
| ) | ||
| } | ||
|
|
||
| } | ||
|
|
||
| private extension String { | ||
| var nonEmpty: String? { isEmpty ? nil : self } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import Foundation | ||
|
|
||
| public struct SocialKeyringAccount: Identifiable, Hashable, Sendable { | ||
| /// Composite ID combining the keyring id and the external user id (or the | ||
| /// keyring's primary external_ID when `externalUserID` is nil). Stable | ||
| /// across fetches as long as the backend identifiers don't change. | ||
| public let id: String | ||
| public let name: String | ||
| public let profilePictureURL: URL? | ||
| public let keyring: SocialKeyringConnection | ||
| /// Nil when the row represents the keyring's primary external account. | ||
| public let externalUserID: String? | ||
|
|
||
| public init( | ||
| id: String, | ||
| name: String, | ||
| profilePictureURL: URL?, | ||
| keyring: SocialKeyringConnection, | ||
| externalUserID: String? | ||
| ) { | ||
| self.id = id | ||
| self.name = name | ||
| self.profilePictureURL = profilePictureURL | ||
| self.keyring = keyring | ||
| self.externalUserID = externalUserID | ||
| } | ||
|
|
||
| /// The external account ID used to match against existing | ||
| /// `SocialConnection.externalID` values. | ||
| public var externalIDForMatching: String { | ||
| externalUserID ?? keyring.externalID | ||
| } | ||
|
|
||
| /// Flattens a list of keyrings into one account row per (keyring, | ||
| /// external user) pair. Every keyring yields at least the primary row. | ||
| public static func flatten(_ keyrings: [SocialKeyringConnection]) -> [SocialKeyringAccount] { | ||
| keyrings.flatMap { keyring -> [SocialKeyringAccount] in | ||
| // The `primary:` and `user:` discriminators keep IDs unique even | ||
| // when a keyring's externalID happens to equal an additional | ||
| // user's id. | ||
| let primary = SocialKeyringAccount( | ||
| id: "\(keyring.id):primary:\(keyring.externalID)", | ||
| name: keyring.externalDisplay, | ||
| profilePictureURL: keyring.externalProfilePictureURL, | ||
| keyring: keyring, | ||
| externalUserID: nil | ||
| ) | ||
| let additional = keyring.additionalExternalUsers.map { user in | ||
| SocialKeyringAccount( | ||
| id: "\(keyring.id):user:\(user.id)", | ||
| name: user.name, | ||
| profilePictureURL: user.profilePictureURL, | ||
| keyring: keyring, | ||
| externalUserID: user.id | ||
| ) | ||
| } | ||
| return [primary] + additional | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import Foundation | ||
| import WordPressAPI | ||
|
|
||
| public struct SocialKeyringConnection: Identifiable, Hashable, Sendable { | ||
| public let id: Int64 | ||
| public let service: String | ||
| public let externalID: String | ||
| public let externalName: String | ||
| public let externalDisplay: String | ||
| public let externalProfilePictureURL: URL? | ||
| public let additionalExternalUsers: [AdditionalExternalUser] | ||
| public let status: ConnectionStatus | ||
|
|
||
| public init( | ||
| id: Int64, | ||
| service: String, | ||
| externalID: String, | ||
| externalName: String, | ||
| externalDisplay: String, | ||
| externalProfilePictureURL: URL?, | ||
| additionalExternalUsers: [AdditionalExternalUser], | ||
| status: ConnectionStatus | ||
| ) { | ||
| self.id = id | ||
| self.service = service | ||
| self.externalID = externalID | ||
| self.externalName = externalName | ||
| self.externalDisplay = externalDisplay | ||
| self.externalProfilePictureURL = externalProfilePictureURL | ||
| self.additionalExternalUsers = additionalExternalUsers | ||
| self.status = status | ||
| } | ||
|
|
||
| init(from wire: KeyringConnectionResponse) { | ||
| // Some keyrings come back with an empty `external_display`. Direct | ||
| // port of the legacy v1.1 fallback in SharingServiceRemote: use | ||
| // `external_name` so the picker doesn't show a blank account. | ||
| let externalDisplay = wire.externalDisplay.isEmpty ? wire.externalName : wire.externalDisplay | ||
| self.init( | ||
| id: wire.id, | ||
| service: wire.service, | ||
| externalID: wire.externalId, | ||
| externalName: wire.externalName, | ||
| externalDisplay: externalDisplay, | ||
| externalProfilePictureURL: wire.externalProfilePicture.flatMap(URL.init(string:)), | ||
| additionalExternalUsers: wire.additionalExternalUsers.map(AdditionalExternalUser.init(from:)), | ||
| status: ConnectionStatus(wireString: wire.status) | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import Foundation | ||
| import WordPressAPI | ||
|
|
||
| public struct SocialService: Identifiable, Hashable, Sendable { | ||
| public let id: String | ||
| public let label: String | ||
| public let description: String | ||
| public let supportsAdditionalUsers: Bool | ||
| /// True for services like Facebook where Publicize can only target sub-accounts | ||
| /// (Pages) and not the keyring's primary external account (the user's profile). | ||
| /// `AccountConfirmationView` uses this to hide the primary row from the picker. | ||
| public let additionalUsersOnly: Bool | ||
| public let isActive: Bool | ||
| public let connectURL: URL? | ||
|
|
||
| public init( | ||
| id: String, | ||
| label: String, | ||
| description: String, | ||
| supportsAdditionalUsers: Bool, | ||
| additionalUsersOnly: Bool, | ||
| isActive: Bool, | ||
| connectURL: URL? = nil | ||
| ) { | ||
| self.id = id | ||
| self.label = label | ||
| self.description = description | ||
| self.supportsAdditionalUsers = supportsAdditionalUsers | ||
| self.additionalUsersOnly = additionalUsersOnly | ||
| self.isActive = isActive | ||
| self.connectURL = connectURL | ||
| } | ||
|
|
||
| init(from wire: PublicizeServiceResponse) { | ||
| self.init( | ||
| id: wire.id, | ||
| label: wire.label, | ||
| description: wire.description, | ||
|
Comment on lines
+8
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note Sent the PR to GPT first and Opus next to verify. Here's the only relevant comment. @crazytonyli was the slight change in domain model intentional? The wire model exposes both The pre-
The two bits aren't equivalent:
The service picker needs the second bit to suppress the primary “Connect” affordance for those services. Posted by Claude (Opus 4.7) on behalf of @mokagio with approval.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a great finding, actually. I happened to find an issue caused by this during review this afternoon. f636640 adds the missing property, which will be used in a future PR. |
||
| supportsAdditionalUsers: wire.supports.additionalUsers, | ||
| additionalUsersOnly: wire.supports.additionalUsersOnly, | ||
| isActive: wire.status == "ok", | ||
| connectURL: wire.url.isEmpty ? nil : URL(string: wire.url) | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import Foundation | ||
|
|
||
| public enum SocialSharingError: Error, Sendable { | ||
| case network(Error) | ||
| case notAuthenticated | ||
| case connectionNotFound(id: String) | ||
| case keyringNotFound(id: Int64) | ||
| case noKeyringForService(serviceLabel: String) | ||
| case decoding(Error) | ||
| case unknown(Error) | ||
|
crazytonyli marked this conversation as resolved.
|
||
| } | ||
|
|
||
| extension SocialSharingError: LocalizedError { | ||
| public var errorDescription: String? { | ||
| switch self { | ||
| case .network: | ||
| return Strings.Errors.network | ||
| case .notAuthenticated: | ||
| return Strings.Errors.notAuthenticated | ||
| case .connectionNotFound(let id): | ||
| return String.localizedStringWithFormat(Strings.Errors.connectionNotFoundFormat, id) | ||
| case .keyringNotFound(let id): | ||
| return String.localizedStringWithFormat(Strings.Errors.keyringNotFoundFormat, String(id)) | ||
| case .noKeyringForService(let label): | ||
| return String.localizedStringWithFormat(Strings.Errors.noKeyringForServiceFormat, label) | ||
| case .decoding: | ||
| return Strings.Errors.decoding | ||
| case .unknown: | ||
| return Strings.Errors.unknown | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "info" : { | ||
| "author" : "xcode", | ||
| "version" : 1 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "images" : [ | ||
| { | ||
| "filename" : "publicize-bluesky.svg", | ||
| "idiom" : "universal" | ||
| } | ||
| ], | ||
| "info" : { | ||
| "author" : "xcode", | ||
| "version" : 1 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "images" : [ | ||
| { | ||
| "idiom" : "universal", | ||
| "filename" : "publicize-default.pdf" | ||
| } | ||
| ], | ||
| "info" : { | ||
| "version" : 1, | ||
| "author" : "xcode" | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.