// // ExploreViewController.swift // Tusker // // Created by Shadowfacts on 12/14/19. // Copyright © 2019 Shadowfacts. All rights reserved. // import UIKit import Combine import Pachyderm import CoreData import WebURLFoundationExtras 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() if mastodonController.instance == nil { mastodonController.getOwnInstance(completion: self.ownInstanceLoaded(_:)) } 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) NotificationCenter.default.addObserver(self, selector: #selector(reloadLists), name: .listsChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, 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() { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections(Section.allCases.filter { $0 != .discover }) snapshot.appendItems([.bookmarks], toSection: .bookmarks) if mastodonController.instanceFeatures.trends, !Preferences.shared.hideDiscover { addDiscoverSection(to: &snapshot) } snapshot.appendItems([.addList], toSection: .lists) let hashtags = fetchSavedHashtags().map { Item.savedHashtag(Hashtag(name: $0.name, url: $0.url)) } snapshot.appendItems(hashtags, toSection: .savedHashtags) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags) let instances = fetchSavedInstances().map { Item.savedInstance($0.url) } snapshot.appendItems(instances, toSection: .savedInstances) snapshot.appendItems([.findInstance], toSection: .savedInstances) dataSource.apply(snapshot, animatingDifferences: false) reloadLists() } private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot) { snapshot.insertSections([.discover], afterSection: .bookmarks) snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover) if mastodonController.instanceFeatures.trendingStatusesAndLinks { snapshot.insertItems([.trendingStatuses], beforeItem: .trendingTags) snapshot.insertItems([.trendingLinks], afterItem: .trendingTags) } } private func ownInstanceLoaded(_ instance: Instance) { var snapshot = self.dataSource.snapshot() if mastodonController.instanceFeatures.trends, !snapshot.sectionIdentifiers.contains(.discover) { snapshot.insertSections([.discover], afterSection: .bookmarks) snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover) } self.dataSource.apply(snapshot) } @objc 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 listRenamed(_ notification: Foundation.Notification) { let list = notification.userInfo!["list"] as! List var snapshot = dataSource.snapshot() let existing = snapshot.itemIdentifiers(inSection: .lists).first(where: { if case .list(let existingList) = $0, existingList.id == list.id { return true } else { return false } }) if let existing { snapshot.insertItems([.list(list)], afterItem: existing) snapshot.deleteItems([existing]) dataSource.apply(snapshot) } } @MainActor private func fetchSavedHashtags() -> [SavedHashtag] { let req = SavedHashtag.fetchRequest() req.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedCompare(_:)))] do { return try mastodonController.persistentContainer.viewContext.fetch(req) } catch { return [] } } @MainActor private func fetchSavedInstances() -> [SavedInstance] { let req = SavedInstance.fetchRequest() req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)] do { return try mastodonController.persistentContainer.viewContext.fetch(req) } catch { return [] } } @objc private func savedHashtagsChanged() { var snapshot = dataSource.snapshot() snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags)) let hashtags = fetchSavedHashtags().map { Item.savedHashtag(Hashtag(name: $0.name, url: $0.url)) } snapshot.appendItems(hashtags, toSection: .savedHashtags) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags) dataSource.apply(snapshot) } @objc private func savedInstancesChanged() { var snapshot = dataSource.snapshot() snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedInstances)) let instances = fetchSavedInstances().map { Item.savedInstance($0.url) } snapshot.appendItems(instances, toSection: .savedInstances) snapshot.appendItems([.findInstance], toSection: .savedInstances) dataSource.apply(snapshot) } @objc private func preferencesChanged() { var snapshot = dataSource.snapshot() let hasSection = snapshot.sectionIdentifiers.contains(.discover) let hide = Preferences.shared.hideDiscover if hasSection && hide { snapshot.deleteSections([.discover]) } else if !hasSection && !hide { addDiscoverSection(to: &snapshot) } else { return } dataSource.apply(snapshot) } private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) { Task { @MainActor in let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) if await service.run() { var snapshot = dataSource.snapshot() snapshot.deleteItems([.list(list)]) await dataSource.apply(snapshot) completion(true) } else { completion(false) } } } func removeSavedHashtag(_ hashtag: Hashtag) { let context = mastodonController.persistentContainer.viewContext if let hashtag = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first { context.delete(hashtag) try! context.save() } } func removeSavedInstance(_ instanceURL: URL) { let context = mastodonController.persistentContainer.viewContext if let instance = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first { context.delete(instance) try! context.save() } } private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? { let title: String let handler: UIContextualAction.Handler switch dataSource.itemIdentifier(for: indexPath) { case let .list(list): title = NSLocalizedString("Delete", comment: "delete swipe action title") handler = { (_, _, completion) in self.deleteList(list, completion: completion) } case let .savedHashtag(hashtag): title = NSLocalizedString("Unsave", comment: "unsave swipe action title") handler = { (_, _, completion) in self.removeSavedHashtag(hashtag) completion(true) } case let .savedInstance(url): title = NSLocalizedString("Unsave", comment: "unsave swipe action title") handler = { (_, _, completion) in self.removeSavedInstance(url) completion(true) } default: return nil } return UISwipeActionsConfiguration(actions: [ UIContextualAction(style: .destructive, title: 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 .trendingStatuses: show(TrendingStatusesViewController(mastodonController: mastodonController), sender: nil) case .trendingTags: show(TrendingHashtagsViewController(mastodonController: mastodonController), sender: nil) case .trendingLinks: show(TrendingLinksViewController(mastodonController: mastodonController), sender: nil) case .profileDirectory: show(ProfileDirectoryViewController(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 service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true) }) { list in let listTimelineController = ListTimelineViewController(for: list, mastodonController: self.mastodonController) listTimelineController.presentEditOnAppear = true self.show(listTimelineController, sender: nil) } service.run() 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 trendingStatuses case trendingTags case trendingLinks case profileDirectory 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 .trendingStatuses: return NSLocalizedString("Trending Posts", comment: "trending statuses nav item title") case .trendingTags: return NSLocalizedString("Trending Hashtags", comment: "trending hashtags nav item title") case .trendingLinks: return NSLocalizedString("Trending Links", comment: "trending links nav item title") case .profileDirectory: return NSLocalizedString("Profile Directory", comment: "profile directory 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 .trendingStatuses: name = "doc.text.image" case .trendingTags: name = "number" case .trendingLinks: name = "link" case .profileDirectory: name = "person.2.fill" 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 (.trendingStatuses, .trendingStatuses): return true case (.trendingTags, .trendingTags): return true case (.trendingLinks, .trendingLinks): return true case (.profileDirectory, .profileDirectory): return true case let (.list(a), .list(b)): return a.id == b.id && a.title == b.title 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 .trendingStatuses: hasher.combine("trendingStatuses") case .trendingTags: hasher.combine("trendingTags") case .trendingLinks: hasher.combine("trendingLinks") case .profileDirectory: hasher.combine("profileDirectory") case let .list(list): hasher.combine("list") hasher.combine(list.id) hasher.combine(list.title) 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: let activity = UserActivityManager.bookmarksActivity() activity.displaysAuxiliaryScene = true provider = NSItemProvider(object: activity) case let .list(list): guard let activity = UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: accountID) else { return [] } activity.displaysAuxiliaryScene = true provider = NSItemProvider(object: activity) case let .savedHashtag(hashtag): guard let url = URL(hashtag.url) else { return [] } provider = NSItemProvider(object: url as NSURL) if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) { activity.displaysAuxiliaryScene = true 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? default: return [] } return [UIDragItem(itemProvider: provider)] } }