// // MainSidebarViewController.swift // Tusker // // Created by Shadowfacts on 6/24/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import Pachyderm protocol MainSidebarViewControllerDelegate: AnyObject { func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) } class MainSidebarViewController: UIViewController { private weak var mastodonController: MastodonController! weak var sidebarDelegate: MainSidebarViewControllerDelegate? private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! var allItems: [Item] { [ .tab(.timelines), .tab(.notifications), .tab(.myProfile), ] + exploreTabItems } var exploreTabItems: [Item] { var items: [Item] = [.search, .bookmarks, .trendingTags, .profileDirectory] let snapshot = dataSource.snapshot() for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) { items.append(.list(list)) } for case let .savedHashtag(hashtag) in snapshot.itemIdentifiers(inSection: .savedHashtags) { items.append(.savedHashtag(hashtag)) } for case let .savedInstance(instance) in snapshot.itemIdentifiers(inSection: .savedInstances) { items.append(.savedInstance(instance)) } return items } private(set) var previouslySelectedItem: Item? var selectedItem: Item? { guard let indexPath = collectionView?.indexPathsForSelectedItems?.first else { return nil } return dataSource.itemIdentifier(for: indexPath) } private(set) var itemLastSelectedTimestamps = [Item: Date]() init(mastodonController: MastodonController) { self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() navigationItem.title = "Tusker" navigationItem.largeTitleDisplayMode = .always navigationController!.navigationBar.prefersLargeTitles = true let layout = UICollectionViewCompositionalLayout.list(using: .init(appearance: .sidebar)) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] collectionView.backgroundColor = .clear collectionView.delegate = self collectionView.dragDelegate = self collectionView.isSpringLoaded = true view.addSubview(collectionView) dataSource = createDataSource() applyInitialSnapshot() if mastodonController.instance == nil { mastodonController.getOwnInstance(completion: self.ownInstanceLoaded(_:)) } select(item: .tab(.timelines), animated: false) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil) } func select(item: Item, animated: Bool) { // ensure view is loaded, since dataSource is created in viewDidLoad loadViewIfNeeded() guard let indexPath = dataSource.indexPath(for: item) else { return } collectionView.selectItem(at: indexPath, animated: animated, scrollPosition: .top) itemLastSelectedTimestamps[item] = Date() } private func createDataSource() -> UICollectionViewDiffableDataSource { let listCell = UICollectionView.CellRegistration { (cell, indexPath, item) in var config = cell.defaultContentConfiguration() config.text = item.title if let imageName = item.imageName { config.image = UIImage(systemName: imageName) } cell.contentConfiguration = config } let outlineHeaderCell = UICollectionView.CellRegistration { (cell, indexPath, item) in var config = cell.defaultContentConfiguration() config.attributedText = NSAttributedString(string: item.title, attributes: [ .font: UIFont.boldSystemFont(ofSize: 21) ]) cell.contentConfiguration = config cell.accessories = [.outlineDisclosure(options: .init(style: .header))] } return UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in if item.hasChildren { return collectionView.dequeueConfiguredReusableCell(using: outlineHeaderCell, for: indexPath, item: item) } else { return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item) } }) } private func applyInitialSnapshot() { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections(Section.allCases.filter { $0 != .discover }) snapshot.appendItems([ .tab(.timelines), .tab(.notifications), .search, .bookmarks, .tab(.myProfile) ], toSection: .tabs) snapshot.appendItems([ .tab(.compose) ], toSection: .compose) if case .mastodon = mastodonController.instance?.instanceType { snapshot.insertSections([.discover], afterSection: .compose) snapshot.appendItems([ .trendingTags, .profileDirectory, ], toSection: .discover) } dataSource.apply(snapshot, animatingDifferences: false) reloadLists() reloadSavedHashtags() reloadSavedInstances() } private func ownInstanceLoaded(_ instance: Instance) { var snapshot = self.dataSource.snapshot() if case .mastodon = mastodonController.instance?.instanceType { snapshot.insertSections([.discover], afterSection: .compose) snapshot.appendItems([ .trendingTags, .profileDirectory, ], toSection: .discover) } let prevSelected = collectionView.indexPathsForSelectedItems dataSource.apply(snapshot, animatingDifferences: false) if let prevSelected = prevSelected?.first { collectionView.selectItem(at: prevSelected, animated: false, scrollPosition: .top) } } private func reloadLists() { let request = Client.getLists() mastodonController.run(request) { [weak self] (response) in guard let self = self, case let .success(lists, _) = response else { return } var exploreSnapshot = NSDiffableDataSourceSectionSnapshot() exploreSnapshot.append([.listsHeader]) exploreSnapshot.expand([.listsHeader]) exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader) exploreSnapshot.append([.addList], to: .listsHeader) DispatchQueue.main.async { let selected = self.collectionView.indexPathsForSelectedItems?.first self.dataSource.apply(exploreSnapshot, to: .lists) { if let selected = selected { self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically) } } } } } @objc private func reloadSavedHashtags() { let selected = collectionView.indexPathsForSelectedItems?.first var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot() hashtagsSnapshot.append([.savedHashtagsHeader]) hashtagsSnapshot.expand([.savedHashtagsHeader]) let sortedHashtags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!) hashtagsSnapshot.append(sortedHashtags.map { .savedHashtag($0) }, to: .savedHashtagsHeader) hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader) self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false) { if let selected = selected { self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically) } } } @objc private func reloadSavedInstances() { let selected = collectionView.indexPathsForSelectedItems?.first var instancesSnapshot = NSDiffableDataSourceSectionSnapshot() instancesSnapshot.append([.savedInstancesHeader]) instancesSnapshot.expand([.savedInstancesHeader]) let sortedInstances = SavedDataManager.shared.savedInstances(for: mastodonController.accountInfo!) instancesSnapshot.append(sortedInstances.map { .savedInstance($0) }, to: .savedInstancesHeader) instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader) self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false) { if let selected = selected { self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically) } } } // todo: deduplicate with ExploreViewController private func showAddList() { let alert = UIAlertController(title: "New List", message: "Choose a title for your new list", preferredStyle: .alert) alert.addTextField(configurationHandler: nil) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) alert.addAction(UIAlertAction(title: "Create List", 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 { self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list)) } } })) present(alert, animated: true) } // todo: deduplicate with ExploreViewController private func showAddSavedHashtag() { let navController = EnhancedNavigationViewController(rootViewController: AddSavedHashtagViewController(mastodonController: mastodonController)) present(navController, animated: true) } // todo: deduplicate with ExploreViewController private func showAddSavedInstance() { let findController = FindInstanceViewController(parentMastodonController: mastodonController) findController.instanceTimelineDelegate = self let navController = EnhancedNavigationViewController(rootViewController: findController) present(navController, animated: true) } private func userActivityForItem(_ item: Item) -> NSUserActivity? { guard let id = mastodonController.accountInfo?.id else { return nil } switch item { case .tab(.notifications): return UserActivityManager.checkNotificationsActivity(mode: Preferences.shared.defaultNotificationsMode) case .tab(.compose): return UserActivityManager.newPostActivity(accountID: id) case .search: return UserActivityManager.searchActivity() case .bookmarks: return UserActivityManager.bookmarksActivity() case .tab(.myProfile): return UserActivityManager.myProfileActivity() case let .list(list): return UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: id) case let .savedHashtag(tag): return UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: id) case .savedInstance(_): // todo: show timeline activity doesn't work for public timelines return nil default: return nil } } } extension MainSidebarViewController { enum Section: Int, Hashable, CaseIterable { case tabs case compose case discover case lists case savedHashtags case savedInstances } enum Item: Hashable { case tab(MainTabBarViewController.Tab) case search, bookmarks case trendingTags, profileDirectory case listsHeader, list(List), addList case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag case savedInstancesHeader, savedInstance(URL), addSavedInstance var title: String { switch self { case let .tab(tab): return tab.title case .search: return "Search" case .bookmarks: return "Bookmarks" case .trendingTags: return "Trending Hashtags" case .profileDirectory: return "Profile Directory" case .listsHeader: return "Lists" case let .list(list): return list.title case .addList: return "New List..." case .savedHashtagsHeader: return "Saved Hashtags" case let .savedHashtag(hashtag): return hashtag.name case .addSavedHashtag: return "Save Hashtag..." case .savedInstancesHeader: return "Saved Instances" case let .savedInstance(url): return url.host! case .addSavedInstance: return "Find An Instance..." } } var imageName: String? { switch self { case let .tab(tab): return tab.imageName case .search: return "magnifyingglass" case .bookmarks: return "bookmark" case .trendingTags: return "arrow.up.arrow.down" case .profileDirectory: return "person.2.fill" case .list(_): return "list.bullet" case .savedHashtag(_): return "number" case .savedInstance(_): return "globe" case .listsHeader, .savedHashtagsHeader, .savedInstancesHeader: return nil case .addList, .addSavedHashtag, .addSavedInstance: return "plus" } } var hasChildren: Bool { switch self { case .listsHeader, .savedHashtagsHeader, .savedInstancesHeader: return true default: return false } } } } fileprivate extension MainTabBarViewController.Tab { var title: String { switch self { case .timelines: return "Home" case .notifications: return "Notifications" case .compose: return "Compose" case .explore: return "Explore" case .myProfile: return "My Profile" } } var imageName: String? { switch self { case .timelines: return "house" case .notifications: return "bell" case .compose: return "pencil" case .explore: return "magnifyingglass" case .myProfile: // todo: use user avatar image return "person" } } } extension MainSidebarViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { previouslySelectedItem = selectedItem return true } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { collectionView.deselectItem(at: indexPath, animated: true) return } itemLastSelectedTimestamps[item] = Date() if [MainSidebarViewController.Item.tab(.compose), .addList, .addSavedHashtag, .addSavedInstance].contains(item) { if let previous = previouslySelectedItem, let indexPath = dataSource.indexPath(for: previous) { collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically) } switch item { case .tab(.compose): sidebarDelegate?.sidebarRequestPresentCompose(self) case .addList: showAddList() case .addSavedHashtag: showAddSavedHashtag() case .addSavedInstance: showAddSavedInstance() default: fatalError("unreachable") } } else { sidebarDelegate?.sidebar(self, didSelectItem: item) } } } extension MainSidebarViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard let item = dataSource.itemIdentifier(for: indexPath), let activity = userActivityForItem(item) else { return [] } let provider = NSItemProvider(object: activity) return [UIDragItem(itemProvider: provider)] } } extension MainSidebarViewController: InstanceTimelineViewControllerDelegate { func didSaveInstance(url: URL) { dismiss(animated: true) { self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url)) } } func didUnsaveInstance(url: URL) { dismiss(animated: true) } }