// // 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, CollectionViewController { weak var mastodonController: MastodonController! var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private(set) var resultsController: SearchResultsViewController! private(set) var searchController: UISearchController! var searchControllerStatusOnAppearance: Bool? = nil private var cancellables = Set() 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.backgroundColor = .appGroupedBackground configuration.trailingSwipeActionsConfigurationProvider = { [unowned self] in self.trailingSwipeActionsForCell(at: $0) } 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 collectionView.allowsFocus = true 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 if #available(iOS 16.0, *) { searchController.scopeBarActivation = .onSearchActivation } searchController.searchBar.autocapitalizationType = .none searchController.searchBar.delegate = resultsController searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title) definesPresentationContext = true navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) mastodonController.$lists .sink { [unowned self] in self.reloadLists($0) } .store(in: &cancellables) mastodonController.$followedHashtags .merge(with: NotificationCenter.default.publisher(for: .savedHashtagsChanged) .map { [unowned self] _ in self.mastodonController.followedHashtags } ) .sink { [unowned self] in self.updateHashtagsSection(followed: $0) } .store(in: &cancellables) let a = PassthroughSubject() let b = PassthroughSubject() a.merge(with: b) .sink(receiveValue: { print($0) }) .store(in: &cancellables) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // UISearchController exists outside of the normal VC hierarchy, // so we manually propagate this down to the results controller // so that it can deselect on appear if searchController.isActive { resultsController.viewWillAppear(animated) } clearSelectionOnAppear(animated: animated) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if searchController.isActive { resultsController.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 cell.configurationUpdateHandler = { cell, state in var config = UIBackgroundConfiguration.listGroupedCell() if state.isHighlighted || state.isSelected { config.backgroundColor = .appSelectedCellBackground } else { config.backgroundColor = .appGroupedCellBackground } cell.backgroundConfiguration = 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, .favorites], toSection: .bookmarks) if mastodonController.instanceFeatures.trends, !Preferences.shared.hideTrends { addDiscoverSection(to: &snapshot) } snapshot.appendItems([.addList], toSection: .lists) let hashtags = fetchHashtagItems(followed: mastodonController.followedHashtags) 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(mastodonController.lists) } private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot) { snapshot.insertSections([.discover], afterSection: .bookmarks) snapshot.appendItems([.trends], toSection: .discover) } private func ownInstanceLoaded(_ instance: Instance) { var snapshot = self.dataSource.snapshot() if mastodonController.instanceFeatures.trends, !snapshot.sectionIdentifiers.contains(.discover) { addDiscoverSection(to: &snapshot) } else if !mastodonController.instanceFeatures.trends, snapshot.sectionIdentifiers.contains(.discover) { snapshot.deleteSections([.discover]) } self.dataSource.apply(snapshot) } private func reloadLists(_ lists: [List]) { var snapshot = self.dataSource.snapshot() snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .lists)) snapshot.appendItems(lists.map { .list($0) }, toSection: .lists) snapshot.appendItems([.addList], toSection: .lists) self.dataSource.apply(snapshot) } @MainActor private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] { let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!) let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [] var items = saved.map { Item.savedHashtag(Hashtag(name: $0.name, url: $0.url)) } for followed in followed where !saved.contains(where: { $0.name == followed.name }) { items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url))) } items = items.uniques() items.sort(using: SemiCaseSensitiveComparator.keyPath(\.label)) return items } @MainActor private func fetchSavedInstances() -> [SavedInstance] { let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!) req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)] do { return try mastodonController.persistentContainer.viewContext.fetch(req).uniques(by: \.url) } catch { return [] } } private func updateHashtagsSection(followed: [FollowedHashtag]) { var snapshot = dataSource.snapshot() snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags)) let hashtags = fetchHashtagItems(followed: followed) 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.hideTrends 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 let req = SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!) if let hashtag = try? context.fetch(req).first { context.delete(hashtag) try! context.save() } } func removeSavedInstance(_ instanceURL: URL) { let context = mastodonController.persistentContainer.viewContext let req = SavedInstance.fetchRequest(url: instanceURL, account: mastodonController.accountInfo!) if let instance = try? context.fetch(req).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(BookmarksViewController(mastodonController: mastodonController), sender: nil) case .favorites: show(FavoritesViewController(mastodonController: mastodonController), sender: nil) case .trends: show(TrendsViewController(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 nil case .lists: return NSLocalizedString("Lists", comment: "explore lists section title") case .savedHashtags: return NSLocalizedString("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 favorites case trends 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 .favorites: return NSLocalizedString("Favorites", comment: "favorites nav item title") case .trends: return NSLocalizedString("Trends", comment: "trends 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("Add 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 .favorites: name = "star.fill" case .trends: name = "chart.line.uptrend.xyaxis" 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 (.favorites, .favorites): return true case (.trends, .trends): 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 .favorites: hasher.combine("favorites") case .trends: hasher.combine("trends") 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: StateRestorableViewController { func stateRestorationActivity() -> NSUserActivity? { if searchController.isActive { return UserActivityManager.searchActivity(query: searchController.searchBar.text, accountID: mastodonController.accountInfo!.id) } else { return nil } } } 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(accountID: accountID) 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)] } }