Tusker/Tusker/Screens/Explore/ExploreViewController.swift

591 lines
24 KiB
Swift

//
// ExploreViewController.swift
// Tusker
//
// Created by Shadowfacts on 12/14/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Combine
import Pachyderm
import CoreData
import WebURLFoundationExtras
class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController {
weak var mastodonController: MastodonController!
var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private(set) var resultsController: SearchResultsViewController!
private(set) var searchController: UISearchController!
var searchControllerStatusOnAppearance: Bool? = nil
private var cancellables = Set<AnyCancellable>()
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.backgroundColor = .appGroupedBackground
configuration.trailingSwipeActionsConfigurationProvider = { [unowned self] in self.trailingSwipeActionsForCell(at: $0) }
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
collectionView.allowsFocus = true
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
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
}
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
definesPresentationContext = true
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, 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)
let a = PassthroughSubject<Int, Never>()
let b = PassthroughSubject<Int, Never>()
a.merge(with: b)
.sink(receiveValue: { print($0) })
.store(in: &cancellables)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// UISearchController exists outside of the normal VC hierarchy,
// so we manually propagate this down to the results controller
// so that it can deselect on appear
if searchController.isActive {
resultsController.viewWillAppear(animated)
}
clearSelectionOnAppear(animated: animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if searchController.isActive {
resultsController.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
cell.configurationUpdateHandler = { cell, state in
var config = UIBackgroundConfiguration.listGroupedCell()
if state.isHighlighted || state.isSelected {
config.backgroundColor = .appSelectedCellBackground
} else {
config.backgroundColor = .appGroupedCellBackground
}
cell.backgroundConfiguration = 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() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases.filter { $0 != .discover })
snapshot.appendItems([.bookmarks, .favorites], toSection: .bookmarks)
if mastodonController.instanceFeatures.trends,
!Preferences.shared.hideTrends {
addDiscoverSection(to: &snapshot)
}
snapshot.appendItems([.addList], toSection: .lists)
let hashtags = fetchHashtagItems(followed: mastodonController.followedHashtags)
snapshot.appendItems(hashtags, toSection: .savedHashtags)
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
let instances = fetchSavedInstances().map {
Item.savedInstance($0.url)
}
snapshot.appendItems(instances, toSection: .savedInstances)
snapshot.appendItems([.findInstance], toSection: .savedInstances)
dataSource.apply(snapshot, animatingDifferences: false)
reloadLists(mastodonController.lists)
}
private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
snapshot.insertSections([.discover], afterSection: .bookmarks)
snapshot.appendItems([.trends], toSection: .discover)
}
private func ownInstanceLoaded(_ instance: Instance) {
var snapshot = self.dataSource.snapshot()
if mastodonController.instanceFeatures.trends,
!snapshot.sectionIdentifiers.contains(.discover) {
addDiscoverSection(to: &snapshot)
} else if !mastodonController.instanceFeatures.trends,
snapshot.sectionIdentifiers.contains(.discover) {
snapshot.deleteSections([.discover])
}
self.dataSource.apply(snapshot)
}
private func reloadLists(_ lists: [List]) {
var snapshot = self.dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .lists))
snapshot.appendItems(lists.map { .list($0) }, toSection: .lists)
snapshot.appendItems([.addList], toSection: .lists)
self.dataSource.apply(snapshot)
}
@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(\.label))
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]) {
var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags))
let hashtags = fetchHashtagItems(followed: followed)
snapshot.appendItems(hashtags, toSection: .savedHashtags)
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
dataSource.apply(snapshot)
}
@objc private func savedInstancesChanged() {
var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedInstances))
let instances = fetchSavedInstances().map {
Item.savedInstance($0.url)
}
snapshot.appendItems(instances, toSection: .savedInstances)
snapshot.appendItems([.findInstance], toSection: .savedInstances)
dataSource.apply(snapshot)
}
@objc private func preferencesChanged() {
var snapshot = dataSource.snapshot()
let hasSection = snapshot.sectionIdentifiers.contains(.discover)
let hide = Preferences.shared.hideTrends
if hasSection && hide {
snapshot.deleteSections([.discover])
} else if !hasSection && !hide {
addDiscoverSection(to: &snapshot)
} else {
return
}
dataSource.apply(snapshot)
}
private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
Task { @MainActor in
let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
if await service.run() {
var snapshot = dataSource.snapshot()
snapshot.deleteItems([.list(list)])
await dataSource.apply(snapshot)
completion(true)
} else {
completion(false)
}
}
}
func removeSavedHashtag(_ hashtag: Hashtag) {
let context = mastodonController.persistentContainer.viewContext
let req = SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)
if let hashtag = try? context.fetch(req).first {
context.delete(hashtag)
try! context.save()
}
}
func removeSavedInstance(_ instanceURL: URL) {
let context = mastodonController.persistentContainer.viewContext
let req = SavedInstance.fetchRequest(url: instanceURL, account: mastodonController.accountInfo!)
if let instance = try? context.fetch(req).first {
context.delete(instance)
try! context.save()
}
}
private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let title: String
let handler: UIContextualAction.Handler
switch dataSource.itemIdentifier(for: indexPath) {
case let .list(list):
title = NSLocalizedString("Delete", comment: "delete swipe action title")
handler = { (_, _, completion) in
self.deleteList(list, completion: completion)
}
case let .savedHashtag(hashtag):
title = NSLocalizedString("Unsave", comment: "unsave swipe action title")
handler = { (_, _, completion) in
self.removeSavedHashtag(hashtag)
completion(true)
}
case let .savedInstance(url):
title = NSLocalizedString("Unsave", comment: "unsave swipe action title")
handler = { (_, _, completion) in
self.removeSavedInstance(url)
completion(true)
}
default:
return nil
}
return UISwipeActionsConfiguration(actions: [
UIContextualAction(style: .destructive, title: 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(BookmarksViewController(mastodonController: mastodonController), sender: nil)
case .favorites:
show(FavoritesViewController(mastodonController: mastodonController), sender: nil)
case .trends:
show(TrendsViewController(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 service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true) }) { list in
let listTimelineController = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
listTimelineController.presentEditOnAppear = true
self.show(listTimelineController, sender: nil)
}
service.run()
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 nil
case .lists:
return NSLocalizedString("Lists", comment: "explore lists section title")
case .savedHashtags:
return NSLocalizedString("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 favorites
case trends
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 .favorites:
return NSLocalizedString("Favorites", comment: "favorites nav item title")
case .trends:
return NSLocalizedString("Trends", comment: "trends 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("Add 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 .favorites:
name = "star.fill"
case .trends:
name = "chart.line.uptrend.xyaxis"
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 (.favorites, .favorites):
return true
case (.trends, .trends):
return true
case let (.list(a), .list(b)):
return a.id == b.id && a.title == b.title
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 .favorites:
hasher.combine("favorites")
case .trends:
hasher.combine("trends")
case let .list(list):
hasher.combine("list")
hasher.combine(list.id)
hasher.combine(list.title)
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: StateRestorableViewController {
func stateRestorationActivity() -> NSUserActivity? {
if searchController.isActive {
return UserActivityManager.searchActivity(query: searchController.searchBar.text, accountID: mastodonController.accountInfo!.id)
} else {
return nil
}
}
}
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:
let activity = UserActivityManager.bookmarksActivity(accountID: accountID)
activity.displaysAuxiliaryScene = true
provider = NSItemProvider(object: activity)
case let .list(list):
guard let activity = UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: accountID) else { return [] }
activity.displaysAuxiliaryScene = true
provider = NSItemProvider(object: activity)
case let .savedHashtag(hashtag):
guard let url = URL(hashtag.url) else {
return []
}
provider = NSItemProvider(object: url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) {
activity.displaysAuxiliaryScene = true
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?
default:
return []
}
return [UIDragItem(itemProvider: provider)]
}
}