Tusker/Tusker/Screens/Main/MainSidebarViewController.s...

557 lines
21 KiB
Swift

//
// 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<Section, Item>!
private var cancellables = Set<AnyCancellable>()
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<Section, Item> {
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (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<MainSidebarMyProfileCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
Task {
await cell.updateUI(item: item, account: self.mastodonController.accountInfo!)
}
}
let outlineHeaderCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (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<Section, Item>(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<Section, Item>()
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<Item>()
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<Item>()
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<Item>()
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)
}
}