From 57bc5c6247504dab103f05a8db1398eeabebdca3 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 30 Apr 2026 22:19:10 +1200 Subject: [PATCH] Add JetpackSocial module for managing social connections Introduces a new JetpackSocial Swift package containing the core models, services, and views for managing a site's connected social media accounts. Adds a new "Social" screen, accessible from Site Settings > Sharing when the socialSharingV2 feature flag is enabled, that lists existing connections and lets the user connect or disconnect accounts via OAuth. Also bumps wordpress-rs and updates the call sites affected by the SiteSettings additionalFields and UserCapabilitiesMap signature changes. --- Modules/Package.resolved | 5 +- Modules/Package.swift | 386 +++++++++++------- .../Models/AdditionalExternalUser.swift | 25 ++ .../Models/ConnectionStatus.swift | 32 ++ .../Models/SocialConnection.swift | 65 +++ .../Models/SocialKeyringAccount.swift | 60 +++ .../Models/SocialKeyringConnection.swift | 50 +++ .../JetpackSocial/Models/SocialService.swift | 38 ++ .../Models/SocialSharingError.swift | 29 ++ .../Resources/Icons.xcassets/Contents.json | 6 + .../publicize-bluesky.imageset/Contents.json | 12 + .../publicize-bluesky.svg | 1 + .../publicize-default.imageset/Contents.json | 12 + .../publicize-default.pdf | Bin 0 -> 4124 bytes .../publicize-facebook.imageset/Contents.json | 12 + .../publicize-facebook.svg | 1 + .../Contents.json | 12 + .../publicize-google-plus.pdf | Bin 0 -> 4137 bytes .../Contents.json | 12 + .../publicize-instagram-business.svg | 1 + .../publicize-linkedin.imageset/Contents.json | 12 + .../publicize-linkedin.svg | 1 + .../publicize-mastodon.imageset/Contents.json | 12 + .../publicize-mastodon.svg | 1 + .../publicize-nextdoor.imageset/Contents.json | 12 + .../publicize-nextdoor.svg | 4 + .../publicize-threads.imageset/Contents.json | 12 + .../publicize-threads.png | Bin 0 -> 7689 bytes .../publicize-tumblr.imageset/Contents.json | 22 + .../publicize-tumblr-dark.svg | 1 + .../publicize-tumblr.svg | 1 + .../publicize-twitter.imageset/Contents.json | 12 + .../publicize-twitter.svg | 1 + .../Contents.json | 12 + .../publicize-wordpress.pdf | Bin 0 -> 4452 bytes .../PublicizeConnectionURLMatcher.swift | 10 +- ...SiteSocialConnectionsService+Preview.swift | 19 + .../SiteSocialConnectionsService.swift | 211 ++++++++++ .../Services/SocialConnectionsState.swift | 17 + .../Services/SocialOAuthAuthenticator.swift | 20 + .../Services/SocialServicesState.swift | 17 + .../JetpackSocial/Strings/Strings.swift | 230 +++++++++++ .../Views/AccountConfirmationView.swift | 267 ++++++++++++ .../Views/AddConnectionCoordinator.swift | 152 +++++++ .../ManageConnectionsHostingController.swift | 43 ++ .../Views/ManageSocialConnectionsView.swift | 132 ++++++ .../Views/SocialConnectionDetailView.swift | 94 +++++ .../Views/SocialConnectionRow.swift | 71 ++++ .../Views/SocialOAuthWebViewController.swift | 227 ++++++++++ .../Views/SocialServiceIcon.swift | 35 ++ .../Views/SocialServicePickerView.swift | 111 +++++ .../WordPressCore/Users/UserService.swift | 2 +- .../WordPressKit/SharingServiceRemote.swift | 6 + .../ConnectionStatusTests.swift | 33 ++ .../SiteSocialConnectionsServiceTests.swift | 33 ++ .../SocialConnectionTests.swift | 120 ++++++ .../SocialKeyringAccountTests.swift | 69 ++++ .../SocialKeyringConnectionTests.swift | 83 ++++ .../SocialServiceTests.swift | 57 +++ .../SocialSharingErrorTests.swift | 23 ++ .../MockWordPressClientAPI.swift | 23 +- .../Features/Posts/PostSettingsTests.swift | 76 ++-- ...ogServiceRemoteCoreRESTSettingsTests.swift | 27 +- ...icizeAuthorizationURLComponentsTests.swift | 1 + .../WordPressUnitTests.xctestplan | 7 + .../Networking/JetpackSocialFactory.swift | 89 ++++ WordPress/Classes/Utility/AccountHelper.swift | 35 +- .../BuildInformation/FeatureFlag.swift | 14 +- .../BlogDetailsViewController+Swift.swift | 56 ++- ...haringAuthorizationWebViewController.swift | 41 +- .../Post/PostSettings/PostSettings.swift | 68 +-- .../V2/BlogSocialOAuthAuthenticator.swift | 54 +++ ...nageConnectionsHostingController+App.swift | 20 + 73 files changed, 3187 insertions(+), 268 deletions(-) create mode 100644 Modules/Sources/JetpackSocial/Models/AdditionalExternalUser.swift create mode 100644 Modules/Sources/JetpackSocial/Models/ConnectionStatus.swift create mode 100644 Modules/Sources/JetpackSocial/Models/SocialConnection.swift create mode 100644 Modules/Sources/JetpackSocial/Models/SocialKeyringAccount.swift create mode 100644 Modules/Sources/JetpackSocial/Models/SocialKeyringConnection.swift create mode 100644 Modules/Sources/JetpackSocial/Models/SocialService.swift create mode 100644 Modules/Sources/JetpackSocial/Models/SocialSharingError.swift create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-bluesky.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-bluesky.imageset/publicize-bluesky.svg create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-default.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-default.imageset/publicize-default.pdf create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-facebook.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-facebook.imageset/publicize-facebook.svg create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-google-plus.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-google-plus.imageset/publicize-google-plus.pdf create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-instagram-business.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-instagram-business.imageset/publicize-instagram-business.svg create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-linkedin.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-linkedin.imageset/publicize-linkedin.svg create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-mastodon.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-mastodon.imageset/publicize-mastodon.svg create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-nextdoor.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-nextdoor.imageset/publicize-nextdoor.svg create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-threads.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-threads.imageset/publicize-threads.png create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/publicize-tumblr-dark.svg create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-tumblr.imageset/publicize-tumblr.svg create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-twitter.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-twitter.imageset/publicize-twitter.svg create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-wordpress.imageset/Contents.json create mode 100644 Modules/Sources/JetpackSocial/Resources/Icons.xcassets/publicize-wordpress.imageset/publicize-wordpress.pdf rename {WordPress/Classes/ViewRelated/Blog/Sharing => Modules/Sources/JetpackSocial/Services}/PublicizeConnectionURLMatcher.swift (94%) create mode 100644 Modules/Sources/JetpackSocial/Services/SiteSocialConnectionsService+Preview.swift create mode 100644 Modules/Sources/JetpackSocial/Services/SiteSocialConnectionsService.swift create mode 100644 Modules/Sources/JetpackSocial/Services/SocialConnectionsState.swift create mode 100644 Modules/Sources/JetpackSocial/Services/SocialOAuthAuthenticator.swift create mode 100644 Modules/Sources/JetpackSocial/Services/SocialServicesState.swift create mode 100644 Modules/Sources/JetpackSocial/Strings/Strings.swift create mode 100644 Modules/Sources/JetpackSocial/Views/AccountConfirmationView.swift create mode 100644 Modules/Sources/JetpackSocial/Views/AddConnectionCoordinator.swift create mode 100644 Modules/Sources/JetpackSocial/Views/ManageConnectionsHostingController.swift create mode 100644 Modules/Sources/JetpackSocial/Views/ManageSocialConnectionsView.swift create mode 100644 Modules/Sources/JetpackSocial/Views/SocialConnectionDetailView.swift create mode 100644 Modules/Sources/JetpackSocial/Views/SocialConnectionRow.swift create mode 100644 Modules/Sources/JetpackSocial/Views/SocialOAuthWebViewController.swift create mode 100644 Modules/Sources/JetpackSocial/Views/SocialServiceIcon.swift create mode 100644 Modules/Sources/JetpackSocial/Views/SocialServicePickerView.swift create mode 100644 Modules/Tests/JetpackSocialTests/ConnectionStatusTests.swift create mode 100644 Modules/Tests/JetpackSocialTests/SiteSocialConnectionsServiceTests.swift create mode 100644 Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift create mode 100644 Modules/Tests/JetpackSocialTests/SocialKeyringAccountTests.swift create mode 100644 Modules/Tests/JetpackSocialTests/SocialKeyringConnectionTests.swift create mode 100644 Modules/Tests/JetpackSocialTests/SocialServiceTests.swift create mode 100644 Modules/Tests/JetpackSocialTests/SocialSharingErrorTests.swift create mode 100644 WordPress/Classes/Networking/JetpackSocialFactory.swift create mode 100644 WordPress/Classes/ViewRelated/Publicize/V2/BlogSocialOAuthAuthenticator.swift create mode 100644 WordPress/Classes/ViewRelated/Publicize/V2/ManageConnectionsHostingController+App.swift diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 12dfa2b46bce..bf60dfa80e19 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3b1752272a0fe3abf273e5ecd0ed7e9a717f7b5001bf47f146ee94027d59f1ef", + "originHash" : "1833ce82330401dc070e7bb925a356375e6ef24262fdcdca15a54866fc8137b1", "pins" : [ { "identity" : "alamofire", @@ -345,8 +345,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Automattic/wordpress-rs", "state" : { - "branch" : "alpha-20260313.1", - "revision" : "1d1e0fc637f9d5198ed8b0cf211d7a7a3de9c6c4" + "revision" : "0bd4c186ed9265533bcefe836c0ecb6718dfcdfa" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 182826e1cf60..53f8250d6b35 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -5,13 +5,14 @@ import PackageDescription let package = Package( name: "Modules", platforms: [ - .iOS(.v17), + .iOS(.v17) ], products: XcodeSupport.products + [ .library(name: "AsyncImageKit", targets: ["AsyncImageKit"]), .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"]), @@ -24,7 +25,7 @@ let package = Package( .library(name: "WordPressReader", targets: ["WordPressReader"]), .library(name: "WordPressCore", targets: ["WordPressCore"]), .library(name: "WordPressCoreProtocols", targets: ["WordPressCoreProtocols"]), - .library(name: "WordPressKit", targets: ["WordPressKit"]), + .library(name: "WordPressKit", targets: ["WordPressKit"]) ], dependencies: [ .package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"), @@ -53,34 +54,47 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/MediaEditor-iOS", branch: "task/spm-support"), .package(url: "https://github.com/wordpress-mobile/NSObject-SafeExpectations", from: "0.0.6"), .package(url: "https://github.com/wordpress-mobile/wpxmlrpc", from: "0.9.0"), - .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), + .package( + url: "https://github.com/wordpress-mobile/NSURL-IDN", + revision: "b34794c9a3f32312e1593d4a3d120572afa0d010" + ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.15.0"), // We can't use wordpress-rs branches nor commits here. Only tags work. - .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20260313.1"), + .package( + url: "https://github.com/Automattic/wordpress-rs", + revision: "0bd4c186ed9265533bcefe836c0ecb6718dfcdfa" + ), .package( url: "https://github.com/Automattic/color-studio", revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de" ), .package(url: "https://github.com/wordpress-mobile/AztecEditor-iOS", from: "1.20.0"), - .package(url: "https://github.com/kean/Pulse", from: "5.0.0"), + .package(url: "https://github.com/kean/Pulse", from: "5.0.0") ], targets: XcodeSupport.targets + [ - .target(name: "AsyncImageKit", dependencies: [ - .product(name: "Collections", package: "swift-collections"), - .product(name: "Gifu", package: "Gifu"), - ]), - .target(name: "AztecExtensions", dependencies: [ - "WordPressShared", - .product(name: "Gridicons", package: "Gridicons-iOS"), - .product(name: "Aztec", package: "AztecEditor-iOS"), - ], swiftSettings: [.swiftLanguageMode(.v5)]), + .target( + name: "AsyncImageKit", + dependencies: [ + .product(name: "Collections", package: "swift-collections"), + .product(name: "Gifu", package: "Gifu") + ] + ), + .target( + name: "AztecExtensions", + dependencies: [ + "WordPressShared", + .product(name: "Gridicons", package: "Gridicons-iOS"), + .product(name: "Aztec", package: "AztecEditor-iOS") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), .target(name: "BuildSettingsKit"), .target( name: "DesignSystem", dependencies: [ "BuildSettingsKit", - .product(name: "ColorStudio", package: "color-studio"), + .product(name: "ColorStudio", package: "color-studio") ], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)] @@ -92,7 +106,7 @@ let package = Package( "WordPressUI", // TODO: Remove — It's here just for a NSMutableParagraphStyle init helper "WordPressKit", - .product(name: "Gridicons", package: "Gridicons-iOS"), + .product(name: "Gridicons", package: "Gridicons-iOS") ], // Set to v5 to avoid @Sendable warnings and errors swiftSettings: [.swiftLanguageMode(.v5)] @@ -101,11 +115,24 @@ let package = Package( name: "JetpackStats", dependencies: [ "WordPressUI", - "WordPressKit", + "WordPressKit" ], 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: [ @@ -123,7 +150,7 @@ let package = Package( // "_OBJC_CLASS_$_DDLog", referenced from: // in SharedCoreDataStack.o .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), - .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), + .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack") ], resources: [.process("Resources/Extensions.xcdatamodeld")] ), @@ -141,7 +168,7 @@ let package = Package( name: "Support", dependencies: [ "AsyncImageKit", - "WordPressCoreProtocols", + "WordPressCoreProtocols" ] ), .target(name: "TextBundle"), @@ -150,33 +177,49 @@ let package = Package( dependencies: ["BuildSettingsKit"], swiftSettings: [.swiftLanguageMode(.v5)] ), - .target(name: "UITestsFoundation", dependencies: [ - .product(name: "ScreenObject", package: "ScreenObject"), - .product(name: "XCUITestHelpers", package: "ScreenObject"), - ], swiftSettings: [.swiftLanguageMode(.v5)]), + .target( + name: "UITestsFoundation", + dependencies: [ + .product(name: "ScreenObject", package: "ScreenObject"), + .product(name: "XCUITestHelpers", package: "ScreenObject") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), .target(name: "WordPressFlux", swiftSettings: [.swiftLanguageMode(.v5)]), - .target(name: "WordPressCore", dependencies: [ + .target( + name: "WordPressCore", + dependencies: [ "WordPressCoreProtocols", "WordPressShared", - .product(name: "WordPressAPI", package: "wordpress-rs"), + .product(name: "WordPressAPI", package: "wordpress-rs") + ] + ), + .target( + name: "WordPressCoreProtocols", + dependencies: [ + // This package should never have dependencies – it exists to expose protocols implemented in WordPressCore + // to UI code, because `wordpress-rs` doesn't work nicely with previews. + ] + ), + .target( + name: "WordPressIntelligence", + dependencies: [ + "WordPressShared", + .product(name: "SwiftSoup", package: "SwiftSoup") ] ), - .target(name: "WordPressCoreProtocols", dependencies: [ - // This package should never have dependencies – it exists to expose protocols implemented in WordPressCore - // to UI code, because `wordpress-rs` doesn't work nicely with previews. - ]), - .target(name: "WordPressIntelligence", dependencies: [ - "WordPressShared", - .product(name: "SwiftSoup", package: "SwiftSoup"), - ]), .target(name: "WordPressLegacy", dependencies: ["DesignSystem", "WordPressShared"]), - .target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), + .target( + name: "WordPressSharedObjC", + resources: [.process("Resources")], + swiftSettings: [.swiftLanguageMode(.v5)] + ), .target( name: "WordPressShared", dependencies: [ .product(name: "SwiftSoup", package: "SwiftSoup"), .target(name: "SFHFKeychainUtils"), - .target(name: "WordPressSharedObjC"), + .target(name: "WordPressSharedObjC") ], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)] @@ -189,7 +232,7 @@ let package = Package( "DesignSystem", "WordPressShared", "WordPressLegacy", - .product(name: "ColorStudio", package: "color-studio"), + .product(name: "ColorStudio", package: "color-studio") ], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)] @@ -198,7 +241,7 @@ let package = Package( name: "WordPressKitModels", dependencies: [ "NSObject-SafeExpectations", - "WordPressShared", + "WordPressShared" ] ), .target( @@ -221,7 +264,7 @@ let package = Package( "WordPressKitModels", "NSObject-SafeExpectations", "WordPressShared", - "wpxmlrpc", + "wpxmlrpc" ], swiftSettings: [.swiftLanguageMode(.v5)] ), @@ -231,30 +274,54 @@ let package = Package( "AsyncImageKit", "WordPressUI", "WordPressShared", - .product(name: "SwiftSoup", package: "SwiftSoup"), + .product(name: "SwiftSoup", package: "SwiftSoup") ], resources: [.process("Resources")] ), .testTarget(name: "JetpackStatsTests", dependencies: ["JetpackStats"]), - .testTarget(name: "JetpackStatsWidgetsCoreTests", dependencies: [.target(name: "JetpackStatsWidgetsCore")], swiftSettings: [.swiftLanguageMode(.v5)]), - .testTarget(name: "DesignSystemTests", dependencies: [.target(name: "DesignSystem")], swiftSettings: [.swiftLanguageMode(.v5)]), + .testTarget( + name: "JetpackStatsWidgetsCoreTests", + dependencies: [.target(name: "JetpackStatsWidgetsCore")], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .testTarget( + name: "DesignSystemTests", + dependencies: [.target(name: "DesignSystem")], + swiftSettings: [.swiftLanguageMode(.v5)] + ), .testTarget( name: "WordPressFluxTests", dependencies: ["WordPressFlux"], exclude: ["WordPressFluxTests.xctestplan"], swiftSettings: [.swiftLanguageMode(.v5)] ), - .testTarget(name: "AsyncImageKitTests", dependencies: [ - .target(name: "AsyncImageKit"), - .target(name: "WordPressTesting"), - .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs") - ]), - .testTarget(name: "WordPressSharedTests", dependencies: [.target(name: "WordPressShared")], swiftSettings: [.swiftLanguageMode(.v5)]), - .testTarget(name: "WordPressSharedObjCTests", dependencies: [.target(name: "WordPressShared"), .target(name: "WordPressTesting")], swiftSettings: [.swiftLanguageMode(.v5)]), - .testTarget(name: "WordPressUIUnitTests", dependencies: [.target(name: "WordPressUI")], swiftSettings: [.swiftLanguageMode(.v5)]), + .testTarget( + name: "AsyncImageKitTests", + dependencies: [ + .target(name: "AsyncImageKit"), + .target(name: "WordPressTesting"), + .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs") + ] + ), + .testTarget( + name: "WordPressSharedTests", + dependencies: [.target(name: "WordPressShared")], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .testTarget( + name: "WordPressSharedObjCTests", + dependencies: [.target(name: "WordPressShared"), .target(name: "WordPressTesting")], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .testTarget( + name: "WordPressUIUnitTests", + dependencies: [.target(name: "WordPressUI")], + swiftSettings: [.swiftLanguageMode(.v5)] + ), .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")]) ] ) @@ -282,13 +349,19 @@ enum XcodeSupport { .library(name: "XcodeTarget_WordPressData", targets: ["XcodeTarget_WordPressData"]), .library(name: "XcodeTarget_WordPressDataTests", targets: ["XcodeTarget_WordPressDataTests"]), .library(name: "XcodeTarget_WordPressAuthentificator", targets: ["XcodeTarget_WordPressAuthentificator"]), - .library(name: "XcodeTarget_WordPressAuthentificatorTests", targets: ["XcodeTarget_WordPressAuthentificatorTests"]), + .library( + name: "XcodeTarget_WordPressAuthentificatorTests", + targets: ["XcodeTarget_WordPressAuthentificatorTests"] + ), .library(name: "XcodeTarget_ShareExtension", targets: ["XcodeTarget_ShareExtension"]), .library(name: "XcodeTarget_DraftActionExtension", targets: ["XcodeTarget_DraftActionExtension"]), - .library(name: "XcodeTarget_NotificationServiceExtension", targets: ["XcodeTarget_NotificationServiceExtension"]), + .library( + name: "XcodeTarget_NotificationServiceExtension", + targets: ["XcodeTarget_NotificationServiceExtension"] + ), .library(name: "XcodeTarget_Intents", targets: ["XcodeTarget_Intents"]), .library(name: "XcodeTarget_StatsWidget", targets: ["XcodeTarget_StatsWidget"]), - .library(name: "XcodeTarget_UITests", targets: ["XcodeTarget_UITests"]), + .library(name: "XcodeTarget_UITests", targets: ["XcodeTarget_UITests"]) ] } @@ -302,7 +375,7 @@ enum XcodeSupport { .product(name: "NSURL-IDN", package: "NSURL-IDN"), .product(name: "SVProgressHUD", package: "SVProgressHUD"), .product(name: "Gravatar", package: "Gravatar-SDK-iOS"), - .product(name: "GravatarUI", package: "Gravatar-SDK-iOS"), + .product(name: "GravatarUI", package: "Gravatar-SDK-iOS") ] let shareAndDraftExtensionsDependencies: [Target.Dependency] = [ @@ -331,12 +404,12 @@ enum XcodeSupport { .product(name: "SVProgressHUD", package: "SVProgressHUD"), .product(name: "ZIPFoundation", package: "ZIPFoundation"), .product(name: "Aztec", package: "AztecEditor-iOS"), - .product(name: "WordPressEditor", package: "AztecEditor-iOS"), + .product(name: "WordPressEditor", package: "AztecEditor-iOS") ] let testDependencies: [Target.Dependency] = [ .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs"), - .product(name: "OCMock", package: "OCMock"), + .product(name: "OCMock", package: "OCMock") ] let keystoneDependencies: [Target.Dependency] = [ @@ -345,6 +418,7 @@ enum XcodeSupport { "DesignSystem", "BuildSettingsKit", "FormattableContentKit", + "JetpackSocial", "JetpackStats", "JetpackStatsWidgetsCore", "NotificationServiceExtensionCore", @@ -389,100 +463,124 @@ enum XcodeSupport { .product(name: "ColorStudio", package: "color-studio"), .product(name: "Aztec", package: "AztecEditor-iOS"), .product(name: "WordPressEditor", package: "AztecEditor-iOS"), - .product(name: "Logging", package: "swift-log"), + .product(name: "Logging", package: "swift-log") ] return [ .xcodeTarget("XcodeTarget_App", dependencies: keystoneDependencies), .xcodeTarget("XcodeTarget_Keystone", dependencies: keystoneDependencies), - .xcodeTarget("XcodeTarget_WordPressTests", dependencies: testDependencies + [ - "WordPressShared", - "WordPressUI", - .product(name: "Gravatar", package: "Gravatar-SDK-iOS"), - // Needed by WordPressData because of how linkage works... - // - "BuildSettingsKit", - "FormattableContentKit", - "SFHFKeychainUtils", - "WordPressKit", - .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), - .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), - .product(name: "CocoaLumberjackSwiftLogBackend", package: "CocoaLumberjack"), - .product(name: "Logging", package: "swift-log"), - .product(name: "NSObject-SafeExpectations", package: "NSObject-SafeExpectations"), - .product(name: "NSURL-IDN", package: "NSURL-IDN"), - .product(name: "WordPressAPI", package: "wordpress-rs"), - ]), - .xcodeTarget("XcodeTarget_WordPressKitTests", dependencies: testDependencies + [ - "wpxmlrpc", - "WordPressKit", - ]), - .xcodeTarget("XcodeTarget_WordPressDataTests", dependencies: [ - "WordPressKit", - ]), + .xcodeTarget( + "XcodeTarget_WordPressTests", + dependencies: testDependencies + [ + "WordPressShared", + "WordPressUI", + .product(name: "Gravatar", package: "Gravatar-SDK-iOS"), + // Needed by WordPressData because of how linkage works... + // + "BuildSettingsKit", + "FormattableContentKit", + "SFHFKeychainUtils", + "WordPressKit", + .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), + .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), + .product(name: "CocoaLumberjackSwiftLogBackend", package: "CocoaLumberjack"), + .product(name: "Logging", package: "swift-log"), + .product(name: "NSObject-SafeExpectations", package: "NSObject-SafeExpectations"), + .product(name: "NSURL-IDN", package: "NSURL-IDN"), + .product(name: "WordPressAPI", package: "wordpress-rs") + ] + ), + .xcodeTarget( + "XcodeTarget_WordPressKitTests", + dependencies: testDependencies + [ + "wpxmlrpc", + "WordPressKit" + ] + ), + .xcodeTarget( + "XcodeTarget_WordPressDataTests", + dependencies: [ + "WordPressKit" + ] + ), .xcodeTarget("XcodeTarget_WordPressAuthentificator", dependencies: wordPresAuthentificatorDependencies), - .xcodeTarget("XcodeTarget_WordPressAuthentificatorTests", dependencies: wordPresAuthentificatorDependencies + testDependencies), + .xcodeTarget( + "XcodeTarget_WordPressAuthentificatorTests", + dependencies: wordPresAuthentificatorDependencies + testDependencies + ), .xcodeTarget("XcodeTarget_ShareExtension", dependencies: shareAndDraftExtensionsDependencies), .xcodeTarget("XcodeTarget_DraftActionExtension", dependencies: shareAndDraftExtensionsDependencies), - .xcodeTarget("XcodeTarget_NotificationServiceExtension", dependencies: [ - "BuildSettingsKit", - "FormattableContentKit", - "NotificationServiceExtensionCore", - "SFHFKeychainUtils", - "TracksMini", - "WordPressShared", - // Even though the extensions are all in Swift, we need to include the Objective-C - // version of CocoaLumberjack to avoid linking issues with other dependencies that - // use it. - // - // Example: - // - // EmitSwiftModule normal arm64 (in target 'WordPressNotificationServiceExtension' from project 'WordPress') - // cd /path/to/repo/WordPress - // - // :0: error: missing required modules: 'CocoaLumberjack', 'CocoaLumberjackSwiftSupport' - .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), - .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), - ]), - .xcodeTarget("XcodeTarget_StatsWidget", dependencies: [ - "BuildSettingsKit", - "JetpackStatsWidgetsCore", - "SFHFKeychainUtils", - "TracksMini", - "WordPressShared", - "WordPressUI", - "WordPressKit", - // Even though the extensions are all in Swift, we need to include the Objective-C - // version of CocoaLumberjack to avoid linking issues with other dependencies that - // use it. - // - // Example: - // - // Undefined symbols for architecture arm64: - // "_OBJC_CLASS_$_DDLog", referenced from: - // in AppExtensionsService.o - .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), - .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), - .product(name: "WordPressAPI", package: "wordpress-rs"), - ]), - .xcodeTarget("XcodeTarget_Intents", dependencies: [ - "BuildSettingsKit", - "JetpackStatsWidgetsCore", - // Even though the extensions are all in Swift, we need to include the Objective-C - // version of CocoaLumberjack to avoid linking issues with other dependencies that - // use it. - // - // Example: - // - // Undefined symbols for architecture arm64: - // "_OBJC_CLASS_$_DDLog", referenced from: - // in AppExtensionsService.o - .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), - .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), - ]), - .xcodeTarget("XcodeTarget_UITests", dependencies: [ - "UITestsFoundation", - ]), + .xcodeTarget( + "XcodeTarget_NotificationServiceExtension", + dependencies: [ + "BuildSettingsKit", + "FormattableContentKit", + "NotificationServiceExtensionCore", + "SFHFKeychainUtils", + "TracksMini", + "WordPressShared", + // Even though the extensions are all in Swift, we need to include the Objective-C + // version of CocoaLumberjack to avoid linking issues with other dependencies that + // use it. + // + // Example: + // + // EmitSwiftModule normal arm64 (in target 'WordPressNotificationServiceExtension' from project 'WordPress') + // cd /path/to/repo/WordPress + // + // :0: error: missing required modules: 'CocoaLumberjack', 'CocoaLumberjackSwiftSupport' + .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), + .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack") + ] + ), + .xcodeTarget( + "XcodeTarget_StatsWidget", + dependencies: [ + "BuildSettingsKit", + "JetpackStatsWidgetsCore", + "SFHFKeychainUtils", + "TracksMini", + "WordPressShared", + "WordPressUI", + "WordPressKit", + // Even though the extensions are all in Swift, we need to include the Objective-C + // version of CocoaLumberjack to avoid linking issues with other dependencies that + // use it. + // + // Example: + // + // Undefined symbols for architecture arm64: + // "_OBJC_CLASS_$_DDLog", referenced from: + // in AppExtensionsService.o + .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), + .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), + .product(name: "WordPressAPI", package: "wordpress-rs") + ] + ), + .xcodeTarget( + "XcodeTarget_Intents", + dependencies: [ + "BuildSettingsKit", + "JetpackStatsWidgetsCore", + // Even though the extensions are all in Swift, we need to include the Objective-C + // version of CocoaLumberjack to avoid linking issues with other dependencies that + // use it. + // + // Example: + // + // Undefined symbols for architecture arm64: + // "_OBJC_CLASS_$_DDLog", referenced from: + // in AppExtensionsService.o + .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), + .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack") + ] + ), + .xcodeTarget( + "XcodeTarget_UITests", + dependencies: [ + "UITestsFoundation" + ] + ), .xcodeTarget( "XcodeTarget_WordPressData", dependencies: [ @@ -495,9 +593,9 @@ enum XcodeSupport { .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), .product(name: "Gravatar", package: "Gravatar-SDK-iOS"), .product(name: "NSURL-IDN", package: "NSURL-IDN"), - .product(name: "WordPressAPI", package: "wordpress-rs"), + .product(name: "WordPressAPI", package: "wordpress-rs") ] - ), + ) ] } } 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..e2378649079f --- /dev/null +++ b/Modules/Sources/JetpackSocial/Models/SocialService.swift @@ -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) + ) + } +} diff --git a/Modules/Sources/JetpackSocial/Models/SocialSharingError.swift b/Modules/Sources/JetpackSocial/Models/SocialSharingError.swift new file mode 100644 index 000000000000..05c03f87b0ea --- /dev/null +++ b/Modules/Sources/JetpackSocial/Models/SocialSharingError.swift @@ -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 + } + } +} 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 0000000000000000000000000000000000000000..facf4b10b80a9720ad24658f453f7098ac077c4d GIT binary patch literal 4124 zcmai%c|26@`^PO)7(ykol#>|AHq6+bvM-bDM8-a2X6zJ`HEWh^$(AUDvSy3QHe@Fr zlk61|L-s7$F=&NanN{GS00xhIf(tO_9 zqc<&WU^oB;+|Z8Tix&ZLZLF&u-X4%5ONM~BhJ!O6>rQ?;qwrWYtgRac3n(anJ@D>W zlndCKS|?uTvYIke=;ksTlCf=R{ovj4s#Pl5Z0OmJ54?MY!>wG;>dw6Rxz93w>`Ccy)yBH{WotZo2mRDIZpGAQbGX&GV9;PZ8 z>$jP$Qe4{l?W}O#S}9{9F>Ek=l3%v^iMK9a*?wvCxt3s-9qI><>MT{h466oO?kZeQ z8_!+-j9b>*aOte|AL*J;9~u{3Tn;FoK^rW3wy>^p({&qT8}s~!-;3jfB5A=`SIp0| zCXXm3qSS@*gh|0DA1Oo0->P@T=Nc9e*GJiX|8>W@;sMx?N*ZE4+-|trVm$!qAA+iz zE1vw_1E5r&TxuOG#sQ_`<_(xb$$})Dd|QIawf?yZW!5i`=)1ew8e#E(Ie95H4Zso* zS9NoCb2qw%vc&=v6RCPg05adxDkxC=@-v6*Z#jPYOzwM>xGs5{;>r}Sxsr_rh^t{e z9c;0N2$lbB!Ss7xSBys3BZveU!kc4!fWSJJF9Y!Q!le@+gGkLFeVXu4h^N{i4Ys2~ zCYUR)s1Xr-w(4w*w?fcE46CgAZH)$M>ZH*aCx+!>skO1R{dVN|$jr;35xZsop&Bav zy%10n-jq5Bo@L6mG~Z}(?08qpCe2A!YGzaFR}(lK2(hI`)4xZMPfg9g)K;;vBo>6J zeJ|3W5Ahg02#n{GQRP2O1y&!Bz8C=EAkiclRkU(S9)Mo2Cksi2OGvV3F=RjGn`|j{ z-pHF4uI;>^0-4I)|J8(NI z6-L`=3E`w>or&At+QjurraMrMpi;Mc>eK>AmsI=s#bTbtMdaYL{dU91_~`(nefbXG zUE>wV0x?55CIdO{yzo?eCDO!Gy=8!E^$=C%*u%Bc;m%eG zEuWYK8KV~#oIR*Boy3Gc%j{-`xV>%&m)lJ3TG}+WV^ zP9b9UH(3uRU&%TA-PtViF!L41O4w&(gU?9}p^^5rVHbl~>V(sj7WQ>mV%<#$!|rSYFPXO#eV)YsV;$CrA;9aORAMIycfByGw_E1cnETL1{#6j_?G*uF;MGRG~qaSZVLkid~{{ zPy#(uRv4xND@zPhd){)qM%5qe`v#;NB7NKVPRP#RZkdEWk`-#g{>Z%8zfg&U zSvMupD|OJ*_G*~YbFZkW+%@H=K6=I}p6@YDfqL3q=EvOCv6tenPthKZ$U@ye$==2} z8G($MK%1Zj(U+kE_igW+Xmd z9Y!r&;9+im#rcWnT0qrF3rSb063|PQp_tmB`Q{@NkQMK`^J8qwAuNp^U(h$t)Pn}@wPPx-eoWYIfbyCxd-q&^Iu`*qMxDP4?WrhHwezx!}Eo+`m|V}h05;?E7xiA zKy(w?G{gBh4EcppD}%3SI`UaU+L${F#8Y084_Re$qJzo8Dy} zb(Ilaxy#i`K1x2SPYjRbyY`zF-mghF(<7+)U-lPAB*IeGQYJf#6N}=VlIK(W1oV=I zQoK^E#WDr0&jeonmb+I)W6-G7XcU}NW|E~bQQ0IPrm3Emos7PXPD4kcZ+((#H4vyv z2u^r;Os_y`w(PCIlQUc@eNTMeR#oa${`C&~4tfe9^LV!}akh1x_cLLg0V{wtC!Hhl zkhn+{uMH13L2b5RhMZ4g5@Tv(;@05P)x=5Sd+AaKtAjeB%3ibM-ju1zb4}AUV z4e-t;TKPd^epXag11bZRGhutSFuthwe2!Vpg*I8+7uTa!6ei6fWs&Kc37WB;I!43& zV^?fyh?PVpr)hf#yhy6HJ-h4!68ThP@R3~e#g3afJ`c8q8Rer*qtn?(*{wwuMT|t} zMR<`y$U~+sm2+h~&F@;$oGfkq+ru3vZ7X_mN2i|`7l@8~HZ1yINV6;(Wv|t(C2lZp z@a;l&CFtZMydwzAo5Og|bG4!V14rjSPgM1+#7xo&`U#fzY37ZmBYT8;j`ZX(GI4z2 z=odZd-g|wbipM=(3?mj}?R_n)-}rsVafIPZ>UgAA>YNCUgpnk_^WV-2gQ=Qa9Fy@QS~+=C|l)r%~Bc^$fenAjM>PV#nj8 zmsB5K|iXbbd zG-AwRfLwWYUhWfq{GRRwn{P+8A|@9;fhU*5U>5f(7T&o(Clz!NOYT}XBCnr6&CPpi zob@2`cvAsMyJX$E+KU7lO0138O_*sDo{3$t+`{g~gHl4Dh4!kYv{**kYL;l8(43^v zQ-1#O#xv{H;HiS7yGc1hu!7!#za2s=w<`No1W-|B!j>?3L4tTQJO`>;^V(rx$@qA- zQg6KWIS(YR_Wqr3+Fvil@IMf^g=DO7d^To>z-GQS=sr5VK9x4*+I6aPt#da(3Mb`t z!>-=*Tlik1)RGf(aoAvq#rtYqFU!W7iLdqZaV~(%rI9aP8eOqi?s3d{(_*(OsXSKIsX3NSGf-`d{51?cNAo zBs|%8vg`HHliusY=xoiJX(}3NHS(t9qh&Fvdo_FYW$o34dJ~lSbsH9kI80onWd1Y4 z11STj>-|v+>jgc5Lc96)U^6qCQMK zZ6?lV&($>81zHEHEnZrQCnCZSPuiQ}ZVX)LACk$JNs?}gO=v5hyx#7%|M9D;-|?bZ zeOONQ7D)Dt+{&l+E#Bn}ri&v!QT~Oqg8P{pFW#6%nPm-r7>s=JSlYo)+i!d}s4x9g z&nZ#G2M0ITV>VLLDqh=Z5#xJKJbWOR=@qc5(iXN79mx^#*HyeiP36?v^pdiYv992h zQmd6}_k#C?-96)!BD)ef%M1SL{)ENw{n)|NwHf!U7Xln+pD()*X0KK3-R zX2)_@d~Bn}pLFLSotrlL@XnH0z2AY=2l-k5vHf1U0v0R9ZeQ7b`+eJm$!tNe-ua9| z#p3;etsz_iVSVAzkPWfowq0C!m8$qb@^-0S^C4mZQCOo=V-8V_AjWLRjQWQ-Lgd67f_kVWk?T*EPp@0Mo4E?_gkd~5yNdY+Erv{UTk+(*<0M}m{ z6fR9Z_CGZk97^s-{?wr4fAB924kwrP|HVnkkWcwf4F;1TpW~kzR2ug0bzxHfii5#r z$yWJuF1b7VFPhwM>*C!}4$fHj?=82HgD?5~$PJL8n;Y356c3OOQqvXZMmEcju^QPf z7cnxja#*-68i$dQ!`jM1Wl&Ho3JQnfBymzW7*1LN{J%qf*v$h^w#N5@z+|8@U;zPj IJq_@G0DkERKL7v# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6837a88e99aab4648f9a8783102eea1ee870660c GIT binary patch literal 4137 zcmai%cT^MG7RD)2ARt94Qq+-45k*J`M0$x)lrFu55IO`Q(v&LY(nOjF3P_PEMX7@H zE=WY0sDKEeNE6}Gk(XHB)qCGsZ`Q2L$v6A#bLQ;tkKY#7QdK_-m4JhVTghL@^SP@J zU$wS_5dZ`rSUG^tp9ds0@GiDQI{-iEenT zGuVsvvQD=HJ(T77vcarroI@vU2n6=XRmceXRDKq<` zr-P|al@{il!AtH_i*E!g7|jDF!kooTag{|HJKb=+eH6QXExNPZS%J{U05!S`ZHMbFj;OLNm~#UTH3u`G+eDw^fIJh-3pPrf4tp^aG>n#gHRO4CTpb6(aY6*)&+i$gNT?o7Z_2&!^0UL z_4$LBiIT!@!0L&Qva*e-^<$M5&3dz8^784*JMnS*X|i>bH(jCEg7waDfPZ?3`MJ*Eb2}hx*FR4S=FQwG;cEtmdT3Fj}zixOJA^`thRvo-M z;kp|R?+&283n~N`BIUh1K(zv;?w9e__E=?t7hnRR2rvZYHUm=%|6>*Ete=L_awFh$ z@kGFcvXrViU7ZPc_ev9MZq!_+dJkZ&8w0DBF}& zq8iVI;yOT574Kn>!|R}x|Gyto?|SO#j&O&O>ZIyk9To(H*Z6#xfY-7YxIo(Bm;f!h z&|nb{)dT9B9Aa0k^%`l>VS+d{PUc%dR>MqR*tOc5v{lu}qfw4b%SFi5v6Q_Iqw$fM zs^JmaW#8c%8m-+RP&3hxHUN=f$hkD%WP12Wck2e-QFdBZL)yj(8ygUMQU4m{|8!v1h^kOYh3>FH7 zac3}PJ{Fv8Epb}Uojz0BbuUR|Dud6cm-jg$Evi;)EJb)CD+-ihdQbR+ZPrZ&e4QR9 zg@(Lws=t5pfd&nPGufhNMkK>29Hz#}yA=L~j)pOzRpz-HYvh)UT^fy7j_p(gC>&#z z!!dl%o;=%Wv%ONLyJZDElhAT`;!iIP(povoZ3s_53E{39t+0`EvR&+Ak-*}j(a^Nr zR>R2naX;NXxlW%Q{S}b~Qkqg!n$ft^!ehxgZklx=J#;8%AF4?k|!Q(E(0`Q z4$zd3Jy<;+>SP|*`jJJHIdWma$(=UcQR2+3)J}R3;YCBJ%tmte(#}=c93F+hKQLTy zyc#(tH|LUSPY(-g63KdRgMELpQO5pdSBv1a+s^Sz@f+gfqo?v&Ms+n&=MSVIV#PBLXELbmEqW1Oni$^kV=` zaKHt2`n&WJ7wGI2K~I(B-_U@Spl@hBZaKKp3Pp87f+G zYkv^=Y8cIKZ9imq1t1RY(fS5++@tHq9(qGl88A{RE5L|u?*5?a#r1%Jr}5py#cFPO z)~aTn3I5MC;vp8z@r;U{jP!l#hK&3xs>*i^g=jfWI>riS)8(tB++oe;e-?cq_SzKv z!LSVMy`$Xiypv%@Q4>~Iu|rlDA%pjD_pWO2Orvbr;elI0kE{ltZ7JG7i2N0ifuTmHekNx(1gf}*Yne3oXbIM zP41tqZl0_M`wRFVACEcF-XMC1$r$7q#CGy7Amk*Z$Iiz%!?+jB(Tvaz%+n+ahO&FN znp%lHx&21zIYvO_N<1efRES4MNG!QLP!HoEXeJ^jQg<@G>zp;(?WmEWxn{9YI$$Bp z7N?+gc3ps|C%HHXZpKDt? zFQZC6ejg)GE*q_=B`&3#J z?o++(@K?(xxli$YDSY*OynOn6Nr-kNZ;DGwR|+|02GLP&XUtpyGA0|(RG32&lb3q* zY=p}qWipD27jw_{$!Cui$`#@A$_#C@u9Wp2F^iKhky5HMtW*1RNXC}~lmy__Mh zonDll-GFW|@qf|y3V3T7sdT?7FC!wO0h@-+n!x>05L?)HI?Fgqwp|)mc`af^e$qsw zG&~g(hl%dGtovqQOwY20R8C@XoVF7|6e4RoGE3hZ89i1XdMML!zVl|5_x-Ii%yN;2 zk*VCH+!o@C;<{(&#ZMTC867ZmF8@@z-SW0I#nBAs+Y#z8i7V^P8J(^u%0D~q(Xi+% zn_^Zv%3XVmj_fT&ZGYijWo`JJR-TJOgR13JpN?1!oS$Mf-4Cud0oHU-_?zuc1J>${p+&cP3 zZe*`|=M38=wpeszM?!A=3?GTv{LiEkgII%-s<;iSWryWl`E&B&^3n1~>OJZUeI+-s zdvtpq+oP+)pJ%p*cQirLpvO$J;B1gO=qiIeGmPo*f$N}_CfcUjpc9g>Erd)5xapL4 zRXjq@Ga*^bxVGJoN{AyKi!69o>wEcmd4kSbsi~^8)?SF|eBFtTjOMuEF4cm#EX+JX)y<)bB-;D(&1wRe$ zQ%!0$3&&xKFqiB?iy$%YSJfT)tV}uPls>C_Q5amlLg% zY(Zo}RBB$>4=(8+$yDr%)i~vDWK(gQve0p|-NpS#t& zqw)OXE)in`+7Go$>n-idmZ#g5pDAlk)Sc}$VRdSA;&NIXwJ9rdc$Xw--uIVP_s58j zn!@kn=EseE4Yo#m)`J)89<4vx@%-Sy==pwhu4dIR*~-X#Ae%fgIHU|0Q zoC44NJ2$i(ijk3R?abtWgpg;9k@3FHA_xEqEMXe{Nl)bRMM2hX@dT?JR-P7-ja(l>1WH?XQ zIRm16P5IR8)Z)_OvF^a667!X3Zu#%(c6RlX3T=yJ%w&C2ed`uO_o9c6*QVXISn#u- zt5|lfn{zGOyE|EQyZ(Uh%(mH%8Ef7-@`R;x(vCvKH!wj3bklg_A@ ztA9clp-E9&QKLRj*4jRIMT3P$*f*c7aB(cJUS@k} z-Fe1(cBdwEZ zno6PHP#Oh={e)F2zfv^{#Wa7Wc+x>sDG!>GsQ+ON- zNM5qHcBjBI72GM=egR}C6}^AYsDdS8od~wyq2B!m&Hs(mQ0R{tr!Hf0fTk{B2tXkc zP)ffCXkW*=5q$s(p?^btFQVAbeM5h?45w7j!5~l+1O`DskWd%|WekCcQtrPaf7uU( z`mL|yD6QeQH7Ol~3Z)|?+7nz0fK_D>7XZ=G%p#H5Pr73m$Jq`|+rgTBS zYY-Ij-|JE~_|G^vlCs->%!MP7|JG2DU)CkMVeOspZr}QEU3(wO`B54m9Rh*k52^&$W Tjc)~kOTi#uVPQ2*b?|=x`auZx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e75640c24a0248163fd3c590ecfdd594c74c7071 GIT binary patch literal 7689 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 \ 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 0000000000000000000000000000000000000000..2ef1263917cc8da3f2f60288dade86129aafe5a7 GIT binary patch literal 4452 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 literal 0 HcmV?d00001 diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/PublicizeConnectionURLMatcher.swift b/Modules/Sources/JetpackSocial/Services/PublicizeConnectionURLMatcher.swift similarity index 94% rename from WordPress/Classes/ViewRelated/Blog/Sharing/PublicizeConnectionURLMatcher.swift rename to Modules/Sources/JetpackSocial/Services/PublicizeConnectionURLMatcher.swift index 98c7673b05c8..3e3593869837 100644 --- a/WordPress/Classes/ViewRelated/Blog/Sharing/PublicizeConnectionURLMatcher.swift +++ b/Modules/Sources/JetpackSocial/Services/PublicizeConnectionURLMatcher.swift @@ -1,8 +1,8 @@ import Foundation /// Used to detect whether a URL matches a particular Publicize authorization success or failure route. -struct PublicizeConnectionURLMatcher { - enum MatchComponent { +public struct PublicizeConnectionURLMatcher { + public enum MatchComponent { case verifyActionItem case denyActionItem case requestActionItem @@ -60,7 +60,7 @@ struct PublicizeConnectionURLMatcher { /// @return True if the url matches the current authorization component /// - static func url(_ url: URL, contains matchComponent: MatchComponent) -> Bool { + public static func url(_ url: URL, contains matchComponent: MatchComponent) -> Bool { if let queryItem = matchComponent.queryItem { return self.url(url, contains: queryItem) } @@ -100,7 +100,7 @@ struct PublicizeConnectionURLMatcher { /// Classify actions taken by the web API /// - enum AuthorizeAction: Int { + public enum AuthorizeAction: Int { case none case unknown case request @@ -108,7 +108,7 @@ struct PublicizeConnectionURLMatcher { case deny } - static func authorizeAction(for matchURL: URL) -> AuthorizeAction { + public static func authorizeAction(for matchURL: URL) -> AuthorizeAction { // Path oauth declines are handled by a redirect to a path.com URL, so check this first. if url(matchURL, contains: .declinePath) { return .deny diff --git a/Modules/Sources/JetpackSocial/Services/SiteSocialConnectionsService+Preview.swift b/Modules/Sources/JetpackSocial/Services/SiteSocialConnectionsService+Preview.swift new file mode 100644 index 000000000000..0a34c94eb1f0 --- /dev/null +++ b/Modules/Sources/JetpackSocial/Services/SiteSocialConnectionsService+Preview.swift @@ -0,0 +1,19 @@ +#if DEBUG +import Foundation +import WordPressAPI + +extension SiteSocialConnectionsService { + /// Pre-populates the service with in-memory state for SwiftUI previews and + /// view-layer unit tests. Network calls still go through the supplied + /// client if invoked, but the initial `@Published` state is seeded. + @MainActor + public static func preview( + connections: [SocialConnection] = [] + ) -> SiteSocialConnectionsService { + let client = WPComApiClient(authentication: .none) + let service = SiteSocialConnectionsService(client: client, siteId: 0) + service._seedForPreview(connections: connections) + return service + } +} +#endif diff --git a/Modules/Sources/JetpackSocial/Services/SiteSocialConnectionsService.swift b/Modules/Sources/JetpackSocial/Services/SiteSocialConnectionsService.swift new file mode 100644 index 000000000000..1aaaffcf6906 --- /dev/null +++ b/Modules/Sources/JetpackSocial/Services/SiteSocialConnectionsService.swift @@ -0,0 +1,211 @@ +import Foundation +import Logging +import WordPressAPI + +public final class SiteSocialConnectionsService: ObservableObject, @unchecked Sendable { + @MainActor @Published public private(set) var connections: SocialConnectionsState = .loading + @MainActor @Published public private(set) var services: SocialServicesState = .loading + + private let client: WPComApiClient + private let siteId: Int64 + + /// Only sites hosted on or connected to WP.com are supported. The + /// factory is responsible for not constructing this service when the + /// blog has no WP.com account. + public init(client: WPComApiClient, siteId: Int64) { + self.client = client + self.siteId = siteId + } + + // MARK: - Reads + + public func loadConnections(force: Bool = false) async { + if !force, await isConnectionsLoaded() { + return + } + await setConnections(.loading) + do { + let wireResponse = try await client.publicize.listConnections(wpComSiteId: UInt64(siteId)) + let mapped = wireResponse.data.map(SocialConnection.init(from:)) + await setConnections(.loaded(mapped)) + } catch { + let wrapped = wrap(error) + log.error("loadConnections failed: \(wrapped)") + await setConnections(.failed(wrapped)) + } + } + + public func loadServices(force: Bool = false) async { + if !force, await isServicesLoaded() { + return + } + await setServices(.loading) + do { + let wireResponse = try await client.publicize.listServices(wpComSiteId: UInt64(siteId)) + let mapped = wireResponse.data.map(SocialService.init(from:)) + await setServices(.loaded(mapped)) + } catch { + let wrapped = wrap(error) + log.error("loadServices failed: \(wrapped)") + await setServices(.failed(wrapped)) + } + } + + public func fetchKeyringConnections() async throws(SocialSharingError) -> [SocialKeyringConnection] { + do { + let wireResponse = try await client.meConnections.list() + return wireResponse.data.connections.map(SocialKeyringConnection.init(from:)) + } catch { + let wrapped = wrap(error) + log.error("fetchKeyringConnections failed: \(wrapped)") + throw wrapped + } + } + + /// Snapshot of the currently loaded connection IDs. Returns `[]` if + /// `connections` has not been loaded yet. + @MainActor + public func currentConnectionIDs() -> [String] { + connections.value?.map(\.id) ?? [] + } + + // MARK: - Mutations + + @discardableResult + public func createConnection( + keyringID: Int64, + externalUserID: String? = nil, + shared: Bool = false + ) async throws(SocialSharingError) -> SocialConnection { + do { + let params = CreatePublicizeConnectionParams( + keyringConnectionId: keyringID, + externalUserId: externalUserID, + shared: shared + ) + let wireResponse = try await client.publicize.createConnection( + wpComSiteId: UInt64(siteId), + params: params + ) + let connection = SocialConnection(from: wireResponse.data) + await appendOrReplace(connection) + return connection + } catch { + let wrapped = wrap(error) + log.error("createConnection keyringID=\(keyringID) failed: \(wrapped)") + throw wrapped + } + } + + public func deleteConnection(id: String) async throws(SocialSharingError) { + do { + _ = try await client.publicize.deleteConnection( + wpComSiteId: UInt64(siteId), + publicizeConnectionId: id + ) + await remove(connectionWithID: id) + } catch { + let wrapped = wrap(error) + log.error("deleteConnection id=\(id) failed: \(wrapped)") + throw wrapped + } + } + + @discardableResult + public func updateConnection( + id: String, + shared: Bool + ) async throws(SocialSharingError) -> SocialConnection { + // Optimistically reflect the change in the @Published state before + // the network round-trip so SwiftUI can render immediately. Capture + // the pre-change connection for rollback on failure. + let rollback = await findConnection(id: id) + if let rollback, rollback.isShared != shared { + var optimistic = rollback + optimistic.isShared = shared + await appendOrReplace(optimistic) + } + + do { + let params = UpdatePublicizeConnectionParams(shared: shared) + let wireResponse = try await client.publicize.updateConnection( + wpComSiteId: UInt64(siteId), + publicizeConnectionId: id, + params: params + ) + let connection = SocialConnection(from: wireResponse.data) + await appendOrReplace(connection) + return connection + } catch { + if let rollback { + await appendOrReplace(rollback) + } + let wrapped = wrap(error) + log.error("updateConnection id=\(id) failed: \(wrapped)") + throw wrapped + } + } + + // MARK: - MainActor-isolated state mutation + + @MainActor + private func setConnections(_ value: SocialConnectionsState) { + connections = value + } + + @MainActor + private func isConnectionsLoaded() -> Bool { + if case .loaded = connections { return true } + return false + } + + @MainActor + private func setServices(_ value: SocialServicesState) { + services = value + } + + @MainActor + private func isServicesLoaded() -> Bool { + if case .loaded = services { return true } + return false + } + + @MainActor + private func appendOrReplace(_ connection: SocialConnection) { + var current = connections.value ?? [] + if let idx = current.firstIndex(where: { $0.id == connection.id }) { + current[idx] = connection + } else { + current.append(connection) + } + connections = .loaded(current) + } + + @MainActor + private func findConnection(id: String) -> SocialConnection? { + connections.value?.first(where: { $0.id == id }) + } + + @MainActor + private func remove(connectionWithID id: String) { + guard var current = connections.value else { return } + current.removeAll { $0.id == id } + connections = .loaded(current) + } + + private func wrap(_ error: Error) -> SocialSharingError { + if let already = error as? SocialSharingError { + return already + } + return .network(error) + } + + #if DEBUG + @MainActor + internal func _seedForPreview(connections: [SocialConnection]) { + self.connections = connections.isEmpty ? .loading : .loaded(connections) + } + #endif +} + +private let log: Logger = Logger(label: "org.wordpress.jetpack-social") diff --git a/Modules/Sources/JetpackSocial/Services/SocialConnectionsState.swift b/Modules/Sources/JetpackSocial/Services/SocialConnectionsState.swift new file mode 100644 index 000000000000..e5ee95639782 --- /dev/null +++ b/Modules/Sources/JetpackSocial/Services/SocialConnectionsState.swift @@ -0,0 +1,17 @@ +import Foundation + +public enum SocialConnectionsState: Sendable { + case loading + case loaded([SocialConnection]) + case failed(SocialSharingError) + + public var value: [SocialConnection]? { + if case .loaded(let v) = self { return v } + return nil + } + + public var error: SocialSharingError? { + if case .failed(let e) = self { return e } + return nil + } +} diff --git a/Modules/Sources/JetpackSocial/Services/SocialOAuthAuthenticator.swift b/Modules/Sources/JetpackSocial/Services/SocialOAuthAuthenticator.swift new file mode 100644 index 000000000000..1e2f04f576c5 --- /dev/null +++ b/Modules/Sources/JetpackSocial/Services/SocialOAuthAuthenticator.swift @@ -0,0 +1,20 @@ +import Foundation +@preconcurrency import WebKit + +/// Authentication hook for the OAuth kick-off web view inside +/// `JetpackSocial`. The caller seeds any cookies the wp.com +/// authorize page needs into the web view's cookie store and returns +/// an authenticated `URLRequest` ready to load. +/// +/// `WKHTTPCookieStore` is exposed directly because the app's existing +/// `CookieJar` protocol (in `WordPress/Classes/Utility/WebViewController/ +/// CookieJar.swift`) already has a `CookieJar` conformance on it — the +/// app-side adapter can pass the store straight through to +/// `RequestAuthenticator.request(url:cookieJar:completion:)` without +/// any wrapping type. +public protocol SocialOAuthAuthenticator: Sendable { + func authenticatedRequest( + for url: URL, + into cookieStore: WKHTTPCookieStore + ) async -> URLRequest +} diff --git a/Modules/Sources/JetpackSocial/Services/SocialServicesState.swift b/Modules/Sources/JetpackSocial/Services/SocialServicesState.swift new file mode 100644 index 000000000000..defc46d13c55 --- /dev/null +++ b/Modules/Sources/JetpackSocial/Services/SocialServicesState.swift @@ -0,0 +1,17 @@ +import Foundation + +public enum SocialServicesState: Sendable { + case loading + case loaded([SocialService]) + case failed(SocialSharingError) + + public var value: [SocialService]? { + if case .loaded(let v) = self { return v } + return nil + } + + public var error: SocialSharingError? { + if case .failed(let e) = self { return e } + return nil + } +} diff --git a/Modules/Sources/JetpackSocial/Strings/Strings.swift b/Modules/Sources/JetpackSocial/Strings/Strings.swift new file mode 100644 index 000000000000..37baf5b0c4bd --- /dev/null +++ b/Modules/Sources/JetpackSocial/Strings/Strings.swift @@ -0,0 +1,230 @@ +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 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 favourite 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 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 finalises 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/Sources/JetpackSocial/Views/AccountConfirmationView.swift b/Modules/Sources/JetpackSocial/Views/AccountConfirmationView.swift new file mode 100644 index 000000000000..9af80e1ad367 --- /dev/null +++ b/Modules/Sources/JetpackSocial/Views/AccountConfirmationView.swift @@ -0,0 +1,267 @@ +import AsyncImageKit +import SwiftUI + +public struct AccountConfirmationView: View { + private let service: SocialService + private let connectionsService: SiteSocialConnectionsService + private let onCancel: () -> Void + private let onFinish: (Result) -> Void + + @State private var state: LoadingState = .loading + @State private var connectedExternalIDs: Set = [] + @State private var selectedAccountID: String? + @State private var sharedEnabled: Bool = true + @State private var submitting: Bool = false + @State private var submitTask: Task? + + public init( + service: SocialService, + connectionsService: SiteSocialConnectionsService, + onCancel: @escaping () -> Void, + onFinish: @escaping (Result) -> Void + ) { + self.service = service + self.connectionsService = connectionsService + self.onCancel = onCancel + self.onFinish = onFinish + } + + public var body: some View { + Form { + switch state { + case .loading: + loadingSection + case .loaded(let accounts): + loadedSections(accounts: accounts) + case .failed(let error): + failureSection(error: error) + } + } + .disabled(submitting) + .overlay { + if submitting { + ZStack { + Color(.systemBackground).opacity(0.7) + ProgressView() + .controlSize(.large) + } + .ignoresSafeArea() + } + } + .navigationTitle(Strings.AccountConfirmation.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + if #available(iOS 26.0, *) { + Button(role: .cancel, action: cancel) + } else { + Button(role: .cancel, action: cancel) { + Image(systemName: "xmark") + } + } + } + } + .task { + await load() + } + } + + @MainActor + private func load() async { + state = .loading + connectedExternalIDs = Set( + (connectionsService.connections.value ?? []) + .filter { $0.serviceName == service.id } + .map(\.externalID) + ) + do { + let keyrings = try await connectionsService.fetchKeyringConnections() + let matching = keyrings.filter { $0.service == service.id } + if matching.isEmpty { + state = .failed(.keyringNotFound(id: 0)) + return + } + let accounts = SocialKeyringAccount.flatten(matching) + state = .loaded(accounts) + if selectedAccountID == nil { + selectedAccountID = + accounts.first { + !connectedExternalIDs.contains($0.externalIDForMatching) + }? + .id + } + } catch { + state = .failed(error) + } + } + + @MainActor + private func submit(account: SocialKeyringAccount) { + submitting = true + submitTask = Task { + let result: Result + do throws(SocialSharingError) { + _ = try await connectionsService.createConnection( + keyringID: account.keyring.id, + externalUserID: account.externalUserID, + shared: sharedEnabled + ) + result = .success(account) + } catch { + result = .failure(error) + } + guard !Task.isCancelled else { return } + submitting = false + onFinish(result) + } + } + + @MainActor + private func cancel() { + submitTask?.cancel() + onCancel() + } + + private var loadingSection: some View { + Section { + HStack { + Spacer() + ProgressView(Strings.AccountConfirmation.loadingMessage) + Spacer() + } + } + } + + @ViewBuilder + private func loadedSections(accounts: [SocialKeyringAccount]) -> some View { + let connectable = accounts.filter { !connectedExternalIDs.contains($0.externalIDForMatching) } + let connected = accounts.filter { connectedExternalIDs.contains($0.externalIDForMatching) } + + Section { + Text(Strings.AccountConfirmation.description) + .foregroundStyle(.primary) + ForEach(connectable) { account in + Button { + selectedAccountID = account.id + } label: { + AccountSelectableRow( + account: account, + isSelected: selectedAccountID == account.id, + showsSelectionIndicator: connectable.count > 1 + ) + } + .buttonStyle(.plain) + } + } + + if !connected.isEmpty { + Section(Strings.AccountConfirmation.connectedSectionTitle) { + ForEach(connected) { account in + AccountInfoRow(account: account) + } + } + } + + if !connectable.isEmpty { + Section { + Toggle(Strings.AccountConfirmation.markAsSharedLabel, isOn: $sharedEnabled) + } footer: { + Text(Strings.AccountConfirmation.markAsSharedFooter) + } + + Section { + Button { + if let account = currentSelection { + submit(account: account) + } + } label: { + Text(Strings.AccountConfirmation.confirm) + .frame(maxWidth: .infinity) + .foregroundStyle(.tint) + } + .disabled(currentSelection == nil || submitting) + } + } + } + + private func failureSection(error: SocialSharingError) -> some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(error.errorDescription ?? "") + .foregroundStyle(.red) + Button(Strings.AccountConfirmation.retry) { + Task { await load() } + } + } + } + } + + private var currentSelection: SocialKeyringAccount? { + guard let id = selectedAccountID, case .loaded(let accounts) = state else { + return nil + } + return accounts.first { $0.id == id } + } +} + +private enum LoadingState { + case loading + case loaded([SocialKeyringAccount]) + case failed(SocialSharingError) +} + +private struct AccountSelectableRow: View { + let account: SocialKeyringAccount + let isSelected: Bool + let showsSelectionIndicator: Bool + + var body: some View { + HStack(spacing: 12) { + KeyringAvatar(url: account.profilePictureURL) + Text(account.name) + .foregroundStyle(.primary) + Spacer() + if showsSelectionIndicator && isSelected { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } + } + .contentShape(Rectangle()) + } +} + +private struct AccountInfoRow: View { + let account: SocialKeyringAccount + + var body: some View { + HStack(spacing: 12) { + KeyringAvatar(url: account.profilePictureURL) + Text(account.name) + .foregroundStyle(.secondary) + Spacer() + } + } +} + +private struct KeyringAvatar: View { + let url: URL? + + var body: some View { + Group { + if let url { + CachedAsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + default: + Color.secondary.opacity(0.15) + } + } + } else { + Color.secondary.opacity(0.15) + } + } + .frame(width: 32, height: 32) + .clipShape(Circle()) + } +} diff --git a/Modules/Sources/JetpackSocial/Views/AddConnectionCoordinator.swift b/Modules/Sources/JetpackSocial/Views/AddConnectionCoordinator.swift new file mode 100644 index 000000000000..da57d987be32 --- /dev/null +++ b/Modules/Sources/JetpackSocial/Views/AddConnectionCoordinator.swift @@ -0,0 +1,152 @@ +import Foundation +import Logging +import SwiftUI +import UIKit + +@MainActor +public final class AddConnectionCoordinator { + private let connectionsService: SiteSocialConnectionsService + private let authenticator: any SocialOAuthAuthenticator + private weak var presenter: UIViewController? + + private var navController: UINavigationController? + private var confirmationHost: UIHostingController? + + private static let log = Logger(label: "org.wordpress.jetpack-social.add-connection") + + public init( + connectionsService: SiteSocialConnectionsService, + authenticator: any SocialOAuthAuthenticator, + presenter: UIViewController + ) { + self.connectionsService = connectionsService + self.authenticator = authenticator + self.presenter = presenter + } + + public func start() { + let pickerView = SocialServicePickerView( + connections: connectionsService, + onPick: { [weak self] service in + self?.handlePick(service) + }, + onCancel: { [weak self] in + self?.cancelAndDismiss() + } + ) + let host = UIHostingController(rootView: pickerView) + let nav = UINavigationController(rootViewController: host) + nav.modalPresentationStyle = .formSheet + navController = nav + presenter?.present(nav, animated: true) + } + + // MARK: - Forward transitions + + private func handlePick(_ service: SocialService) { + guard let nav = navController else { return } + guard let connectURL = service.connectURL else { + Self.log.error("Social picker tapped \(service.id) but the service has no connect URL") + return + } + let web = SocialOAuthWebViewController( + startURL: connectURL, + serviceLabel: service.label, + authenticator: authenticator + ) { [weak self] outcome in + Task { @MainActor in + self?.handleOAuth(outcome: outcome, for: service) + } + } + nav.pushViewController(web, animated: true) + } + + private func handleOAuth( + outcome: SocialOAuthWebViewController.Outcome, + for service: SocialService + ) { + switch outcome { + case .success: + pushConfirmation(for: service) + case .cancelled: + cancelAndDismiss() + case .failure(let error): + dismissAndAlertFailure(.network(error)) + } + } + + private func pushConfirmation(for service: SocialService) { + guard let nav = navController else { return } + let view = makeConfirmationView(for: service) + let host = UIHostingController(rootView: view) + confirmationHost = host + // Replace the stack (picker + OAuth) with just the confirmation screen. + // `hidesBackButton` is unreliable when SwiftUI also sets `.toolbar` + // items, and backing up into OAuth would require re-running it for no + // gain — so there is nothing to back to, and Cancel is the only exit. + nav.setViewControllers([host], animated: true) + } + + private func makeConfirmationView(for service: SocialService) -> AccountConfirmationView { + AccountConfirmationView( + service: service, + connectionsService: connectionsService, + onCancel: { [weak self] in + self?.cancelAndDismiss() + }, + onFinish: { [weak self] result in + guard let self else { return } + switch result { + case .success: + self.dismissNav() + case .failure(let error): + self.dismissAndAlertFailure(error) + } + } + ) + } + + // MARK: - Dismissal + + private func cancelAndDismiss() { + dismissNav() + } + + private func dismissAndAlertFailure(_ error: SocialSharingError) { + dismissNav { [weak self] in + self?.presentFailureAlert(error: error) + } + } + + private func dismissNav(_ completion: (() -> Void)? = nil) { + confirmationHost = nil + let nav = navController + navController = nil + nav?.dismiss(animated: true, completion: completion) + } + + private func presentFailureAlert(error: SocialSharingError) { + guard let presenter else { return } + let alert = UIAlertController( + title: Strings.ServiceDetail.failureAlertTitle, + message: error.errorDescription, + preferredStyle: .alert + ) + alert.addAction( + UIAlertAction( + title: Strings.ServiceDetail.failureAlertRetry, + style: .default, + handler: { [weak self] _ in + self?.start() + } + ) + ) + alert.addAction( + UIAlertAction( + title: Strings.ServiceDetail.failureAlertCancel, + style: .cancel + ) + ) + presenter.present(alert, animated: true) + } +} diff --git a/Modules/Sources/JetpackSocial/Views/ManageConnectionsHostingController.swift b/Modules/Sources/JetpackSocial/Views/ManageConnectionsHostingController.swift new file mode 100644 index 000000000000..b4e985cf8f6b --- /dev/null +++ b/Modules/Sources/JetpackSocial/Views/ManageConnectionsHostingController.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftUI +import UIKit + +@MainActor +public final class ManageConnectionsHostingController: UIHostingController { + private let connectionsService: SiteSocialConnectionsService + private let authenticator: any SocialOAuthAuthenticator + private var addCoordinator: AddConnectionCoordinator? + + public init( + connectionsService: SiteSocialConnectionsService, + authenticator: any SocialOAuthAuthenticator + ) { + self.connectionsService = connectionsService + self.authenticator = authenticator + super.init(rootView: AnyView(EmptyView())) + + rootView = AnyView( + ManageSocialConnectionsView( + connections: connectionsService, + onAddConnection: { [weak self] in + self?.presentAdd() + } + ) + ) + } + + @preconcurrency required dynamic init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func presentAdd() { + let coordinator = AddConnectionCoordinator( + connectionsService: connectionsService, + authenticator: authenticator, + presenter: self + ) + // Strong reference keeps the coordinator alive during the OAuth flow. + addCoordinator = coordinator + coordinator.start() + } +} diff --git a/Modules/Sources/JetpackSocial/Views/ManageSocialConnectionsView.swift b/Modules/Sources/JetpackSocial/Views/ManageSocialConnectionsView.swift new file mode 100644 index 000000000000..fb877c40aba3 --- /dev/null +++ b/Modules/Sources/JetpackSocial/Views/ManageSocialConnectionsView.swift @@ -0,0 +1,132 @@ +import SwiftUI + +public struct ManageSocialConnectionsView: View { + @ObservedObject private var connections: SiteSocialConnectionsService + private let onAddConnection: () -> Void + + public init( + connections: SiteSocialConnectionsService, + onAddConnection: @escaping () -> Void + ) { + self.connections = connections + self.onAddConnection = onAddConnection + } + + public var body: some View { + Form { + connectionsSection + errorSection + } + .navigationTitle(Strings.ManageConnections.navigationTitle) + .task { + await connections.loadConnections(force: false) + } + .refreshable { + await connections.loadConnections(force: true) + } + } + + @ViewBuilder + private var connectionsSection: some View { + Section { + connectionsRows + connectRow + } header: { + Text(Strings.ManageConnections.connectedHeader) + } footer: { + Text(Strings.ManageConnections.connectedFooter) + } + } + + @ViewBuilder + private var connectionsRows: some View { + switch connections.connections { + case .loading: + loadingRow + case .loaded(let list): + ForEach(list) { connection in + NavigationLink { + SocialConnectionDetailView( + connection: connection, + connections: connections + ) + } label: { + SocialConnectionRow(connection: connection) + } + } + case .failed: + EmptyView() + } + } + + private var loadingRow: some View { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + + private var connectRow: some View { + Button { + onAddConnection() + } label: { + Text(Strings.ManageConnections.connectNewAccount) + .foregroundStyle(.tint) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var errorSection: some View { + if let error = connections.connections.error { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(error.errorDescription ?? "") + .foregroundStyle(.red) + Button(Strings.ManageConnections.retry) { + Task { await connections.loadConnections(force: true) } + } + } + } + } + } + +} + +#if DEBUG +#Preview("Loading") { + NavigationStack { + ManageSocialConnectionsView( + connections: SiteSocialConnectionsService.preview(), + onAddConnection: {} + ) + } +} + +#Preview("Populated") { + NavigationStack { + ManageSocialConnectionsView( + connections: SiteSocialConnectionsService.preview( + connections: [ + SocialConnection( + id: "1", + externalID: "@tony", + serviceName: "mastodon", + serviceLabel: "Mastodon", + displayName: "Tony", + externalHandle: "@tony@mastodon.social", + profileLink: nil, + profilePictureURL: nil, + isShared: false, + status: .ok + ) + ] + ), + onAddConnection: {} + ) + } +} +#endif diff --git a/Modules/Sources/JetpackSocial/Views/SocialConnectionDetailView.swift b/Modules/Sources/JetpackSocial/Views/SocialConnectionDetailView.swift new file mode 100644 index 000000000000..ead260cc9f59 --- /dev/null +++ b/Modules/Sources/JetpackSocial/Views/SocialConnectionDetailView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +public struct SocialConnectionDetailView: View { + let connection: SocialConnection + @ObservedObject var connections: SiteSocialConnectionsService + + @Environment(\.dismiss) private var dismiss + @State private var pendingDeletion = false + @State private var isDeleting = false + + public var body: some View { + Form { + if let current { + Section { + Toggle(isOn: sharedBinding(for: current)) { + Text(Strings.ConnectionDetail.availableToAllUsers) + } + } header: { + Text(Strings.ConnectionDetail.settingsHeader) + } footer: { + Text(Strings.ConnectionDetail.availableToAllUsersFooter) + } + + Section { + Button(role: .destructive) { + pendingDeletion = true + } label: { + Text(Strings.ManageConnections.deleteButton) + .frame(maxWidth: .infinity) + } + } + } + } + .disabled(isDeleting) + .overlay { + if isDeleting { + ZStack { + Color(.systemBackground).opacity(0.7) + ProgressView() + } + .ignoresSafeArea() + } + } + .navigationTitle(handleTitle) + .navigationBarTitleDisplayMode(.inline) + .alert( + String.localizedStringWithFormat( + Strings.ManageConnections.deleteConfirmTitleFormat, + connection.displayName + ), + isPresented: $pendingDeletion + ) { + Button(Strings.ManageConnections.cancelButton, role: .cancel) {} + Button(Strings.ManageConnections.yesButton, role: .destructive) { + Task { + isDeleting = true + try? await connections.deleteConnection(id: connection.id) + isDeleting = false + dismiss() + } + } + } + .onChange(of: currentId == nil) { _, missing in + if missing { + dismiss() + } + } + } + + /// The current server-confirmed connection, looked up fresh on every render. + /// Nil when the connection has been deleted (triggers auto-dismiss). + private var current: SocialConnection? { + (connections.connections.value ?? []).first(where: { $0.id == connection.id }) + } + + private var currentId: String? { + current?.id + } + + private var handleTitle: String { + current?.externalHandle ?? connection.externalHandle ?? connection.displayName + } + + private func sharedBinding(for connection: SocialConnection) -> Binding { + Binding( + get: { connection.isShared }, + set: { newValue in + Task { + try? await connections.updateConnection(id: connection.id, shared: newValue) + } + } + ) + } +} diff --git a/Modules/Sources/JetpackSocial/Views/SocialConnectionRow.swift b/Modules/Sources/JetpackSocial/Views/SocialConnectionRow.swift new file mode 100644 index 000000000000..ea7cb80659aa --- /dev/null +++ b/Modules/Sources/JetpackSocial/Views/SocialConnectionRow.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct SocialConnectionRow: View { + let connection: SocialConnection + + var body: some View { + HStack(spacing: 12) { + avatar + VStack(alignment: .leading, spacing: 2) { + Text(connection.displayName) + .font(.body) + HStack(spacing: 6) { + Text(connection.serviceLabel) + .font(.caption) + .foregroundStyle(.secondary) + if connection.isShared { + Text(Strings.ManageConnections.sharedBadge) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.15), in: Capsule()) + } + } + } + Spacer() + if connection.status.isBroken { + Text(Strings.ManageConnections.brokenStatus) + .font(.caption) + .foregroundStyle(.red) + } + } + } + + private var avatar: some View { + profileImage + .frame(width: 42, height: 42) + .clipShape(Circle()) + .overlay(alignment: .bottomTrailing) { + serviceBadge + .offset(x: 4, y: 4) + } + } + + @ViewBuilder + private var profileImage: some View { + if let url = connection.profilePictureURL { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + default: + Color.secondary.opacity(0.15) + } + } + } else { + Color.secondary.opacity(0.15) + } + } + + @ViewBuilder + private var serviceBadge: some View { + if let icon = SocialServiceIcon.image(forServiceID: connection.serviceName) { + icon + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .padding(2) + .background(Circle().fill(Color(.systemBackground))) + } + } +} diff --git a/Modules/Sources/JetpackSocial/Views/SocialOAuthWebViewController.swift b/Modules/Sources/JetpackSocial/Views/SocialOAuthWebViewController.swift new file mode 100644 index 000000000000..48b81a90b3c6 --- /dev/null +++ b/Modules/Sources/JetpackSocial/Views/SocialOAuthWebViewController.swift @@ -0,0 +1,227 @@ +import Logging +import UIKit +@preconcurrency import WebKit +import WordPressShared + +/// A minimal `WKWebView` host for the Publicize OAuth kick-off flow. +/// +/// Navigation routing is delegated to +/// `PublicizeConnectionURLMatcher.authorizeAction(for:)`, which handles +/// every wp.com Publicize service (Mastodon, Bluesky, Facebook, LinkedIn, …). +/// +/// Success is fired from `didFinish` after the `action=verify` URL loads, +/// because the wp.com server needs that request to actually complete in +/// order to persist the keyring record; cancelling the navigation earlier +/// would leave the user staring at a "request couldn't be completed" page. +public final class SocialOAuthWebViewController: UIViewController, WKNavigationDelegate { + public enum Outcome: Sendable { + case success + case cancelled + case failure(Error) + } + + private let startURL: URL + private let serviceLabel: String + private let authenticator: any SocialOAuthAuthenticator + private let onOutcome: (Outcome) -> Void + + private var loadingVerify = false + private var didReport = false + + private let titleLabel = UILabel() + private let hostLabel = UILabel() + private let progressView = UIProgressView(progressViewStyle: .bar) + private var kvoObservations: [NSKeyValueObservation] = [] + + private static let log = Logger(label: "org.wordpress.jetpack-social.oauth-webview") + + public init( + startURL: URL, + serviceLabel: String, + authenticator: any SocialOAuthAuthenticator, + onOutcome: @escaping (Outcome) -> Void + ) { + self.startURL = startURL + self.serviceLabel = serviceLabel + self.authenticator = authenticator + self.onOutcome = onOutcome + super.init(nibName: nil, bundle: nil) + } + + private var defaultTitle: String { + String.localizedStringWithFormat(Strings.OAuthWebView.connectTitleFormat, serviceLabel) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + // A non-persistent data store keeps OAuth cookies local to this + // webview instance. Persisting them across attempts leaks + // half-finished session state into subsequent retries, which for + // services like Mastodon surfaces as a 404 after Authorize. + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + let web = WKWebView(frame: .zero, configuration: configuration) + web.translatesAutoresizingMaskIntoConstraints = false + web.navigationDelegate = self + web.customUserAgent = WPUserAgent.wordPress() + view.addSubview(web) + NSLayoutConstraint.activate([ + web.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + web.leadingAnchor.constraint(equalTo: view.leadingAnchor), + web.trailingAnchor.constraint(equalTo: view.trailingAnchor), + web.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + configureTitleView() + configureProgressView(on: web) + observeWebView(web) + + let cookieStore = web.configuration.websiteDataStore.httpCookieStore + Task { [weak self, weak web] in + guard let self else { return } + let request = await self.authenticator.authenticatedRequest( + for: self.startURL, + into: cookieStore + ) + await MainActor.run { [weak web] in web?.load(request) } + } + } + + private func report(_ outcome: Outcome) { + guard !didReport else { return } + didReport = true + onOutcome(outcome) + } + + private func dismissSelf() { + if let navigationController { + navigationController.dismiss(animated: true) + } else { + dismiss(animated: true) + } + } + + // MARK: - Navigation bar title + progress + + private func configureTitleView() { + titleLabel.font = .preferredFont(forTextStyle: .headline) + titleLabel.textAlignment = .center + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.text = defaultTitle + + hostLabel.font = .preferredFont(forTextStyle: .caption2) + hostLabel.textColor = .secondaryLabel + hostLabel.textAlignment = .center + hostLabel.lineBreakMode = .byTruncatingTail + hostLabel.text = startURL.host + + let stack = UIStackView(arrangedSubviews: [titleLabel, hostLabel]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 0 + navigationItem.titleView = stack + } + + private func configureProgressView(on webView: WKWebView) { + progressView.translatesAutoresizingMaskIntoConstraints = false + progressView.isHidden = true + view.addSubview(progressView) + NSLayoutConstraint.activate([ + progressView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + progressView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + progressView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + private func observeWebView(_ webView: WKWebView) { + kvoObservations.append( + webView.observe(\.estimatedProgress, options: [.new]) { [weak self] web, _ in + Task { @MainActor [weak self] in + self?.updateProgress(Float(web.estimatedProgress)) + } + } + ) + kvoObservations.append( + webView.observe(\.title, options: [.new]) { [weak self] web, _ in + Task { @MainActor [weak self] in + guard let self else { return } + let pageTitle = web.title ?? "" + self.titleLabel.text = pageTitle.isEmpty ? self.defaultTitle : pageTitle + } + } + ) + kvoObservations.append( + webView.observe(\.url, options: [.new]) { [weak self] web, _ in + Task { @MainActor [weak self] in + self?.hostLabel.text = web.url?.host ?? self?.startURL.host + } + } + ) + } + + private func updateProgress(_ progress: Float) { + progressView.progress = progress + progressView.isHidden = progress >= 1.0 || progress <= 0.0 + } + + // MARK: - WKNavigationDelegate + + public func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction + ) async -> WKNavigationActionPolicy { + // Prevent a second verify load by someone happy-clicking. + guard !loadingVerify, let url = navigationAction.request.url else { + return .cancel + } + + switch PublicizeConnectionURLMatcher.authorizeAction(for: url) { + case .none, .unknown, .request: + return .allow + case .verify: + loadingVerify = true + return .allow + case .deny: + report(.cancelled) + dismissSelf() + return .cancel + } + } + + public func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + let nsError = error as NSError + // Some services (historically Facebook, Twitter) return a spurious + // `NSURLErrorCancelled` during the verify step even though the + // connection actually succeeded on the server. Treat that as success. + if loadingVerify && nsError.code == NSURLErrorCancelled { + report(.success) + return + } + Self.log.error( + "OAuth navigation failed: url=\(webView.url?.absoluteString ?? "nil") domain=\(nsError.domain) code=\(nsError.code) description=\(nsError.localizedDescription)" + ) + report(.failure(error)) + } + + public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + Self.log.error("OAuth web content process terminated: url=\(webView.url?.absoluteString ?? "nil")") + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + if loadingVerify { + report(.success) + } + } +} diff --git a/Modules/Sources/JetpackSocial/Views/SocialServiceIcon.swift b/Modules/Sources/JetpackSocial/Views/SocialServiceIcon.swift new file mode 100644 index 000000000000..16cb1134b996 --- /dev/null +++ b/Modules/Sources/JetpackSocial/Views/SocialServiceIcon.swift @@ -0,0 +1,35 @@ +import SwiftUI + +/// Resolves the icon for a Publicize service using assets shipped inside the +/// JetpackSocial bundle. Returns `nil` when no icon is available — callers +/// should gracefully omit the icon in that case. +/// +/// Asset names use the `publicize-` prefix so they don't collide with the +/// main app's `social-*` assets if both bundles are loaded. +enum SocialServiceIcon { + static func image(forServiceID serviceID: String) -> Image? { + let normalized = serviceID.lowercased().replacingOccurrences(of: "_", with: "-") + let mapped = alias(for: normalized) ?? normalized + return loadImage(name: "publicize-\(mapped)") + ?? loadImage(name: "publicize-default") + } + + private static func alias(for serviceID: String) -> String? { + switch serviceID { + case "google-plus-1": return "google-plus" + case "press-this": return "wordpress" + default: return nil + } + } + + private static func loadImage(name: String) -> Image? { + // SwiftUI's `Image(_:bundle:)` returns non-optional and renders a + // placeholder for missing assets, so probe via UIImage to detect + // existence first; UIKit caches the lookup, so the discarded + // instance is cheap. + guard UIImage(named: name, in: .module, with: nil) != nil else { + return nil + } + return Image(name, bundle: .module) + } +} diff --git a/Modules/Sources/JetpackSocial/Views/SocialServicePickerView.swift b/Modules/Sources/JetpackSocial/Views/SocialServicePickerView.swift new file mode 100644 index 000000000000..b357441a121a --- /dev/null +++ b/Modules/Sources/JetpackSocial/Views/SocialServicePickerView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +public struct SocialServicePickerView: View { + @ObservedObject private var connections: SiteSocialConnectionsService + private let onPick: (SocialService) -> Void + private let onCancel: () -> Void + + public init( + connections: SiteSocialConnectionsService, + onPick: @escaping (SocialService) -> Void, + onCancel: @escaping () -> Void + ) { + self.connections = connections + self.onPick = onPick + self.onCancel = onCancel + } + + public var body: some View { + Form { + content + } + .navigationTitle(Strings.ServicePicker.navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + if #available(iOS 26.0, *) { + Button(role: .cancel, action: onCancel) + } else { + Button(role: .cancel, action: onCancel) { + Image(systemName: "xmark") + } + } + } + } + .task { + await connections.loadServices(force: false) + } + } + + @ViewBuilder + private var content: some View { + switch connections.services { + case .loading: + loadingSection + case .loaded(let services): + loadedSection(services: services) + case .failed(let error): + failureSection(error: error) + } + } + + private var loadingSection: some View { + Section { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + } + + @ViewBuilder + private func loadedSection(services: [SocialService]) -> some View { + let visible = services.filter(\.isActive) + Section { + ForEach(visible) { service in + Button { + onPick(service) + } label: { + HStack(spacing: 12) { + icon(for: service) + Text(service.label) + .foregroundStyle(.primary) + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } footer: { + Text(Strings.ManageConnections.footer) + } + } + + private func failureSection(error: SocialSharingError) -> some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(error.errorDescription ?? "") + .foregroundStyle(.red) + Button(Strings.ManageConnections.retry) { + Task { await connections.loadServices(force: true) } + } + } + } + } + + @ViewBuilder + private func icon(for service: SocialService) -> some View { + if let image = SocialServiceIcon.image(forServiceID: service.id) { + image + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + RoundedRectangle(cornerRadius: 6) + .fill(Color.secondary.opacity(0.15)) + .frame(width: 28, height: 28) + } + } +} diff --git a/Modules/Sources/WordPressCore/Users/UserService.swift b/Modules/Sources/WordPressCore/Users/UserService.swift index cfe71d7333b9..78295f6230ed 100644 --- a/Modules/Sources/WordPressCore/Users/UserService.swift +++ b/Modules/Sources/WordPressCore/Users/UserService.swift @@ -37,7 +37,7 @@ public actor UserService: UserServiceProtocol { } public func isCurrentUserCapableOf(_ capability: UserCapability) async -> Bool { - await currentUser?.capabilities.keys.contains(capability) == true + await currentUser?.capabilities.hasCap(capability: capability) == true } public func deleteUser(id: Int64, reassigningPostsTo newUserId: Int64) async throws { diff --git a/Modules/Sources/WordPressKit/SharingServiceRemote.swift b/Modules/Sources/WordPressKit/SharingServiceRemote.swift index a6bd0e6ebf1b..ad444ad3a1cc 100644 --- a/Modules/Sources/WordPressKit/SharingServiceRemote.swift +++ b/Modules/Sources/WordPressKit/SharingServiceRemote.swift @@ -118,6 +118,9 @@ open class SharingServiceRemote: ServiceRemoteWordPressComREST { conn.externalDisplay = dict.string(forKey: ConnectionDictionaryKeys.externalDisplay) ?? conn.externalDisplay conn.externalID = dict.string(forKey: ConnectionDictionaryKeys.externalID) ?? conn.externalID conn.externalName = dict.string(forKey: ConnectionDictionaryKeys.externalName) ?? conn.externalName + if conn.externalDisplay.isEmpty { + conn.externalDisplay = conn.externalName + } conn.externalProfilePicture = dict.string(forKey: ConnectionDictionaryKeys.externalProfilePicture) ?? conn.externalProfilePicture conn.keyringID = dict.number(forKey: ConnectionDictionaryKeys.ID) ?? conn.keyringID conn.label = dict.string(forKey: ConnectionDictionaryKeys.label) ?? conn.label @@ -359,6 +362,9 @@ open class SharingServiceRemote: ServiceRemoteWordPressComREST { conn.externalDisplay = dict.string(forKey: ConnectionDictionaryKeys.externalDisplay) ?? conn.externalDisplay conn.externalID = dict.string(forKey: ConnectionDictionaryKeys.externalID) ?? conn.externalID conn.externalName = dict.string(forKey: ConnectionDictionaryKeys.externalName) ?? conn.externalName + if conn.externalDisplay.isEmpty { + conn.externalDisplay = conn.externalName + } conn.externalProfilePicture = dict.string(forKey: ConnectionDictionaryKeys.externalProfilePicture) ?? conn.externalProfilePicture conn.externalProfileURL = dict.string(forKey: ConnectionDictionaryKeys.externalProfileURL) ?? conn.externalProfileURL conn.keyringConnectionID = dict.number(forKey: ConnectionDictionaryKeys.keyringConnectionID) ?? conn.keyringConnectionID 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/SiteSocialConnectionsServiceTests.swift b/Modules/Tests/JetpackSocialTests/SiteSocialConnectionsServiceTests.swift new file mode 100644 index 000000000000..8872bdfcb322 --- /dev/null +++ b/Modules/Tests/JetpackSocialTests/SiteSocialConnectionsServiceTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +import WordPressAPI +@testable import JetpackSocial + +@Suite("SiteSocialConnectionsService initial state") +struct SiteSocialConnectionsServiceTests { + @Test("currentConnectionIDs is empty before loading") + @MainActor + func emptyBeforeLoad() { + let client = WPComApiClient(authentication: .none) + let service = SiteSocialConnectionsService( + client: client, + siteId: 1 + ) + #expect(service.currentConnectionIDs().isEmpty) + } + + @Test("connections starts in loading state") + @MainActor + func connectionsLoadingOnInit() { + let client = WPComApiClient(authentication: .none) + let service = SiteSocialConnectionsService( + client: client, + siteId: 1 + ) + if case .loading = service.connections { + } else { + Issue.record("Expected .loading, got \(service.connections)") + } + } + +} diff --git a/Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift b/Modules/Tests/JetpackSocialTests/SocialConnectionTests.swift new file mode 100644 index 000000000000..006fad49afb2 --- /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 == "") + #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 == "") + #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..b0634de33f57 --- /dev/null +++ b/Modules/Tests/JetpackSocialTests/SocialServiceTests.swift @@ -0,0 +1,57 @@ +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.isActive) + #expect(model.connectURL == URL(string: "https://mastodon.example")) + } + + @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..d4b8ff7190cd --- /dev/null +++ b/Modules/Tests/JetpackSocialTests/SocialSharingErrorTests.swift @@ -0,0 +1,23 @@ +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), + .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/Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift b/Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift index 2889a66169a7..0036631a7648 100644 --- a/Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift +++ b/Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift @@ -82,7 +82,10 @@ final class MockWordPressClientAPI: WordPressClientAPI, @unchecked Sendable { fatalError("Not implemented") } - func uploadMedia(params: MediaCreateParams, fulfilling progress: Progress) async throws -> MediaRequestCreateResponse { + func uploadMedia( + params: MediaCreateParams, + fulfilling progress: Progress + ) async throws -> MediaRequestCreateResponse { fatalError("Not implemented") } } @@ -118,7 +121,9 @@ final class MockUsersRequestExecutor: UsersRequestExecutor { super.init(unsafeFromHandle: handle) } - override func retrieveMeWithEditContextCancellation(context: RequestContext?) async throws -> UsersRequestRetrieveMeWithEditContextResponse { + override func retrieveMeWithEditContextCancellation( + context: RequestContext? + ) async throws -> UsersRequestRetrieveMeWithEditContextResponse { let mockUser = UserWithEditContext( id: UserId(1), username: "testuser", @@ -134,7 +139,7 @@ final class MockUsersRequestExecutor: UsersRequestExecutor { slug: "testuser", registeredDate: "2024-01-01T00:00:00", roles: [], - capabilities: [:], + capabilities: UserCapabilitiesMap(map: [:]), extraCapabilities: [:], avatarUrls: nil ) @@ -156,7 +161,10 @@ final class MockThemesRequestExecutor: ThemesRequestExecutor { super.init(unsafeFromHandle: handle) } - override func listWithEditContextCancellation(params: ThemeListParams, context: RequestContext?) async throws -> ThemesRequestListWithEditContextResponse { + override func listWithEditContextCancellation( + params: ThemeListParams, + context: RequestContext? + ) async throws -> ThemesRequestListWithEditContextResponse { let mockTheme = ThemeWithEditContext( stylesheet: ThemeStylesheet(value: "twentytwentyfour"), template: "twentytwentyfour", @@ -196,7 +204,9 @@ final class MockSiteSettingsRequestExecutor: SiteSettingsRequestExecutor { super.init(unsafeFromHandle: handle) } - override func retrieveWithEditContextCancellation(context: RequestContext?) async throws -> SiteSettingsRequestRetrieveWithEditContextResponse { + override func retrieveWithEditContextCancellation( + context: RequestContext? + ) async throws -> SiteSettingsRequestRetrieveWithEditContextResponse { let mockSettings = SiteSettingsWithEditContext( title: "Test Site", description: "A test site", @@ -217,7 +227,8 @@ final class MockSiteSettingsRequestExecutor: SiteSettingsRequestExecutor { defaultPingStatus: .open, defaultCommentStatus: .open, siteLogo: nil, - siteIcon: 0 + siteIcon: 0, + additionalFields: WpAdditionalFields() ) let mockHeaderMap = WpNetworkHeaderMap(noHandle: WpNetworkHeaderMap.NoHandle()) return SiteSettingsRequestRetrieveWithEditContextResponse(data: mockSettings, headerMap: mockHeaderMap) diff --git a/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift index b79df67167aa..aee5f7a1b4a7 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/PostSettingsTests.swift @@ -531,7 +531,11 @@ struct PostSettingsTests { settings.setTerms("tag1, tag2", forTaxonomySlug: "genre") // Then - #expect(settings.getTerms(forTaxonomySlug: "genre") == [PostSettings.Term(id: 0, name: "tag1"), PostSettings.Term(id: 0, name: "tag2")]) + #expect( + settings.getTerms(forTaxonomySlug: "genre") == [ + PostSettings.Term(id: 0, name: "tag1"), PostSettings.Term(id: 0, name: "tag2") + ] + ) #expect(settings.getTerms(forTaxonomySlug: "nonexistent") == []) // Verify apply persists the terms to the post @@ -573,7 +577,11 @@ struct PostSettingsTests { let settings = PostSettings(from: sourcePost) // Then — init captures the custom terms - #expect(settings.otherTerms == ["genre": [PostSettings.Term(id: 0, name: "fiction"), PostSettings.Term(id: 0, name: "drama")]]) + #expect( + settings.otherTerms == [ + "genre": [PostSettings.Term(id: 0, name: "fiction"), PostSettings.Term(id: 0, name: "drama")] + ] + ) // When — apply to a different post let targetPost = PostBuilder(context, blog: blog).build() @@ -588,7 +596,7 @@ struct PostSettingsTests { arguments: [ ("swift, ios, testing", ["swift", "ios", "testing"]), ("", []), - (" swift , , ios ", ["swift", "ios"]), + (" swift , , ios ", ["swift", "ios"]) ] as [(String, [String])] ) func testMakeTags(input: String, expected: [String]) { @@ -645,10 +653,12 @@ struct PostSettingsTests { let settings = PostSettings(from: post) // Then - #expect(settings.tags == [ - PostSettings.Term(id: 0, name: "swift"), - PostSettings.Term(id: 0, name: "ios"), - ]) + #expect( + settings.tags == [ + PostSettings.Term(id: 0, name: "swift"), + PostSettings.Term(id: 0, name: "ios") + ] + ) } @Test("init(from: Post) creates other terms with id=0") @@ -663,10 +673,12 @@ struct PostSettingsTests { let settings = PostSettings(from: post) // Then - #expect(settings.otherTerms["genre"] == [ - PostSettings.Term(id: 0, name: "fiction"), - PostSettings.Term(id: 0, name: "drama"), - ]) + #expect( + settings.otherTerms["genre"] == [ + PostSettings.Term(id: 0, name: "fiction"), + PostSettings.Term(id: 0, name: "drama") + ] + ) } @Test("init(from: AnyPostWithEditContext) stores tag IDs with empty names") @@ -678,10 +690,12 @@ struct PostSettingsTests { let settings = PostSettings(from: post) // Then - #expect(settings.tags == [ - PostSettings.Term(id: 5, name: ""), - PostSettings.Term(id: 8, name: ""), - ]) + #expect( + settings.tags == [ + PostSettings.Term(id: 5, name: ""), + PostSettings.Term(id: 8, name: "") + ] + ) } @Test("apply(to:) converts terms back to name strings") @@ -694,7 +708,7 @@ struct PostSettingsTests { var settings = PostSettings(from: post) settings.tags = [ PostSettings.Term(id: 0, name: "swift"), - PostSettings.Term(id: 0, name: "ios"), + PostSettings.Term(id: 0, name: "ios") ] // When @@ -717,10 +731,12 @@ struct PostSettingsTests { settings.setTerms("tag1, tag2", forTaxonomySlug: "genre") // Then - #expect(settings.getTerms(forTaxonomySlug: "genre") == [ - PostSettings.Term(id: 0, name: "tag1"), - PostSettings.Term(id: 0, name: "tag2"), - ]) + #expect( + settings.getTerms(forTaxonomySlug: "genre") == [ + PostSettings.Term(id: 0, name: "tag1"), + PostSettings.Term(id: 0, name: "tag2") + ] + ) } @Test("makeUpdateParameters(from: AnyPostWithEditContext) produces TermIds from Term storage") @@ -732,7 +748,7 @@ struct PostSettingsTests { // Simulate resolved tags with an additional new tag settings.tags = [ PostSettings.Term(id: 5, name: "swift"), - PostSettings.Term(id: 8, name: "ios"), + PostSettings.Term(id: 8, name: "ios") ] // When @@ -859,7 +875,7 @@ struct PostSettingsTests { func testMakeCreateParametersIncludesCustomTerms() { // Given let taxonomies = [ - SiteTaxonomy.makeTaxonomy(slug: "genre", restBase: "genre"), + SiteTaxonomy.makeTaxonomy(slug: "genre", restBase: "genre") ] let existing = PostCreateParams(meta: nil) @@ -867,7 +883,7 @@ struct PostSettingsTests { settings.otherTerms = [ "genre": [ PostSettings.Term(id: 10, name: "fiction"), - PostSettings.Term(id: 20, name: "drama"), + PostSettings.Term(id: 20, name: "drama") ] ] @@ -883,22 +899,24 @@ struct PostSettingsTests { func testInitFromCreateParamsReadsCustomTerms() { // Given let taxonomies = [ - SiteTaxonomy.makeTaxonomy(slug: "genre", restBase: "genre"), + SiteTaxonomy.makeTaxonomy(slug: "genre", restBase: "genre") ] let termMap: [String: [TermId]] = ["genre": [TermId(10), TermId(20)]] let params = PostCreateParams( meta: nil, - additionalFields: AnyJson.fromTermIdMap(map: termMap) + additionalFields: WpAdditionalFields.fromTermIdMap(map: termMap) ) // When let settings = PostSettings(from: params, taxonomies: taxonomies) // Then - #expect(settings.otherTerms["genre"] == [ - PostSettings.Term(id: 10, name: ""), - PostSettings.Term(id: 20, name: ""), - ]) + #expect( + settings.otherTerms["genre"] == [ + PostSettings.Term(id: 10, name: ""), + PostSettings.Term(id: 20, name: "") + ] + ) } @Test("init(from: PostCreateParams) populates parentPageID") diff --git a/Tests/KeystoneTests/Tests/Services/BlogServiceRemoteCoreRESTSettingsTests.swift b/Tests/KeystoneTests/Tests/Services/BlogServiceRemoteCoreRESTSettingsTests.swift index 76d10cb9cced..f2a35eff80fa 100644 --- a/Tests/KeystoneTests/Tests/Services/BlogServiceRemoteCoreRESTSettingsTests.swift +++ b/Tests/KeystoneTests/Tests/Services/BlogServiceRemoteCoreRESTSettingsTests.swift @@ -1,5 +1,6 @@ import Testing import WordPressAPI +import WordPressAPIInternal @testable import WordPress @testable import WordPressKit @@ -19,15 +20,27 @@ struct BlogServiceRemoteCoreRESTSettingsTests { postsPerPage: UInt64 = 10 ) -> SiteSettingsWithEditContext { SiteSettingsWithEditContext( - title: title, description: description, url: "", email: "", - timezone: timezone, dateFormat: dateFormat, timeFormat: timeFormat, - startOfWeek: startOfWeek, language: "", useSmilies: false, - defaultCategory: defaultCategory, defaultPostFormat: defaultPostFormat, - postsPerPage: postsPerPage, showOnFront: "posts", - pageOnFront: 0, pageForPosts: 0, + title: title, + description: description, + url: "", + email: "", + timezone: timezone, + dateFormat: dateFormat, + timeFormat: timeFormat, + startOfWeek: startOfWeek, + language: "", + useSmilies: false, + defaultCategory: defaultCategory, + defaultPostFormat: defaultPostFormat, + postsPerPage: postsPerPage, + showOnFront: "posts", + pageOnFront: 0, + pageForPosts: 0, defaultPingStatus: .closed, defaultCommentStatus: .closed, - siteLogo: nil, siteIcon: 0 + siteLogo: nil, + siteIcon: 0, + additionalFields: WpAdditionalFields() ) } diff --git a/Tests/KeystoneTests/Tests/Utility/PublicizeAuthorizationURLComponentsTests.swift b/Tests/KeystoneTests/Tests/Utility/PublicizeAuthorizationURLComponentsTests.swift index 5f8c07a6dd66..caa1636c2a6c 100644 --- a/Tests/KeystoneTests/Tests/Utility/PublicizeAuthorizationURLComponentsTests.swift +++ b/Tests/KeystoneTests/Tests/Utility/PublicizeAuthorizationURLComponentsTests.swift @@ -1,3 +1,4 @@ +import JetpackSocial import XCTest @testable import WordPress 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", diff --git a/WordPress/Classes/Networking/JetpackSocialFactory.swift b/WordPress/Classes/Networking/JetpackSocialFactory.swift new file mode 100644 index 000000000000..1e51aa90220f --- /dev/null +++ b/WordPress/Classes/Networking/JetpackSocialFactory.swift @@ -0,0 +1,89 @@ +import Foundation +import os +import JetpackSocial +import WordPressAPI +import WordPressAPIInternal +import WordPressCore +import WordPressData + +public final class JetpackSocialFactory: Sendable { + public static let shared = JetpackSocialFactory() + + private let instances = OSAllocatedUnfairLock<[WordPressSite: SiteSocialConnectionsService]>(initialState: [:]) + + private init() {} + + public func connectionsService(for site: WordPressSite) -> SiteSocialConnectionsService? { + if let cached = instances.withLock({ $0[site] }) { + return cached + } + guard let siteId = wpComSiteId(for: site) else { + return nil + } + let client = makeClient(for: site) + let service = SiteSocialConnectionsService(client: client, siteId: siteId) + return instances.withLock { dict in + if let existing = dict[site] { + return existing + } + dict[site] = service + return service + } + } + + public func reset() { + instances.withLock { dict in + dict.removeAll() + } + } + + private func makeClient(for site: WordPressSite) -> WPComApiClient { + let session = URLSession(configuration: .ephemeral) + let authentication = readWPComAuthentication(for: site) + return WPComApiClient( + urlSession: session, + authentication: authentication + ) + } + + private func readWPComAuthentication(for site: WordPressSite) -> WpAuthentication { + switch site { + case .dotCom(_, _, let authToken): + return .bearer(token: authToken) + case .selfHosted: + // Self-hosted blogs reach WP.com via the Jetpack tunnel using the + // default WP.com account token. Mirrors + // AutoUpdatingWPComAuthenticationProvider in WordPressDotComClient.swift. + guard + let token = ContextManager.shared.performQuery({ context in + try? WPAccount.lookupDefaultWordPressComAccountToken(in: context) + }) + else { + return .none + } + return .bearer(token: token) + } + } + + private func wpComSiteId(for site: WordPressSite) -> Int64? { + switch site { + case let .dotCom(_, siteId, _): + return Int64(siteId) + case let .selfHosted(blogId, _, _, _, _): + // Self-hosted with a WP.com account: resolve the dotComID from + // the Blog to route requests through public-api.wordpress.com. + // Sharing v2 requires a WP.com-linked blog, so blogs without a + // dotComID return nil (no service is created). + let dotComID = ContextManager.shared.performQuery { context -> Int64? in + guard let blog = try? context.existingObject(with: blogId) else { + return nil + } + return blog.dotComID?.int64Value + } + guard let dotComID, dotComID > 0 else { + return nil + } + return dotComID + } + } +} diff --git a/WordPress/Classes/Utility/AccountHelper.swift b/WordPress/Classes/Utility/AccountHelper.swift index 4edbadaa4410..7a9f6963b588 100644 --- a/WordPress/Classes/Utility/AccountHelper.swift +++ b/WordPress/Classes/Utility/AccountHelper.swift @@ -20,13 +20,14 @@ import WordPressData @objc static var isLoggedIn: Bool { get { - return !(noSelfHostedBlogs && noWordPressDotComAccount) + !(noSelfHostedBlogs && noWordPressDotComAccount) } } @objc static var noSelfHostedBlogs: Bool { let context = ContextManager.shared.mainContext - return BlogQuery().hostedByWPCom(false).count(in: context) == 0 && (try? Blog.hasAnyJetpackBlogs(in: context)) == false + return BlogQuery().hostedByWPCom(false).count(in: context) == 0 + && (try? Blog.hasAnyJetpackBlogs(in: context)) == false } static var hasBlogs: Bool { @@ -35,7 +36,7 @@ import WordPressData } @objc static var noWordPressDotComAccount: Bool { - return !AccountHelper.isDotcomAvailable() + !AccountHelper.isDotcomAvailable() } static var defaultSiteId: NSNumber? { @@ -60,15 +61,19 @@ import WordPressData let otherAccounts = accountCount > 1 ? " + \(accountCount - 1) others" : "" let accountsDescription = "wp.com account: " + (defaultAccount?.logDescription ?? "") + otherAccounts - let blogTree = blogsByAccount.map({ (account, blogs) -> String in - let accountDescription = account?.logDescription ?? "" - let isDefault = (account != nil && account == defaultAccount) ? " (default)" : "" - let blogsDescription = blogs.map({ (blog) -> String in - return "└─ " + blog.logDescription - }).joined(separator: "\n") - - return accountDescription + isDefault + "\n" + blogsDescription - }).joined(separator: "\n") + let blogTree = + blogsByAccount.map({ (account, blogs) -> String in + let accountDescription = account?.logDescription ?? "" + let isDefault = (account != nil && account == defaultAccount) ? " (default)" : "" + let blogsDescription = + blogs.map({ (blog) -> String in + "└─ " + blog.logDescription + }) + .joined(separator: "\n") + + return accountDescription + isDefault + "\n" + blogsDescription + }) + .joined(separator: "\n") let blogTreeDescription = !blogsByAccount.isEmpty ? blogTree : "No account/blogs configured on device" DDLogInfo("\(accountsDescription)\nAll accounts and blogs:\n\(blogTreeDescription)") @@ -82,8 +87,9 @@ import WordPressData // We don't just clear all reminders, in case the user has self-hosted // sites added to the app. if let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext), - let blogs = account.blogs, - let scheduler = try? ReminderScheduleCoordinator() { + let blogs = account.blogs, + let scheduler = try? ReminderScheduleCoordinator() + { blogs.forEach { scheduler.unschedule(for: $0) } } @@ -92,6 +98,7 @@ import WordPressData deleteAccountData() WordPressClientFactory.shared.reset() + JetpackSocialFactory.shared.reset() } static func deleteAccountData() { diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 800d1dfaaf22..e55c8328a1e9 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -29,6 +29,7 @@ public enum FeatureFlag: Int, CaseIterable { case statsAds case customPostTypes case cptPostsAndPages + case socialSharingV2 /// Returns a boolean indicating if the feature is enabled. /// @@ -92,11 +93,13 @@ public enum FeatureFlag: Int, CaseIterable { return BuildConfiguration.current == .debug case .cptPostsAndPages: return BuildConfiguration.current == .debug + case .socialSharingV2: + return BuildConfiguration.current == .debug } } var disabled: Bool { - return enabled == false + enabled == false } } @@ -106,14 +109,14 @@ public enum FeatureFlag: Int, CaseIterable { public class Feature: NSObject { /// Returns a boolean indicating if the feature is enabled @objc public static func enabled(_ feature: FeatureFlag) -> Bool { - return feature.enabled + feature.enabled } } extension FeatureFlag { /// Descriptions used to display the feature flag override menu in debug builds public var description: String { - return switch self { + switch self { case .signUp: "Sign Up" case .domainRegistration: "Domain Registration" case .selfHostedSites: "Self-Hosted Sites" @@ -137,6 +140,7 @@ extension FeatureFlag { case .statsAds: "Stats Ads Tab" case .customPostTypes: "Custom Post Types" case .cptPostsAndPages: "Custom Post Types: Posts and Pages" + case .socialSharingV2: "Social Sharing v2" } } } @@ -144,7 +148,7 @@ extension FeatureFlag { extension FeatureFlag: OverridableFlag { var originalValue: Bool { - return enabled + enabled } var key: String { @@ -160,6 +164,6 @@ extension FeatureFlag: RolloutConfigurableFlag { /// If a percentage rollout isn't applicable for the flag, return nil. /// var rolloutPercentage: Double? { - return nil + nil } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 9d742cbe45c9..fb246e155d5c 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import SwiftUI +import JetpackSocial import WordPressData import WordPressShared import WordPressAPI @@ -108,13 +109,14 @@ extension BlogDetailsViewController { blog: blog, localizedFeatureName: feature, source: "custom_post_types", - presentingViewController: self) { [blog, weak self] client in - CustomPostTypesView( - blog: blog, - service: CustomPostTypeService(client: client, blog: blog), - presentingViewController: self - ) - } + presentingViewController: self + ) { [blog, weak self] client in + CustomPostTypesView( + blog: blog, + service: CustomPostTypeService(client: client, blog: blog), + presentingViewController: self + ) + } let controller = UIHostingController(rootView: rootView) controller.navigationItem.largeTitleDisplayMode = .never presentationDelegate?.presentBlogDetailsViewController(controller) @@ -130,14 +132,15 @@ extension BlogDetailsViewController { blog: blog, localizedFeatureName: feature, source: "custom_post_types", - presentingViewController: self) { [blog, weak self] client in - PinnedPostTypeView( - blog: blog, - service: CustomPostTypeService(client: client, blog: blog), - postType: postType, - presentingViewController: self - ) - } + presentingViewController: self + ) { [blog, weak self] client in + PinnedPostTypeView( + blog: blog, + service: CustomPostTypeService(client: client, blog: blog), + postType: postType, + presentingViewController: self + ) + } let controller = UIHostingController(rootView: rootView) controller.navigationItem.largeTitleDisplayMode = .never presentationDelegate?.presentBlogDetailsViewController(controller) @@ -317,7 +320,11 @@ extension BlogDetailsViewController { guard let presentationDelegate else { return wpAssertionFailure("presentationDelegate mising") } - DomainsDashboardCoordinator.presentDomainsDashboard(with: presentationDelegate, source: source.string, blog: blog) + DomainsDashboardCoordinator.presentDomainsDashboard( + with: presentationDelegate, + source: source.string, + blog: blog + ) } public func showJetpackSettings() { @@ -332,6 +339,10 @@ extension BlogDetailsViewController { if !blog.supports(.publicize) { // if publicize is disabled, show the sharing buttons settings. sharingVC = SharingButtonsViewController(blog: blog) + } else if FeatureFlag.socialSharingV2.enabled, + let manage = ManageConnectionsHostingController.make(for: blog) + { + sharingVC = manage } else { sharingVC = SharingViewController(blog: blog, delegate: nil) } @@ -388,8 +399,17 @@ extension BlogDetailsViewController { } public func showApplicationPasswords() { - let feature = NSLocalizedString("applicationPasswordRequired.feature.applicationPasswords", value: "Application Passwords Management", comment: "Feature name for managing application passwords in the app") - let view = ApplicationPasswordRequiredView(blog: blog, localizedFeatureName: feature, source: "application_passwords", presentingViewController: self) { + let feature = NSLocalizedString( + "applicationPasswordRequired.feature.applicationPasswords", + value: "Application Passwords Management", + comment: "Feature name for managing application passwords in the app" + ) + let view = ApplicationPasswordRequiredView( + blog: blog, + localizedFeatureName: feature, + source: "application_passwords", + presentingViewController: self + ) { ApplicationTokenListView(dataProvider: ApplicationPasswordService(api: $0)) } presentationDelegate?.presentBlogDetailsViewController(UIHostingController(rootView: view)) diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationWebViewController.swift b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationWebViewController.swift index 6f9beb4101d1..c8bdadb62fce 100644 --- a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationWebViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationWebViewController.swift @@ -1,3 +1,4 @@ +import JetpackSocial import WebKit import CoreMedia import WordPressData @@ -44,13 +45,13 @@ class SharingAuthorizationWebViewController: WebKitViewController { fatalError("init(coder:) has not been implemented") } - // MARK: - View Lifecycle + // MARK: - View Lifecycle - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) - cleanupCookies() - } + cleanupCookies() + } // MARK: - Cookies Management @@ -62,8 +63,9 @@ class SharingAuthorizationWebViewController: WebKitViewController { func saveHostForCookiesCleanup(from url: URL) { guard let host = url.host, !host.contains("wordpress"), - !hosts.contains(host) else { - return + !hosts.contains(host) + else { + return } let components = host.components(separatedBy: ".") @@ -117,16 +119,25 @@ class SharingAuthorizationWebViewController: WebKitViewController { // MARK: - WKNavigationDelegate extension SharingAuthorizationWebViewController { - override func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + override func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { decidePolicy(webView: webView, navigationAction: navigationAction, decisionHandler: decisionHandler) } - private func decidePolicy(webView: WKWebView, navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { + private func decidePolicy( + webView: WKWebView, + navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void + ) { // Prevent a second verify load by someone happy clicking. guard !loadingVerify, - let url = navigationAction.request.url else { - decisionHandler(.cancel) - return + let url = navigationAction.request.url + else { + decisionHandler(.cancel) + return } let action = PublicizeConnectionURLMatcher.authorizeAction(for: url) @@ -150,7 +161,11 @@ extension SharingAuthorizationWebViewController { } } - override func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + override func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { if loadingVerify && (error as NSError).code == NSURLErrorCancelled { // Authenticating to Facebook and Twitter can return an false // NSURLErrorCancelled (-999) error. However the connection still succeeds. diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift index f53d410aec6f..7a82a5cfb2a1 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift @@ -161,9 +161,12 @@ struct PostSettings: Hashable { postFormat = post.postFormat isStickyPost = post.isStickyPost tags = AbstractPost.makeTags(from: post.tags ?? "").map { Term(id: 0, name: $0) } - categoryIDs = Set((post.categories ?? []).map { - $0.categoryID.intValue - }) + categoryIDs = Set( + (post.categories ?? []) + .map { + $0.categoryID.intValue + } + ) sharing = PostSocialSharingSettings.make(for: post) allowComments = post.allowComments allowPings = post.allowPings @@ -253,7 +256,10 @@ struct PostSettings: Hashable { if let featuredImageID { // Only update if changed if post.featuredImage?.mediaID?.intValue != featuredImageID { - post.featuredImage = Media.existingOrStubMediaWith(mediaID: NSNumber(value: featuredImageID), inBlog: post.blog) + post.featuredImage = Media.existingOrStubMediaWith( + mediaID: NSNumber(value: featuredImageID), + inBlog: post.blog + ) } } else { post.featuredImage = nil @@ -433,9 +439,15 @@ struct PostSettings: Hashable { customTermChanges[restBase] = termIds } } - if !customTermChanges.isEmpty { - params.additionalFields = AnyJson.fromTermIdMap(map: customTermChanges) + var fields: WpAdditionalFields? = nil + for (restBase, termIds) in customTermChanges { + fields = (fields ?? WpAdditionalFields()) + .withValue( + key: restBase, + value: .array(termIds.map { .int($0) }) + ) } + params.additionalFields = fields let postParentPageID = post.parent.map { Int($0) } if postParentPageID != self.parentPageID { @@ -459,9 +471,14 @@ struct PostSettings: Hashable { customTerms[taxonomy.restBase] = termIds } } - let additionalFields: AnyJson? = customTerms.isEmpty - ? nil - : AnyJson.fromTermIdMap(map: customTerms) + var fields: WpAdditionalFields? = nil + for (restBase, termIds) in customTerms { + fields = (fields ?? WpAdditionalFields()) + .withValue( + key: restBase, + value: .array(termIds.map { .int($0) }) + ) + } var params = existing params.dateGmt = publishDate @@ -478,7 +495,7 @@ struct PostSettings: Hashable { params.categories = categoryIds params.tags = tagIds params.parent = parentPageID.map { PostId(Int64($0)) } - params.additionalFields = additionalFields + params.additionalFields = fields return params } } @@ -519,9 +536,10 @@ extension PostSettings { } mutating func setTerms(_ terms: String, forTaxonomySlug taxonomySlug: String) { - otherTerms[taxonomySlug] = AbstractPost.makeTags(from: terms).map { - Term(id: 0, name: $0) - } + otherTerms[taxonomySlug] = AbstractPost.makeTags(from: terms) + .map { + Term(id: 0, name: $0) + } } } @@ -622,12 +640,13 @@ struct PostSocialSharingSettings: Hashable { // first, build a dictionary to categorize the connections. var connectionsMap = [PublicizeService.ServiceName: [PublicizeConnection]]() - connections.filter { !$0.requiresUserAction() }.forEach { connection in - let name = PublicizeService.ServiceName(rawValue: connection.service) ?? .unknown - var serviceConnections = connectionsMap[name] ?? [] - serviceConnections.append(connection) - connectionsMap[name] = serviceConnections - } + connections.filter { !$0.requiresUserAction() } + .forEach { connection in + let name = PublicizeService.ServiceName(rawValue: connection.service) ?? .unknown + var serviceConnections = connectionsMap[name] ?? [] + serviceConnections.append(connection) + connectionsMap[name] = serviceConnections + } let publicizeServices: [PublicizeService] do { @@ -640,16 +659,19 @@ struct PostSocialSharingSettings: Hashable { let services = publicizeServices.compactMap { service -> PostSocialSharingSettings.Service? in // skip services without connections. guard let serviceConnections = connectionsMap[service.name], - !serviceConnections.isEmpty else { + !serviceConnections.isEmpty + else { return nil } return PostSocialSharingSettings.Service( name: service.name, connections: serviceConnections.map { - .init(account: $0.externalDisplay, - keyringID: $0.keyringConnectionID.intValue, - enabled: !post.publicizeConnectionDisabledForKeyringID($0.keyringConnectionID)) + .init( + account: $0.externalDisplay, + keyringID: $0.keyringConnectionID.intValue, + enabled: !post.publicizeConnectionDisabledForKeyringID($0.keyringConnectionID) + ) } ) } diff --git a/WordPress/Classes/ViewRelated/Publicize/V2/BlogSocialOAuthAuthenticator.swift b/WordPress/Classes/ViewRelated/Publicize/V2/BlogSocialOAuthAuthenticator.swift new file mode 100644 index 000000000000..b822c36aaa0f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Publicize/V2/BlogSocialOAuthAuthenticator.swift @@ -0,0 +1,54 @@ +import Foundation +import JetpackSocial +import WebKit +import WordPressData + +/// Adapts the app's `Blog`-backed `RequestAuthenticator` to the +/// module-facing `SocialOAuthAuthenticator` protocol. +/// +/// Keeps every reference to `Blog`, `WPAccount`, and `CookieJar` inside +/// the app target so the JetpackSocial module stays free of Core Data. +/// `WKHTTPCookieStore` already conforms to `CookieJar` via the extension +/// in `WordPress/Classes/Utility/WebViewController/CookieJar.swift`, so +/// we can hand it to `RequestAuthenticator.request` as-is. +struct BlogSocialOAuthAuthenticator: SocialOAuthAuthenticator { + private let blogID: TaggedManagedObjectID + private let coreDataStack: CoreDataStack + + init(blog: Blog, coreDataStack: CoreDataStack = ContextManager.shared) { + self.blogID = TaggedManagedObjectID(blog) + self.coreDataStack = coreDataStack + } + + func authenticatedRequest( + for url: URL, + into cookieStore: WKHTTPCookieStore + ) async -> URLRequest { + let authenticator: RequestAuthenticator? + do { + authenticator = try await coreDataStack.performQuery { [blogID] context in + let blog = try context.existingObject(with: blogID) + return RequestAuthenticator(blog: blog) + } + } catch { + Loggers.app.error("BlogSocialOAuthAuthenticator failed to resolve blog: \(error)") + return URLRequest(url: url) + } + guard let authenticator else { + Loggers.app.error( + "BlogSocialOAuthAuthenticator: RequestAuthenticator(blog:) returned nil — using unauthenticated request" + ) + return URLRequest(url: url) + } + return await withCheckedContinuation { continuation in + // `WKHTTPCookieStore` mutation requires the main thread, and + // `RequestAuthenticator.request` seeds cookies synchronously + // into the jar before invoking the completion. + DispatchQueue.main.async { + authenticator.request(url: url, cookieJar: cookieStore) { request in + continuation.resume(returning: request) + } + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Publicize/V2/ManageConnectionsHostingController+App.swift b/WordPress/Classes/ViewRelated/Publicize/V2/ManageConnectionsHostingController+App.swift new file mode 100644 index 000000000000..6f4c12f36eb5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Publicize/V2/ManageConnectionsHostingController+App.swift @@ -0,0 +1,20 @@ +import Foundation +import JetpackSocial +import WordPressCore +import WordPressData + +extension ManageConnectionsHostingController { + /// Returns `nil` gracefully when the blog cannot be represented as a + /// `WordPressSite` or has no WP.com account linked. + static func make(for blog: Blog) -> ManageConnectionsHostingController? { + guard let site = try? WordPressSite(blog: blog), + let service = JetpackSocialFactory.shared.connectionsService(for: site) + else { + return nil + } + return ManageConnectionsHostingController( + connectionsService: service, + authenticator: BlogSocialOAuthAuthenticator(blog: blog) + ) + } +}