Tusker/Tusker/Screens/Explore/ExploreViewController.swift

505 lines
21 KiB
Swift

//
// ExploreViewController.swift
// Tusker
//
// Created by Shadowfacts on 12/14/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Combine
import Pachyderm
class ExploreViewController: UIViewController, UICollectionViewDelegate {
weak var mastodonController: MastodonController!
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private(set) var resultsController: SearchResultsViewController!
private(set) var searchController: UISearchController!
var searchControllerStatusOnAppearance: Bool? = nil
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.trailingSwipeActionsConfigurationProvider = self.trailingSwipeActionsForCell(at:)
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
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
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
definesPresentationContext = true
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Can't use UICollectionViewController's builtin version of this because it requires
// the collection view layout be passed into the constructor. Swipe actions for list collection views
// are created by passing a closure to the layout's configuration. This closure needs to capture
// `self`, so it can't be passed into the super constructor.
if let indexPaths = collectionView.indexPathsForSelectedItems {
for indexPath in indexPaths {
collectionView.deselectItem(at: indexPath, animated: true)
}
}
}
override func viewDidAppear(_ animated: Bool) {
super.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<Section, Item> {
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(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<UICollectionViewListCell, Item> { (cell, indexPath, item) in
var config = cell.defaultContentConfiguration()
config.text = item.label
config.image = item.image
cell.contentConfiguration = config
switch item {
case .addList, .addSavedHashtag, .findInstance:
cell.accessories = []
default:
cell.accessories = [.disclosureIndicator()]
}
}
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(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() {
let account = mastodonController.accountInfo!
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases.filter { $0 != .discover })
snapshot.appendItems([.bookmarks], toSection: .bookmarks)
if case .mastodon = mastodonController.instance?.instanceType {
snapshot.insertSections([.discover], afterSection: .bookmarks)
snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover)
}
snapshot.appendItems([.addList], toSection: .lists)
snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) }, toSection: .savedHashtags)
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) }, toSection: .savedInstances)
snapshot.appendItems([.findInstance], toSection: .savedInstances)
dataSource.apply(snapshot, animatingDifferences: false)
reloadLists()
}
private func ownInstanceLoaded(_ instance: Instance) {
var snapshot = self.dataSource.snapshot()
if case .mastodon = instance.instanceType {
snapshot.insertSections([.discover], afterSection: .bookmarks)
snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover)
}
self.dataSource.apply(snapshot)
}
private func reloadLists() {
let request = Client.getLists()
mastodonController.run(request) { (response) in
guard case let .success(lists, _) = response else {
return
}
var snapshot = self.dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .lists))
snapshot.appendItems(lists.map { .list($0) }, toSection: .lists)
snapshot.appendItems([.addList], toSection: .lists)
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
}
}
}
@objc private func savedHashtagsChanged() {
let account = mastodonController.accountInfo!
var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags))
snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) }, toSection: .savedHashtags)
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
dataSource.apply(snapshot)
}
@objc private func savedInstancesChanged() {
let account = mastodonController.accountInfo!
var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedInstances))
snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) }, toSection: .savedInstances)
snapshot.appendItems([.findInstance], toSection: .savedInstances)
dataSource.apply(snapshot)
}
private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
let titleFormat = NSLocalizedString("Are you sure you want to delete the '%@' list?", comment: "delete list alert title")
let title = String(format: titleFormat, list.title)
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "delete list alert cancel button"), style: .cancel, handler: { (_) in
completion(false)
}))
alert.addAction(UIAlertAction(title: NSLocalizedString("Delete List", comment: "delete list alert confirm button"), style: .destructive, handler: { (_) in
let request = List.delete(list)
self.mastodonController.run(request) { (response) in
guard case .success(_, _) = response else {
fatalError()
}
var snapshot = self.dataSource.snapshot()
snapshot.deleteItems([.list(list)])
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
completion(true)
}
}
}))
present(alert, animated: true)
}
func removeSavedHashtag(_ hashtag: Hashtag) {
let account = mastodonController.accountInfo!
SavedDataManager.shared.remove(hashtag: hashtag, for: account)
}
func removeSavedInstance(_ instanceURL: URL) {
let account = mastodonController.accountInfo!
SavedDataManager.shared.remove(instance: instanceURL, for: account)
}
private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let handler: UIContextualAction.Handler
switch dataSource.itemIdentifier(for: indexPath) {
case let .list(list):
handler = { (_, _, completion) in
self.deleteList(list, completion: completion)
}
case let .savedHashtag(hashtag):
handler = { (_, _, completion) in
self.removeSavedHashtag(hashtag)
completion(true)
}
case let .savedInstance(url):
handler = { (_, _, completion) in
self.removeSavedInstance(url)
completion(true)
}
default:
return nil
}
return UISwipeActionsConfiguration(actions: [
UIContextualAction(style: .destructive, title: NSLocalizedString("Delete", comment: "delete swipe action 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(BookmarksTableViewController(mastodonController: mastodonController), sender: nil)
case .trendingTags:
show(TrendingHashtagsViewController(mastodonController: mastodonController), sender: nil)
case .profileDirectory:
show(ProfileDirectoryViewController(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 alert = UIAlertController(title: NSLocalizedString("New List", comment: "new list alert title"), message: NSLocalizedString("Choose a title for your new list", comment: "new list alert message"), preferredStyle: .alert)
alert.addTextField(configurationHandler: nil)
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "new list alert cancel button"), style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: NSLocalizedString("Create List", comment: "new list create button"), 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 {
let listTimelineController = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
listTimelineController.presentEditOnAppear = true
self.show(listTimelineController, sender: nil)
}
}
}))
present(alert, animated: true)
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 NSLocalizedString("Discover", comment: "discover section title")
case .lists:
return NSLocalizedString("Lists", comment: "explore lists section title")
case .savedHashtags:
return NSLocalizedString("Saved 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 trendingTags
case profileDirectory
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 .trendingTags:
return NSLocalizedString("Trending Hashtags", comment: "trending hashtags nav item title")
case .profileDirectory:
return NSLocalizedString("Profile Directory", comment: "profile directory 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("Save 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 .trendingTags:
name = "arrow.up.arrow.down"
case .profileDirectory:
name = "person.2.fill"
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 (.trendingTags, .trendingTags):
return true
case (.profileDirectory, .profileDirectory):
return true
case let (.list(a), .list(b)):
return a.id == b.id
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 .trendingTags:
hasher.combine("trendingTags")
case .profileDirectory:
hasher.combine("profileDirectory")
case let .list(list):
hasher.combine("list")
hasher.combine(list.id)
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: 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:
provider = NSItemProvider(object: UserActivityManager.bookmarksActivity())
case let .list(list):
guard let activity = UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: accountID) else { return [] }
provider = NSItemProvider(object: activity)
case let .savedHashtag(hashtag):
provider = NSItemProvider(object: hashtag.url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) {
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?
case .trendingTags, .profileDirectory, .addList, .addSavedHashtag, .findInstance:
return []
}
return [UIDragItem(itemProvider: provider)]
}
}