// // MainSidebarViewController.swift // Tusker // // Created by Shadowfacts on 6/24/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine protocol MainSidebarViewControllerDelegate: AnyObject { func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) } class MainSidebarViewController: UIViewController { private weak var mastodonController: MastodonController! weak var sidebarDelegate: MainSidebarViewControllerDelegate? var onViewDidLoad: (() -> Void)? = nil { willSet { precondition(onViewDidLoad == nil) } } private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var cancellables = Set() var allItems: [Item] { [ .tab(.timelines), .tab(.notifications), .tab(.myProfile), ] + exploreTabItems } var exploreTabItems: [Item] { var items: [Item] = [.explore, .bookmarks, .favorites] 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 // TODO: allow focusing sidebar once there's a workaround for keyboard shortcuts from main split content not being accessible when not in the responder chain collectionView.allowsFocus = false 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(reloadSavedInstances), name: .savedInstancesChanged, 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) onViewDidLoad?() } 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 myProfileCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in Task { await cell.updateUI(item: item, account: self.mastodonController.accountInfo!) } } 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 case .tab(.myProfile) = item { return collectionView.dequeueConfiguredReusableCell(using: myProfileCell, for: indexPath, item: item) } else 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) snapshot.appendItems([ .tab(.timelines), .tab(.notifications), .explore, .bookmarks, .favorites, .tab(.myProfile) ], toSection: .tabs) snapshot.appendItems([ .tab(.compose) ], toSection: .compose) dataSource.apply(snapshot, animatingDifferences: false) reloadLists(mastodonController.lists) updateHashtagsSection(followed: mastodonController.followedHashtags) reloadSavedInstances() } private func ownInstanceLoaded(_ instance: Instance) { let prevSelected = collectionView.indexPathsForSelectedItems if let prevSelected = prevSelected?.first { collectionView.selectItem(at: prevSelected, animated: false, scrollPosition: .top) } } private func reloadLists(_ lists: [List]) { if let selectedItem, case .list(let list) = selectedItem, !lists.contains(where: { $0.id == list.id }) { returnToPreviousItem() } var exploreSnapshot = NSDiffableDataSourceSectionSnapshot() exploreSnapshot.append([.listsHeader]) exploreSnapshot.expand([.listsHeader]) exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader) exploreSnapshot.append([.addList], to: .listsHeader) self.dataSource.apply(exploreSnapshot, to: .lists) } @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(\.title)) 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]) { let hashtags = fetchHashtagItems(followed: followed) if let selectedItem, case .savedHashtag(_) = selectedItem, !hashtags.contains(selectedItem) { returnToPreviousItem() } var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot() hashtagsSnapshot.append([.savedHashtagsHeader]) hashtagsSnapshot.expand([.savedHashtagsHeader]) hashtagsSnapshot.append(hashtags, to: .savedHashtagsHeader) hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader) self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags) } @objc private func reloadSavedInstances() { let instances = fetchSavedInstances().map { Item.savedInstance($0.url) } if let selectedItem, case .savedInstance(_) = selectedItem, !instances.contains(selectedItem) { returnToPreviousItem() } var instancesSnapshot = NSDiffableDataSourceSectionSnapshot() instancesSnapshot.append([.savedInstancesHeader]) instancesSnapshot.expand([.savedInstancesHeader]) instancesSnapshot.append(instances, to: .savedInstancesHeader) instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader) self.dataSource.apply(instancesSnapshot, to: .savedInstances) } private func returnToPreviousItem() { let item = previouslySelectedItem ?? .tab(.timelines) previouslySelectedItem = nil select(item: item, animated: true) sidebarDelegate?.sidebar(self, didSelectItem: item) } private func showAddList() { let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true ) }) { list in self.select(item: .list(list), animated: false) let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController) list.presentEditOnAppear = true self.sidebarDelegate?.sidebar(self, showViewController: list) } service.run() } // 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, accountID: id) case .tab(.compose): return UserActivityManager.newPostActivity(accountID: id) case .explore: return UserActivityManager.searchActivity(query: nil, accountID: id) case .bookmarks: return UserActivityManager.bookmarksActivity(accountID: id) case .tab(.myProfile): return UserActivityManager.myProfileActivity(accountID: id) 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 } } func myProfileCell() -> UICollectionViewCell? { guard let indexPath = dataSource.indexPath(for: .tab(.myProfile)), let item = collectionView.cellForItem(at: indexPath) else { return nil } return item } } extension MainSidebarViewController { enum Section: Int, Hashable, CaseIterable { case tabs case compose case lists case savedHashtags case savedInstances } enum Item: Hashable { case tab(MainTabBarViewController.Tab) case explore, bookmarks, favorites 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 .explore: return "Explore" case .bookmarks: return "Bookmarks" case .favorites: return "Favorites" case .listsHeader: return "Lists" case let .list(list): return list.title case .addList: return "New List..." case .savedHashtagsHeader: return "Hashtags" case let .savedHashtag(hashtag): return hashtag.name case .addSavedHashtag: return "Add Hashtag..." case .savedInstancesHeader: return "Instance Timelines" 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 .explore: return "magnifyingglass" case .bookmarks: return "bookmark" case .favorites: return "star" 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: 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) } } @available(iOS 15.0, *) func collectionView(_ collectionView: UICollectionView, selectionFollowsFocusForItemAt indexPath: IndexPath) -> Bool { guard let item = dataSource.itemIdentifier(for: indexPath) else { return true } // don't immediately select items that present VCs when the they're focused, only when deliberately selected switch item { case .tab(.compose), .addList, .addSavedHashtag, .addSavedInstance: return false default: return true } } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let item = dataSource.itemIdentifier(for: indexPath), let activity = userActivityForItem(item) else { return nil } if case .tab(.myProfile) = item, // only disable context menu on long-press, to allow fast account switching collectionView.contextMenuInteraction?.menuAppearance == .rich { return nil } activity.displaysAuxiliaryScene = true return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in var actions: [UIAction] = [ UIWindowScene.ActivationAction({ action in return UIWindowScene.ActivationConfiguration(userActivity: activity) }), ] if case .list(let list) = item { actions.append(UIAction(title: "Delete List", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [unowned self] _ in Task { let service = DeleteListService(list: list, mastodonController: self.mastodonController, present: { self.present($0, animated: true) }) await service.run() } })) } return UIMenu(children: actions) } } } 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 [] } if case .tab(.myProfile) = item { return [] } activity.displaysAuxiliaryScene = true let provider = NSItemProvider(object: activity) return [UIDragItem(itemProvider: provider)] } } extension MainSidebarViewController: InstanceTimelineViewControllerDelegate { func didSaveInstance(url: URL) { dismiss(animated: true) { self.select(item: .savedInstance(url), animated: true) self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url)) } } func didUnsaveInstance(url: URL) { dismiss(animated: true) } }