From c1b80a73c248310e30d69bf6ed1b3baeb628f56c Mon Sep 17 00:00:00 2001 From: Nathan Mattes Date: Mon, 18 Sep 2023 21:17:39 +0200 Subject: [PATCH] Refactor navigation-logic into a coordinator (IOS-141) --- Mastodon.xcodeproj/project.pbxproj | 4 + Mastodon/Coordinator/SceneCoordinator.swift | 2 +- .../SearchResultOverviewCoordinator.swift | 173 ++++++++++++++++++ ...chResultsOverviewTableViewController.swift | 171 +++-------------- .../SearchDetailViewController.swift | 22 ++- ...ySectionHeaderCollectionReusableView.swift | 2 +- .../SearchHistoryViewController.swift | 2 - 7 files changed, 220 insertions(+), 156 deletions(-) create mode 100644 Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index a61ad3f16..20af38cce 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -152,6 +152,7 @@ D8BE30B32A179E26006B8270 /* SuggestionAccountTableViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */; }; D8BEBCB62A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */; }; D8D688F62AB869CB000F651A /* SearchResultsProfileTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D688F52AB869CB000F651A /* SearchResultsProfileTableViewCell.swift */; }; + D8D688F92AB8B970000F651A /* SearchResultOverviewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D688F82AB8B970000F651A /* SearchResultOverviewCoordinator.swift */; }; D8E5C346296DAB84007E76A7 /* DataSourceFacade+Status+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */; }; D8E5C349296DB8A3007E76A7 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */; }; D8F0372C29D232730027DE2E /* HashtagIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */; }; @@ -804,6 +805,7 @@ D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewFooter.swift; sourceTree = ""; }; D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountTableViewCell+ViewModel.swift"; sourceTree = ""; }; D8D688F52AB869CB000F651A /* SearchResultsProfileTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsProfileTableViewCell.swift; sourceTree = ""; }; + D8D688F82AB8B970000F651A /* SearchResultOverviewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultOverviewCoordinator.swift; sourceTree = ""; }; D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status+History.swift"; sourceTree = ""; }; D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = ""; }; D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagIntentHandler.swift; sourceTree = ""; }; @@ -1803,6 +1805,7 @@ D81A22792AB47B8400905D71 /* Cells */, D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */, D81A22772AB4782400905D71 /* SearchResultOverviewSection.swift */, + D8D688F82AB8B970000F651A /* SearchResultOverviewCoordinator.swift */, ); path = "Search Results Overview"; sourceTree = ""; @@ -3806,6 +3809,7 @@ DB63F77B279ACAE500455B82 /* DataSourceFacade+Favorite.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, + D8D688F92AB8B970000F651A /* SearchResultOverviewCoordinator.swift in Sources */, DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */, 2A3D9B7E29A8F33A00F30313 /* StatusHistoryView.swift in Sources */, D81A227B2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 73eda67eb..4455e46d2 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -417,7 +417,7 @@ private extension SceneCoordinator { case .searchDetail(let viewModel): - let _viewController = SearchDetailViewController() + let _viewController = SearchDetailViewController(appContext: appContext, sceneCoordinator: self, authContext: viewModel.authContext) _viewController.viewModel = viewModel viewController = _viewController case .searchResult(let viewModel): diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift new file mode 100644 index 000000000..758b01684 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift @@ -0,0 +1,173 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import UIKit +import MastodonCore +import MastodonSDK +import MastodonLocalization + +protocol Coordinator { + func start() +} + +class SearchResultOverviewCoordinator: Coordinator { + + let overviewViewController: SearchResultsOverviewTableViewController + let sceneCoordinator: SceneCoordinator + let context: AppContext + let authContext: AuthContext + + var activeTask: Task? + + init(appContext: AppContext, authContext: AuthContext, sceneCoordinator: SceneCoordinator) { + self.sceneCoordinator = sceneCoordinator + self.context = appContext + self.authContext = authContext + + overviewViewController = SearchResultsOverviewTableViewController(appContext: appContext, authContext: authContext, sceneCoordinator: sceneCoordinator) + } + + func start() { + overviewViewController.delegate = self + } +} + +extension SearchResultOverviewCoordinator: SearchResultsOverviewTableViewControllerDelegate { + @MainActor + func searchForPosts(_ viewController: SearchResultsOverviewTableViewController, withSearchText searchText: String) { + let searchResultViewModel = SearchResultViewModel(context: context, authContext: authContext, searchScope: .posts) + searchResultViewModel.searchText.value = searchText + + sceneCoordinator.present(scene: .searchResult(viewModel: searchResultViewModel), transition: .show) + } + + func showPosts(_ viewController: SearchResultsOverviewTableViewController, tag: Mastodon.Entity.Tag) { + Task { + await DataSourceFacade.coordinateToHashtagScene( + provider: viewController, + tag: tag + ) + + await DataSourceFacade.responseToCreateSearchHistory(provider: viewController, + item: .hashtag(tag: .entity(tag))) + } + } + + @MainActor + func searchForPeople(_ viewController: SearchResultsOverviewTableViewController, withName searchText: String) { + let searchResultViewModel = SearchResultViewModel(context: context, authContext: authContext, searchScope: .people) + searchResultViewModel.searchText.value = searchText + + sceneCoordinator.present(scene: .searchResult(viewModel: searchResultViewModel), transition: .show) + } + + func goTo(_ viewController: SearchResultsOverviewTableViewController, urlString: String) { + + let query = Mastodon.API.V2.Search.Query( + q: urlString, + type: .default, + resolve: true + ) + + let authContext = self.authContext + let managedObjectContext = context.managedObjectContext + + Task { + let searchResult = try await context.apiService.search( + query: query, + authenticationBox: authContext.mastodonAuthenticationBox + ).value + + if let account = searchResult.accounts.first { + showProfile(viewController, for: account) + } else if let status = searchResult.statuses.first { + + let status = try await managedObjectContext.perform { + return Persistence.Status.fetch(in: managedObjectContext, context: Persistence.Status.PersistContext( + domain: authContext.mastodonAuthenticationBox.domain, + entity: status, + me: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: managedObjectContext)?.user, + statusCache: nil, + userCache: nil, + networkDate: Date())) + } + + guard let status else { return } + + await DataSourceFacade.coordinateToStatusThreadScene( + provider: viewController, + target: .status, // remove reblog wrapper + status: status.asRecord + ) + } else if let url = URL(string: urlString) { + let prefixedURL: URL? + if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + if components.scheme == nil { + components.scheme = "https" + } + prefixedURL = components.url + } else { + prefixedURL = url + } + + guard let prefixedURL else { return } + + await sceneCoordinator.present(scene: .safari(url: prefixedURL), transition: .safariPresent(animated: true)) + } + } + } + + func showProfile(_ viewController: SearchResultsOverviewTableViewController, for account: Mastodon.Entity.Account) { + let managedObjectContext = context.managedObjectContext + let domain = authContext.mastodonAuthenticationBox.domain + + Task { + let user = try await managedObjectContext.perform { + return Persistence.MastodonUser.fetch(in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: domain, + entity: account, + cache: nil, + networkDate: Date() + )) + } + + if let user { + await DataSourceFacade.coordinateToProfileScene(provider: viewController, + user: user.asRecord) + + await DataSourceFacade.responseToCreateSearchHistory(provider: viewController, + item: .user(record: user.asRecord)) + } + } + } + + func searchForPerson(_ viewController: SearchResultsOverviewTableViewController, username: String, domain: String) { + let acct = "\(username)@\(domain)" + let query = Mastodon.API.V2.Search.Query( + q: acct, + type: .default, + resolve: true + ) + + Task { + let searchResult = try await context.apiService.search( + query: query, + authenticationBox: authContext.mastodonAuthenticationBox + ).value + + if let account = searchResult.accounts.first(where: { $0.acctWithDomainIfMissing(domain).lowercased() == acct.lowercased() }) { + showProfile(viewController, for: account) + } else { + await MainActor.run { + let alertTitle = L10n.Scene.Search.Searching.NoUser.title + let alertMessage = L10n.Scene.Search.Searching.NoUser.message(username, domain) + + let alertController = UIAlertController(title: alertTitle, message: alertMessage, preferredStyle: .alert) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default) + alertController.addAction(okAction) + sceneCoordinator.present(scene: .alertController(alertController: alertController), transition: .alertController(animated: true)) + } + } + } + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift index 89b8e2e7e..726a19b2b 100644 --- a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift @@ -5,22 +5,32 @@ import MastodonCore import MastodonSDK import MastodonLocalization -// we could move lots of this stuff to a coordinator, it's too much for work a viewcontroller +protocol SearchResultsOverviewTableViewControllerDelegate: AnyObject { + func goTo(_ viewController: SearchResultsOverviewTableViewController, urlString: String) + func showPosts(_ viewController: SearchResultsOverviewTableViewController, tag: Mastodon.Entity.Tag) + func searchForPosts(_ viewController: SearchResultsOverviewTableViewController, withSearchText searchText: String) + func searchForPeople(_ viewController: SearchResultsOverviewTableViewController, withName searchText: String) + func showProfile(_ viewController: SearchResultsOverviewTableViewController, for account: Mastodon.Entity.Account) + func searchForPerson(_ viewController: SearchResultsOverviewTableViewController, username: String, domain: String) +} + class SearchResultsOverviewTableViewController: UIViewController, NeedsDependency, AuthContextProvider { - var context: AppContext! let authContext: AuthContext + var context: AppContext! var coordinator: SceneCoordinator! private let tableView: UITableView var dataSource: UITableViewDiffableDataSource? + weak var delegate: SearchResultsOverviewTableViewControllerDelegate? + var activeTask: Task? - init(appContext: AppContext, authContext: AuthContext, coordinator: SceneCoordinator) { + init(appContext: AppContext, authContext: AuthContext, sceneCoordinator: SceneCoordinator) { - self.context = appContext self.authContext = authContext - self.coordinator = coordinator + self.context = appContext + self.coordinator = sceneCoordinator tableView = UITableView(frame: .zero, style: .insetGrouped) tableView.translatesAutoresizingMaskIntoConstraints = false @@ -160,145 +170,6 @@ class SearchResultsOverviewTableViewController: UIViewController, NeedsDependenc activeTask = searchTask } - - //MARK: - Actions - - func showPosts(tag: Mastodon.Entity.Tag) { - Task { - await DataSourceFacade.coordinateToHashtagScene( - provider: self, - tag: tag - ) - - await DataSourceFacade.responseToCreateSearchHistory(provider: self, - item: .hashtag(tag: .entity(tag))) - } - } - - func showProfile(for account: Mastodon.Entity.Account) { - let managedObjectContext = context.managedObjectContext - let domain = authContext.mastodonAuthenticationBox.domain - - Task { - let user = try await managedObjectContext.perform { - return Persistence.MastodonUser.fetch(in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: domain, - entity: account, - cache: nil, - networkDate: Date() - )) - } - - if let user { - await DataSourceFacade.coordinateToProfileScene(provider:self, - user: user.asRecord) - - await DataSourceFacade.responseToCreateSearchHistory(provider: self, - item: .user(record: user.asRecord)) - } - } - } - - func searchForPeople(withName searchText: String) { - let searchResultViewModel = SearchResultViewModel(context: context, authContext: authContext, searchScope: .people) - searchResultViewModel.searchText.value = searchText - - coordinator.present(scene: .searchResult(viewModel: searchResultViewModel), transition: .show) - } - - func searchForPosts(withSearchText searchText: String) { - let searchResultViewModel = SearchResultViewModel(context: context, authContext: authContext, searchScope: .posts) - searchResultViewModel.searchText.value = searchText - - coordinator.present(scene: .searchResult(viewModel: searchResultViewModel), transition: .show) - } - - func searchForPerson(username: String, domain: String) { - let acct = "\(username)@\(domain)" - let query = Mastodon.API.V2.Search.Query( - q: acct, - type: .default, - resolve: true - ) - - Task { - let searchResult = try await context.apiService.search( - query: query, - authenticationBox: authContext.mastodonAuthenticationBox - ).value - - if let account = searchResult.accounts.first(where: { $0.acctWithDomainIfMissing(domain).lowercased() == acct.lowercased() }) { - showProfile(for: account) - } else { - await MainActor.run { - let alertTitle = L10n.Scene.Search.Searching.NoUser.title - let alertMessage = L10n.Scene.Search.Searching.NoUser.message(username, domain) - - let alertController = UIAlertController(title: alertTitle, message: alertMessage, preferredStyle: .alert) - let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default) - alertController.addAction(okAction) - coordinator.present(scene: .alertController(alertController: alertController), transition: .alertController(animated: true)) - } - } - } - } - - func goTo(link: String) { - - let query = Mastodon.API.V2.Search.Query( - q: link, - type: .default, - resolve: true - ) - - let authContext = self.authContext - let managedObjectContext = context.managedObjectContext - - Task { - let searchResult = try await context.apiService.search( - query: query, - authenticationBox: authContext.mastodonAuthenticationBox - ).value - - if let account = searchResult.accounts.first { - showProfile(for: account) - } else if let status = searchResult.statuses.first { - - let status = try await managedObjectContext.perform { - return Persistence.Status.fetch(in: managedObjectContext, context: Persistence.Status.PersistContext( - domain: authContext.mastodonAuthenticationBox.domain, - entity: status, - me: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: managedObjectContext)?.user, - statusCache: nil, - userCache: nil, - networkDate: Date())) - } - - guard let status else { return } - - await DataSourceFacade.coordinateToStatusThreadScene( - provider: self, - target: .status, // remove reblog wrapper - status: status.asRecord - ) - } else if var url = URL(string: link) { - let prefixedURL: URL? - if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) { - if components.scheme == nil { - components.scheme = "https" - } - prefixedURL = components.url - } else { - prefixedURL = url - } - - guard let prefixedURL else { return } - - coordinator.present(scene: .safari(url: prefixedURL), transition: .safariPresent(animated: true)) - } - } - } } //MARK: UITableViewDelegate @@ -313,21 +184,21 @@ extension SearchResultsOverviewTableViewController: UITableViewDelegate { case .default(let defaultSectionEntry): switch defaultSectionEntry { case .posts(let searchText): - searchForPosts(withSearchText: searchText) + delegate?.searchForPosts(self, withSearchText: searchText) case .people(let searchText): - searchForPeople(withName: searchText) + delegate?.searchForPeople(self, withName: searchText) case .profile(let username, let domain): - searchForPerson(username: username, domain: domain) + delegate?.searchForPerson(self, username: username, domain: domain) case .openLink(let urlString): - goTo(link: urlString) + delegate?.goTo(self, urlString: urlString) } case .suggestion(let suggestionSectionEntry): switch suggestionSectionEntry { case .hashtag(let tag): - showPosts(tag: tag) + delegate?.showPosts(self, tag: tag) case .profile(let account): - showProfile(for: account) + delegate?.showProfile(self, for: account) } } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift index 990dab66e..82cb9795f 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift @@ -26,6 +26,7 @@ final class SearchDetailViewController: UIViewController, NeedsDependency { var disposeBag = Set() var observations = Set() + let searchResultOverviewCoordinator: SearchResultOverviewCoordinator weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -82,11 +83,28 @@ final class SearchDetailViewController: UIViewController, NeedsDependency { }() private(set) lazy var searchResultsOverviewViewController: SearchResultsOverviewTableViewController = { - let searchResultsOverviewViewController = SearchResultsOverviewTableViewController(appContext: context, authContext: viewModel.authContext, coordinator: coordinator) - return searchResultsOverviewViewController + return searchResultOverviewCoordinator.overviewViewController }() + //MARK: - init + + init(appContext: AppContext, sceneCoordinator: SceneCoordinator, authContext: AuthContext) { + self.context = appContext + self.coordinator = sceneCoordinator + + self.searchResultOverviewCoordinator = SearchResultOverviewCoordinator(appContext: appContext, authContext: authContext, sceneCoordinator: sceneCoordinator) + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + //MARK: - UIViewController + override func viewDidLoad() { + + searchResultOverviewCoordinator.start() + super.viewDidLoad() setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistorySectionHeaderCollectionReusableView.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistorySectionHeaderCollectionReusableView.swift index ecab554e1..6375f5d29 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistorySectionHeaderCollectionReusableView.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistorySectionHeaderCollectionReusableView.swift @@ -11,7 +11,7 @@ import MastodonAsset import MastodonLocalization import MastodonUI -protocol SearchHistorySectionHeaderCollectionReusableViewDelegate: AnyObject, UserViewDelegate { +protocol SearchHistorySectionHeaderCollectionReusableViewDelegate: AnyObject { func searchHistorySectionHeaderCollectionReusableView(_ searchHistorySectionHeaderCollectionReusableView: SearchHistorySectionHeaderCollectionReusableView, clearButtonDidPressed button: UIButton) } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift index 91d51d1c7..8645da5ac 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift @@ -125,5 +125,3 @@ extension SearchHistoryViewController: SearchHistorySectionHeaderCollectionReusa } } } - -extension SearchHistoryViewController: UserTableViewCellDelegate {}