// // SearchResultsViewController.swift // Tusker // // Created by Shadowfacts on 9/14/19. // Copyright © 2019 Shadowfacts. All rights reserved. // import UIKit import Combine import Pachyderm import WebURLFoundationExtras fileprivate let accountCell = "accountCell" fileprivate let statusCell = "statusCell" fileprivate let hashtagCell = "hashtagCell" protocol SearchResultsViewControllerDelegate: AnyObject { func selectedSearchResult(account accountID: String) func selectedSearchResult(hashtag: Hashtag) func selectedSearchResult(status statusID: String) } extension SearchResultsViewControllerDelegate { func selectedSearchResult(account accountID: String) {} func selectedSearchResult(hashtag: Hashtag) {} func selectedSearchResult(status statusID: String) {} } class SearchResultsViewController: UIViewController, CollectionViewController { weak var mastodonController: MastodonController! weak var exploreNavigationController: UINavigationController? weak var delegate: SearchResultsViewControllerDelegate? var collectionView: UICollectionView! { view as? UICollectionView } private var dataSource: UICollectionViewDiffableDataSource! /// Types of results to search for. var scope: Scope /// Whether to limit results to accounts the users is following. var following: Bool? = nil let searchSubject = PassthroughSubject() var currentQuery: String? init(mastodonController: MastodonController, scope: Scope = .all) { self.mastodonController = mastodonController self.scope = scope super.init(nibName: nil, bundle: nil) title = NSLocalizedString("Search", comment: "search screen title") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in var config = UICollectionLayoutListConfiguration(appearance: .grouped) config.backgroundColor = .appGroupedBackground config.headerMode = .supplementary switch self.dataSource.sectionIdentifier(for: sectionIndex) { case .loadingIndicator: config.showsSeparators = false config.headerMode = .none case .statuses: config.leadingSwipeActionsConfigurationProvider = { [unowned self] in (self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() } config.trailingSwipeActionsConfigurationProvider = { [unowned self] in (self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() } config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets default: break } let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.contentInsetsReference = .readableContent } return section } view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self collectionView.allowsFocus = true collectionView.backgroundColor = .appGroupedBackground collectionView.keyboardDismissMode = .interactive dataSource = createDataSource() } override func viewDidLoad() { super.viewDidLoad() _ = searchSubject .debounce(for: .milliseconds(500), scheduler: RunLoop.main) .map { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { $0 != self.currentQuery } .sink(receiveValue: performSearch(query:)) userActivity = UserActivityManager.searchActivity() NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) } private func createDataSource() -> UICollectionViewDiffableDataSource { let sectionHeader = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] supplementaryView, elementKind, indexPath in let section = self.dataSource.sectionIdentifier(for: indexPath.section)! var config = UIListContentConfiguration.groupedHeader() config.text = section.displayName supplementaryView.contentConfiguration = config } let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in cell.indicator.startAnimating() } let accountCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in cell.delegate = self cell.updateUI(accountID: itemIdentifier) } let hashtagCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in cell.updateUI(hashtag: itemIdentifier) } let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self cell.updateUI(statusID: itemIdentifier.0, state: itemIdentifier.1, filterResult: .allow, precomputedContent: nil) } let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in let cell: UICollectionViewCell switch itemIdentifier { case .loadingIndicator: return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) case .account(let accountID): cell = collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: accountID) case .hashtag(let hashtag): cell = collectionView.dequeueConfiguredReusableCell(using: hashtagCell, for: indexPath, item: hashtag) case .status(let id, let state): cell = collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) } cell.configurationUpdateHandler = { cell, state in var config = UIBackgroundConfiguration.listGroupedCell().updated(for: state) if state.isHighlighted || state.isSelected { config.backgroundColor = .appSelectedCellBackground } else { config.backgroundColor = .appGroupedCellBackground } cell.backgroundConfiguration = config } return cell } dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in if elementKind == UICollectionView.elementKindSectionHeader { return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeader, for: indexPath) } else { return nil } } return dataSource } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) } override func targetViewController(forAction action: Selector, sender: Any?) -> UIViewController? { // if we're showing a view controller, we need to go up to the explore VC's nav controller // the UISearchController that is our parent is not part of the normal VC hierarchy and itself doesn't have a parent if action == #selector(UIViewController.show(_:sender:)), let exploreNavController = exploreNavigationController { return exploreNavController } return super.targetViewController(forAction: action, sender: sender) } func loadResults(from source: SearchResultsViewController) { currentQuery = source.currentQuery if let sourceDataSource = source.dataSource { dataSource.apply(sourceDataSource.snapshot()) } } func performSearch(query: String?) { guard let query = query, !query.isEmpty else { self.dataSource.apply(NSDiffableDataSourceSnapshot()) return } self.currentQuery = query var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.loadingIndicator]) snapshot.appendItems([.loadingIndicator]) dataSource.apply(snapshot) let request = Client.search(query: query, types: scope.resultTypes, resolve: true, limit: 10, following: following) mastodonController.run(request) { (response) in switch response { case let .success(results, _): guard self.currentQuery == query else { return } self.showSearchResults(results) case let .failure(error): DispatchQueue.main.async { self.showSearchError(error) } } } } private func showSearchResults(_ results: SearchResults) { var snapshot = NSDiffableDataSourceSnapshot() self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in let resultTypes = self.scope.resultTypes if !results.accounts.isEmpty && resultTypes.contains(.accounts) { snapshot.appendSections([.accounts]) snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts) addAccounts(results.accounts) } if !results.hashtags.isEmpty && resultTypes.contains(.hashtags) { snapshot.appendSections([.hashtags]) snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags) } if !results.statuses.isEmpty && resultTypes.contains(.statuses) { snapshot.appendSections([.statuses]) snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses) addStatuses(results.statuses) } }, completion: { DispatchQueue.main.async { self.dataSource.apply(snapshot) } }) } private func showSearchError(_ error: Client.Error) { let snapshot = NSDiffableDataSourceSnapshot() dataSource.apply(snapshot) let config = ToastConfiguration(from: error, with: "Error Searching", in: self) { [unowned self] toast in toast.dismissToast(animated: true) self.performSearch(query: self.currentQuery) } showToast(configuration: config, animated: true) } @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, let accountID = mastodonController.accountInfo?.id, userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } var snapshot = self.dataSource.snapshot() let toDelete = statusIDs .map { id in Item.status(id, .unknown) } .filter { item in snapshot.itemIdentifiers.contains(item) } if !toDelete.isEmpty { snapshot.deleteItems(toDelete) self.dataSource.apply(snapshot, animatingDifferences: true) } } } extension SearchResultsViewController { enum Scope: CaseIterable { case all case people case hashtags case posts var title: String { switch self { case .all: return "All" case .people: return "People" case .hashtags: return "Hashtags" case .posts: return "Posts" } } var resultTypes: [SearchResultType] { switch self { case .all: return [.accounts, .statuses, .hashtags] case .people: return [.accounts] case .hashtags: return [.hashtags] case .posts: return [.statuses] } } } } extension SearchResultsViewController { enum Section: CaseIterable { case loadingIndicator case accounts case hashtags case statuses var displayName: String? { switch self { case .loadingIndicator: return nil case .accounts: return NSLocalizedString("People", comment: "accounts search results section") case .hashtags: return NSLocalizedString("Hashtags", comment: "hashtag search results section") case .statuses: return NSLocalizedString("Posts", comment: "statuses search results section") } } } enum Item: Hashable { case loadingIndicator case account(String) case hashtag(Hashtag) case status(String, CollapseState) func hash(into hasher: inout Hasher) { switch self { case .loadingIndicator: hasher.combine("loadingIndicator") case let .account(id): hasher.combine("account") hasher.combine(id) case let .hashtag(hashtag): hasher.combine("hashtag") hasher.combine(hashtag.url) case let .status(id, _): hasher.combine("status") hasher.combine(id) } } static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case (.loadingIndicator, .loadingIndicator): return true case (.account(let a), .account(let b)): return a == b case (.hashtag(let a), .hashtag(let b)): return a.name == b.name case (.status(let a, _), .status(let b, _)): return a == b default: return false } } } } extension SearchResultsViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { switch dataSource.itemIdentifier(for: indexPath) { case .loadingIndicator: return false default: return true } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { switch dataSource.itemIdentifier(for: indexPath) { case nil, .loadingIndicator: return case let .account(id): if let delegate { delegate.selectedSearchResult(account: id) } else { selected(account: id) } case let .hashtag(hashtag): if let delegate { delegate.selectedSearchResult(hashtag: hashtag) } else { selected(tag: hashtag) } case let .status(id, state): if let delegate { delegate.selectedSearchResult(status: id) } else { selected(status: id, state: state.copy()) } } } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let item = dataSource.itemIdentifier(for: indexPath), let cell = collectionView.cellForItem(at: indexPath) else { return nil } switch item { case .loadingIndicator: return nil case .account(let id): return UIContextMenuConfiguration { ProfileViewController(accountID: id, mastodonController: self.mastodonController) } actionProvider: { _ in UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell))) } case .hashtag(let tag): return UIContextMenuConfiguration { HashtagTimelineViewController(for: tag, mastodonController: self.mastodonController) } actionProvider: { _ in UIMenu(children: self.actionsForHashtag(tag, source: .view(cell))) } case .status(_, _): return (cell as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() } } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) } } extension SearchResultsViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard let accountInfo = mastodonController.accountInfo, let item = dataSource.itemIdentifier(for: indexPath) else { return [] } let url: URL let activity: NSUserActivity switch item { case .loadingIndicator: return [] case .account(let id): guard let account = mastodonController.persistentContainer.account(for: id) else { return [] } url = account.url activity = UserActivityManager.showProfileActivity(id: id, accountID: accountInfo.id) case .hashtag(let tag): url = URL(tag.url)! activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: accountInfo.id)! case .status(let id, _): guard let status = mastodonController.persistentContainer.status(for: id), status.url != nil else { return [] } url = status.url! activity = UserActivityManager.showConversationActivity(mainStatusID: id, accountID: accountInfo.id) } activity.displaysAuxiliaryScene = true let provider = NSItemProvider(object: url as NSURL) provider.registerObject(activity, visibility: .all) return [UIDragItem(itemProvider: provider)] } } extension SearchResultsViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { searchSubject.send(searchController.searchBar.text) } } extension SearchResultsViewController: UISearchBarDelegate { func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { // perform a search immedaitely when the search button is pressed performSearch(query: searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines)) } func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { let newQuery = searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines) let newScope = Scope.allCases[selectedScope] if self.scope == .all && currentQuery == newQuery { self.scope = newScope var snapshot = dataSource.snapshot() if snapshot.sectionIdentifiers.contains(.accounts) && scope != .people { snapshot.deleteSections([.accounts]) } if snapshot.sectionIdentifiers.contains(.hashtags) && scope != .hashtags { snapshot.deleteSections([.hashtags]) } if snapshot.sectionIdentifiers.contains(.statuses) && scope != .posts { snapshot.deleteSections([.statuses]) } dataSource.apply(snapshot) } else { self.scope = newScope performSearch(query: newQuery) } } } extension SearchResultsViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension SearchResultsViewController: ToastableViewController { } extension SearchResultsViewController: MenuActionProvider { } extension SearchResultsViewController: StatusCollectionViewCellDelegate { func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { if let indexPath = collectionView.indexPath(for: cell) { var snapshot = dataSource.snapshot() snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) dataSource.apply(snapshot, animatingDifferences: animated, completion: completion) } } func statusCellShowFiltered(_ cell: StatusCollectionViewCell) { // not yet supported } }