// // SearchResultsViewController.swift // Tusker // // Created by Shadowfacts on 9/14/19. // Copyright © 2019 Shadowfacts. All rights reserved. // import UIKit import Combine import Pachyderm fileprivate let accountCell = "accountCell" fileprivate let statusCell = "statusCell" fileprivate let hashtagCell = "hashtagCell" protocol SearchResultsViewControllerDelegate: class { func selectedSearchResult(account accountID: String) func selectedSearchResult(hashtag: Hashtag) func selectedSearchResult(status statusID: String) } extension SearchResultsViewControllerDelegate { func selectedSearchResult(account accountID: String) {} func selectedSearchResult(hashtag: Hashtag) {} func selectedSearchResult(status statusID: String) {} } class SearchResultsViewController: EnhancedTableViewController { weak var exploreNavigationController: UINavigationController? weak var delegate: SearchResultsViewControllerDelegate? var dataSource: UITableViewDiffableDataSource! var activityIndicator: UIActivityIndicatorView! var onlySections: [Section] = Section.allCases let searchSubject = PassthroughSubject() var currentQuery: String? init() { super.init(style: .grouped) title = NSLocalizedString("Search", comment: "search screen title") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell) tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell) tableView.register(UINib(nibName: "HashtagTableViewCell", bundle: .main), forCellReuseIdentifier: hashtagCell) dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in switch item { case let .account(id): let cell = tableView.dequeueReusableCell(withIdentifier: accountCell, for: indexPath) as! AccountTableViewCell cell.updateUI(accountID: id) cell.delegate = self return cell case let .hashtag(tag): let cell = tableView.dequeueReusableCell(withIdentifier: hashtagCell, for: indexPath) as! HashtagTableViewCell cell.updateUI(hashtag: tag) cell.delegate = self return cell case let .status(id, state): let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as! TimelineStatusTableViewCell cell.updateUI(statusID: id, state: state) cell.delegate = self return cell } }) activityIndicator = UIActivityIndicatorView(style: .large) activityIndicator.translatesAutoresizingMaskIntoConstraints = false activityIndicator.isHidden = true view.addSubview(activityIndicator) NSLayoutConstraint.activate([ activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), activityIndicator.topAnchor.constraint(equalTo: view.topAnchor, constant: 8) ]) _ = searchSubject .debounce(for: .milliseconds(500), scheduler: RunLoop.main) .map { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { $0 != self.currentQuery } .sink(receiveValue: performSearch(query:)) userActivity = UserActivityManager.searchActivity() } override func targetViewController(forAction action: Selector, sender: Any?) -> UIViewController? { // if we're showing a view controller, we need to go up to the explore VC's nav controller // the UISearchController that is our parent is not part of the normal VC hierarchy and itself doesn't have a parent if action == #selector(UIViewController.show(_:sender:)), let exploreNavController = exploreNavigationController { return exploreNavController } return super.targetViewController(forAction: action, sender: sender) } func performSearch(query: String?) { guard let query = query, !query.isEmpty else { self.dataSource.apply(NSDiffableDataSourceSnapshot()) return } self.currentQuery = query if self.dataSource.snapshot().numberOfItems == 0 { activityIndicator.isHidden = false activityIndicator.startAnimating() } let request = MastodonController.client.search(query: query, resolve: true, limit: 10) MastodonController.client.run(request) { (response) in guard case let .success(results, _) = response else { fatalError() } DispatchQueue.main.async { self.activityIndicator.isHidden = true self.activityIndicator.stopAnimating() } guard self.currentQuery == query else { return } var snapshot = NSDiffableDataSourceSnapshot() if self.onlySections.contains(.accounts) && !results.accounts.isEmpty { snapshot.appendSections([.accounts]) snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts) MastodonCache.addAll(accounts: results.accounts) } if self.onlySections.contains(.hashtags) && !results.hashtags.isEmpty { snapshot.appendSections([.hashtags]) snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags) } if self.onlySections.contains(.statuses) && !results.statuses.isEmpty { snapshot.appendSections([.statuses]) snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses) MastodonCache.addAll(statuses: results.statuses) MastodonCache.addAll(accounts: results.statuses.map { $0.account }) } self.dataSource.apply(snapshot) } } // MARK: - Table view delegate override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if let delegate = delegate { switch dataSource.itemIdentifier(for: indexPath) { case nil: return case let .account(id): delegate.selectedSearchResult(account: id) case let .hashtag(hashtag): delegate.selectedSearchResult(hashtag: hashtag) case let .status(id, _): delegate.selectedSearchResult(status: id) } } else { super.tableView(tableView, didSelectRowAt: indexPath) } } } extension SearchResultsViewController { enum Section: CaseIterable { case accounts case hashtags case statuses var displayName: String { switch self { case .accounts: return NSLocalizedString("People", comment: "accounts search results section") case .hashtags: return NSLocalizedString("Hashtags", comment: "hashtag search results section") case .statuses: return NSLocalizedString("Posts", comment: "statuses search results section") } } } enum Item: Hashable { case account(String) case hashtag(Hashtag) case status(String, StatusState) } class DataSource: UITableViewDiffableDataSource { override func tableView(_ tableView: UITableView, titleForHeaderInSection sectionIndex: Int) -> String? { let currentSnapshot = snapshot() for section in Section.allCases where currentSnapshot.indexOfSection(section) == sectionIndex { return section.displayName } return nil } } } extension SearchResultsViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { searchSubject.send(searchController.searchBar.text) } } extension SearchResultsViewController: UISearchBarDelegate { func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { // perform a search immedaitely when the search button is pressed performSearch(query: searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines)) } } extension SearchResultsViewController: StatusTableViewCellDelegate { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { tableView.beginUpdates() tableView.endUpdates() } }