// // ExploreViewController.swift // Tusker // // Created by Shadowfacts on 12/14/19. // Copyright © 2019 Shadowfacts. All rights reserved. // import UIKit import Combine import Pachyderm class ExploreViewController: UIViewController, UICollectionViewDelegate { weak var mastodonController: MastodonController! private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private(set) var resultsController: SearchResultsViewController! private(set) 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") tabBarItem.image = UIImage(systemName: "magnifyingglass") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped) configuration.trailingSwipeActionsConfigurationProvider = self.trailingSwipeActionsForCell(at:) configuration.headerMode = .supplementary let layout = UICollectionViewCompositionalLayout.list(using: configuration) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] collectionView.delegate = self collectionView.dragDelegate = self view.addSubview(collectionView) dataSource = createDataSource() applyInitialSnapshot() resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController.exploreNavigationController = self.navigationController! searchController = UISearchController(searchResultsController: resultsController) searchController.searchResultsUpdater = resultsController searchController.searchBar.autocapitalizationType = .none searchController.searchBar.delegate = resultsController definesPresentationContext = true navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // Can't use UICollectionViewController's builtin version of this because it requires // the collection view layout be passed into the constructor. Swipe actions for list collection views // are created by passing a closure to the layout's configuration. This closure needs to capture // `self`, so it can't be passed into the super constructor. if let indexPaths = collectionView.indexPathsForSelectedItems { for indexPath in indexPaths { collectionView.deselectItem(at: indexPath, animated: true) } } } 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) { (headerView, collectionView, indexPath) in let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] var config = headerView.defaultContentConfiguration() config.text = section.label headerView.contentConfiguration = config } let listCell = UICollectionView.CellRegistration { (cell, indexPath, item) in var config = cell.defaultContentConfiguration() config.text = item.label config.image = item.image cell.contentConfiguration = config switch item { case .addList, .addSavedHashtag, .findInstance: cell.accessories = [] default: cell.accessories = [.disclosureIndicator()] } } let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, item) in return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item) } dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in if elementKind == UICollectionView.elementKindSectionHeader { return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath) } else { return nil } } return dataSource } private func applyInitialSnapshot() { let account = mastodonController.accountInfo! var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections(Section.allCases) snapshot.appendItems([.bookmarks], toSection: .bookmarks) snapshot.appendItems([.trendingTags], toSection: .discover) snapshot.appendItems([.addList], toSection: .lists) snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) }, toSection: .savedHashtags) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags) snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) }, toSection: .savedInstances) snapshot.appendItems([.findInstance], toSection: .savedInstances) dataSource.apply(snapshot, animatingDifferences: false) reloadLists() } private func reloadLists() { let request = Client.getLists() mastodonController.run(request) { (response) in guard case let .success(lists, _) = response else { return } var snapshot = self.dataSource.snapshot() snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .lists)) snapshot.appendItems(lists.map { .list($0) }, toSection: .lists) snapshot.appendItems([.addList], toSection: .lists) DispatchQueue.main.async { self.dataSource.apply(snapshot) } } } @objc private func savedHashtagsChanged() { let account = mastodonController.accountInfo! var snapshot = dataSource.snapshot() snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags)) snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) }, toSection: .savedHashtags) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags) dataSource.apply(snapshot) } @objc private func savedInstancesChanged() { let account = mastodonController.accountInfo! var snapshot = dataSource.snapshot() snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedInstances)) snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) }, toSection: .savedInstances) snapshot.appendItems([.findInstance], toSection: .savedInstances) dataSource.apply(snapshot) } private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) { let titleFormat = NSLocalizedString("Are you sure you want to delete the '%@' list?", comment: "delete list alert title") let title = String(format: titleFormat, list.title) let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "delete list alert cancel button"), style: .cancel, handler: { (_) in completion(false) })) alert.addAction(UIAlertAction(title: NSLocalizedString("Delete List", comment: "delete list alert confirm button"), style: .destructive, handler: { (_) in let request = List.delete(list) self.mastodonController.run(request) { (response) in guard case .success(_, _) = response else { fatalError() } var snapshot = self.dataSource.snapshot() snapshot.deleteItems([.list(list)]) DispatchQueue.main.async { self.dataSource.apply(snapshot) completion(true) } } })) present(alert, animated: true) } func removeSavedHashtag(_ hashtag: Hashtag) { let account = mastodonController.accountInfo! SavedDataManager.shared.remove(hashtag: hashtag, for: account) } func removeSavedInstance(_ instanceURL: URL) { let account = mastodonController.accountInfo! SavedDataManager.shared.remove(instance: instanceURL, for: account) } private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? { let handler: UIContextualAction.Handler switch dataSource.itemIdentifier(for: indexPath) { case let .list(list): handler = { (_, _, completion) in self.deleteList(list, completion: completion) } case let .savedHashtag(hashtag): handler = { (_, _, completion) in self.removeSavedHashtag(hashtag) completion(true) } case let .savedInstance(url): handler = { (_, _, completion) in self.removeSavedInstance(url) completion(true) } default: return nil } return UISwipeActionsConfiguration(actions: [ UIContextualAction(style: .destructive, title: NSLocalizedString("Delete", comment: "delete swipe action title"), handler: handler) ]) } // MARK: - Collection View Delegate func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { switch dataSource.itemIdentifier(for: indexPath) { case nil: return case .bookmarks: show(BookmarksTableViewController(mastodonController: mastodonController), sender: nil) case .trendingTags: show(TrendingHashtagsViewController(mastodonController: mastodonController), sender: nil) case let .list(list): show(ListTimelineViewController(for: list, mastodonController: mastodonController), sender: nil) case .addList: collectionView.deselectItem(at: indexPath, animated: true) let alert = UIAlertController(title: NSLocalizedString("New List", comment: "new list alert title"), message: NSLocalizedString("Choose a title for your new list", comment: "new list alert message"), preferredStyle: .alert) alert.addTextField(configurationHandler: nil) alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "new list alert cancel button"), style: .cancel, handler: nil)) alert.addAction(UIAlertAction(title: NSLocalizedString("Create List", comment: "new list create button"), style: .default, handler: { (_) in guard let title = alert.textFields?.first?.text else { fatalError() } let request = Client.createList(title: title) self.mastodonController.run(request) { (response) in guard case let .success(list, _) = response else { fatalError() } self.reloadLists() DispatchQueue.main.async { let listTimelineController = ListTimelineViewController(for: list, mastodonController: self.mastodonController) listTimelineController.presentEditOnAppear = true self.show(listTimelineController, sender: nil) } } })) present(alert, animated: true) case let .savedHashtag(hashtag): show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil) case .addSavedHashtag: collectionView.deselectItem(at: indexPath, animated: true) let navController = UINavigationController(rootViewController: AddSavedHashtagViewController(mastodonController: mastodonController)) present(navController, animated: true) case let .savedInstance(url): show(InstanceTimelineViewController(for: url, parentMastodonController: mastodonController), sender: nil) case .findInstance: collectionView.deselectItem(at: indexPath, animated: true) let findController = FindInstanceViewController(parentMastodonController: mastodonController) findController.instanceTimelineDelegate = self let navController = UINavigationController(rootViewController: findController) present(navController, animated: true) } } } extension ExploreViewController { enum Section: CaseIterable { case bookmarks case discover case lists case savedHashtags case savedInstances var label: String? { switch self { case .bookmarks: return nil case .discover: return NSLocalizedString("Discover", comment: "discover section title") case .lists: return NSLocalizedString("Lists", comment: "explore lists section title") case .savedHashtags: return NSLocalizedString("Saved Hashtags", comment: "explore saved hashtags section title") case .savedInstances: return NSLocalizedString("Instance Timelines", comment: "explore instance timelines section title") } } } enum Item: Hashable { case bookmarks case trendingTags case list(List) case addList case savedHashtag(Hashtag) case addSavedHashtag case savedInstance(URL) case findInstance var label: String { switch self { case .bookmarks: return NSLocalizedString("Bookmarks", comment: "bookmarks nav item title") case .trendingTags: return NSLocalizedString("Trending Hashtags", comment: "trending hashtags nav item title") case let .list(list): return list.title case .addList: return NSLocalizedString("New List...", comment: "new list nav item title") case let .savedHashtag(hashtag): return hashtag.name case .addSavedHashtag: return NSLocalizedString("Save Hashtag...", comment: "save hashtag nav item title") case let .savedInstance(url): return url.host! case .findInstance: return NSLocalizedString("Find An Instance...", comment: "find instance nav item title") } } var image: UIImage { let name: String switch self { case .bookmarks: name = "bookmark.fill" case .trendingTags: name = "arrow.up.arrow.down" case .list(_): name = "list.bullet" case .addList, .addSavedHashtag: name = "plus" case .savedHashtag(_): name = "number" case .savedInstance(_): name = "globe" case .findInstance: name = "magnifyingglass" } return UIImage(systemName: name)! } static func == (lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case (.bookmarks, .bookmarks): return true case (.trendingTags, .trendingTags): return true case let (.list(a), .list(b)): return a.id == b.id case (.addList, .addList): return true case let (.savedHashtag(a), .savedHashtag(b)): return a == b case (.addSavedHashtag, .addSavedHashtag): return true case let (.savedInstance(a), .savedInstance(b)): return a == b case (.findInstance, .findInstance): return true default: return false } } func hash(into hasher: inout Hasher) { switch self { case .bookmarks: hasher.combine("bookmarks") case .trendingTags: hasher.combine("trendingTags") case let .list(list): hasher.combine("list") hasher.combine(list.id) case .addList: hasher.combine("addList") case let .savedHashtag(hashtag): hasher.combine("savedHashtag") hasher.combine(hashtag.name) case .addSavedHashtag: hasher.combine("addSavedHashtag") case let .savedInstance(url): hasher.combine("savedInstance") hasher.combine(url) case .findInstance: hasher.combine("findInstance") } } } } extension ExploreViewController: InstanceTimelineViewControllerDelegate { func didSaveInstance(url: URL) { dismiss(animated: true) { self.show(InstanceTimelineViewController(for: url, parentMastodonController: self.mastodonController), sender: nil) } } func didUnsaveInstance(url: URL) { dismiss(animated: true) } } extension ExploreViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard let item = dataSource.itemIdentifier(for: indexPath), let accountID = mastodonController.accountInfo?.id else { return [] } let provider: NSItemProvider switch item { case .bookmarks: provider = NSItemProvider(object: UserActivityManager.bookmarksActivity()) case let .list(list): guard let activity = UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: accountID) else { return [] } provider = NSItemProvider(object: activity) case let .savedHashtag(hashtag): provider = NSItemProvider(object: hashtag.url as NSURL) if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) { provider.registerObject(activity, visibility: .all) } case let .savedInstance(url): provider = NSItemProvider(object: url as NSURL) // todo: should dragging public timelines into new windows be supported? case .trendingTags, .addList, .addSavedHashtag, .findInstance: return [] } return [UIDragItem(itemProvider: provider)] } }