A WIP iOS app for Mastodon and Pleroma.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

InstanceSelectorTableViewController.swift 8.1KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. //
  2. // InstanceSelectorTableViewController.swift
  3. // Tusker
  4. //
  5. // Created by Shadowfacts on 9/15/19.
  6. // Copyright © 2019 Shadowfacts. All rights reserved.
  7. //
  8. import UIKit
  9. import Combine
  10. import Pachyderm
  11. protocol InstanceSelectorTableViewControllerDelegate {
  12. func didSelectInstance(url: URL)
  13. }
  14. fileprivate let instanceCell = "instanceCell"
  15. class InstanceSelectorTableViewController: UITableViewController {
  16. var delegate: InstanceSelectorTableViewControllerDelegate?
  17. var dataSource: DataSource!
  18. var searchController: UISearchController!
  19. var recommendedInstances: [InstanceSelector.Instance] = []
  20. let urlCheckerSubject = PassthroughSubject<String?, Never>()
  21. var urlHandler: AnyCancellable?
  22. var currentQuery: String?
  23. override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
  24. if UIDevice.current.userInterfaceIdiom == .phone {
  25. return .portrait
  26. } else {
  27. return .all
  28. }
  29. }
  30. init() {
  31. super.init(style: .grouped)
  32. title = NSLocalizedString("Choose Your Instance", comment: "onboarding screen title")
  33. }
  34. required init?(coder: NSCoder) {
  35. fatalError("init(coder:) has not been implemented")
  36. }
  37. override func viewDidLoad() {
  38. super.viewDidLoad()
  39. tableView.register(UINib(nibName: "InstanceTableViewCell", bundle: .main), forCellReuseIdentifier: instanceCell)
  40. tableView.rowHeight = UITableView.automaticDimension
  41. tableView.estimatedRowHeight = 120
  42. dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
  43. switch item {
  44. case let .selected(instance):
  45. let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
  46. cell.updateUI(instance: instance)
  47. return cell
  48. case let .recommended(instance):
  49. let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
  50. cell.updateUI(instance: instance)
  51. return cell
  52. }
  53. })
  54. searchController = UISearchController(searchResultsController: nil)
  55. searchController.searchResultsUpdater = self
  56. searchController.obscuresBackgroundDuringPresentation = false
  57. searchController.searchBar.searchTextField.autocapitalizationType = .none
  58. navigationItem.searchController = searchController
  59. definesPresentationContext = true
  60. urlHandler = urlCheckerSubject
  61. .debounce(for: .seconds(1), scheduler: RunLoop.main)
  62. .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
  63. .sink(receiveValue: updateSpecificInstance)
  64. loadRecommendedInstances()
  65. }
  66. private func parseURLComponents(input: String) -> URLComponents {
  67. // we can't just use the URLComponents(string:) initializer, because when given just a domain (w/o protocol), it interprets it as the path
  68. var input = input
  69. var components = URLComponents()
  70. // extract protocol
  71. if input.contains("://") {
  72. let parts = input.components(separatedBy: "://")
  73. components.scheme = parts.first!
  74. input = parts.last!
  75. }
  76. if components.scheme != "https" && components.scheme != "http" {
  77. components.scheme = "https"
  78. }
  79. // drop path
  80. if input.contains("/") {
  81. let parts = input.components(separatedBy: "/")
  82. input = parts.first!
  83. }
  84. // parse port
  85. if input.contains(":") {
  86. let parts = input.components(separatedBy: ":")
  87. input = parts.first!
  88. components.port = Int(parts.last!)
  89. }
  90. components.host = input
  91. return components
  92. }
  93. private func updateSpecificInstance(domain: String) {
  94. let components = parseURLComponents(input: domain)
  95. let client = Client(baseURL: components.url!)
  96. let request = client.getInstance()
  97. client.run(request) { (response) in
  98. var snapshot = self.dataSource.snapshot()
  99. snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .selected))
  100. if case let .success(instance, _) = response {
  101. if !snapshot.sectionIdentifiers.contains(.selected) {
  102. snapshot.appendSections([.selected])
  103. }
  104. snapshot.appendItems([.selected(instance)], toSection: .selected)
  105. DispatchQueue.main.async {
  106. self.dataSource.apply(snapshot)
  107. }
  108. }
  109. }
  110. }
  111. private func loadRecommendedInstances() {
  112. InstanceSelector.getInstances(category: nil) { (response) in
  113. guard case let .success(instances, _) = response else { fatalError() }
  114. self.recommendedInstances = instances
  115. self.filterRecommendedResults()
  116. }
  117. }
  118. func filterRecommendedResults() {
  119. let filteredInstances: [InstanceSelector.Instance]
  120. if let currentQuery = currentQuery, !currentQuery.isEmpty {
  121. filteredInstances = recommendedInstances.filter {
  122. $0.domain.contains(currentQuery) || $0.description.lowercased().contains(currentQuery)
  123. }
  124. } else {
  125. filteredInstances = recommendedInstances
  126. }
  127. var snapshot = self.dataSource.snapshot()
  128. snapshot.deleteSections([.recommendedInstances])
  129. snapshot.appendSections([.recommendedInstances])
  130. snapshot.appendItems(filteredInstances.map { Item.recommended($0) }, toSection: .recommendedInstances)
  131. DispatchQueue.main.async {
  132. self.dataSource.apply(snapshot)
  133. }
  134. }
  135. // MARK: - Table view delegate
  136. override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  137. guard let delegate = delegate, let item = dataSource.itemIdentifier(for: indexPath) else {
  138. return
  139. }
  140. switch item {
  141. case let .selected(instance):
  142. // we can't just turn the URI string from the API into a URL instance, because Mastodon only includes the domain in the "URI"
  143. let components = parseURLComponents(input: instance.uri)
  144. delegate.didSelectInstance(url: components.url!)
  145. case let .recommended(instance):
  146. var components = URLComponents()
  147. components.scheme = "https"
  148. components.host = instance.domain
  149. components.path = "/"
  150. delegate.didSelectInstance(url: components.url!)
  151. }
  152. }
  153. }
  154. extension InstanceSelectorTableViewController {
  155. enum Section {
  156. case selected
  157. case recommendedInstances
  158. }
  159. enum Item: Equatable, Hashable {
  160. case selected(Instance)
  161. case recommended(InstanceSelector.Instance)
  162. static func ==(lhs: Item, rhs: Item) -> Bool {
  163. if case let .selected(instance) = lhs,
  164. case let .selected(other) = rhs {
  165. return instance.uri == other.uri
  166. } else if case let .recommended(instance) = lhs,
  167. case let .recommended(other) = rhs {
  168. return instance.domain == other.domain
  169. }
  170. return false
  171. }
  172. func hash(into hasher: inout Hasher) {
  173. switch self {
  174. case let .selected(instance):
  175. hasher.combine(Section.selected)
  176. hasher.combine(instance.uri)
  177. case let .recommended(instance):
  178. hasher.combine(Section.recommendedInstances)
  179. hasher.combine(instance.domain)
  180. }
  181. }
  182. }
  183. class DataSource: UITableViewDiffableDataSource<Section, Item> {
  184. }
  185. }
  186. extension InstanceSelectorTableViewController: UISearchResultsUpdating {
  187. func updateSearchResults(for searchController: UISearchController) {
  188. currentQuery = searchController.searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  189. filterRecommendedResults()
  190. urlCheckerSubject.send(currentQuery)
  191. }
  192. }