From db1bbf714837d2c9758b212e4ac86aac01951516 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 17 Jan 2023 19:32:50 -0500 Subject: [PATCH] Add delete status action --- .../Sources/Pachyderm/Model/Status.swift | 4 +- Tusker.xcodeproj/project.pbxproj | 4 ++ Tusker/API/DeleteStatusService.swift | 63 +++++++++++++++++++ .../BookmarksTableViewController.swift | 17 +++++ .../ConversationTableViewController.swift | 4 +- .../ConversationViewController.swift | 19 ++++++ .../TrendingStatusesViewController.swift | 27 ++++++++ .../NotificationsTableViewController.swift | 25 ++++++++ .../ProfileStatusesViewController.swift | 28 +++++++++ .../Search/SearchResultsViewController.swift | 24 +++++++ .../Timeline/TimelineViewController.swift | 21 +++++++ Tusker/Screens/Utilities/Previewing.swift | 59 ++++++++++------- 12 files changed, 268 insertions(+), 27 deletions(-) create mode 100644 Tusker/API/DeleteStatusService.swift diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift index 6c105bf0..a5e65fb8 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift @@ -64,8 +64,8 @@ public final class Status: StatusProtocol, Decodable { return request } - public static func delete(_ status: Status) -> Request { - return Request(method: .delete, path: "/api/v1/statuses/\(status.id)") + public static func delete(_ statusID: String) -> Request { + return Request(method: .delete, path: "/api/v1/statuses/\(statusID)") } public static func reblog(_ statusID: String, visibility: Visibility? = nil) -> Request { diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 1e302bf5..d386462c 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -152,6 +152,7 @@ D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B5D2973040D00DABDFB /* ReportAddStatusView.swift */; }; D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */; }; D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */; }; + D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */; }; D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; }; D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; }; D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; @@ -541,6 +542,7 @@ D65B4B5D2973040D00DABDFB /* ReportAddStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportAddStatusView.swift; sourceTree = ""; }; D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchStatusService.swift; sourceTree = ""; }; D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewController.swift; sourceTree = ""; }; + D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteStatusService.swift; sourceTree = ""; }; D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = ""; }; D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1612,6 +1614,7 @@ D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */, D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */, D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */, + D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */, ); path = API; sourceTree = ""; @@ -1985,6 +1988,7 @@ D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */, + D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */, D61DC84628F498F200B82C6E /* Logging.swift in Sources */, D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */, D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */, diff --git a/Tusker/API/DeleteStatusService.swift b/Tusker/API/DeleteStatusService.swift new file mode 100644 index 00000000..881b9b4d --- /dev/null +++ b/Tusker/API/DeleteStatusService.swift @@ -0,0 +1,63 @@ +// +// DeleteStatusService.swift +// Tusker +// +// Created by Shadowfacts on 1/17/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +@MainActor +class DeleteStatusService { + let status: StatusMO + let mastodonController: MastodonController + let presenter: any TuskerNavigationDelegate + + init(status: StatusMO, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate) { + self.status = status + self.mastodonController = mastodonController + self.presenter = presenter + } + + func run() async { + do { + let req = Status.delete(status.id) + let _ = try await mastodonController.run(req) + + // we deliberately don't remove the status from the cache because there are almost certainly places where it'll still be fetched again + + var reblogIDs = [String]() + let reblogsReq = StatusMO.fetchRequest() + reblogsReq.predicate = NSPredicate(format: "reblog = %@", status) + if let reblogs = try? mastodonController.persistentContainer.viewContext.fetch(reblogsReq) { + reblogIDs = reblogs.map(\.id) + } + + NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [ + "accountID": mastodonController.accountInfo!.id, + "statusIDs": [status.id] + reblogIDs, + ]) + } catch { + let message: String + if let error = error as? Client.Error { + message = error.localizedDescription + } else { + message = error.localizedDescription + } + let alert = UIAlertController(title: "Error Deleting Post", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in + Task { + await self.run() + } + })) + presenter.present(alert, animated: true) + } + } +} + +extension Foundation.Notification.Name { + static let statusDeleted = Foundation.Notification.Name("statusDeleted") +} diff --git a/Tusker/Screens/Bookmarks/BookmarksTableViewController.swift b/Tusker/Screens/Bookmarks/BookmarksTableViewController.swift index 80865605..ade8ffcc 100644 --- a/Tusker/Screens/Bookmarks/BookmarksTableViewController.swift +++ b/Tusker/Screens/Bookmarks/BookmarksTableViewController.swift @@ -48,6 +48,8 @@ class BookmarksTableViewController: EnhancedTableViewController { tableView.prefetchDataSource = self userActivity = UserActivityManager.bookmarksActivity() + + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) } override func viewWillAppear(_ animated: Bool) { @@ -152,6 +154,21 @@ class BookmarksTableViewController: EnhancedTableViewController { return config } + @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { + guard let userInfo = notification.userInfo, + let accountID = mastodonController.accountInfo?.id, + userInfo["accountID"] as? String == accountID, + let statusIDs = userInfo["statusIDs"] as? [String] else { + return + } + let indicesToDelete = statusIDs + .compactMap { id in + self.statuses.firstIndex(where: { $0.id == id }) + } + self.statuses.remove(atOffsets: IndexSet(indicesToDelete)) + self.tableView.deleteRows(at: indicesToDelete.map { IndexPath(row: $0, section: 0) }, with: .automatic) + } + } extension BookmarksTableViewController: TuskerNavigationDelegate { diff --git a/Tusker/Screens/Conversation/ConversationTableViewController.swift b/Tusker/Screens/Conversation/ConversationTableViewController.swift index b1ea2098..297350d7 100644 --- a/Tusker/Screens/Conversation/ConversationTableViewController.swift +++ b/Tusker/Screens/Conversation/ConversationTableViewController.swift @@ -134,7 +134,9 @@ class ConversationTableViewController: EnhancedTableViewController { let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState) - var snapshot = self.dataSource.snapshot() + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.statuses]) + snapshot.appendItems([mainStatusItem], toSection: .statuses) snapshot.insertItems(parentIDs.map { .status(id: $0, state: .unknown) }, beforeItem: mainStatusItem) // fetch all descendant status managed objects diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift index 01c05646..89aadfbf 100644 --- a/Tusker/Screens/Conversation/ConversationViewController.swift +++ b/Tusker/Screens/Conversation/ConversationViewController.swift @@ -91,6 +91,8 @@ class ConversationViewController: UIViewController { let appearance = UINavigationBarAppearance() appearance.configureWithDefaultBackground() navigationItem.scrollEdgeAppearance = appearance + + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) } private func updateVisibilityBarButtonItem() { @@ -113,6 +115,23 @@ class ConversationViewController: UIViewController { } } + @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { + guard let userInfo = notification.userInfo, + let accountID = mastodonController.accountInfo?.id, + userInfo["accountID"] as? String == accountID, + let statusIDs = userInfo["statusIDs"] as? [String] else { + return + } + if statusIDs.contains(mainStatusID) { + state = .notFound + } else if case .displaying(_) = state { + let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID)! + Task { + await loadContext(for: mainStatus) + } + } + } + // MARK: Loading private func loadMainStatus() async { diff --git a/Tusker/Screens/Explore/TrendingStatusesViewController.swift b/Tusker/Screens/Explore/TrendingStatusesViewController.swift index 40c83fd0..86a572a1 100644 --- a/Tusker/Screens/Explore/TrendingStatusesViewController.swift +++ b/Tusker/Screens/Explore/TrendingStatusesViewController.swift @@ -100,6 +100,12 @@ class TrendingStatusesViewController: UIViewController { } } } + + override func viewDidLoad() { + super.viewDidLoad() + + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -137,6 +143,27 @@ class TrendingStatusesViewController: UIViewController { snapshot.appendItems(statuses.map { .status(id: $0.id, collapseState: .unknown, filterState: .unknown) }) await dataSource.apply(snapshot) } + + @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { + guard let userInfo = notification.userInfo, + let accountID = mastodonController.accountInfo?.id, + userInfo["accountID"] as? String == accountID, + let statusIDs = userInfo["statusIDs"] as? [String] else { + return + } + var snapshot = self.dataSource.snapshot() + let toDelete = statusIDs + .map { id in + Item.status(id: id, collapseState: .unknown, filterState: .unknown) + } + .filter { item in + snapshot.itemIdentifiers.contains(item) + } + if !toDelete.isEmpty { + snapshot.deleteItems(toDelete) + self.dataSource.apply(snapshot, animatingDifferences: true) + } + } } extension TrendingStatusesViewController { diff --git a/Tusker/Screens/Notifications/NotificationsTableViewController.swift b/Tusker/Screens/Notifications/NotificationsTableViewController.swift index b9f70a46..7c974d5f 100644 --- a/Tusker/Screens/Notifications/NotificationsTableViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsTableViewController.swift @@ -58,8 +58,33 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController< tableView.cellLayoutMarginsFollowReadableWidth = true tableView.allowsFocus = true + + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) } + @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { + guard let userInfo = notification.userInfo, + let accountID = mastodonController.accountInfo?.id, + userInfo["accountID"] as? String == accountID, + let statusIDs = userInfo["statusIDs"] as? [String] else { + return + } + var snapshot = self.dataSource.snapshot() + // this is not efficient, since the number of notifications is almost certainly greater than the number of deleted statuses + // but we can't just check if the status is in the data source, since we don't have the corresponding notification/group + let toDelete = snapshot.itemIdentifiers + .filter { item in + guard case .notificationGroup(let group) = item else { + return false + } + return group.kind == .mention && statusIDs.contains(group.notifications.first!.status!.id) + } + if !toDelete.isEmpty { + snapshot.deleteItems(toDelete) + self.dataSource.apply(snapshot, animatingDifferences: true) + } + } + private func request(range: RequestRange) -> Request<[Pachyderm.Notification]> { if mastodonController.instanceFeatures.notificationsAllowedTypes { return Client.getNotifications(allowedTypes: allowedTypes, range: range) diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index d17c0e5e..a70f14ae 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -143,6 +143,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie filterer.filtersChanged = { [unowned self] actionsChanged in self.reapplyFilters(actionsChanged: actionsChanged) } + + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) } private func createDataSource() -> UICollectionViewDiffableDataSource { @@ -344,6 +346,31 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie } } + @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { + guard let userInfo = notification.userInfo, + let accountID = mastodonController.accountInfo?.id, + userInfo["accountID"] as? String == accountID, + let statusIDs = userInfo["statusIDs"] as? [String] else { + return + } + var snapshot = self.dataSource.snapshot() + let toDelete = statusIDs + .flatMap { id in + // need to delete from both pinned and non-pinned sections + [ + Item.status(id: id, collapseState: .unknown, filterState: .unknown, pinned: false), + Item.status(id: id, collapseState: .unknown, filterState: .unknown, pinned: true), + ] + } + .filter { item in + snapshot.itemIdentifiers.contains(item) + } + if !toDelete.isEmpty { + snapshot.deleteItems(toDelete) + self.dataSource.apply(snapshot, animatingDifferences: true) + } + } + } extension ProfileStatusesViewController { @@ -376,6 +403,7 @@ extension ProfileStatusesViewController { typealias TimelineItem = String case header(String) + // the status item must contain the pinned state, since a status can appear in both the pinned and regular sections simultaneously case status(id: String, collapseState: CollapseState, filterState: FilterState, pinned: Bool) case loadingIndicator case confirmLoadMore diff --git a/Tusker/Screens/Search/SearchResultsViewController.swift b/Tusker/Screens/Search/SearchResultsViewController.swift index bc723d13..0bfb2653 100644 --- a/Tusker/Screens/Search/SearchResultsViewController.swift +++ b/Tusker/Screens/Search/SearchResultsViewController.swift @@ -121,6 +121,8 @@ class SearchResultsViewController: EnhancedTableViewController { .sink(receiveValue: performSearch(query:)) userActivity = UserActivityManager.searchActivity() + + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) } override func targetViewController(forAction action: Selector, sender: Any?) -> UIViewController? { @@ -207,6 +209,28 @@ class SearchResultsViewController: EnhancedTableViewController { errorLabel.text = error.localizedDescription } + @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { + guard let userInfo = notification.userInfo, + let accountID = mastodonController.accountInfo?.id, + userInfo["accountID"] as? String == accountID, + let statusIDs = userInfo["statusIDs"] as? [String] else { + return + } + var snapshot = self.dataSource.snapshot() + let toDelete = statusIDs + .map { id in + Item.status(id, .unknown) + } + .filter { item in + snapshot.itemIdentifiers.contains(item) + } + if !toDelete.isEmpty { + snapshot.deleteItems(toDelete) + self.dataSource.apply(snapshot, animatingDifferences: true) + } + } + + // MARK: - Table view delegate override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index aa14aaa9..07c79163 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -146,6 +146,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro _ = syncPositionIfNecessary(alwaysPrompt: true) } .store(in: &cancellables) + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) } // separate method because InstanceTimelineViewController needs to be able to customize it @@ -830,6 +831,26 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } } + @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { + guard let userInfo = notification.userInfo, + let accountID = mastodonController.accountInfo?.id, + userInfo["accountID"] as? String == accountID, + let statusIDs = userInfo["statusIDs"] as? [String] else { + return + } + var snapshot = self.dataSource.snapshot() + let toDelete = statusIDs + .map { id in + Item.status(id: id, collapseState: .unknown, filterState: .unknown) + } + .filter { item in + snapshot.itemIdentifiers.contains(item) + } + if !toDelete.isEmpty { + snapshot.deleteItems(toDelete) + self.dataSource.apply(snapshot, animatingDifferences: true) + } + } } extension TimelineViewController { diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index a99f954a..7467551a 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -231,7 +231,7 @@ extension MenuActionProvider { }), at: 1) } - var actionsSection: [UIAction] = [] + var actionsSection: [UIMenuElement] = [] if includeStatusButtonActions { actionsSection.insert(createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in @@ -257,27 +257,8 @@ extension MenuActionProvider { })) } - // only allowing pinning user's own statuses - if account.id == status.account.id, - mastodonController.instanceFeatures.profilePinnedStatuses { - let pinned = status.pinned ?? false - toggleableSection.append(createAction(identifier: "pin", title: pinned ? "Unpin from Profile" : "Pin to Profile", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in - guard let self = self else { return } - let request = (pinned ? Status.unpin : Status.pin)(status.id) - self.mastodonController?.run(request, completion: { [weak self] (response) in - guard let self = self else { return } - switch response { - case .success(let status, _): - self.mastodonController?.persistentContainer.addOrUpdate(status: status) - case .failure(let error): - self.handleError(error, title: "Error \(pinned ? "Unp" :"P")inning") - } - }) - })) - } - if status.poll != nil { - actionsSection.insert(createAction(identifier: "refresh", title: "Refresh Poll", systemImageName: "arrow.clockwise", handler: { [weak self] (_) in + actionsSection.append(createAction(identifier: "refresh", title: "Refresh Poll", systemImageName: "arrow.clockwise", handler: { [weak self] (_) in guard let mastodonController = self?.mastodonController else { return } let request = Client.getStatus(id: status.id) mastodonController.run(request, completion: { (response) in @@ -292,11 +273,41 @@ extension MenuActionProvider { self?.handleError(error, title: "Error Refreshing Poll") } }) - }), at: 0) + })) } - // can only report other people's posts - if account.id != status.account.id { + if account.id == status.account.id { + if mastodonController.instanceFeatures.profilePinnedStatuses { + let pinned = status.pinned ?? false + toggleableSection.append(createAction(identifier: "pin", title: pinned ? "Unpin from Profile" : "Pin to Profile", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in + guard let self = self else { return } + let request = (pinned ? Status.unpin : Status.pin)(status.id) + self.mastodonController?.run(request, completion: { [weak self] (response) in + guard let self = self else { return } + switch response { + case .success(let status, _): + self.mastodonController?.persistentContainer.addOrUpdate(status: status) + case .failure(let error): + self.handleError(error, title: "Error \(pinned ? "Unp" :"P")inning") + } + }) + })) + } + + actionsSection.append(UIMenu(title: "Delete Status", image: UIImage(systemName: "trash"), children: [ + UIAction(title: "Cancel", handler: { _ in }), + UIAction(title: "Delete Status", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [weak self] _ in + guard let self, + let navigationDelegate = self.navigationDelegate else { + return + } + Task { @MainActor in + let service = DeleteStatusService(status: status, mastodonController: mastodonController, presenter: navigationDelegate) + await service.run() + } + }) + ])) + } else { actionsSection.append(createAction(identifier: "report", title: "Report", systemImageName: "flag", handler: { [weak self] _ in let report = EditedReport(accountID: status.account.id) report.statusIDs = [status.id]