forked from shadowfacts/Tusker
323 lines
13 KiB
Swift
323 lines
13 KiB
Swift
//
|
|
// 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<String?, Never>()
|
|
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
|
|
if #available(iOS 16.0, *) {
|
|
navigationItem.preferredSearchBarPlacement = .stacked
|
|
}
|
|
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, session: .appDefault)
|
|
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.ErrorType) {
|
|
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<Section, Item> {
|
|
|
|
}
|
|
}
|
|
|
|
extension InstanceSelectorTableViewController: UISearchResultsUpdating {
|
|
func updateSearchResults(for searchController: UISearchController) {
|
|
currentQuery = searchController.searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
filterRecommendedResults()
|
|
urlCheckerSubject.send(currentQuery)
|
|
}
|
|
}
|