From b966e2e7672cdf20fe771997f9e7cd9b1bbe8eb5 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 27 Oct 2023 13:38:47 -0400 Subject: [PATCH 1/6] Move post deletion to PostCoordinator --- .../Classes/Services/PostCoordinator.swift | 110 +++++++++++++++- ...sAppDelegate+PostCoordinatorDelegate.swift | 27 ++++ .../Classes/System/WordPressAppDelegate.swift | 1 + .../Pages/PageListViewController.swift | 21 ---- .../Post/AbstractPostListViewController.swift | 119 +----------------- .../Post/PostListViewController.swift | 21 ---- WordPress/WordPress.xcodeproj/project.pbxproj | 6 + 7 files changed, 147 insertions(+), 158 deletions(-) create mode 100644 WordPress/Classes/System/WordPressAppDelegate+PostCoordinatorDelegate.swift diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index a26a3ba81441..63ca319fe7c6 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -2,7 +2,11 @@ import Aztec import Foundation import WordPressFlux -class PostCoordinator: NSObject { +protocol PostCoordinatorDelegate: AnyObject { + func postCoordinator(_ postCoordinator: PostCoordinator, promptForPasswordForBlog blog: Blog) +} + +final class PostCoordinator: NSObject { enum SavingError: Error { case mediaFailure(AbstractPost) @@ -17,8 +21,11 @@ class PostCoordinator: NSObject { coreDataStack.mainContext } + weak var delegate: PostCoordinatorDelegate? + private let queue = DispatchQueue(label: "org.wordpress.postcoordinator") + private var pendingDeletionPostIDs: Set = [] private var observerUUIDs: [AbstractPost: UUID] = [:] private let mediaCoordinator: MediaCoordinator @@ -469,6 +476,107 @@ class PostCoordinator: NSObject { self.actionDispatcherFacade.dispatch(NoticeAction.post(model.notice)) } } + + // MARK: - Trash/Delete + + @MainActor + func isDeleting(_ post: AbstractPost) -> Bool { + pendingDeletionPostIDs.contains(post.objectID) + } + + /// Moves the post to trash or delets it permanently in case it's already in trash. + @MainActor + func delete(_ post: AbstractPost) async { + assert(post.managedObjectContext == mainContext) + + WPAnalytics.track(.postListTrashAction, withProperties: propertiesForAnalytics(for: post)) + + pendingDeletionPostIDs.insert(post.objectID) + + let originalStatus = post.status + + let trashed = (post.status == .trash) + + let repository = PostRepository(coreDataStack: ContextManager.shared) + do { + try await repository.trash(TaggedManagedObjectID(post)) + + if trashed { + cancelAnyPendingSaveOf(post: post) + MediaCoordinator.shared.cancelUploadOfAllMedia(for: post) + } + + // Remove the trashed post from spotlight + SearchManager.shared.deleteSearchableItem(post) + + let message: String + if post is Post { + message = NSLocalizedString("postsList.movePostToTrash.message", value: "Post moved to trash", comment: "A short message explaining that a post was moved to the trash bin.") + } else { + message = NSLocalizedString("postsList.movePageToTrash.message", value: "Page moved to trash", comment: "A short message explaining that a page was moved to the trash bin.") + } + let undoAction = NSLocalizedString("postsList.movePostToTrash.undo", value: "Undo", comment: "The title of an 'undo' button. Tapping the button moves a trashed post or page out of the trash folder.") + + let notice = Notice(title: message, actionTitle: undoAction, actionHandler: { [weak self] accepted in + if accepted { + Task { + await self?.restore(post, toStatus: originalStatus ?? .draft) + } + } + }) + ActionDispatcher.dispatch(NoticeAction.dismiss) + ActionDispatcher.dispatch(NoticeAction.post(notice)) + + pendingDeletionPostIDs.remove(post.objectID) + } catch { + if let error = error as NSError?, error.code == Constants.httpCodeForbidden { + delegate?.postCoordinator(self, promptForPasswordForBlog: post.blog) + } else { + WPError.showXMLRPCErrorAlert(error) + } + + pendingDeletionPostIDs.remove(post.objectID) + } + } + + // MARK: - Restore + + @MainActor + private func restore(_ post: AbstractPost, toStatus status: BasePost.Status) async { + WPAnalytics.track(.postListRestoreAction, withProperties: propertiesForAnalytics(for: post)) + let repository = PostRepository(coreDataStack: ContextManager.shared) + Task { @MainActor in + do { + try await repository.restore(.init(post), to: status) + + // Reindex the restored post in spotlight + SearchManager.shared.indexItem(post) + } catch { + if let error = error as NSError?, error.code == Constants.httpCodeForbidden { + delegate?.postCoordinator(self, promptForPasswordForBlog: post.blog) + } else { + WPError.showXMLRPCErrorAlert(error) + } + } + } + } + + private func propertiesForAnalytics(for post: AbstractPost) -> [String: AnyObject] { + var properties = [String: AnyObject]() + properties["type"] = ((post is Post) ? "post" : "page") as AnyObject + if let dotComID = post.blog.dotComID { + properties[WPAppAnalyticsKeyBlogID] = dotComID + } + return properties + } +} + +private struct Constants { + static let httpCodeForbidden = 403 +} + +extension Foundation.Notification.Name { + static let didChangePostCordinatorStatus = "org.automattic.didChangePostCordinatorStatus" } // MARK: - Automatic Uploads diff --git a/WordPress/Classes/System/WordPressAppDelegate+PostCoordinatorDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate+PostCoordinatorDelegate.swift new file mode 100644 index 000000000000..4f0cec5f647f --- /dev/null +++ b/WordPress/Classes/System/WordPressAppDelegate+PostCoordinatorDelegate.swift @@ -0,0 +1,27 @@ +import UIKit + +extension WordPressAppDelegate: PostCoordinatorDelegate { + func postCoordinator(_ postCoordinator: PostCoordinator, promptForPasswordForBlog blog: Blog) { + showPasswordInvalidPrompt(for: blog) + } + + func showPasswordInvalidPrompt(for blog: Blog) { + WPError.showAlert(withTitle: Strings.unableToConnect, message: Strings.invalidPasswordMessage, withSupportButton: true) { _ in + + let editSiteViewController = SiteSettingsViewController(blog: blog) + + let navController = UINavigationController(rootViewController: editSiteViewController!) + + editSiteViewController?.navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak navController] _ in + navController?.presentingViewController?.dismiss(animated: true) + }) + + self.window?.topmostPresentedViewController?.present(navController, animated: true) + } + } +} + +private enum Strings { + static let invalidPasswordMessage = NSLocalizedString("common.reEnterPasswordMessage", value: "The username or password stored in the app may be out of date. Please re-enter your password in the settings and try again.", comment: "Error message informing a user about an invalid password.") + static let unableToConnect = NSLocalizedString("common.unableToConnect", value: "Unable to Connect", comment: "An error message.") +} diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 70de76da1865..d9ed7619cffa 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -93,6 +93,7 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { AppAppearance.overrideAppearance() MemoryCache.shared.register() MediaImageService.migrateCacheIfNeeded() + PostCoordinator.shared.delegate = self // Start CrashLogging as soon as possible (in case a crash happens during startup) try? loggingStack.start() diff --git a/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift b/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift index 7d114cd91e3f..b8b166834119 100644 --- a/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift +++ b/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift @@ -436,27 +436,6 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe } } - override func promptThatPostRestoredToFilter(_ filter: PostListFilter) { - var message = NSLocalizedString("Page Restored to Drafts", comment: "Prompts the user that a restored page was moved to the drafts list.") - - switch filter.filterType { - case .published: - message = NSLocalizedString("Page Restored to Published", comment: "Prompts the user that a restored page was moved to the published list.") - break - case .scheduled: - message = NSLocalizedString("Page Restored to Scheduled", comment: "Prompts the user that a restored page was moved to the scheduled list.") - break - default: - break - } - - let alertCancel = NSLocalizedString("OK", comment: "Title of an OK button. Pressing the button acknowledges and dismisses a prompt.") - - let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert) - alertController.addCancelActionWithTitle(alertCancel, handler: nil) - alertController.presentFromRootViewController() - } - // MARK: - Cell Action Handling fileprivate func handleMenuAction(fromCell cell: UITableViewCell, fromButton button: UIButton, forPage page: AbstractPost) { diff --git a/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift b/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift index 62294b0dfe75..107fa3f6bab1 100644 --- a/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift @@ -611,7 +611,7 @@ class AbstractPostListViewController: UIViewController, @objc func handleSyncFailure(_ error: NSError) { if error.domain == WPXMLRPCFaultErrorDomain && error.code == type(of: self).httpErrorCodeForbidden { - promptForPassword() + WordPressAppDelegate.shared?.showPasswordInvalidPrompt(for: blog) return } @@ -628,23 +628,6 @@ class AbstractPostListViewController: UIViewController, } } - @objc func promptForPassword() { - let message = NSLocalizedString("The username or password stored in the app may be out of date. Please re-enter your password in the settings and try again.", comment: "Error message informing a user about an invalid password.") - - // bad login/pass combination - let editSiteViewController = SiteSettingsViewController(blog: blog) - - let navController = UINavigationController(rootViewController: editSiteViewController!) - navController.navigationBar.isTranslucent = false - - navController.modalTransitionStyle = .crossDissolve - navController.modalPresentationStyle = .formSheet - - WPError.showAlert(withTitle: NSLocalizedString("Unable to Connect", comment: "An error message."), message: message, withSupportButton: true) { _ in - self.present(navController, animated: true) - } - } - // MARK: - Actions @objc func publishPost(_ apost: AbstractPost, completion: (() -> Void)? = nil) { @@ -690,99 +673,9 @@ class AbstractPostListViewController: UIViewController, navigationController?.present(navWrapper, animated: true) } - @MainActor - func deletePost(_ apost: AbstractPost) async { - assert(apost.managedObjectContext == ContextManager.shared.mainContext) - - WPAnalytics.track(.postListTrashAction, withProperties: propertiesForAnalytics()) - - // Remove the trashed post from spotlight - SearchManager.shared.deleteSearchableItem(apost) - - // Update the fetch request *before* making the service call. - updateAndPerformFetchRequest() - - let originalStatus = apost.status - - let trashed = (apost.status == .trash) - - let repository = PostRepository(coreDataStack: ContextManager.shared) - do { - try await repository.trash(TaggedManagedObjectID(apost)) - if trashed { - PostCoordinator.shared.cancelAnyPendingSaveOf(post: apost) - MediaCoordinator.shared.cancelUploadOfAllMedia(for: apost) - } - - var message = "" - switch postTypeToSync() { - case .post: - message = NSLocalizedString("postsList.movePostToTrash.message", value: "Post moved to trash", comment: "A short message explaining that a post was moved to the trash bin.") - case .page: - message = NSLocalizedString("postsList.movePageToTrash.message", value: "Page moved to trash", comment: "A short message explaining that a page was moved to the trash bin.") - default: - break - } - let undoAction = NSLocalizedString("postsList.movePostToTrash.undo", value: "Undo", comment: "The title of an 'undo' button. Tapping the button moves a trashed post or page out of the trash folder.") - - let notice = Notice(title: message, actionTitle: undoAction, actionHandler: { [weak self] accepted in - if accepted { - self?.restorePost(apost, toStatus: originalStatus ?? .draft) - } - }) - ActionDispatcher.dispatch(NoticeAction.dismiss) - ActionDispatcher.dispatch(NoticeAction.post(notice)) - } catch { - if let error = error as NSError?, error.code == AbstractPostListViewController.httpErrorCodeForbidden { - promptForPassword() - } else { - WPError.showXMLRPCErrorAlert(error) - } - - // We don't really know what happened here, why did the request fail? - // Maybe we could not delete the post or maybe the post was already deleted - // It is safer to re fetch the results than to reload that specific row - DispatchQueue.main.async { - self.updateAndPerformFetchRequestRefreshingResults() - } - } - } - - func restorePost(_ apost: AbstractPost, toStatus status: BasePost.Status) { - WPAnalytics.track(.postListRestoreAction, withProperties: propertiesForAnalytics()) - - if filterSettings.currentPostListFilter().filterType != .draft { - // Needed or else the post will remain in the published list. - updateAndPerformFetchRequest() - tableView.reloadData() - } - - let repository = PostRepository(coreDataStack: ContextManager.shared) - Task { @MainActor in - do { - try await repository.restore(.init(apost), to: status) - - if let postStatus = apost.status { - // If the post was restored, see if it appears in the current filter. - // If not, prompt the user to let it know under which filter it appears. - let filter = filterSettings.filterThatDisplaysPostsWithStatus(postStatus) - - if filter.filterType == filterSettings.currentPostListFilter().filterType { - return - } - - promptThatPostRestoredToFilter(filter) - - // Reindex the restored post in spotlight - SearchManager.shared.indexItem(apost) - } - } catch { - if let error = error as NSError?, error.code == AbstractPostListViewController.httpErrorCodeForbidden { - promptForPassword() - } else { - WPError.showXMLRPCErrorAlert(error) - } - } + func deletePost(_ post: AbstractPost) { + Task { + await PostCoordinator.shared.delete(post) } } @@ -796,10 +689,6 @@ class AbstractPostListViewController: UIViewController, ActionDispatcher.dispatch(NoticeAction.post(notice)) } - @objc func promptThatPostRestoredToFilter(_ filter: PostListFilter) { - assert(false, "You should implement this method in the subclass") - } - private func dismissAllNetworkErrorNotices() { dismissNoNetworkAlert() WPError.dismissNetworkingNotice() diff --git a/WordPress/Classes/ViewRelated/Post/PostListViewController.swift b/WordPress/Classes/ViewRelated/Post/PostListViewController.swift index 78c1aff88fda..4cc47b4fb7eb 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListViewController.swift @@ -305,27 +305,6 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe PostListEditorPresenter.handleCopy(post: post, in: self) } - override func promptThatPostRestoredToFilter(_ filter: PostListFilter) { - var message = NSLocalizedString("Post Restored to Drafts", comment: "Prompts the user that a restored post was moved to the drafts list.") - - switch filter.filterType { - case .published: - message = NSLocalizedString("Post Restored to Published", comment: "Prompts the user that a restored post was moved to the published list.") - break - case .scheduled: - message = NSLocalizedString("Post Restored to Scheduled", comment: "Prompts the user that a restored post was moved to the scheduled list.") - break - default: - break - } - - let alertCancel = NSLocalizedString("OK", comment: "Title of an OK button. Pressing the button acknowledges and dismisses a prompt.") - - let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert) - alertController.addCancelActionWithTitle(alertCancel, handler: nil) - alertController.presentFromRootViewController() - } - fileprivate func viewStatsForPost(_ apost: AbstractPost) { // Check the blog let blog = apost.blog diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index c9c70c2c1825..c28a282b9a87 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -496,6 +496,8 @@ 0CB424F42ADF3CBE0080B807 /* PostSearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB424F32ADF3CBE0080B807 /* PostSearchViewModelTests.swift */; }; 0CB424F62AE0416D0080B807 /* SolidColorActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB424F52AE0416D0080B807 /* SolidColorActivityIndicator.swift */; }; 0CB424F72AE0416D0080B807 /* SolidColorActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB424F52AE0416D0080B807 /* SolidColorActivityIndicator.swift */; }; + 0CB54F572AEC320700582080 /* WordPressAppDelegate+PostCoordinatorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB54F562AEC320700582080 /* WordPressAppDelegate+PostCoordinatorDelegate.swift */; }; + 0CB54F582AEC320700582080 /* WordPressAppDelegate+PostCoordinatorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB54F562AEC320700582080 /* WordPressAppDelegate+PostCoordinatorDelegate.swift */; }; 0CD223DF2AA8ADFD002BD761 /* DashboardQuickActionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD223DE2AA8ADFD002BD761 /* DashboardQuickActionsViewModel.swift */; }; 0CD223E02AA8ADFD002BD761 /* DashboardQuickActionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD223DE2AA8ADFD002BD761 /* DashboardQuickActionsViewModel.swift */; }; 0CD382832A4B699E00612173 /* DashboardBlazeCardCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD382822A4B699E00612173 /* DashboardBlazeCardCellViewModel.swift */; }; @@ -6170,6 +6172,7 @@ 0CB424F02ADEE52A0080B807 /* PostSearchToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSearchToken.swift; sourceTree = ""; }; 0CB424F32ADF3CBE0080B807 /* PostSearchViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSearchViewModelTests.swift; sourceTree = ""; }; 0CB424F52AE0416D0080B807 /* SolidColorActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolidColorActivityIndicator.swift; sourceTree = ""; }; + 0CB54F562AEC320700582080 /* WordPressAppDelegate+PostCoordinatorDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WordPressAppDelegate+PostCoordinatorDelegate.swift"; sourceTree = ""; }; 0CD223DE2AA8ADFD002BD761 /* DashboardQuickActionsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardQuickActionsViewModel.swift; sourceTree = ""; }; 0CD382822A4B699E00612173 /* DashboardBlazeCardCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardBlazeCardCellViewModel.swift; sourceTree = ""; }; 0CD382852A4B6FCE00612173 /* DashboardBlazeCardCellViewModelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardBlazeCardCellViewModelTest.swift; sourceTree = ""; }; @@ -13747,6 +13750,7 @@ BE87E19E1BD4052F0075D45B /* 3DTouch */, B5FD4520199D0C9A00286FBB /* WordPress-Bridging-Header.h */, 1749965E2271BF08007021BD /* WordPressAppDelegate.swift */, + 0CB54F562AEC320700582080 /* WordPressAppDelegate+PostCoordinatorDelegate.swift */, 43B0BA952229927F00328C69 /* WordPressAppDelegate+openURL.swift */, F1E3536A25B9F74C00992E3A /* WindowManager.swift */, 591A428D1A6DC6F2003807A6 /* WPGUIConstants.h */, @@ -21921,6 +21925,7 @@ 983DBBAB22125DD500753988 /* StatsTableFooter.swift in Sources */, 85D239AE1AE5A5FC0074768D /* BlogSyncFacade.m in Sources */, 0CAE8EF22A9E9E8D0073EEB9 /* SiteMediaCollectionCell.swift in Sources */, + 0CB54F572AEC320700582080 /* WordPressAppDelegate+PostCoordinatorDelegate.swift in Sources */, 8B0732F3242BF99B00E7FBD3 /* PrepublishingNavigationController.swift in Sources */, 5D97C2F315CAF8D8009B44DD /* UINavigationController+KeyboardFix.m in Sources */, 4034FDEA2007C42400153B87 /* ExpandableCell.swift in Sources */, @@ -24682,6 +24687,7 @@ FABB23672602FC2C00C8785C /* NotificationAction.swift in Sources */, 3F39C93627A09927001EC300 /* WordPressLibraryLogger.swift in Sources */, FABB23682602FC2C00C8785C /* NSManagedObject.swift in Sources */, + 0CB54F582AEC320700582080 /* WordPressAppDelegate+PostCoordinatorDelegate.swift in Sources */, FABB23692602FC2C00C8785C /* ExportableAsset.swift in Sources */, FABB236A2602FC2C00C8785C /* PlanListViewModel.swift in Sources */, FABB236B2602FC2C00C8785C /* InsightsManagementViewController.swift in Sources */, From b860290969fa1a4737800b11b96d7685e6e6bf8e Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 27 Oct 2023 16:26:37 -0400 Subject: [PATCH 2/6] Update post cell status when post is being deleted --- .../Classes/Services/PostCoordinator.swift | 24 ++++++++++++------- .../Post/AbstractPostListViewController.swift | 19 +++++++++++++++ .../Post/PostCardStatusViewModel.swift | 11 +++++++++ .../ViewRelated/Post/PostListCell.swift | 4 ++++ .../Post/PostListItemViewModel.swift | 2 ++ 5 files changed, 52 insertions(+), 8 deletions(-) diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index 63ca319fe7c6..fac9e56a6f1d 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -479,7 +479,6 @@ final class PostCoordinator: NSObject { // MARK: - Trash/Delete - @MainActor func isDeleting(_ post: AbstractPost) -> Bool { pendingDeletionPostIDs.contains(post.objectID) } @@ -491,10 +490,9 @@ final class PostCoordinator: NSObject { WPAnalytics.track(.postListTrashAction, withProperties: propertiesForAnalytics(for: post)) - pendingDeletionPostIDs.insert(post.objectID) + setPendingDeletion(true, post: post) let originalStatus = post.status - let trashed = (post.status == .trash) let repository = PostRepository(coreDataStack: ContextManager.shared) @@ -527,7 +525,7 @@ final class PostCoordinator: NSObject { ActionDispatcher.dispatch(NoticeAction.dismiss) ActionDispatcher.dispatch(NoticeAction.post(notice)) - pendingDeletionPostIDs.remove(post.objectID) + setPendingDeletion(false, post: post) } catch { if let error = error as NSError?, error.code == Constants.httpCodeForbidden { delegate?.postCoordinator(self, promptForPasswordForBlog: post.blog) @@ -535,12 +533,10 @@ final class PostCoordinator: NSObject { WPError.showXMLRPCErrorAlert(error) } - pendingDeletionPostIDs.remove(post.objectID) + setPendingDeletion(false, post: post) } } - // MARK: - Restore - @MainActor private func restore(_ post: AbstractPost, toStatus status: BasePost.Status) async { WPAnalytics.track(.postListRestoreAction, withProperties: propertiesForAnalytics(for: post)) @@ -561,6 +557,17 @@ final class PostCoordinator: NSObject { } } + private func setPendingDeletion(_ isDeleting: Bool, post: AbstractPost) { + if isDeleting { + pendingDeletionPostIDs.insert(post.objectID) + } else { + pendingDeletionPostIDs.remove(post.objectID) + } + NotificationCenter.default.post(name: .postCoordinatorDidUpdate, object: self, userInfo: [ + NSUpdatedObjectsKey: Set([post]) + ]) + } + private func propertiesForAnalytics(for post: AbstractPost) -> [String: AnyObject] { var properties = [String: AnyObject]() properties["type"] = ((post is Post) ? "post" : "page") as AnyObject @@ -576,7 +583,8 @@ private struct Constants { } extension Foundation.Notification.Name { - static let didChangePostCordinatorStatus = "org.automattic.didChangePostCordinatorStatus" + /// Contains a set of updated objects under the `NSUpdatedObjectsKey` key + static let postCoordinatorDidUpdate = Foundation.Notification.Name("org.automattic.postCoordinatorDidUpdate") } // MARK: - Automatic Uploads diff --git a/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift b/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift index 107fa3f6bab1..dc858fdb4fcc 100644 --- a/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift @@ -112,6 +112,8 @@ class AbstractPostListViewController: UIViewController, tableView.reloadData() observeNetworkStatus() + + NotificationCenter.default.addObserver(self, selector: #selector(postCoordinatorDidUpdate), name: .postCoordinatorDidUpdate, object: nil) } override func viewWillAppear(_ animated: Bool) { @@ -238,6 +240,23 @@ class AbstractPostListViewController: UIViewController, return properties } + // MARK: - Notifications + + @objc private func postCoordinatorDidUpdate(_ notification: Foundation.Notification) { + guard let updatedObjects = (notification.userInfo?[NSUpdatedObjectsKey] as? Set) else { + return + } + let updatedIndexPaths = (tableView.indexPathsForVisibleRows ?? []).filter { + guard let post = tableViewHandler.resultsController?.object(at: $0) as? NSManagedObject else { return false } + return updatedObjects.contains(post) + } + if !updatedIndexPaths.isEmpty { + tableView.beginUpdates() + tableView.reloadRows(at: updatedIndexPaths, with: .automatic) + tableView.endUpdates() + } + } + // MARK: - Author Filter private func configureAuthorFilter() { diff --git a/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift index 89a973a3a753..71c49d3802d5 100644 --- a/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift @@ -63,6 +63,8 @@ class PostCardStatusViewModel: NSObject { // TODO Move these string constants to the StatusMessages enum if MediaCoordinator.shared.isUploadingMedia(for: post) { return NSLocalizedString("Uploading media...", comment: "Message displayed on a post's card while the post is uploading media") + } else if PostCoordinator.shared.isDeleting(post) { + return post.status == .trash ? Strings.deletingPostPermanently : Strings.movingPostToTrash } else if post.isFailed { return generateFailedStatusMessage() } else if post.remoteStatus == .pushing { @@ -99,6 +101,10 @@ class PostCardStatusViewModel: NSObject { return .neutral(.shade30) } + if PostCoordinator.shared.isDeleting(post) { + return .systemRed + } + if post.isFailed && isInternetReachable { return .error } @@ -270,3 +276,8 @@ class PostCardStatusViewModel: NSObject { comment: "Message displayed on a post's card when the post has unsaved changes") } } + +private enum Strings { + static let movingPostToTrash = NSLocalizedString("post.movingToTrashStatusMessage", value: "Moving post to trash...", comment: "Status mesasge for post cells") + static let deletingPostPermanently = NSLocalizedString("post.deletingPostPermanentlyStatusMessage", value: "Deleting post...", comment: "Status mesasge for post cells") +} diff --git a/WordPress/Classes/ViewRelated/Post/PostListCell.swift b/WordPress/Classes/ViewRelated/Post/PostListCell.swift index f2bf514b2e16..15c8f73ed050 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListCell.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListCell.swift @@ -2,6 +2,7 @@ import Foundation import UIKit final class PostListCell: UITableViewCell, PostSearchResultCell, Reusable { + var isEnabled = true // MARK: - Views @@ -67,6 +68,9 @@ final class PostListCell: UITableViewCell, PostSearchResultCell, Reusable { statusLabel.text = viewModel.status statusLabel.textColor = viewModel.statusColor statusLabel.isHidden = viewModel.status.isEmpty + + contentView.isUserInteractionEnabled = viewModel.isEnabled + contentStackView.alpha = viewModel.isEnabled ? 1.0 : 0.33 } // MARK: - Setup diff --git a/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift index c37f8443624d..b0650e42413a 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift @@ -6,6 +6,7 @@ final class PostListItemViewModel { let imageURL: URL? let badges: NSAttributedString let accessibilityIdentifier: String? + let isEnabled: Bool let statusViewModel: PostCardStatusViewModel var status: String { statusViewModel.statusAndBadges(separatedBy: " ยท ")} @@ -17,6 +18,7 @@ final class PostListItemViewModel { self.imageURL = post.featuredImageURL self.badges = makeBadgesString(for: post) self.statusViewModel = PostCardStatusViewModel(post: post) + self.isEnabled = !PostCoordinator.shared.isDeleting(post) self.accessibilityIdentifier = post.slugForDisplay() } } From bdcf73686beaf7f7deb4eaf0b8d237dcffd555f6 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 27 Oct 2023 16:39:23 -0400 Subject: [PATCH 3/6] Add support for post deletion status in search --- WordPress/Classes/ViewRelated/Post/PostListCell.swift | 1 - .../ViewRelated/Post/Search/PostSearchViewModel.swift | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Post/PostListCell.swift b/WordPress/Classes/ViewRelated/Post/PostListCell.swift index 15c8f73ed050..cfd39489ee1d 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListCell.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListCell.swift @@ -70,7 +70,6 @@ final class PostListCell: UITableViewCell, PostSearchResultCell, Reusable { statusLabel.isHidden = viewModel.status.isEmpty contentView.isUserInteractionEnabled = viewModel.isEnabled - contentStackView.alpha = viewModel.isEnabled ? 1.0 : 0.33 } // MARK: - Setup diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift index 2cdd54815a47..75cd27c89d91 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift @@ -69,6 +69,11 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { .sink { [weak self] in self?.reload(with: $0) } .store(in: &cancellables) + NotificationCenter.default + .publisher(for: .postCoordinatorDidUpdate, object: nil) + .sink { [weak self] in self?.reload(with: $0) } + .store(in: &cancellables) + reload() } From 8600f2fad5355a28a977ecd1a0962130851ceedb Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 27 Oct 2023 16:51:29 -0400 Subject: [PATCH 4/6] Animate reloads --- .../ViewRelated/Post/Search/PostSearchViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift index 6fe4ac3ac745..9f4b1423769a 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift @@ -51,7 +51,7 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS private func bindViewModel() { viewModel.$snapshot.sink { [weak self] in - self?.dataSource.apply($0, animatingDifferences: false) + self?.dataSource.apply($0, animatingDifferences: $0.reloadedItemIdentifiers.count == 1) self?.updateSuggestedTokenCells() }.store(in: &cancellables) From 8c85002184691ba471f53fcfe027ebe68eef70d9 Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 30 Oct 2023 13:17:06 -0400 Subject: [PATCH 5/6] Show latest posts in search --- .../Search/PostSearchViewController.swift | 4 +-- .../Post/Search/PostSearchViewModel.swift | 33 ++++++++++++++----- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift index 9f4b1423769a..663385cf131e 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift @@ -95,7 +95,7 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS cell.separatorInset = UIEdgeInsets(top: 0, left: view.bounds.size.width, bottom: 0, right: 0) // Hide the native separator return cell case .posts: - let post = viewModel.posts[indexPath.row] + let post = viewModel.posts[indexPath.row].latest() switch post { case let post as Post: let cell = tableView.dequeueReusableCell(withIdentifier: Constants.postCellID, for: indexPath) as! PostListCell @@ -153,7 +153,7 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS // TODO: Move to viewWillAppear (the way editor is displayed doesn't allow) tableView.deselectRow(at: indexPath, animated: true) - switch viewModel.posts[indexPath.row] { + switch viewModel.posts[indexPath.row].latest() { case let post as Post: guard post.status != .trash else { return } (listViewController as! PostListViewController) diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift index 75cd27c89d91..09d2d5ab9c43 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift @@ -86,27 +86,42 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { private func reload(with notification: Foundation.Notification) { guard let userInfo = notification.userInfo else { return } - let existingObjects = Set(posts) - var updated = (userInfo[NSUpdatedObjectsKey] as? Set) ?? [] - var deleted = (userInfo[NSDeletedObjectsKey] as? Set) ?? [] + // The list displays the latest versions of a post when available, + // but it uses the original posts as identifiers (because they are stable). + // This method ensures that the list updates whenever either the + // original version or the latest version changes. + let existingOriginalPosts = Set(posts) + var existingLatestPosts: [NSManagedObject: NSManagedObject] = [:] + for object in posts { + existingLatestPosts[object] = object.latest() + } - updated.formIntersection(existingObjects) - deleted.formIntersection(existingObjects) + let updatedObjects = (userInfo[NSUpdatedObjectsKey] as? Set) ?? [] + var updatedPosts = updatedObjects.intersection(existingOriginalPosts) + for (original, latest) in existingLatestPosts { + if updatedObjects.contains(latest) { + updatedPosts.insert(original) + } + } - guard !updated.isEmpty || !deleted.isEmpty else { + let deletedPosts = ((userInfo[NSDeletedObjectsKey] as? Set) ?? []) + .intersection(existingOriginalPosts) + + guard !updatedPosts.isEmpty || !deletedPosts.isEmpty else { return } var snapshot = makeSnapshot() - snapshot.reloadItems(updated.map({ ItemID.post($0.objectID) })) - for object in deleted { + snapshot.reloadItems(updatedPosts.map({ ItemID.post($0.objectID) })) + + for object in deletedPosts { if let post = object as? AbstractPost, let index = posts.firstIndex(of: post) { posts.remove(at: index) } - snapshot.deleteItems(deleted.map({ ItemID.post($0.objectID) })) } + snapshot.deleteItems(deletedPosts.map({ ItemID.post($0.objectID) })) self.snapshot = snapshot } From 2b8e2ecf7e99d837bbc5b2f48f1585ea151b574a Mon Sep 17 00:00:00 2001 From: kean Date: Tue, 31 Oct 2023 11:43:02 -0400 Subject: [PATCH 6/6] Remove Undo button --- .../Classes/Services/PostCoordinator.swift | 29 +------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index fac9e56a6f1d..6edff19acb60 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -513,15 +513,8 @@ final class PostCoordinator: NSObject { } else { message = NSLocalizedString("postsList.movePageToTrash.message", value: "Page moved to trash", comment: "A short message explaining that a page was moved to the trash bin.") } - let undoAction = NSLocalizedString("postsList.movePostToTrash.undo", value: "Undo", comment: "The title of an 'undo' button. Tapping the button moves a trashed post or page out of the trash folder.") - let notice = Notice(title: message, actionTitle: undoAction, actionHandler: { [weak self] accepted in - if accepted { - Task { - await self?.restore(post, toStatus: originalStatus ?? .draft) - } - } - }) + let notice = Notice(title: message) ActionDispatcher.dispatch(NoticeAction.dismiss) ActionDispatcher.dispatch(NoticeAction.post(notice)) @@ -537,26 +530,6 @@ final class PostCoordinator: NSObject { } } - @MainActor - private func restore(_ post: AbstractPost, toStatus status: BasePost.Status) async { - WPAnalytics.track(.postListRestoreAction, withProperties: propertiesForAnalytics(for: post)) - let repository = PostRepository(coreDataStack: ContextManager.shared) - Task { @MainActor in - do { - try await repository.restore(.init(post), to: status) - - // Reindex the restored post in spotlight - SearchManager.shared.indexItem(post) - } catch { - if let error = error as NSError?, error.code == Constants.httpCodeForbidden { - delegate?.postCoordinator(self, promptForPasswordForBlog: post.blog) - } else { - WPError.showXMLRPCErrorAlert(error) - } - } - } - } - private func setPendingDeletion(_ isDeleting: Bool, post: AbstractPost) { if isDeleting { pendingDeletionPostIDs.insert(post.objectID)