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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ To automatically sign in to the app on an iOS simulator, see @docs/simulator-sig
- Use semantics text sizes like `.headline`
- Use swift-log (see the `WordPress/Classes/System/Logging.swift` file) instead of CocoaLumberjack (`DDLogError`, etc)

## Core Data Concurrency

Don't capture an `NSManagedObject` (e.g. `Blog`, `WPAccount`) across threads — touching its properties off its context's queue violates Core Data's concurrency model.

Store a `TaggedManagedObjectID<Model>` instead, inject a `CoreDataStack` (typically `ContextManager.shared`), and resolve the object inside `coreDataStack.performQuery { context in ... }` (or `performAndSave` for writes):

```swift
try await coreDataStack.performQuery { [blogID] context in
let blog = try context.existingObject(with: blogID)
return blog.someValue // return value types, not the managed object
}
```

## Development Workflow
- Branch from `trunk` (main branch)
- PR target should be `trunk`
Expand Down
5 changes: 2 additions & 3 deletions Modules/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

386 changes: 242 additions & 144 deletions Modules/Package.swift

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions Modules/Sources/JetpackSocial/Models/AdditionalExternalUser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation
import WordPressAPI

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

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

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

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

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

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

extension PostMeta {
/// Returns a new `PostMeta` with `jetpack_publicize_message` set to the
/// given message string. The publicize plugin reads this from `_wpas_mess`
/// post meta, registered via `register_meta` and exposed at
/// `meta.jetpack_publicize_message`.
public func addingPublicizeMessage(_ message: String) -> PostMeta {
self.withValue(key: "jetpack_publicize_message", value: .string(message))
}
}
65 changes: 65 additions & 0 deletions Modules/Sources/JetpackSocial/Models/SocialConnection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation
import WordPressAPI

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

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

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

}

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

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

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

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

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

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

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

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

public init(
id: String,
label: String,
description: String,
supportsAdditionalUsers: Bool,
isActive: Bool,
connectURL: URL? = nil
) {
self.id = id
self.label = label
self.description = description
self.supportsAdditionalUsers = supportsAdditionalUsers
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,
isActive: wire.status == "ok",
connectURL: wire.url.isEmpty ? nil : URL(string: wire.url)
)
}
}
29 changes: 29 additions & 0 deletions Modules/Sources/JetpackSocial/Models/SocialSharingError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

public enum SocialSharingError: Error, Sendable {
case network(Error)
case notAuthenticated
case connectionNotFound(id: String)
case keyringNotFound(id: Int64)
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 .decoding:
return Strings.Errors.decoding
case .unknown:
return Strings.Errors.unknown
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation
import WordPressAPI

extension WpAdditionalFields {
/// Returns a new `WpAdditionalFields` with the `jetpack_publicize_connections`
/// key populated for the given site connection IDs. Each entry encodes whether
/// that connection is enabled for the post (i.e., absent from `disabled`).
public func addingPublicizeConnections(_ ids: [String], disabled: Set<String>) -> WpAdditionalFields {
let entries: [JsonValue] = ids.map { id in
.object([
"connection_id": .string(id),
"enabled": .bool(!disabled.contains(id))
])
}
return self.withValue(key: "jetpack_publicize_connections", value: .array(entries))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "publicize-bluesky.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading