Skip to content
Open
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 @@ -67,6 +67,19 @@ To recover, delete all `*.pcm` files in the directory reported by the error and
- 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
26 changes: 26 additions & 0 deletions Modules/Sources/JetpackSocial/Models/PostMeta+Publicize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Foundation
import WordPressAPIInternal

extension PostMeta {
/// The Jetpack publicize message stored in this meta, if any.
///
/// The publicize plugin reads this from `_wpas_mess` post meta, registered
/// via `register_meta` and exposed at `meta.jetpack_publicize_message`.
/// Empty strings are reported as `nil` since the server treats them as
/// "no override".
public var publicizeMessage: String? {
guard case let .string(text)? = valueForKey(key: Self.publicizeMessageKey), !text.isEmpty else {
return nil
}
return text
}

/// Returns a new `PostMeta` with the publicize message set to the given
/// string. Pass an empty string to clear a previously-saved value during a
/// partial update.
public func addingPublicizeMessage(_ message: String) -> PostMeta {
self.withValue(key: Self.publicizeMessageKey, value: .string(message))
}

private static let publicizeMessageKey = "jetpack_publicize_message"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation
import WordPressAPI

private enum PublicizeAdditionalFieldKeys {
static let connections = "jetpack_publicize_connections"
}

extension WpAdditionalFields {
/// Parses the post-level Publicize connection state if the REST field was present.
public var publicizeConnectionsByID: [String: PostSocialSharingDraft.Connection]? {
guard keys().contains(PublicizeAdditionalFieldKeys.connections) else {
return nil
}

var connectionsByID: [String: PostSocialSharingDraft.Connection] = [:]
for entry in arrayValueForKey(key: PublicizeAdditionalFieldKeys.connections) ?? [] {
guard case let .object(dict) = entry,
case let .string(id)? = dict["connection_id"],
case let .bool(enabled)? = dict["enabled"]
else {
continue
}
connectionsByID[id] = .init(id: id, enabled: enabled)
}
return connectionsByID
}

/// Returns a new `WpAdditionalFields` with the `jetpack_publicize_connections`
/// key populated with the draft's explicit per-post connection state.
public func addingPublicizeConnections(
_ connectionsByID: [String: PostSocialSharingDraft.Connection]
) -> WpAdditionalFields {
let entries: [JsonValue] = connectionsByID.values.sorted { $0.id < $1.id }.map { connection in
.object([
"connection_id": .string(connection.id),
"enabled": .bool(connection.enabled)
])
}
return self.withValue(key: PublicizeAdditionalFieldKeys.connections, value: .array(entries))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal

public struct PostSocialSharingDraft: Equatable, Hashable, Sendable {
public struct Connection: Identifiable, Equatable, Hashable, Sendable {
public var id: String
public var enabled: Bool

public init(id: String, enabled: Bool) {
self.id = id
self.enabled = enabled
}
}

public var customMessage: String?
public var connectionsByID: [String: Connection]?

// TODO: per-connection customization (`_wpas_customize_per_network`) —
// extend to include per-connection message / attached_media / media_source
// once the backend paid feature lands.

public init(customMessage: String? = nil, connectionsByID: [String: Connection]? = nil) {
self.customMessage = customMessage
self.connectionsByID = connectionsByID
}
}

extension PostSocialSharingDraft {
/// Parses the relevant fields off a fetched post into a draft. Connections
/// come from the post's `additional_fields` blob (where Jetpack registers
/// `jetpack_publicize_connections` as a top-level REST field); the custom
/// message comes from `meta.jetpack_publicize_message`. Unknown or missing
/// keys collapse to defaults.
public init(fromPostAdditionalFields fields: WpAdditionalFields?, meta: PostMeta?) {
self.init(
customMessage: meta?.publicizeMessage,
connectionsByID: fields?.publicizeConnectionsByID
)
}

public func isEnabled(connectionID: String) -> Bool {
connectionsByID?[connectionID]?.enabled ?? true
}

public mutating func setEnabled(
_ enabled: Bool,
for connection: SocialConnection,
availableConnections: [SocialConnection]
) {
var connections = materializedConnectionsByID(availableConnections: availableConnections)
connections[connection.id] = Connection(id: connection.id, enabled: enabled)
connectionsByID = connections
}

public mutating func addConnection(
_ connection: SocialConnection,
availableConnections: [SocialConnection]
) {
var connections = materializedConnectionsByID(availableConnections: availableConnections)
connections[connection.id] = Connection(id: connection.id, enabled: true)
connectionsByID = connections
}

private func materializedConnectionsByID(
availableConnections: [SocialConnection]
) -> [String: Connection] {
var materializedConnectionsByID: [String: Connection] = [:]
for connection in availableConnections {
materializedConnectionsByID[connection.id] =
connectionsByID?[connection.id] ?? Connection(id: connection.id, enabled: true)
}
return materializedConnectionsByID
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,6 @@ public final class SiteSocialConnectionsService: ObservableObject {
}
}

/// Snapshot of the currently loaded connection IDs. Returns `[]` if
/// `connections` has not been loaded yet.
public func currentConnectionIDs() -> [String] {
connections.value?.map(\.id) ?? []
}

// MARK: - Mutations

@discardableResult
Expand Down
54 changes: 54 additions & 0 deletions Modules/Sources/JetpackSocial/Strings/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,4 +287,58 @@ public enum Strings {
comment: "Navigation bar title of the service picker modal shown when adding a new social connection."
)
}

public enum PostSection {
public static let header = NSLocalizedString(
"jetpackSocial.postSection.header",
value: "Share to social media",
comment: "Title of the row that opens the social sharing detail screen in Post Settings."
)

public static let togglesFooter = NSLocalizedString(
"jetpackSocial.postSection.togglesFooter",
value: "When this post is published, it will be shared to the enabled accounts.",
comment: "Footer below the per-connection toggles on the social sharing detail screen."
)

public static let emptyCaption = NSLocalizedString(
"jetpackSocial.postSection.empty",
value: "No social accounts connected yet.",
comment: "Caption shown when the site has no social connections."
)

public static let customMessageLabel = NSLocalizedString(
"jetpackSocial.postSection.customMessage",
value: "Custom message",
comment: "Label above the optional custom social-sharing message field."
)

public static let customMessagePlaceholder = NSLocalizedString(
"jetpackSocial.postSection.customMessagePlaceholder",
value: "Write a custom message for your social audience here.",
comment: "Placeholder for the optional custom social-sharing message field."
)

public static let customMessageFooter = NSLocalizedString(
"jetpackSocial.postSection.customMessageFooter",
value: """
Customize the message you want to share.
If you don't add your own text here, we'll use the post's title as the message.
""",
comment: "Footer caption below the custom social-sharing message field."
)

public static let retry = NSLocalizedString(
"jetpackSocial.postSection.retry",
value: "Retry",
comment: "Button to retry a failed load of the connections list."
)

public static let summaryFormat = NSLocalizedString(
"jetpackSocial.postSection.summary.format",
value: "%1$d of %2$d",
comment:
"Trailing value on the 'Share to Social' post settings row. %1$d is the enabled count, %2$d is the total."
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ public struct AccountConfirmationView: View {
private let service: SocialService
@ObservedObject private var connectionsService: SiteSocialConnectionsService
private let onCancel: () -> Void
private let onFinish: (Result<SocialKeyringAccount, SocialSharingError>) -> Void
private let onFinish: (Result<SocialConnection, SocialSharingError>) -> Void

@State private var state: LoadingState = .loading
@State private var connectedExternalIDs: Set<String> = []
Expand All @@ -18,7 +18,7 @@ public struct AccountConfirmationView: View {
service: SocialService,
connectionsService: SiteSocialConnectionsService,
onCancel: @escaping () -> Void,
onFinish: @escaping (Result<SocialKeyringAccount, SocialSharingError>) -> Void
onFinish: @escaping (Result<SocialConnection, SocialSharingError>) -> Void
) {
self.service = service
self.connectionsService = connectionsService
Expand Down Expand Up @@ -111,14 +111,14 @@ public struct AccountConfirmationView: View {
private func submit(account: SocialKeyringAccount) {
submitting = true
submitTask = Task {
let result: Result<SocialKeyringAccount, SocialSharingError>
let result: Result<SocialConnection, SocialSharingError>
do throws(SocialSharingError) {
_ = try await connectionsService.createConnection(
let connection = try await connectionsService.createConnection(
keyringID: account.keyring.id,
externalUserID: account.externalUserID,
shared: connectionsService.canMarkAsShared ? sharedEnabled : nil
)
result = .success(account)
result = .success(connection)
} catch {
result = .failure(error)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public final class AddConnectionCoordinator {
private let connectionsService: SiteSocialConnectionsService
private let authenticator: any SocialOAuthAuthenticator
private weak var presenter: UIViewController?
private let onConnectionCreated: ((SocialConnection) -> Void)?

private var navController: UINavigationController?
private var confirmationHost: UIHostingController<AccountConfirmationView>?
Expand All @@ -17,11 +18,13 @@ public final class AddConnectionCoordinator {
public init(
connectionsService: SiteSocialConnectionsService,
authenticator: any SocialOAuthAuthenticator,
presenter: UIViewController
presenter: UIViewController,
onConnectionCreated: ((SocialConnection) -> Void)? = nil
) {
self.connectionsService = connectionsService
self.authenticator = authenticator
self.presenter = presenter
self.onConnectionCreated = onConnectionCreated
}

public func start() {
Expand Down Expand Up @@ -97,7 +100,8 @@ public final class AddConnectionCoordinator {
onFinish: { [weak self] result in
guard let self else { return }
switch result {
case .success:
case .success(let connection):
self.onConnectionCreated?(connection)
self.dismissNav()
case .failure(let error):
self.dismissAndAlertFailure(error)
Expand Down
Loading