diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift index f9c10daae4c6..2267a2ae6ca3 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift @@ -193,7 +193,8 @@ private extension CustomPostEditorViewController { } func showPostSettings() { - let viewModel = PostSettingsViewModel(editorService: editorService, blog: blog) + let provider = CustomPostSettingsDataProvider(editorService: editorService, blog: blog) + let viewModel = PostSettingsViewModel(provider: provider) viewModel.onEditorPostSaved = { /* No-op: shared editorService is already up-to-date */ } let settingsVC = PostSettingsViewController(viewModel: viewModel) let navigation = UINavigationController(rootViewController: settingsVC) diff --git a/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift b/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift index 5dc9a56cbb18..7b010a3a591d 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift @@ -10,7 +10,8 @@ extension PostEditor { func displayPostSettings() { // Use the new SwiftUI-based Post Settings let originalFeaturedImageID = post.featuredImage?.mediaID - let viewModel = PostSettingsViewModel(post: post) + let provider = AbstractPostSettingsDataProvider(post: post) + let viewModel = PostSettingsViewModel(provider: provider) viewModel.onEditorPostSaved = { [weak self] in self?.didSavePostSettings(originalFeaturedImageID: originalFeaturedImageID) } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/AbstractPostSettingsDataProvider.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/AbstractPostSettingsDataProvider.swift new file mode 100644 index 000000000000..0a94c657c15d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/AbstractPostSettingsDataProvider.swift @@ -0,0 +1,192 @@ +import Foundation +import BuildSettingsKit +import WordPressData +import WordPressKit + +@MainActor +final class AbstractPostSettingsDataProvider: PostSettingsDataProvider { + let post: AbstractPost + let supportsJetpackMetadata = true + let hasTermNames = true + + var blog: Blog { + post.blog + } + + var capabilities: PostSettingsCapabilities { + post is Post ? .post() : .page() + } + + var postContent: String { + post.content ?? "" + } + + var navigationTitle: String { + isPost ? Strings.postSettingsTitle : Strings.pageSettingsTitle + } + + var isScheduled: Bool { + post.getOriginal().status == .scheduled + } + + var isDraftOrPending: Bool { + post.getOriginal().isStatus(in: [.draft, .pending]) + } + + var isPost: Bool { + post is Post + } + + var authorFallbackDisplayName: String { + post.author?.makePlainText() ?? "" + } + + var suggestedSlug: String? { + post.suggested_slug + } + + var permalinkTemplate: String? { + post.permalinkTemplateURL + } + + var lastEditedText: String? { + guard let date = post.dateModified ?? post.dateCreated else { + return nil + } + return date.toMediumString() + } + + var postID: Int? { + guard let postID = post.postID?.intValue, postID > 0 else { + return nil + } + return postID + } + + var hasRemote: Bool { + post.hasRemote() + } + + var isDeleted: Bool { + guard let context = post.managedObjectContext else { + return true + } + return (try? context.existingObject(with: post.objectID)) == nil + } + + func resolveDisplayedCategories(for settings: PostSettings) -> [String] { + settings.getCategoryNames(for: post) + } + + func customTaxonomies() -> [SiteTaxonomy] { + let postType: String? = switch post { + case is Post: "post" + case is Page: "page" + default: nil + } + guard let postType else { + return [] + } + let taxonomies = try? blog.taxonomies + .filter { + $0.slug != "post_tag" && $0.slug != "category" && $0.supportedPostTypes.contains(postType) + } + .sorted(using: KeyPathComparator(\.name)) + return taxonomies ?? [] + } + + func parentPageText(for pageID: Int?) -> String? { + guard let page = post as? Page, + let context = page.managedObjectContext, + let pageID else { + return nil + } + return Page.parentPageText(in: context, parentID: NSNumber(value: pageID)) + } + + func resolveTerms(in settings: inout PostSettings) async { + let pendingNames = settings.tags.filter { $0.id == 0 }.map(\.name) + guard !pendingNames.isEmpty else { + return + } + + let service = TagsService(blog: blog) + let resolved = await service.resolveTerms(named: pendingNames) + for (name, existing) in resolved { + if let index = settings.tags.firstIndex(where: { $0.name == name }) { + settings.tags[index] = PostSettings.Term(id: Int(existing.id), name: existing.name) + } + } + } + + func suggestedTags() async throws -> [String] { + try await TagSuggestionsService().getSuggestedTags(for: post) + } + + var isEligibleForSocialSharing: Bool { + guard let post = post as? Post else { + return false + } + return BuildSettings.current.brand == .jetpack + && RemoteFeatureFlag.jetpackSocialImprovements.enabled() + && post.status != .publishPrivate + && !getPublicizeServices().isEmpty + && blog.supports(.publicize) + } + + init(post: AbstractPost) { + self.post = post + } + + func makeSettings() -> PostSettings { + PostSettings(from: post) + } + + func makeFeaturedImageViewModel() -> PostSettingsFeaturedImageViewModel? { + PostSettingsFeaturedImageViewModel(post: post) + } + + func applyLocally(settings: PostSettings) { + settings.apply(to: post) + } + + func save(settings: PostSettings) async throws { + let coordinator = PostCoordinator.shared + if coordinator.isSyncAllowed(for: post) && post.status == settings.status { + let revision = post.createRevision() + settings.apply(to: revision) + coordinator.setNeedsSync(for: revision) + } else { + let changes = settings.makeUpdateParameters(from: post) + try await coordinator.save(post, changes: changes) + } + } + + func publish(settings: PostSettings) async throws { + let changes = settings.makeUpdateParameters(from: post) + try await PostCoordinator.shared.publish(post.getOriginal(), parameters: changes) + } + + // MARK: - Private + + private func getPublicizeServices() -> [PublicizeService] { + let context = ContextManager.shared.mainContext + return (try? PublicizeService.allSupportedServices(in: context)) ?? [] + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let postSettingsTitle = NSLocalizedString( + "postSettings.navigationTitle.post", + value: "Post Settings", + comment: "The title of the Post Settings screen." + ) + + static let pageSettingsTitle = NSLocalizedString( + "postSettings.navigationTitle.page", + value: "Page Settings", + comment: "The title of the Page Settings screen." + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/CustomPostSettingsDataProvider.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/CustomPostSettingsDataProvider.swift new file mode 100644 index 000000000000..3336de516246 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/CustomPostSettingsDataProvider.swift @@ -0,0 +1,157 @@ +import Foundation +import WordPressData + +@MainActor +final class CustomPostSettingsDataProvider: PostSettingsDataProvider { + let blog: Blog + let editorService: CustomPostEditorService + + // FIXME: meta support missing in AnyPostWithEditContext + let supportsJetpackMetadata = false + // FIXME: social sharing support missing in AnyPostWithEditContext + let isEligibleForSocialSharing = false + let hasTermNames = false + + var capabilities: PostSettingsCapabilities { + PostSettingsCapabilities(from: editorService.details) + } + + var postContent: String { + editorService.post?.content.raw ?? "" + } + + var navigationTitle: String { + String.localizedStringWithFormat( + Strings.customPostSettingsTitle, + editorService.details.name + ) + } + + var isScheduled: Bool { + editorService.post?.status == .future + } + + var isDraftOrPending: Bool { + if let post = editorService.post { + return post.status == .draft || post.status == .pending + } + return true + } + + var isPost: Bool { + editorService.details.slug == "post" + } + + var authorFallbackDisplayName: String { + "" + } + + var suggestedSlug: String? { + editorService.post?.generatedSlug + } + + var permalinkTemplate: String? { + editorService.post?.permalinkTemplate + } + + var lastEditedText: String? { + editorService.post?.modifiedGmt.toMediumString() + } + + var postID: Int? { + guard let id = editorService.post?.id else { return nil } + return id > 0 ? Int(id) : nil + } + + var hasRemote: Bool { + editorService.post != nil + } + + var isDeleted: Bool { + false + } + + func resolveDisplayedCategories(for settings: PostSettings) -> [String] { + settings.getCategoryNames(for: blog) + } + + func customTaxonomies() -> [SiteTaxonomy] { + editorService.taxonomies + } + + func resolveTerms(in settings: inout PostSettings) async { + do { + let tagsService = AnyTermService(client: editorService.client, endpoint: .tags) + settings.tags = try await TermResolutionService(taxonomyService: tagsService) + .resolveNames(for: settings.tags) + + for taxonomy in editorService.taxonomies { + guard let slugTerms = settings.otherTerms[taxonomy.slug] else { continue } + let termService = AnyTermService(client: editorService.client, endpoint: taxonomy.endpoint) + settings.otherTerms[taxonomy.slug] = try await TermResolutionService(taxonomyService: termService) + .resolveNames(for: slugTerms) + } + } catch { + // TODO: We need better error handling + Loggers.app.log(level: .error, "Failed to resolve taxonomy terms: \(error)") + } + } + + init(editorService: CustomPostEditorService, blog: Blog) { + self.editorService = editorService + self.blog = blog + } + + func makeSettings() -> PostSettings { + var initialSettings = editorService.settings + // Resolve author display name from Blog's cached authors + if let authorId = initialSettings.author?.id, + let authors = blog.authors, + let author = authors.first(where: { $0.userID.intValue == authorId }) { + initialSettings.author = PostSettings.Author( + id: authorId, + displayName: author.displayName ?? "–", + avatarURL: author.avatarURL.flatMap(URL.init) + ) + } + return initialSettings + } + + func makeFeaturedImageViewModel() -> PostSettingsFeaturedImageViewModel? { + guard capabilities.supportsFeaturedImage else { return nil } + + let initialSettings = editorService.settings + let featuredImage = initialSettings.featuredImageID.flatMap { + Media.existingOrStubMediaWith( + mediaID: NSNumber(value: $0), + inBlog: blog + ) + } + return PostSettingsFeaturedImageViewModel( + blog: blog, + featuredImage: featuredImage + ) + } + + func applyLocally(settings: PostSettings) { + editorService.applyLocally(settings: settings) + } + + func save(settings: PostSettings) async throws { + try await editorService.save(settings: settings, publish: false) + } + + func publish(settings: PostSettings) async throws { + try await editorService.save(settings: settings, publish: true) + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let customPostSettingsTitle = NSLocalizedString( + "postSettings.navigationTitle.customPostType", + value: "%1$@ Settings", + comment: "The title of the Post Settings screen for custom post types. %1$@ is the post type name." + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsDataProvider.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsDataProvider.swift new file mode 100644 index 000000000000..f6b1cd96c277 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsDataProvider.swift @@ -0,0 +1,49 @@ +import Foundation +import WordPressData + +@MainActor +protocol PostSettingsDataProvider: AnyObject { + var blog: Blog { get } + var capabilities: PostSettingsCapabilities { get } + + // Post identity and state + var postContent: String { get } + var isScheduled: Bool { get } + var isDraftOrPending: Bool { get } + var isPost: Bool { get } + var postID: Int? { get } + var hasRemote: Bool { get } + var isDeleted: Bool { get } + + // Display strings + var navigationTitle: String { get } + var authorFallbackDisplayName: String { get } + var suggestedSlug: String? { get } + var permalinkTemplate: String? { get } + var lastEditedText: String? { get } + + // Settings management + func makeSettings() -> PostSettings + func makeFeaturedImageViewModel() -> PostSettingsFeaturedImageViewModel? + func resolveDisplayedCategories(for settings: PostSettings) -> [String] + func customTaxonomies() -> [SiteTaxonomy] + func resolveTerms(in settings: inout PostSettings) async + + // Persistence + func applyLocally(settings: PostSettings) + func save(settings: PostSettings) async throws + func publish(settings: PostSettings) async throws + + // Optional features + var isEligibleForSocialSharing: Bool { get } + func parentPageText(for pageID: Int?) -> String? + func suggestedTags() async throws -> [String] + var supportsJetpackMetadata: Bool { get } + var hasTermNames: Bool { get } +} + +extension PostSettingsDataProvider { + func parentPageText(for pageID: Int?) -> String? { nil } + + func suggestedTags() async throws -> [String] { [] } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift index b48e3e5b0fae..206993319d4b 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift @@ -35,7 +35,8 @@ final class PostSettingsViewController: UIHostingController { } static func showStandaloneEditor(for post: AbstractPost, from presentingVC: UIViewController) { - let viewModel = PostSettingsViewModel(post: post, isStandalone: true) + let provider = AbstractPostSettingsDataProvider(post: post) + let viewModel = PostSettingsViewModel(provider: provider, isStandalone: true) let postSettingsVC = PostSettingsViewController(viewModel: viewModel) let navigation = UINavigationController(rootViewController: postSettingsVC) presentingVC.present(navigation, animated: true) @@ -215,7 +216,7 @@ struct PostSettingsFormContentView: View { viewModel.didSelectTags(tags) } } label: { - PostSettingsTagsRow(tags: viewModel.displayedTags, isLoading: viewModel.isResolvingTags) + PostSettingsTagsRow(tags: viewModel.displayedTags, isLoading: !viewModel.provider.hasTermNames && viewModel.isResolvingTags) } .accessibilityIdentifier("post_settings_tags") } @@ -251,7 +252,7 @@ struct PostSettingsFormContentView: View { PostSettingsCustomTaxonomyRow( taxonomy: taxonomy, terms: viewModel.settings.getTerms(forTaxonomySlug: taxonomy.slug).map(\.name), - isLoading: viewModel.isResolvingCustomTerms + isLoading: !viewModel.provider.hasTermNames && viewModel.isResolvingCustomTerms ) } } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index c193f8fe46ee..ad788924a975 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -17,14 +17,7 @@ final class PostSettingsViewModel: NSObject, ObservableObject { let context: Context let featuredImageViewModel: PostSettingsFeaturedImageViewModel? let client: WordPressClient? - - private let details: PostDetails - private let editorService: CustomPostEditorService? - - private var abstractPost: AbstractPost? { - if case .abstractPost(let post) = details { return post } - return nil - } + let provider: PostSettingsDataProvider @Published var settings: PostSettings { didSet { @@ -47,24 +40,11 @@ final class PostSettingsViewModel: NSObject, ObservableObject { /// The content of the post, used for AI excerpt generation. var postContent: String { - switch details { - case .abstractPost(let post): - return post.content ?? "" - case .customPost(let service): - return service.post?.content.raw ?? "" - } + provider.postContent } var navigationTitle: String { - switch details { - case .abstractPost: - return isPost ? Strings.postSettingsTitle : Strings.pageSettingsTitle - case .customPost(let service): - return String.localizedStringWithFormat( - Strings.customPostSettingsTitle, - service.details.name - ) - } + provider.navigationTitle } var deletedAlertTitle: String { @@ -76,19 +56,11 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } var isScheduled: Bool { - switch details { - case .abstractPost(let post): return post.getOriginal().status == .scheduled - case .customPost(let service): return service.post?.status == .future - } + provider.isScheduled } var authorDisplayName: String { - switch details { - case .abstractPost(let post): - return settings.author?.displayName ?? post.author?.makePlainText() ?? "" - case .customPost: - return settings.author?.displayName ?? "" - } + settings.author?.displayName ?? provider.authorFallbackDisplayName } var authorAvatarURL: URL? { @@ -130,21 +102,11 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } var suggestedSlug: String? { - switch details { - case .abstractPost(let post): - return post.suggested_slug - case .customPost(let service): - return service.post?.generatedSlug - } + provider.suggestedSlug } var permalinkTemplate: String? { - switch details { - case .abstractPost(let post): - return post.permalinkTemplateURL - case .customPost(let service): - return service.post?.permalinkTemplate - } + provider.permalinkTemplate } var postFormatText: String { @@ -157,24 +119,11 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } var isDraftOrPending: Bool { - switch details { - case .abstractPost(let post): - return post.getOriginal().isStatus(in: [.draft, .pending]) - case .customPost(let service): - if let post = service.post { - return post.status == .draft || post.status == .pending - } - return true - } + provider.isDraftOrPending } var isPost: Bool { - switch details { - case .abstractPost(let post): - return post is Post - case .customPost(let service): - return service.details.slug == "post" - } + provider.isPost } var shouldShowStickyOption: Bool { @@ -185,43 +134,22 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } var lastEditedText: String? { - switch details { - case .abstractPost(let post): - guard let date = post.dateModified ?? post.dateCreated else { - return nil - } - return date.toMediumString() - case .customPost(let service): - return service.post?.modifiedGmt.toMediumString() - } + provider.lastEditedText } var postID: Int? { - switch details { - case .abstractPost(let post): - guard let postID = post.postID?.intValue, postID > 0 else { - return nil - } - return postID - case .customPost(let service): - guard let id = service.post?.id else { return nil } - return id > 0 ? Int(id) : nil - } + provider.postID } - /// The underlying Page, if this is a Core Data-backed page. + /// The underlying Page for the parent page picker. var page: Page? { - abstractPost as? Page + // FIXME: This will be improved once we add parent page selection support to custom posts. + (provider as? AbstractPostSettingsDataProvider)?.post as? Page } /// Whether the post has a remote representation (used for permalink preview). var hasRemote: Bool { - switch details { - case .abstractPost(let post): - return post.hasRemote() - case .customPost(let service): - return service.post != nil - } + provider.hasRemote } enum SocialSharingSectionState { @@ -254,105 +182,39 @@ final class PostSettingsViewModel: NSObject, ObservableObject { case publishing } - // MARK: - AbstractPost Initializer + // MARK: - Initializer init( - post: AbstractPost, + provider: PostSettingsDataProvider, isStandalone: Bool = false, context: Context = .settings, preferences: UserPersistentRepository = UserDefaults.standard ) { - self.details = .abstractPost(post) - self.blog = post.blog - self.capabilities = post is Post ? .post() : .page() + self.provider = provider + self.blog = provider.blog + self.capabilities = provider.capabilities self.isStandalone = isStandalone self.context = context self.preferences = preferences - self.client = try? WordPressClientFactory.shared.instance(for: .init(blog: post.blog)) - self.editorService = nil + self.client = try? WordPressClientFactory.shared.instance(for: .init(blog: provider.blog)) - // Initialize settings from the post - let initialSettings = PostSettings(from: post) + let initialSettings = provider.makeSettings() self.settings = initialSettings self.originalSettings = initialSettings - - // Initialize featured image view model - self.featuredImageViewModel = PostSettingsFeaturedImageViewModel(post: post) + self.featuredImageViewModel = provider.makeFeaturedImageViewModel() super.init() - // Observe selection changes from featured image view model featuredImageViewModel?.$selection.dropFirst().sink { [weak self] media in self?.settings.featuredImageID = media?.mediaID?.intValue }.store(in: &cancellables) - // Initialize all cached properties refreshDisplayedCategories() refreshDisplayedTags() refreshCustomTaxonomies() refreshParentPageText() refreshSocialSharingState() - resolveAbstractPostTerms() - - WPAnalytics.track(.postSettingsShown) - } - - // MARK: - CustomPostEditorService Initializer - - init( - editorService: CustomPostEditorService, - blog: Blog, - context: Context = .settings, - preferences: UserPersistentRepository = UserDefaults.standard - ) { - self.details = .customPost(editorService) - self.blog = blog - self.capabilities = PostSettingsCapabilities(from: editorService.details) - self.isStandalone = false - self.context = context - self.preferences = preferences - self.client = editorService.client - self.editorService = editorService - - var initialSettings = editorService.settings - // Resolve author display name from Blog's cached authors - if let authorId = initialSettings.author?.id, - let authors = blog.authors, - let author = authors.first(where: { $0.userID.intValue == authorId }) { - initialSettings.author = PostSettings.Author( - id: authorId, - displayName: author.displayName ?? "–", - avatarURL: author.avatarURL.flatMap(URL.init) - ) - } - self.settings = initialSettings - self.originalSettings = initialSettings - - if capabilities.supportsFeaturedImage { - let featuredImage = initialSettings.featuredImageID.flatMap { - Media.existingOrStubMediaWith( - mediaID: NSNumber(value: $0), - inBlog: blog - ) - } - self.featuredImageViewModel = PostSettingsFeaturedImageViewModel( - blog: blog, - featuredImage: featuredImage - ) - } else { - self.featuredImageViewModel = nil - } - - super.init() - - featuredImageViewModel?.$selection.dropFirst().sink { [weak self] media in - self?.settings.featuredImageID = media?.mediaID?.intValue - }.store(in: &cancellables) - - refreshDisplayedCategories() - refreshDisplayedTags() - refreshCustomTaxonomies() - resolveTermNames() + resolveTerms() WPAnalytics.track(.postSettingsShown) } @@ -362,8 +224,7 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } func shouldShow(_ row: Row) -> Bool { - // FIXME: meta support missing in AnyPostWithEditContext - guard case .abstractPost = details else { return false } + guard provider.supportsJetpackMetadata else { return false } switch row { case .jetpackAccessLevel: return blog.supports(.wpComRESTAPI) @@ -375,15 +236,13 @@ final class PostSettingsViewModel: NSObject, ObservableObject { // MARK: - Suggested Tags private func refreshSuggestedTags() { - guard let abstractPost, isSuggestedTagsRefreshNeeded else { - return - } + guard isSuggestedTagsRefreshNeeded else { return } isSuggestedTagsRefreshNeeded = false let task = Task { @MainActor [weak self] in do { - let tags = try await TagSuggestionsService().getSuggestedTags(for: abstractPost) guard let self else { return } + let tags = try await provider.suggestedTags() if !tags.isEmpty { withAnimation { self.suggestedTags = tags @@ -401,83 +260,25 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } private func refreshCustomTaxonomies() { - switch details { - case .abstractPost(let post): - let postType: String? = switch post { - case is Post: "post" - case is Page: "page" - default: nil - } - guard let postType else { - customTaxonomies = [] - return - } - let taxonomies = try? blog.taxonomies - .filter { - $0.slug != "post_tag" && $0.slug != "category" && $0.supportedPostTypes.contains(postType) - } - .sorted(using: KeyPathComparator(\.name)) - customTaxonomies = taxonomies ?? [] - - case .customPost(let service): - customTaxonomies = service.taxonomies - } + customTaxonomies = provider.customTaxonomies() } // MARK: - Term Resolution - /// Resolves tags with `id == 0` in AbstractPost by searching the server. - /// AbstractPost stores tags as name-only strings, so they all start with - /// `id == 0` and need their IDs resolved. - private func resolveAbstractPostTerms() { - let pendingNames = settings.tags.filter { $0.id == 0 }.map(\.name) - guard !pendingNames.isEmpty else { return } - - // No need to set `isResolvingTags`, because the tag name is available to be displayed on screen. - + private func resolveTerms() { Task { [weak self] in guard let self else { return } - let service = TagsService(blog: blog) - let resolved = await service.resolveTerms(named: pendingNames) - for (name, existing) in resolved { - if let index = settings.tags.firstIndex(where: { $0.name == name }) { - settings.tags[index] = PostSettings.Term(id: Int(existing.id), name: existing.name) - } - } - refreshDisplayedTags() - } - } - - private func resolveTermNames() { - guard let editorService else { return } + isResolvingTags = true + isResolvingCustomTerms = true - isResolvingTags = true - isResolvingCustomTerms = !settings.otherTerms.isEmpty - - Task { [weak self] in - guard let self else { return } + var currentSettings = self.settings + await provider.resolveTerms(in: ¤tSettings) - do { - let tagsService = AnyTermService(client: editorService.client, endpoint: .tags) - let resolvedTags = try await TermResolutionService(taxonomyService: tagsService) - .resolveNames(for: settings.tags) - self.settings.tags = resolvedTags - self.refreshDisplayedTags() - self.isResolvingTags = false - - for taxonomy in editorService.taxonomies { - guard let slugTerms = self.settings.otherTerms[taxonomy.slug] else { continue } - let termService = AnyTermService(client: editorService.client, endpoint: taxonomy.endpoint) - let resolved = try await TermResolutionService(taxonomyService: termService) - .resolveNames(for: slugTerms) - self.settings.otherTerms[taxonomy.slug] = resolved - } - self.isResolvingCustomTerms = false - } catch { - // TODO: We need better error handling - Loggers.app.log(level: .error, "Failed to resolve taxonomy terms: \(error)") - } + self.settings = currentSettings + isResolvingTags = false + isResolvingCustomTerms = false + refreshDisplayedTags() } } @@ -498,12 +299,7 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } private func refreshDisplayedCategories() { - switch details { - case .abstractPost(let post): - displayedCategories = settings.getCategoryNames(for: post) - case .customPost: - displayedCategories = settings.getCategoryNames(for: blog) - } + displayedCategories = provider.resolveDisplayedCategories(for: settings) } private func refreshDisplayedTags() { @@ -511,13 +307,7 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } private func refreshParentPageText() { - if let page, - let context = page.managedObjectContext, - let parentPageID = settings.parentPageID { - parentPageText = Page.parentPageText(in: context, parentID: NSNumber(value: parentPageID)) - } else { - parentPageText = nil - } + parentPageText = provider.parentPageText(for: settings.parentPageID) } // MARK: - Actions @@ -527,25 +317,14 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } func buttonSaveTapped() { - switch details { - case .abstractPost(let post): - buttonSaveTappedForAbstractPost(post) - case .customPost: - buttonSaveTappedForCustomPost() - } - } - - private func buttonSaveTappedForAbstractPost(_ post: AbstractPost) { - // Check if the post still exists - guard let context = post.managedObjectContext, - let _ = try? context.existingObject(with: post.objectID) else { + guard !provider.isDeleted else { isShowingDeletedAlert = true return } guard isStandalone else { // Apply settings and return to the editor (editor-specific) - settings.apply(to: post) + provider.applyLocally(settings: settings) didSaveChanges() wpAssert(onEditorPostSaved != nil, "configuration missing") onEditorPostSaved?() @@ -553,47 +332,11 @@ final class PostSettingsViewModel: NSObject, ObservableObject { return } - isSaving = true - Task { - do { - let settings = getSettingsToSave(for: self.settings) - let coordinator = PostCoordinator.shared - if coordinator.isSyncAllowed(for: post) && post.status == settings.status { - let revision = post.createRevision() - settings.apply(to: revision) - coordinator.setNeedsSync(for: revision) - } else { - let changes = settings.makeUpdateParameters(from: post) - try await coordinator.save(post, changes: changes) - } - didSaveChanges() - onDismiss?() - } catch { - isSaving = false - } - } - } - - private func buttonSaveTappedForCustomPost() { - guard let editorService else { - wpAssertionFailure("missing editor service") - return - } - - guard isStandalone else { - let settingsToSave = getSettingsToSave(for: settings) - editorService.applyLocally(settings: settingsToSave) - didSaveChanges() - onEditorPostSaved?() - onDismiss?() - return - } - isSaving = true Task { do { let settingsToSave = getSettingsToSave(for: settings) - try await editorService.save(settings: settingsToSave, publish: false) + try await provider.save(settings: settingsToSave) didSaveChanges() onEditorPostSaved?() onDismiss?() @@ -618,18 +361,7 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } func buttonPublishTapped() { - switch details { - case .abstractPost(let post): - publishAbstractPost(post) - case .customPost: - publishCustomPost() - } - } - - private func publishAbstractPost(_ post: AbstractPost) { - // Check if the post still exists - guard let context = post.managedObjectContext, - let _ = try? context.existingObject(with: post.objectID) else { + guard !provider.isDeleted else { isShowingDeletedAlert = true return } @@ -637,27 +369,7 @@ final class PostSettingsViewModel: NSObject, ObservableObject { isSaving = true Task { do { - let coordinator = PostCoordinator.shared - let changes = settings.makeUpdateParameters(from: post) - try await coordinator.publish(post.getOriginal(), parameters: changes) - onPostPublished?() - } catch { - isSaving = false - // `PostCoordinator` handles errors by showing an alert when needed - } - } - } - - private func publishCustomPost() { - guard let editorService else { - wpAssertionFailure("missing editor service") - return - } - - isSaving = true - Task { - do { - try await editorService.save(settings: settings, publish: true) + try await provider.publish(settings: settings) onPostPublished?() } catch { isSaving = false @@ -704,7 +416,7 @@ final class PostSettingsViewModel: NSObject, ObservableObject { // MARK: - Social Sharing private func refreshSocialSharingState() { - guard let post = abstractPost as? Post, isPostEligibleForSocialSharing(post) else { + guard provider.isEligibleForSocialSharing else { socialSharingState = nil return } @@ -719,19 +431,6 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } } - private func isPostEligibleForSocialSharing(_ post: Post) -> Bool { - BuildSettings.current.brand == .jetpack - && RemoteFeatureFlag.jetpackSocialImprovements.enabled() - && post.status != .publishPrivate - && !getPublicizeServices().isEmpty - && blog.supports(.publicize) - } - - private func getPublicizeServices() -> [PublicizeService] { - let context = ContextManager.shared.mainContext - return (try? PublicizeService.allSupportedServices(in: context)) ?? [] - } - /// Convenience variable representing whether the No Connection view has been dismissed. /// Note: the value is stored per site. private var isSocialConnectionSetupDismissed: Bool { @@ -753,6 +452,11 @@ final class PostSettingsViewModel: NSObject, ObservableObject { } } + private func getPublicizeServices() -> [PublicizeService] { + let context = ContextManager.shared.mainContext + return (try? PublicizeService.allSupportedServices(in: context)) ?? [] + } + private func makeSocialSharingSetupViewModel() -> JetpackSocialNoConnectionViewModel { JetpackSocialNoConnectionViewModel( services: getPublicizeServices(), @@ -917,32 +621,9 @@ extension PostFormat { } } -private enum PostDetails { - case abstractPost(AbstractPost) - case customPost(CustomPostEditorService) -} - // MARK: - Localized Strings private enum Strings { - static let postSettingsTitle = NSLocalizedString( - "postSettings.navigationTitle.post", - value: "Post Settings", - comment: "The title of the Post Settings screen." - ) - - static let pageSettingsTitle = NSLocalizedString( - "postSettings.navigationTitle.page", - value: "Page Settings", - comment: "The title of the Page Settings screen." - ) - - static let customPostSettingsTitle = NSLocalizedString( - "postSettings.navigationTitle.customPostType", - value: "%1$@ Settings", - comment: "The title of the Post Settings screen for custom post types. %1$@ is the post type name." - ) - static let saveFailedMessage = NSLocalizedString( "postSettings.saveFailed.message", value: "Failed to save changes", diff --git a/WordPress/Classes/ViewRelated/Post/Publishing/PublishPostViewController.swift b/WordPress/Classes/ViewRelated/Post/Publishing/PublishPostViewController.swift index 11e8f1cb493a..6dd43b2538aa 100644 --- a/WordPress/Classes/ViewRelated/Post/Publishing/PublishPostViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Publishing/PublishPostViewController.swift @@ -26,7 +26,8 @@ final class PublishPostViewController: UIHostingController { var onCompletion: ((PublishingSheetResult) -> Void)? init(post: AbstractPost, isStandalone: Bool) { - let viewModel = PostSettingsViewModel(post: post, isStandalone: isStandalone, context: .publishing) + let provider = AbstractPostSettingsDataProvider(post: post) + let viewModel = PostSettingsViewModel(provider: provider, isStandalone: isStandalone, context: .publishing) self.viewModel = viewModel let uploadsViewModel = PostMediaUploadsViewModel(post: post) @@ -57,7 +58,8 @@ final class PublishPostViewController: UIHostingController { editorService: CustomPostEditorService, blog: Blog ) { - let viewModel = PostSettingsViewModel(editorService: editorService, blog: blog, context: .publishing) + let provider = CustomPostSettingsDataProvider(editorService: editorService, blog: blog) + let viewModel = PostSettingsViewModel(provider: provider, context: .publishing) self.viewModel = viewModel self.uploadsViewModel = nil