// // ViewController.swift // MastoSearchMobile // // Created by Shadowfacts on 7/3/22. // import UIKit import MastoSearchCore import Combine import SafariServices import AuthenticationServices class ViewController: UIViewController { private let searchQueue = DispatchQueue(label: "Search", qos: .userInitiated) private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var searchQuerySubject = CurrentValueSubject("") private var cancellables = Set() override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground navigationItem.title = "MastoSearch" navigationItem.leadingItemGroups = [ UIBarButtonItemGroup(barButtonItems: [ UIBarButtonItem(title: "Account", menu: createAccountMenu()), UIBarButtonItem(title: "Import", style: .plain, target: self, action: #selector(importPressed)), ], representativeItem: nil) ] let searchController = UISearchController(searchResultsController: nil) searchController.searchResultsUpdater = self navigationItem.searchController = searchController var config = UICollectionLayoutListConfiguration(appearance: .plain) config.headerMode = .supplementary config.itemSeparatorHandler = { indexPath, config in if indexPath.row == 0 { var config = config config.topSeparatorVisibility = .hidden return config } else { return config } } let layout = UICollectionViewCompositionalLayout.list(using: config) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.allowsMultipleSelection = true collectionView.delegate = self view.addSubview(collectionView) NSLayoutConstraint.activate([ collectionView.topAnchor.constraint(equalTo: view.topAnchor), collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) let header = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in } let cell = UICollectionView.CellRegistration { cell, indexPath, status in cell.updateUI(status: status) cell.backgroundColor = indexPath.row % 2 == 0 ? .alternatingTableRow : .systemBackground } dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in collectionView.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: item.status) } dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in guard elementKind == UICollectionView.elementKindSectionHeader else { return nil } return collectionView.dequeueConfiguredReusableSupplementary(using: header, for: indexPath) } searchQuerySubject .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) .sink { [unowned self] query in self.updateStatuses(query: query) } .store(in: &cancellables) updateStatuses(query: "") SyncController.shared.onSync .sink { [unowned self] _ in self.updateStatuses(query: searchQuerySubject.value) } .store(in: &cancellables) } private func updateStatuses(query: String) { searchQueue.async { if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { DatabaseController.shared.getStatuses(sortDescriptor: NSSortDescriptor(key: "published", ascending: false)) { seq in self.applyUpdate(statuses: seq) } } else { DatabaseController.shared.getStatuses(query: query, sortDescriptor: NSSortDescriptor(key: "published", ascending: false)) { seq in self.applyUpdate(statuses: seq) } } } } private func applyUpdate(statuses: StatusSequence) { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) snapshot.appendItems(statuses.map { Item(status: $0) }) DispatchQueue.main.async { self.dataSource.apply(snapshot, animatingDifferences: false) } } private func createAccountMenu() -> UIMenu { if let account = LocalData.account { return UIMenu(children: [ UIAction(title: "Logged in to \(account.instanceURL.host!)", attributes: .disabled, handler: { _ in }), UIAction(title: "Log out", attributes: .destructive, handler: { [unowned self] _ in self.logout() }), ]) } else { return UIMenu(children: [ UIAction(title: "Log in...", handler: { [unowned self] _ in self.login() }), ]) } } private func login() { let alert = UIAlertController(title: "Instance URL", message: nil, preferredStyle: .alert) alert.addTextField { textField in textField.placeholder = "https://mastodon.social/" } alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: { _ in guard let text = alert.textFields!.first!.text, let url = URL(string: text) else { return } LoginController.shared.logIn(with: url, presentationContextProvider: self) { self.navigationItem.leadingItemGroups.first!.barButtonItems.first!.menu = self.createAccountMenu() (self.view.window!.windowScene!.delegate as! SceneDelegate).syncStatuses() } })) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) present(alert, animated: true) } private func logout() { LocalData.account = nil self.navigationItem.leadingItemGroups.first!.barButtonItems.first!.menu = self.createAccountMenu() } @objc private func importPressed() { } } extension ViewController { enum Section { case statuses } struct Item: Equatable, Hashable { let status: Status static func ==(lhs: Item, rhs: Item) -> Bool { return lhs.status.url == rhs.status.url } func hash(into hasher: inout Hasher) { hasher.combine(status.url) } } } extension ViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { searchQuerySubject.send(searchController.searchBar.text ?? "") } } extension ViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let status = dataSource.itemIdentifier(for: indexPath)?.status else { return } present(SFSafariViewController(url: URL(string: status.url)!), animated: true) } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? { let statuses = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.status } switch statuses.count { case 0: return nil case 1: let url = URL(string: statuses.first!.url)! return UIContextMenuConfiguration { SFSafariViewController(url: url) } actionProvider: { _ in UIMenu(children: [ UIAction(title: "Open in Safari", image: UIImage(systemName: "safari"), handler: { [unowned self] _ in self.present(SFSafariViewController(url: url), animated: true) }), UIAction(title: "Copy URL", image: UIImage(systemName: "list.bullet.clipboard"), handler: { _ in UIPasteboard.general.url = url }) ]) } default: return UIContextMenuConfiguration(actionProvider: { _ in UIMenu(children: [ UIAction(title: "Copy URLs", image: UIImage(systemName: "list.bullet.clipboard"), handler: { _ in UIPasteboard.general.urls = statuses.map { URL(string: $0.url)! } }) ]) }) } } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { if let viewController = animator.previewViewController, viewController is SFSafariViewController { animator.preferredCommitStyle = .pop animator.addCompletion { self.present(viewController, animated: true) } } } } extension ViewController: ASWebAuthenticationPresentationContextProviding { func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { return view.window! } }