// // InstanceSelectorTableViewController.swift // Tusker // // Created by Shadowfacts on 9/15/19. // Copyright © 2019 Shadowfacts. All rights reserved. // import UIKit import Combine import Pachyderm protocol InstanceSelectorTableViewControllerDelegate: AnyObject { func didSelectInstance(url: URL) } fileprivate let instanceCell = "instanceCell" class InstanceSelectorTableViewController: UITableViewController { weak var delegate: InstanceSelectorTableViewControllerDelegate? var dataSource: DataSource! var searchController: UISearchController! var recommendedInstances: [InstanceSelector.Instance] = [] let urlCheckerSubject = PassthroughSubject() var urlHandler: AnyCancellable? var currentQuery: String? private var activityIndicator: UIActivityIndicatorView! override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if UIDevice.current.userInterfaceIdiom == .phone { return .portrait } else { return .all } } init() { super.init(style: .grouped) title = NSLocalizedString("Choose Your Instance", comment: "onboarding screen title") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() // disable transparent background when scrolled to top because it gets weird with animating table items in and out let appearance = UINavigationBarAppearance() appearance.configureWithDefaultBackground() navigationItem.scrollEdgeAppearance = appearance tableView.register(UINib(nibName: "InstanceTableViewCell", bundle: .main), forCellReuseIdentifier: instanceCell) tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 120 createActivityIndicatorHeader() dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in switch item { case let .selected(_, instance): let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell cell.updateUI(instance: instance) return cell case let .recommended(instance): let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell cell.updateUI(instance: instance) return cell } }) searchController = UISearchController(searchResultsController: nil) searchController.searchResultsUpdater = self searchController.obscuresBackgroundDuringPresentation = false searchController.searchBar.searchTextField.autocapitalizationType = .none navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false definesPresentationContext = true urlHandler = urlCheckerSubject .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .map { [weak self] (s) -> String in if !s.isEmpty { self?.activityIndicator.startAnimating() } return s } .debounce(for: .seconds(1), scheduler: RunLoop.main) .sink { [weak self] in self?.updateSpecificInstance(domain: $0) } loadRecommendedInstances() } private func parseURLComponents(input: String) -> URLComponents { // we can't just use the URLComponents(string:) initializer, because when given just a domain (w/o protocol), it interprets it as the path var input = input var components = URLComponents() // extract protocol if input.contains("://") { let parts = input.components(separatedBy: "://") components.scheme = parts.first! input = parts.last! } if components.scheme != "https" && components.scheme != "http" { components.scheme = "https" } // drop path if input.contains("/") { let parts = input.components(separatedBy: "/") input = parts.first! } // parse port if input.contains(":") { let parts = input.components(separatedBy: ":") input = parts.first! components.port = Int(parts.last!) } components.host = input return components } private func updateSpecificInstance(domain: String) { activityIndicator.startAnimating() let components = parseURLComponents(input: domain) let url = components.url! let client = Client(baseURL: url) let request = Client.getInstance() client.run(request) { (response) in var snapshot = self.dataSource.snapshot() if snapshot.indexOfSection(.selected) != nil { snapshot.deleteSections([.selected]) } if case let .success(instance, _) = response { if snapshot.indexOfSection(.recommendedInstances) != nil { snapshot.insertSections([.selected], beforeSection: .recommendedInstances) } else { snapshot.appendSections([.selected]) } snapshot.appendItems([.selected(url, instance)], toSection: .selected) DispatchQueue.main.async { self.dataSource.apply(snapshot) { self.activityIndicator.stopAnimating() } } } else { DispatchQueue.main.async { self.activityIndicator.stopAnimating() } } } } private func loadRecommendedInstances() { InstanceSelector.getInstances(category: nil) { (response) in DispatchQueue.main.async { switch response { case let .failure(error): self.showRecommendationsError(error) case let .success(instances, _): self.recommendedInstances = instances self.filterRecommendedResults() } } } } private func createActivityIndicatorHeader() { let header = UITableViewHeaderFooterView() header.translatesAutoresizingMaskIntoConstraints = false header.contentView.backgroundColor = .systemGroupedBackground activityIndicator = UIActivityIndicatorView(style: .large) activityIndicator.translatesAutoresizingMaskIntoConstraints = false header.contentView.addSubview(activityIndicator) NSLayoutConstraint.activate([ activityIndicator.centerXAnchor.constraint(equalTo: header.contentView.centerXAnchor), activityIndicator.topAnchor.constraint(equalTo: header.contentView.topAnchor, constant: 4), activityIndicator.bottomAnchor.constraint(equalTo: header.contentView.bottomAnchor, constant: -4), ]) let fittingSize = CGSize(width: tableView.bounds.width - (tableView.safeAreaInsets.left + tableView.safeAreaInsets.right), height: 0) let size = header.systemLayoutSizeFitting(fittingSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) header.frame = CGRect(origin: .zero, size: size) tableView.tableHeaderView = header } private func showRecommendationsError(_ error: Client.Error) { let footer = UITableViewHeaderFooterView() footer.translatesAutoresizingMaskIntoConstraints = false let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textColor = .secondaryLabel label.textAlignment = .center label.font = .boldSystemFont(ofSize: 17) label.numberOfLines = 0 label.text = "Could not fetch suggested instances: \(error.localizedDescription)" footer.contentView.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalToSystemSpacingAfter: footer.contentView.leadingAnchor, multiplier: 1), footer.contentView.trailingAnchor.constraint(equalToSystemSpacingAfter: label.trailingAnchor, multiplier: 1), label.topAnchor.constraint(equalTo: footer.contentView.topAnchor, constant: 8), label.bottomAnchor.constraint(equalTo: footer.contentView.bottomAnchor, constant: 8), ]) let fittingSize = CGSize(width: tableView.bounds.width - (tableView.safeAreaInsets.left + tableView.safeAreaInsets.right), height: 0) let size = footer.systemLayoutSizeFitting(fittingSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) footer.frame = CGRect(origin: .zero, size: size) tableView.tableFooterView = footer } private func filterRecommendedResults() { let filteredInstances: [InstanceSelector.Instance] if let currentQuery = currentQuery, !currentQuery.isEmpty { filteredInstances = recommendedInstances.filter { $0.domain.contains(currentQuery) || $0.description.lowercased().contains(currentQuery) } } else { filteredInstances = recommendedInstances } var snapshot = self.dataSource.snapshot() if snapshot.indexOfSection(.recommendedInstances) != nil { let toRemove = snapshot.itemIdentifiers(inSection: .recommendedInstances).filter { if case .recommended(_) = $0 { return true } else { return false } } snapshot.deleteItems(toRemove) } else { snapshot.appendSections([.recommendedInstances]) } snapshot.appendItems(filteredInstances.map { Item.recommended($0) }, toSection: .recommendedInstances) self.dataSource.apply(snapshot) } // MARK: - Table view delegate override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let delegate = delegate, let item = dataSource.itemIdentifier(for: indexPath) else { return } switch item { case let .selected(url, _): // We can't rely on the URI reported by the instance API endpoint, because improperly configured instances may // return a domain that they don't listen on. Instead, use the actual base URL that was used to make the /api/v1/instance // request, since we know for certain that succeeded, otherwise this item wouldn't exist. delegate.didSelectInstance(url: url) case let .recommended(instance): var components = URLComponents() components.scheme = "https" components.host = instance.domain components.path = "/" delegate.didSelectInstance(url: components.url!) } } } extension InstanceSelectorTableViewController { enum Section { case selected case recommendedInstances } enum Item: Equatable, Hashable { case selected(URL, Instance) case recommended(InstanceSelector.Instance) static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case let (.selected(urlA, instanceA), .selected(urlB, instanceB)): return urlA == urlB && instanceA.uri == instanceB.uri case let (.recommended(a), .recommended(b)): return a.domain == b.domain default: return false } } func hash(into hasher: inout Hasher) { switch self { case let .selected(url, instance): hasher.combine(0) hasher.combine(url) hasher.combine(instance.uri) case let .recommended(instance): hasher.combine(1) hasher.combine(instance.domain) } } } class DataSource: UITableViewDiffableDataSource { } } extension InstanceSelectorTableViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { currentQuery = searchController.searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() filterRecommendedResults() urlCheckerSubject.send(currentQuery) } }