// // SearchViewController.swift // Tusker // // Created by Shadowfacts on 6/24/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import SafariServices import WebURLFoundationExtras class SearchViewController: UIViewController { weak var mastodonController: MastodonController! private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! var resultsController: SearchResultsViewController! var searchController: UISearchController! var searchControllerStatusOnAppearance: Bool? = nil init(mastodonController: MastodonController) { self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) title = NSLocalizedString("Explore", comment: "explore tab title") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in let sectionIdentifier = self.dataSource.snapshot().sectionIdentifiers[sectionIndex] switch sectionIdentifier { case .trendingHashtags: var listConfig = UICollectionLayoutListConfiguration(appearance: .grouped) listConfig.headerMode = .supplementary return .list(using: listConfig, layoutEnvironment: environment) case .trendingLinks: let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) // todo: i really wish i could just say the height is automatic and let autolayout figure out what it needs to be // using .estimated(whatever) constrains the height to exactly whatever let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(250), heightDimension: .estimated(280)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(8), top: nil, trailing: .fixed(8), bottom: nil) let section = NSCollectionLayoutSection(group: group) section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary section.boundarySupplementaryItems = [ NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading) ] return section default: fatalError("unimplemented") } } collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] collectionView.delegate = self collectionView.dragDelegate = self collectionView.backgroundColor = .secondarySystemBackground view.addSubview(collectionView) dataSource = createDataSource() resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController.exploreNavigationController = self.navigationController searchController = UISearchController(searchResultsController: resultsController) searchController.obscuresBackgroundDuringPresentation = true searchController.searchBar.autocapitalizationType = .none searchController.searchBar.delegate = resultsController searchController.hidesNavigationBarDuringPresentation = false definesPresentationContext = true navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false if #available(iOS 16.0, *) { navigationItem.preferredSearchBarPlacement = .stacked } NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) Task(priority: .userInitiated) { if (try? await mastodonController.getOwnInstance()) != nil { await applySnapshot() } } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // this is a workaround for the issue that setting isActive on a search controller that is not visible // does not cause it to automatically become active once it becomes visible // see FB7814561 if let active = searchControllerStatusOnAppearance { searchController.isActive = active searchControllerStatusOnAppearance = nil } } private func createDataSource() -> UICollectionViewDiffableDataSource { let sectionHeaderCell = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] (headerView, collectionView, indexPath) in let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] var config = UIListContentConfiguration.groupedHeader() config.text = section.title headerView.contentConfiguration = config } let trendingHashtagCell = UICollectionView.CellRegistration { (cell, indexPath, hashtag) in cell.updateUI(hashtag: hashtag) } let trendingLinkCell = UICollectionView.CellRegistration(cellNib: UINib(nibName: "TrendingLinkCardCollectionViewCell", bundle: .main)) { (cell, indexPath, card) in cell.updateUI(card: card) } let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in switch item { case let .tag(hashtag): return collectionView.dequeueConfiguredReusableCell(using: trendingHashtagCell, for: indexPath, item: hashtag) case let .link(card): return collectionView.dequeueConfiguredReusableCell(using: trendingLinkCell, for: indexPath, item: card) default: fatalError("todo") } } dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in if elementKind == UICollectionView.elementKindSectionHeader { return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath) } else { return nil } } return dataSource } @MainActor private func applySnapshot() async { guard mastodonController.instanceFeatures.trends, !Preferences.shared.hideDiscover else { await dataSource.apply(NSDiffableDataSourceSnapshot()) return } var snapshot = NSDiffableDataSourceSnapshot() let hashtagsReq = Client.getTrendingHashtags(limit: 5) async let hashtags = try? mastodonController.run(hashtagsReq).0 let linksReq = Client.getTrendingLinks(limit: 10) async let links = try? mastodonController.run(linksReq).0 if let hashtags = await hashtags { snapshot.appendSections([.trendingHashtags]) snapshot.appendItems(hashtags.map { .tag($0) }, toSection: .trendingHashtags) } if let links = await links { snapshot.appendSections([.trendingLinks]) snapshot.appendItems(links.map { .link($0) }, toSection: .trendingLinks) } await dataSource.apply(snapshot) } @objc private func preferencesChanged() { Task { await applySnapshot() } } } extension SearchViewController { enum Section { case trendingHashtags case trendingLinks case trendingStatuses case profileSuggestions var title: String { switch self { case .trendingHashtags: return "Trending Hashtags" case .trendingLinks: return "Trending Links" case .trendingStatuses: return "Trending Statuses" case .profileSuggestions: return "Suggested Accounts" } } } enum Item: Equatable, Hashable { case status(String) case tag(Hashtag) case link(Card) static func == (lhs: SearchViewController.Item, rhs: SearchViewController.Item) -> Bool { switch (lhs, rhs) { case let (.status(a), .status(b)): return a == b case let (.tag(a), .tag(b)): return a == b case let (.link(a), .link(b)): return a.url == b.url default: return false } } func hash(into hasher: inout Hasher) { switch self { case let .status(id): hasher.combine("status") hasher.combine(id) case let .tag(tag): hasher.combine("tag") hasher.combine(tag.name) case let .link(card): hasher.combine("link") hasher.combine(card.url) } } } } extension SearchViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } switch item { case let .tag(hashtag): show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil) case let .link(card): if let url = URL(card.url) { selected(url: url) } default: fatalError("todo") } } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let item = dataSource.itemIdentifier(for: indexPath) else { return nil } switch item { case let .tag(hashtag): return UIContextMenuConfiguration(identifier: nil) { HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController) } actionProvider: { (_) in UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self.collectionView.cellForItem(at: indexPath)))) } case let .link(card): guard let url = URL(card.url) else { return nil } return UIContextMenuConfiguration { SFSafariViewController(url: url) } actionProvider: { _ in UIMenu(children: self.actionsForTrendingLink(card: card)) } default: fatalError("todo") } } } extension SearchViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard let item = dataSource.itemIdentifier(for: indexPath) else { return [] } switch item { case let .tag(hashtag): guard let url = URL(hashtag.url) else { return [] } let provider = NSItemProvider(object: url as NSURL) if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) { activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) } return [UIDragItem(itemProvider: provider)] case let .link(card): guard let url = URL(card.url) else { return [] } return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))] default: fatalError("todo") } } } extension SearchViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension SearchViewController: ToastableViewController { } extension SearchViewController: MenuActionProvider { }