591 lines
23 KiB
Swift
591 lines
23 KiB
Swift
//
|
|
// 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?
|
|
var onViewDidLoad: (() -> Void)? = nil {
|
|
willSet {
|
|
precondition(onViewDidLoad == nil)
|
|
}
|
|
}
|
|
|
|
private var collectionView: UICollectionView!
|
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
|
|
|
var allItems: [Item] {
|
|
[
|
|
.tab(.timelines),
|
|
.tab(.notifications),
|
|
.tab(.myProfile),
|
|
] + exploreTabItems
|
|
}
|
|
|
|
var exploreTabItems: [Item] {
|
|
var items: [Item] = [.explore, .bookmarks, .trendingStatuses, .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)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
|
|
|
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.filter { $0 != .discover })
|
|
snapshot.appendItems([
|
|
.tab(.timelines),
|
|
.tab(.notifications),
|
|
.explore,
|
|
.bookmarks,
|
|
.tab(.myProfile)
|
|
], toSection: .tabs)
|
|
snapshot.appendItems([
|
|
.tab(.compose)
|
|
], toSection: .compose)
|
|
if mastodonController.instanceFeatures.instanceType.isMastodon,
|
|
!Preferences.shared.hideDiscover {
|
|
snapshot.insertSections([.discover], afterSection: .compose)
|
|
}
|
|
dataSource.apply(snapshot, animatingDifferences: false)
|
|
|
|
applyDiscoverSectionSnapshot()
|
|
reloadLists()
|
|
reloadSavedHashtags()
|
|
reloadSavedInstances()
|
|
}
|
|
|
|
private func applyDiscoverSectionSnapshot() {
|
|
var discoverSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
|
discoverSnapshot.append([.discoverHeader])
|
|
discoverSnapshot.append([
|
|
.profileDirectory,
|
|
], to: .discoverHeader)
|
|
if mastodonController.instanceFeatures.trendingStatusesAndLinks {
|
|
discoverSnapshot.insert([.trendingStatuses], before: .profileDirectory)
|
|
}
|
|
dataSource.apply(discoverSnapshot, to: .discover)
|
|
}
|
|
|
|
private func ownInstanceLoaded(_ instance: Instance) {
|
|
if mastodonController.instanceFeatures.instanceType.isMastodon {
|
|
var snapshot = self.dataSource.snapshot()
|
|
if !snapshot.sectionIdentifiers.contains(.discover) {
|
|
snapshot.appendSections([.discover])
|
|
dataSource.apply(snapshot, animatingDifferences: false)
|
|
}
|
|
applyDiscoverSectionSnapshot()
|
|
}
|
|
let prevSelected = collectionView.indexPathsForSelectedItems
|
|
|
|
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<Item>()
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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 reloadSavedHashtags() {
|
|
let selected = collectionView.indexPathsForSelectedItems?.first
|
|
|
|
var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
|
hashtagsSnapshot.append([.savedHashtagsHeader])
|
|
hashtagsSnapshot.expand([.savedHashtagsHeader])
|
|
let hashtags = fetchSavedHashtags().map {
|
|
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
|
}
|
|
hashtagsSnapshot.append(hashtags, 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<Item>()
|
|
instancesSnapshot.append([.savedInstancesHeader])
|
|
instancesSnapshot.expand([.savedInstancesHeader])
|
|
let instances = fetchSavedInstances().map {
|
|
Item.savedInstance($0.url)
|
|
}
|
|
instancesSnapshot.append(instances, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
@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])
|
|
dataSource.apply(snapshot)
|
|
} else if !hasSection && !hide {
|
|
snapshot.insertSections([.discover], afterSection: .compose)
|
|
dataSource.apply(snapshot)
|
|
applyDiscoverSectionSnapshot()
|
|
} else {
|
|
return
|
|
}
|
|
}
|
|
|
|
// 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 .explore:
|
|
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
|
|
}
|
|
}
|
|
|
|
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 discover
|
|
case lists
|
|
case savedHashtags
|
|
case savedInstances
|
|
}
|
|
enum Item: Hashable {
|
|
case tab(MainTabBarViewController.Tab)
|
|
case explore, bookmarks
|
|
case discoverHeader, trendingStatuses, 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 .explore:
|
|
return "Explore"
|
|
case .bookmarks:
|
|
return "Bookmarks"
|
|
case .discoverHeader:
|
|
return "Discover"
|
|
case .trendingStatuses:
|
|
return "Trending Posts"
|
|
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 .explore:
|
|
return "magnifyingglass"
|
|
case .bookmarks:
|
|
return "bookmark"
|
|
case .trendingStatuses:
|
|
return "square.text.square"
|
|
case .profileDirectory:
|
|
return "person.2.fill"
|
|
case .list(_):
|
|
return "list.bullet"
|
|
case .savedHashtag(_):
|
|
return "number"
|
|
case .savedInstance(_):
|
|
return "globe"
|
|
case .discoverHeader, .listsHeader, .savedHashtagsHeader, .savedInstancesHeader:
|
|
return nil
|
|
case .addList, .addSavedHashtag, .addSavedInstance:
|
|
return "plus"
|
|
}
|
|
}
|
|
|
|
var hasChildren: Bool {
|
|
switch self {
|
|
case .discoverHeader, .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)
|
|
}
|
|
}
|
|
|
|
@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
|
|
return UIMenu(children: [
|
|
UIWindowScene.ActivationAction({ action in
|
|
return UIWindowScene.ActivationConfiguration(userActivity: activity)
|
|
}),
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|