diff --git a/Pachyderm/Client.swift b/Pachyderm/Client.swift index aa5f1c85..895a0c37 100644 --- a/Pachyderm/Client.swift +++ b/Pachyderm/Client.swift @@ -271,10 +271,11 @@ public class Client { } // MARK: - Search - public func search(query: String, resolve: Bool? = nil) -> Request { + public func search(query: String, resolve: Bool? = nil, limit: Int? = nil) -> Request { return Request(method: .get, path: "/api/v2/search", queryParameters: [ "q" => query, - "resolve" => resolve + "resolve" => resolve, + "limit" => limit ]) } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 5a80d835..65890428 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; }; D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; + D6BC9DDA232D8BE5002CA326 /* SearchTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchTableViewController.swift */; }; D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D6BED174212667E900F02DA0 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */; }; D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; }; @@ -420,6 +421,7 @@ D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = ""; }; D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = ""; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = ""; }; + D6BC9DD9232D8BE5002CA326 /* SearchTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTableViewController.swift; sourceTree = ""; }; D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SwiftSoup.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = ""; }; @@ -691,6 +693,7 @@ D641C785213DD83B004B4513 /* Conversation */, D641C786213DD852004B4513 /* Notifications */, D641C787213DD862004B4513 /* Compose */, + D6BC9DD8232D8BCA002CA326 /* Search */, D641C788213DD86D004B4513 /* Large Image */, 0411610422B4571E0030A9B7 /* Attachment */, 0411610522B457290030A9B7 /* Gallery */, @@ -1014,6 +1017,14 @@ path = "Account Activities"; sourceTree = ""; }; + D6BC9DD8232D8BCA002CA326 /* Search */ = { + isa = PBXGroup; + children = ( + D6BC9DD9232D8BE5002CA326 /* SearchTableViewController.swift */, + ); + path = Search; + sourceTree = ""; + }; D6BED1722126661300F02DA0 /* Views */ = { isa = PBXGroup; children = ( @@ -1537,6 +1548,7 @@ D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */, D6333B772138D94E00CE884A /* ComposeMediaView.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, + D6BC9DDA232D8BE5002CA326 /* SearchTableViewController.swift in Sources */, D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index 42dd828b..abccffc9 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -19,6 +19,7 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { embedInNavigationController(TimelinesPageViewController()), embedInNavigationController(NotificationsPageViewController()), ComposeViewController(), + embedInNavigationController(SearchTableViewController()), embedInNavigationController(MyProfileTableViewController()), ] } diff --git a/Tusker/Screens/Search/SearchTableViewController.swift b/Tusker/Screens/Search/SearchTableViewController.swift new file mode 100644 index 00000000..3d2ea3ac --- /dev/null +++ b/Tusker/Screens/Search/SearchTableViewController.swift @@ -0,0 +1,166 @@ +// +// SearchTableViewController.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" + +class SearchTableViewController: EnhancedTableViewController { + + var dataSource: UITableViewDiffableDataSource! + let searchController = UISearchController(searchResultsController: nil) + + var activityIndicator: UIActivityIndicatorView! + + let searchSubject = PassthroughSubject() + var currentQuery: String? + + init() { + super.init(style: .grouped) + + title = NSLocalizedString("Search", comment: "search tab title") + tabBarItem.image = UIImage(systemName: "magnifyingglass") + } + + 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: "StatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell) + + 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 .status(id): + let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as! StatusTableViewCell + cell.updateUI(statusID: id) + cell.delegate = self + return cell + } + }) + + searchController.searchResultsUpdater = self + searchController.obscuresBackgroundDuringPresentation = false + searchController.searchBar.placeholder = NSLocalizedString("Search or Enter URL", comment: "search field placeholder") + searchController.searchBar.delegate = self + navigationItem.searchController = searchController + definesPresentationContext = true + + 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:)) + } + + 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 !results.accounts.isEmpty { + snapshot.appendSections([.accounts]) + snapshot.appendItems(results.accounts.map { Item.account($0.id) }, toSection: .accounts) + MastodonCache.addAll(accounts: results.accounts) + } + if !results.statuses.isEmpty { + snapshot.appendSections([.statuses]) + snapshot.appendItems(results.statuses.map { Item.status($0.id) }, toSection: .statuses) + MastodonCache.addAll(statuses: results.statuses) + MastodonCache.addAll(accounts: results.statuses.map { $0.account }) + } + self.dataSource.apply(snapshot) + } + } + +} + +extension SearchTableViewController { + enum Section: CaseIterable { + case accounts + case statuses + + var displayName: String { + switch self { + case .accounts: + return NSLocalizedString("People", comment: "accounts search results section") + case .statuses: + return NSLocalizedString("Posts", comment: "statuses search results section") + } + } + } + enum Item: Hashable { + case account(String) + case status(String) + } + + class DataSource: UITableViewDiffableDataSource { + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return Section.allCases[section].displayName + } + } +} + +extension SearchTableViewController: UISearchResultsUpdating { + func updateSearchResults(for searchController: UISearchController) { + searchSubject.send(searchController.searchBar.text) + } +} + +extension SearchTableViewController: UISearchBarDelegate { + func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + // perform a search immedaitely when the search button is pressed + performSearch(query: searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines)) + } +} + +extension SearchTableViewController: StatusTableViewCellDelegate { + func statusCollapsedStateChanged() { + tableView.beginUpdates() + tableView.endUpdates() + } +}