From 251a9c453c15167b980d6c9865787785d6d060d5 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 27 Jun 2025 12:26:53 -0400 Subject: [PATCH] Add Social Sharing --- .../WordPressData/Objective-C/include/Blog.h | 3 +- .../JetpackSocialNoConnectionView.swift | 2 +- .../Post/PostSettings/PostSettings.swift | 44 +++ .../Post/PostSettings/PostSettingsView.swift | 31 +++ .../PostSettings/PostSettingsViewModel.swift | 33 ++- .../PostSettingsSocialAccountsView.swift | 23 ++ .../PostSettingsSocialSharingRow.swift | 33 +++ .../PostSettingsSocialSharingViewModel.swift | 252 ++++++++++++++++++ .../PrepublishingAutoSharingView.swift | 3 +- ...blishingViewController+JetpackSocial.swift | 2 +- 10 files changed, 421 insertions(+), 5 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialAccountsView.swift create mode 100644 WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialSharingRow.swift create mode 100644 WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialSharingViewModel.swift diff --git a/Sources/WordPressData/Objective-C/include/Blog.h b/Sources/WordPressData/Objective-C/include/Blog.h index b3a5d4ef86e9..710e44029bde 100644 --- a/Sources/WordPressData/Objective-C/include/Blog.h +++ b/Sources/WordPressData/Objective-C/include/Blog.h @@ -17,6 +17,7 @@ NS_ASSUME_NONNULL_BEGIN @class PublicizeInfo; @class BlobEntity; @class PostCategory; +@class PublicizeConnection; extern NSString * const BlogEntityName; extern NSString * const PostFormatStandard; @@ -136,7 +137,7 @@ typedef NS_ENUM(NSInteger, SiteVisibility) { @property (nonatomic, strong, readwrite, nullable) NSSet *categories; @property (nonatomic, strong, readwrite, nullable) NSSet *tags; @property (nonatomic, strong, readwrite, nullable) NSSet *comments; -@property (nonatomic, strong, readwrite, nullable) NSSet *connections; +@property (nonatomic, strong, readwrite, nullable) NSSet *connections; @property (nonatomic, strong, readwrite, nullable) NSSet *inviteLinks; @property (nonatomic, strong, readwrite, nullable) NSSet *domains; @property (nonatomic, strong, readwrite, nullable) NSSet *themes; diff --git a/WordPress/Classes/ViewRelated/Jetpack/Social/JetpackSocialNoConnectionView.swift b/WordPress/Classes/ViewRelated/Jetpack/Social/JetpackSocialNoConnectionView.swift index 62b63f36aec4..4cecd30b94c0 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Social/JetpackSocialNoConnectionView.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Social/JetpackSocialNoConnectionView.swift @@ -5,7 +5,7 @@ import WordPressUI struct JetpackSocialNoConnectionView: View { - private let viewModel: JetpackSocialNoConnectionViewModel + let viewModel: JetpackSocialNoConnectionViewModel var body: some View { VStack(alignment: .leading, spacing: 12.0) { diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift index e93d1e09fee4..2b4d5701ab3e 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift @@ -25,6 +25,8 @@ struct PostSettings: Hashable { // MARK: - Post-specific var postFormat: String? var isStickyPost = false + var publicizeMessage: String? + var disabledPublicizeConnectionKeyringIDs: Set = [] // MARK: - Page-specific var parentPageID: Int? @@ -57,6 +59,17 @@ struct PostSettings: Hashable { categoryIDs = Set((post.categories ?? []).compactMap { $0.categoryID?.intValue }) + publicizeMessage = post.publicizeMessage + + // Extract disabled connection keyring IDs + if let disabledConnections = post.disabledPublicizeConnections { + disabledPublicizeConnectionKeyringIDs = Set(disabledConnections.compactMap { keyringID, entry in + guard entry[Post.Constants.publicizeValueKey] == Post.Constants.publicizeDisabledValue else { + return nil + } + return keyringID.intValue + }) + } case let page as Page: parentPageID = page.parentID?.intValue default: @@ -129,6 +142,37 @@ struct PostSettings: Hashable { if post.isStickyPost != isStickyPost { post.isStickyPost = isStickyPost } + + // Update publicize message + if post.publicizeMessage != publicizeMessage { + post.publicizeMessage = publicizeMessage + } + + // Update disabled publicize connections + if let disabledConnections = post.disabledPublicizeConnections { + // Get current disabled keyring IDs + let currentDisabledKeyringIDs = Set(disabledConnections.compactMap { keyringID, entry in + guard entry[Post.Constants.publicizeValueKey] == Post.Constants.publicizeDisabledValue else { + return nil + } + return keyringID.intValue + }) + + // Enable connections that were disabled but are now enabled + for keyringID in currentDisabledKeyringIDs.subtracting(disabledPublicizeConnectionKeyringIDs) { + post.enablePublicizeConnectionWithKeyringID(NSNumber(value: keyringID)) + } + + // Disable connections that were enabled but are now disabled + for keyringID in disabledPublicizeConnectionKeyringIDs.subtracting(currentDisabledKeyringIDs) { + post.disablePublicizeConnectionWithKeyringID(NSNumber(value: keyringID)) + } + } else if !disabledPublicizeConnectionKeyringIDs.isEmpty { + // If there were no disabled connections before, disable the ones in settings + for keyringID in disabledPublicizeConnectionKeyringIDs { + post.disablePublicizeConnectionWithKeyringID(NSNumber(value: keyringID)) + } + } } // Apply page-specific settings diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift index 2d28e01d80c3..6f094c0b85fa 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift @@ -49,6 +49,7 @@ private struct PostSettingsView: View { taxonomySection } excerptSection + socialSharingSection moreOptionsSection } .disabled(viewModel.isSaving) @@ -231,6 +232,15 @@ private struct PostSettingsView: View { } } + // MARK: - "Jetpack Social" Section + + @ViewBuilder + private var socialSharingSection: some View { + if let socialViewModel = viewModel.socialSharingViewModel { + PostSettingsSocialSection(viewModel: socialViewModel) + } + } + // MARK: - "More Options" Section @ViewBuilder @@ -376,6 +386,21 @@ private struct SettingsTextFieldView: View { } } +@MainActor +private struct PostSettingsSocialSection: View { + @ObservedObject var viewModel: PostSettingsSocialSharingViewModel + + var body: some View { + if !viewModel.isHidden { + Section { + PostSettingsSocialSharingRow(viewModel: viewModel) + } header: { + Text(Strings.jetpackSocialHeader) + } + } + } +} + private enum Strings { static let generalHeader = NSLocalizedString( "postSettings.section.general", @@ -496,4 +521,10 @@ private enum Strings { value: "The slug is the URL-friendly version of the post title.", comment: "Hint text for the slug field. Should be the same as the text displayed if the user clicks the (i) in Slug in Calypso." ) + + static let jetpackSocialHeader = NSLocalizedString( + "postSettings.jetpackSocial.header", + value: "Jetpack Social", + comment: "Label for the Jetpack Social section in post Settings. Should be the same as WP core." + ) } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index 55eccbecbfd8..344cf2eeee32 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -9,6 +9,7 @@ final class PostSettingsViewModel: ObservableObject { let post: AbstractPost let isStandalone: Bool let featuredImageViewModel: PostSettingsFeaturedImageViewModel + private(set) var socialSharingViewModel: PostSettingsSocialSharingViewModel? @Published var settings: PostSettings { didSet { @@ -94,7 +95,11 @@ final class PostSettingsViewModel: ObservableObject { /// Weak reference to the view controller for navigation. /// This is temporary until we can fully migrate to SwiftUI navigation. - weak var viewController: UIViewController? + weak var viewController: UIViewController? { + didSet { + socialSharingViewModel?.viewController = viewController + } + } init(post: AbstractPost, isStandalone: Bool = false) { self.post = post @@ -116,8 +121,31 @@ final class PostSettingsViewModel: ObservableObject { // Initialize cached text values refresh(with: settings) + // Initialize social sharing view model if this is a post + if let post = post as? Post { + setupSocialSharingViewModel(post: post) + } + WPAnalytics.track(.postSettingsShown) } + + private func setupSocialSharingViewModel(post: Post) { + // Create a binding to settings that the social sharing view model can use + let settingsBinding = Binding( + get: { [weak self] in + self?.settings ?? PostSettings(from: post) + }, + set: { [weak self] newValue in + self?.settings = newValue + } + ) + + socialSharingViewModel = PostSettingsSocialSharingViewModel( + blog: post.blog, + settings: settingsBinding + ) + socialSharingViewModel?.viewController = viewController + } private func refresh(with settings: PostSettings) { hasChanges = settings != originalSettings @@ -194,6 +222,9 @@ final class PostSettingsViewModel: ObservableObject { settings.status = .publishPrivate } settings.password = selection.password.isEmpty ? nil : selection.password + + // Refresh social sharing when visibility changes + socialSharingViewModel?.refresh() } // MARK: - Navigation diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialAccountsView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialAccountsView.swift new file mode 100644 index 000000000000..da120700a45a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialAccountsView.swift @@ -0,0 +1,23 @@ +import SwiftUI +import WordPressData + +@MainActor +struct PostSettingsSocialAccountsView: UIViewControllerRepresentable { + let blogID: Int + let model: PrepublishingAutoSharingModel + weak var delegate: PrepublishingSocialAccountsDelegate? + let coreDataStack: CoreDataStackSwift + + func makeUIViewController(context: Context) -> PrepublishingSocialAccountsViewController { + PrepublishingSocialAccountsViewController( + blogID: blogID, + model: model, + delegate: delegate, + coreDataStack: coreDataStack + ) + } + + func updateUIViewController(_ uiViewController: PrepublishingSocialAccountsViewController, context: Context) { + // No updates needed + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialSharingRow.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialSharingRow.swift new file mode 100644 index 000000000000..2ff4de1884cf --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialSharingRow.swift @@ -0,0 +1,33 @@ +import SwiftUI +import WordPressUI + +@MainActor +struct PostSettingsSocialSharingRow: View { + @ObservedObject var viewModel: PostSettingsSocialSharingViewModel + + var body: some View { + switch viewModel.state { + case .noConnection(let viewModel): + JetpackSocialNoConnectionView(viewModel: viewModel) + case .hasConnections(let viewModel): + autoSharingView(viewModel: viewModel) + case .hidden: + EmptyView() + } + } + + @ViewBuilder + private func autoSharingView(viewModel model: PrepublishingAutoSharingModel) -> some View { + NavigationLink { + PostSettingsSocialAccountsView( + blogID: viewModel.blogID ?? 0, + model: model, + delegate: viewModel, + coreDataStack: viewModel.coreDataStack + ) + .ignoresSafeArea() + } label: { + PrepublishingAutoSharingView(model: model) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialSharingViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialSharingViewModel.swift new file mode 100644 index 000000000000..a40b23c9414b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/SocialSharing/PostSettingsSocialSharingViewModel.swift @@ -0,0 +1,252 @@ +import Foundation +import SwiftUI +import Combine +import WordPressData +import WordPressKit +import WordPressShared + +@MainActor +final class PostSettingsSocialSharingViewModel: NSObject, ObservableObject { + let blog: Blog + @Binding var settings: PostSettings + let coreDataStack: CoreDataStackSwift + private let persistentStore: UserPersistentRepository + + @Published private(set) var state: SocialSharingState = .hidden + + var isHidden: Bool { + if case .hidden = state { return true } + return false + } + + var blogID: Int? { + blog.dotComID?.intValue + } + + /// A temporary workaround for showing the sharing view controller. + weak var viewController: UIViewController? + + enum SocialSharingState { + case noConnection(JetpackSocialNoConnectionViewModel) + case hasConnections(PrepublishingAutoSharingModel) + case hidden + } + + init( + blog: Blog, + settings: Binding, + coreDataStack: CoreDataStackSwift = ContextManager.shared, + persistentStore: UserPersistentRepository = UserPersistentStoreFactory.instance() + ) { + self.blog = blog + self._settings = settings + self.coreDataStack = coreDataStack + self.persistentStore = persistentStore + super.init() + + isNoConnectionDismissed = false + refresh() + } + + func refresh() { + guard canDisplaySocialRow() else { + state = .hidden + return + } + + if hasExistingConnections { + let viewModel = makeAutoSharingViewModel() + state = .hasConnections(viewModel) + + if viewModel.sharingLimit != nil { + WPAnalytics.track(.jetpackSocialShareLimitDisplayed, properties: ["source": Constants.trackingSource]) + } + } else { + let viewModel = makeNoConnectionViewModel() + state = .noConnection(viewModel) + WPAnalytics.track(.jetpackSocialNoConnectionCardDisplayed, properties: ["source": Constants.trackingSource]) + } + } + + // MARK: - Eligibility + + private func canDisplaySocialRow( + isJetpack: Bool = AppConfiguration.isJetpack, + isFeatureEnabled: Bool = RemoteFeatureFlag.jetpackSocialImprovements.enabled() + ) -> Bool { + guard isJetpack && + isFeatureEnabled && + !isPostPrivate && + post.blog.supportsPublicize() && + !getPublisizeServices().isEmpty + else { + return false + } + + guard hasExistingConnections else { + // if the site has no connections, ensure that the No Connection view hasn't been dismissed before. + return !isNoConnectionDismissed + } + + return true + } + + private var postBlogID: Int? { + blog.dotComID?.intValue + } + + private var isPostPrivate: Bool { + settings.status == .publishPrivate + } + + private var hasExistingConnections: Bool { + !(blog.connections ?? []).isEmpty + } + + private func getPublisizeServices() -> [PublicizeService] { + let context = blog.managedObjectContext ?? coreDataStack.mainContext + do { + return try PublicizeService.allSupportedServices(in: context) + } catch { + wpAssertionFailure("Failed to fetch publicize services", userInfo: ["error": error.localizedDescription]) + return [] + } + } + + // MARK: - No Connection Management + + private var isNoConnectionDismissed: Bool { + get { + guard let postBlogID, + let dictionary = persistentStore.dictionary(forKey: Constants.noConnectionKey) as? [String: Bool], + let storedValue = dictionary["\(postBlogID)"] else { + return false + } + return storedValue + } + + set { + guard let postBlogID else { + return + } + var dictionary = (persistentStore.dictionary(forKey: Constants.noConnectionKey) as? [String: Bool]) ?? .init() + dictionary["\(postBlogID)"] = newValue + persistentStore.set(dictionary, forKey: Constants.noConnectionKey) + } + } + + // MARK: - Actions + + func connectSocialAccounts() { + guard let sharingVC = SharingViewController(blog: blog, delegate: self) else { + return + } + + WPAnalytics.track(.jetpackSocialNoConnectionCTATapped, properties: ["source": Constants.trackingSource]) + + let navigationController = UINavigationController(rootViewController: sharingVC) + viewController?.show(navigationController, sender: nil) + } + + func dismissNoConnection() { + WPAnalytics.track(.jetpackSocialNoConnectionCardDismissed, properties: ["source": Constants.trackingSource]) + + withAnimation { + isNoConnectionDismissed = true + state = .hidden + } + } + + // MARK: - Model Creation + + private func makeNoConnectionViewModel() -> JetpackSocialNoConnectionViewModel { + let services = getPublisizeServices() + return JetpackSocialNoConnectionViewModel( + services: services, + padding: EdgeInsets(top: 12, leading: 0, bottom: 0, trailing: 0), + onConnectTap: { [weak self] in + self?.connectSocialAccounts() + }, + onNotNowTap: { [weak self] in + self?.dismissNoConnection() + } + ) + } + + private func makeAutoSharingViewModel() -> PrepublishingAutoSharingModel { + let supportedServices = getPublisizeServices() + let connections = blog.sortedConnections + + // first, build a dictionary to categorize the connections. + var connectionsMap = [PublicizeService.ServiceName: [PublicizeConnection]]() + connections.filter { !$0.requiresUserAction() }.forEach { connection in + let serviceName = PublicizeService.ServiceName(rawValue: connection.service) ?? .unknown + var serviceConnections = connectionsMap[serviceName] ?? [] + serviceConnections.append(connection) + connectionsMap[serviceName] = serviceConnections + } + + // then, transform [PublicizeService] to [PrepublishingAutoSharingModel.Service]. + let modelServices = supportedServices.compactMap { service -> PrepublishingAutoSharingModel.Service? in + // skip services without connections. + guard let serviceConnections = connectionsMap[service.name], + !serviceConnections.isEmpty else { + return nil + } + + return PrepublishingAutoSharingModel.Service( + name: service.name, + connections: serviceConnections.map { + .init(account: $0.externalDisplay, + keyringID: $0.keyringConnectionID.intValue, + enabled: !settings.disabledPublicizeConnectionKeyringIDs.contains($0.keyringConnectionID.intValue)) + } + ) + } + + return PrepublishingAutoSharingModel( + services: modelServices, + message: settings.publicizeMessage ?? "", + sharingLimit: blog.sharingLimit + ) + } + + // MARK: - Constants + + private enum Constants { + static let trackingSource = "post_settings" + static let noConnectionKey = "post-settings-social-no-connection-view-hidden" + } +} + +// MARK: - SharingViewControllerDelegate + +extension PostSettingsSocialSharingViewModel: @preconcurrency SharingViewControllerDelegate { + func didChangePublicizeServices() { + refresh() + } +} + +// MARK: - PrepublishingSocialAccountsDelegate + +extension PostSettingsSocialSharingViewModel: @preconcurrency PrepublishingSocialAccountsDelegate { + func didUpdateSharingLimit(with newValue: PublicizeInfo.SharingLimit?) { + refresh() + } + + func didFinish(with connectionChanges: [Int: Bool], message: String?) { + // Update the settings binding + connectionChanges.forEach { (keyringID, enabled) in + if enabled { + settings.disabledPublicizeConnectionKeyringIDs.remove(keyringID) + } else { + settings.disabledPublicizeConnectionKeyringIDs.insert(keyringID) + } + } + + let isMessageEmpty = message?.isEmpty ?? true + settings.publicizeMessage = isMessageEmpty ? nil : message + + refresh() + } +} diff --git a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingAutoSharingView.swift b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingAutoSharingView.swift index 3dde34bf264e..57a2d8134bfe 100644 --- a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingAutoSharingView.swift +++ b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingAutoSharingView.swift @@ -34,6 +34,7 @@ struct PrepublishingAutoSharingView: View { socialIconsView } } + .lineLimit(1) } private var textStack: some View { @@ -66,7 +67,7 @@ struct PrepublishingAutoSharingView: View { } private var socialIconsView: some View { - HStack(spacing: -2.0) { + HStack(spacing: -6) { ForEach(model.services, id: \.self) { service in iconImage(service.name.localIconImage, opaque: service.usesOpaqueIcon) } diff --git a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift index 0cb054bea797..b0040af0030c 100644 --- a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift +++ b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController+JetpackSocial.swift @@ -89,7 +89,7 @@ private extension PrepublishingViewController { var hasExistingConnections: Bool { coreDataStack.performQuery { [postObjectID = post.objectID] context in guard let post = (try? context.existingObject(with: postObjectID)) as? Post, - let connections = post.blog.connections as? Set else { + let connections = post.blog.connections else { return false } return !connections.isEmpty