From 133921848d0e4149f1a6d0f763ef63d4d0e6c280 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 8 Oct 2022 12:15:12 -0400 Subject: [PATCH] Extract favoriting/reblogging to separate services Allows displaying error popups and retrying --- Tusker.xcodeproj/project.pbxproj | 8 ++ Tusker/API/FavoriteService.swift | 58 +++++++++ Tusker/API/ReblogService.swift | 110 ++++++++++++++++++ Tusker/TuskerNavigationDelegate.swift | 2 +- .../Status/BaseStatusTableViewCell.swift | 85 +------------- .../Status/StatusCollectionViewCell.swift | 77 +----------- 6 files changed, 185 insertions(+), 155 deletions(-) create mode 100644 Tusker/API/FavoriteService.swift create mode 100644 Tusker/API/ReblogService.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 7118f38e..f6cad45f 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -38,12 +38,14 @@ D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; }; D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */; }; D61ABEFC28F105DE00B29151 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D61ABEFB28F105DE00B29151 /* Pachyderm */; }; + D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEFD28F1C92600B29151 /* FavoriteService.swift */; }; D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; }; D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; }; D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; }; D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; }; D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; }; + D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; }; D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */; }; D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757724EE133700B82A16 /* ComposeAssetPicker.swift */; }; D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */; }; @@ -388,12 +390,14 @@ D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = ""; }; D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = ""; }; D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = ""; }; + D61ABEFD28F1C92600B29151 /* FavoriteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteService.swift; sourceTree = ""; }; D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = ""; }; D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = ""; }; D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = ""; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; }; + D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = ""; }; D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsList.swift; sourceTree = ""; }; D622757724EE133700B82A16 /* ComposeAssetPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAssetPicker.swift; sourceTree = ""; }; D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentRow.swift; sourceTree = ""; }; @@ -1475,6 +1479,8 @@ D62E9988279DB2D100C26176 /* InstanceFeatures.swift */, D6F953EF21251A2900CF0F2B /* MastodonController.swift */, D6E9CDA7281A427800BBC98E /* PostService.swift */, + D61ABEFD28F1C92600B29151 /* FavoriteService.swift */, + D621733228F1D5ED004C7DB1 /* ReblogService.swift */, ); path = API; sourceTree = ""; @@ -1896,6 +1902,7 @@ D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */, D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */, D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */, + D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, D663626221360B1900C9CBA2 /* Preferences.swift in Sources */, D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */, D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */, @@ -1947,6 +1954,7 @@ D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */, D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */, D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */, + D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */, D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */, D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */, D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */, diff --git a/Tusker/API/FavoriteService.swift b/Tusker/API/FavoriteService.swift new file mode 100644 index 00000000..b513c6e9 --- /dev/null +++ b/Tusker/API/FavoriteService.swift @@ -0,0 +1,58 @@ +// +// FavoriteService.swift +// Tusker +// +// Created by Shadowfacts on 10/8/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +@MainActor +class FavoriteService { + + private let mastodonController: MastodonController + private let presenter: any TuskerNavigationDelegate + private let status: StatusMO + + var hapticFeedback = true + + init(status: StatusMO, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate) { + self.status = status + self.mastodonController = mastodonController + self.presenter = presenter + } + + func toggleFavorite() async { + let oldValue = status.favourited + status.favourited.toggle() + mastodonController.persistentContainer.statusSubject.send(status.id) + + if hapticFeedback { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + let request = (status.favourited ? Status.favourite : Status.unfavourite)(status.id) + do { + let (newStatus, _) = try await mastodonController.run(request) + mastodonController.persistentContainer.addOrUpdate(status: newStatus) + } catch { + status.favourited = oldValue + mastodonController.persistentContainer.statusSubject.send(status.id) + + let title = oldValue ? "Error Unfavoriting" : "Error Favoriting" + let config = ToastConfiguration(from: error, with: title, in: presenter) { toast in + // deliberately retain a strong reference to self + toast.dismissToast(animated: true) + await self.toggleFavorite() + } + presenter.showToast(configuration: config, animated: true) + + if hapticFeedback { + UINotificationFeedbackGenerator().notificationOccurred(.error) + } + } + } + +} diff --git a/Tusker/API/ReblogService.swift b/Tusker/API/ReblogService.swift new file mode 100644 index 00000000..5c4c6011 --- /dev/null +++ b/Tusker/API/ReblogService.swift @@ -0,0 +1,110 @@ +// +// ReblogService.swift +// Tusker +// +// Created by Shadowfacts on 10/8/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +@MainActor +class ReblogService { + + private let mastodonController: MastodonController + private let presenter: any TuskerNavigationDelegate + private let status: StatusMO + + var hapticFeedback = true + var visibility: Status.Visibility? = nil + var requireConfirmation = Preferences.shared.confirmBeforeReblog + + init(status: StatusMO, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate) { + self.status = status + self.mastodonController = mastodonController + self.presenter = presenter + } + + func toggleReblog() async { + if !status.reblogged, + requireConfirmation { + presentConfirmationAlert() + } else { + await doToggleReblog() + } + } + + private func presentConfirmationAlert() { + let image: UIImage? + let reblogVisibilityActions: [CustomAlertController.MenuAction]? + if mastodonController.instanceFeatures.reblogVisibility { + image = UIImage(systemName: Status.Visibility.public.unfilledImageName) + reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in + CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) { + // deliberately retain a strong reference to self + Task { + await self.doToggleReblog() + } + } + } + } else { + image = nil + reblogVisibilityActions = [] + } + + let preview = ConfirmReblogStatusPreviewView(status: status) + var config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [ + CustomAlertController.Action(title: "Cancel", style: .cancel, handler: nil), + CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: { + // deliberately retain a strong reference to self + Task { + await self.doToggleReblog() + } + }) + ]) + if let reblogVisibilityActions { + var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil) + menuAction.isSecondaryMenu = true + config.actions.append(menuAction) + } + let alert = CustomAlertController(config: config) + presenter.present(alert, animated: true) + } + + private func doToggleReblog() async { + let oldValue = status.reblogged + status.reblogged.toggle() + mastodonController.persistentContainer.statusSubject.send(status.id) + + if hapticFeedback { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + let request: Request + if status.reblogged { + request = Status.reblog(status.id, visibility: visibility) + } else { + request = Status.unreblog(status.id) + } + do { + let (newStatus, _) = try await mastodonController.run(request) + mastodonController.persistentContainer.addOrUpdate(status: newStatus) + } catch { + status.favourited = oldValue + mastodonController.persistentContainer.statusSubject.send(status.id) + + let title = oldValue ? "Error Unfavoriting" : "Error Favoriting" + let config = ToastConfiguration(from: error, with: title, in: presenter) { toast in + toast.dismissToast(animated: true) + await self.toggleReblog() + } + presenter.showToast(configuration: config, animated: true) + + if hapticFeedback { + UINotificationFeedbackGenerator().notificationOccurred(.error) + } + } + } + +} diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 5816f866..917736eb 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -10,7 +10,7 @@ import UIKit import SafariServices import Pachyderm -protocol TuskerNavigationDelegate: UIViewController { +protocol TuskerNavigationDelegate: UIViewController, ToastableViewController { var apiController: MastodonController { get } func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController diff --git a/Tusker/Views/Status/BaseStatusTableViewCell.swift b/Tusker/Views/Status/BaseStatusTableViewCell.swift index 658f9e2d..d49265e2 100644 --- a/Tusker/Views/Status/BaseStatusTableViewCell.swift +++ b/Tusker/Views/Status/BaseStatusTableViewCell.swift @@ -384,93 +384,16 @@ class BaseStatusTableViewCell: UITableViewCell { @IBAction func favoritePressed() { guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") } - let oldValue = favorited - favorited = !favorited - - let realStatus = status.reblog ?? status - let request = (favorited ? Status.favourite : Status.unfavourite)(realStatus.id) - mastodonController.run(request) { response in - DispatchQueue.main.async { - if case let .success(newStatus, _) = response { - self.favorited = newStatus.favourited ?? false - self.mastodonController.persistentContainer.addOrUpdate(status: newStatus) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } else { - self.favorited = oldValue - print("Couldn't favorite status \(realStatus.id)") - // todo: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - return - } - } + Task { + await FavoriteService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleFavorite() } } @IBAction func reblogPressed() { guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") } - // if we are about to reblog and the user has confirmation enabled - if !reblogged, - Preferences.shared.confirmBeforeReblog { - let image: UIImage? - let reblogVisibilityActions: [CustomAlertController.MenuAction]? - if mastodonController.instanceFeatures.reblogVisibility { - image = UIImage(systemName: Status.Visibility.public.unfilledImageName) - reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in - CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) { [unowned self] in - self.toggleReblogInternal(visibility: visibility) - } - } - } else { - image = nil - reblogVisibilityActions = nil - } - - let preview = ConfirmReblogStatusPreviewView(status: status) - var config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [ - CustomAlertController.Action(title: "Cancel", style: .cancel, handler: nil), - CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: { [unowned self] in - self.toggleReblogInternal(visibility: nil) - }), - ]) - if let reblogVisibilityActions { - var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil) - menuAction.isSecondaryMenu = true - config.actions.append(menuAction) - } - let alert = CustomAlertController(config: config) - delegate?.present(alert, animated: true) - } else { - toggleReblogInternal(visibility: nil) - } - } - - private func toggleReblogInternal(visibility: Status.Visibility?) { - guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") } - - let oldValue = reblogged - reblogged = !reblogged - - let realStatus = status.reblog ?? status - let request: Request - if reblogged { - request = Status.reblog(realStatus.id, visibility: visibility) - } else { - request = Status.unreblog(realStatus.id) - } - mastodonController.run(request) { response in - DispatchQueue.main.async { - if case let .success(newStatus, _) = response { - self.reblogged = newStatus.reblogged ?? false - self.mastodonController.persistentContainer.addOrUpdate(status: newStatus) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } else { - self.reblogged = oldValue - print("Couldn't reblog status \(realStatus.id)") - // todo: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - } - } + Task { + await ReblogService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleReblog() } } diff --git a/Tusker/Views/Status/StatusCollectionViewCell.swift b/Tusker/Views/Status/StatusCollectionViewCell.swift index 50a5d0b1..c03bfc6c 100644 --- a/Tusker/Views/Status/StatusCollectionViewCell.swift +++ b/Tusker/Views/Status/StatusCollectionViewCell.swift @@ -75,6 +75,8 @@ extension StatusCollectionViewCell { } func doUpdateUI(status: StatusMO) { + precondition(delegate != nil, "StatusCollectionViewCell must have delegate") + statusID = status.id accountID = status.account.id @@ -186,23 +188,8 @@ extension StatusCollectionViewCell { guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError() } - let oldValue = status.favourited - status.favourited.toggle() - // update ui before network request to make things appear speedy - updateStatusState(status: status) - - let request = (status.favourited ? Status.favourite : Status.unfavourite)(statusID) Task { - do { - let (newStatus, _) = try await mastodonController.run(request) - mastodonController.persistentContainer.addOrUpdate(status: newStatus) - // TODO: should this before the network request - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } catch { - status.favourited = oldValue - // TODO: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - } + await FavoriteService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleFavorite() } } @@ -210,64 +197,8 @@ extension StatusCollectionViewCell { guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError() } - - if !status.reblogged, - Preferences.shared.confirmBeforeReblog { - let image: UIImage? - let reblogVisibilityActions: [CustomAlertController.MenuAction]? - if mastodonController.instanceFeatures.reblogVisibility { - image = UIImage(systemName: Status.Visibility.public.unfilledImageName) - reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in - CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) { [unowned self] in - self.doReblog(status: status, visibility: visibility) - } - } - } else { - image = nil - reblogVisibilityActions = [] - } - - let preview = ConfirmReblogStatusPreviewView(status: status) - var config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [ - CustomAlertController.Action(title: "Cancel", style: .cancel, handler: nil), - CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: { [unowned self] in - self.doReblog(status: status, visibility: nil) - }) - ]) - if let reblogVisibilityActions { - var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil) - menuAction.isSecondaryMenu = true - config.actions.append(menuAction) - } - let alert = CustomAlertController(config: config) - delegate?.present(alert, animated: true) - } else { - doReblog(status: status, visibility: nil) - } - } - - private func doReblog(status: StatusMO, visibility: Status.Visibility?) { - let oldValue = status.reblogged - status.reblogged.toggle() - updateStatusState(status: status) - - let request: Request - if status.reblogged { - request = Status.reblog(statusID, visibility: visibility) - } else { - request = Status.unreblog(statusID) - } Task { - do { - let (newStatus, _) = try await mastodonController.run(request) - mastodonController.persistentContainer.addOrUpdate(status: newStatus) - // TODO: should this before the network request - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } catch { - status.reblogged = oldValue - // TODO: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - } + await ReblogService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleReblog() } } }