Tusker/Tusker/Screens/Explore/ExploreViewController.swift

574 lines
23 KiB
Swift
Raw Normal View History

//
// ExploreViewController.swift
// Tusker
//
// Created by Shadowfacts on 12/14/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Combine
2019-12-17 23:48:29 +00:00
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>()
2022-11-19 19:08:39 +00:00
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()
2019-12-18 02:18:32 +00:00
var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
2023-02-03 04:02:11 +00:00
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()
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController!
searchController = MastodonSearchController(searchResultsController: resultsController)
definesPresentationContext = true
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
2019-12-17 23:48:29 +00:00
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
2022-04-01 23:23:49 +00:00
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
2022-11-19 19:08:39 +00:00
mastodonController.instanceFeatures.featuresUpdated
.sink { [unowned self] in self.instanceFeaturesChanged() }
.store(in: &cancellables)
mastodonController.$lists
2022-11-19 19:08:39 +00:00
.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)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
2019-12-20 02:20:29 +00:00
// 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)
2019-12-18 02:18:32 +00:00
}
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
2023-02-03 04:02:11 +00:00
cell.configurationUpdateHandler = { cell, state in
cell.backgroundConfiguration = .appListGroupedCell(for: state)
2023-02-03 04:02:11 +00:00
}
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 })
2023-02-04 18:21:58 +00:00
snapshot.appendItems([.bookmarks, .favorites], toSection: .bookmarks)
if mastodonController.instanceFeatures.trends,
!Preferences.shared.hideTrends {
2022-04-01 23:23:49 +00:00
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)
2022-11-19 19:08:39 +00:00
reloadLists(mastodonController.lists)
}
2022-04-01 23:23:49 +00:00
private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
snapshot.insertSections([.discover], afterSection: .bookmarks)
snapshot.appendItems([.trends], toSection: .discover)
2022-04-01 23:23:49 +00:00
}
private func instanceFeaturesChanged() {
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)
}
2022-11-19 19:08:39 +00:00
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]) {
2019-12-20 02:20:29 +00:00
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)
2019-12-20 02:20:29 +00:00
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)
}
2022-04-01 23:23:49 +00:00
@objc private func preferencesChanged() {
var snapshot = dataSource.snapshot()
let hasSection = snapshot.sectionIdentifiers.contains(.discover)
let hide = Preferences.shared.hideTrends
2022-04-01 23:23:49 +00:00
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) {
2022-11-11 23:16:44 +00:00
Task { @MainActor in
let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
if await service.run() {
var snapshot = dataSource.snapshot()
2019-12-20 02:20:29 +00:00
snapshot.deleteItems([.list(list)])
2022-11-11 23:16:44 +00:00
await dataSource.apply(snapshot)
completion(true)
} else {
completion(false)
2019-12-20 02:20:29 +00:00
}
2022-11-11 23:16:44 +00:00
}
2019-12-20 02:20:29 +00:00
}
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? {
var actions = [UIContextualAction]()
switch dataSource.itemIdentifier(for: indexPath) {
case let .list(list):
actions.append(UIContextualAction(style: .destructive, title: "Delete", handler: { _, _, completion in
self.deleteList(list, completion: completion)
}))
case let .savedHashtag(hashtag):
let name = hashtag.name.lowercased()
let context = mastodonController.persistentContainer.viewContext
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name, account: mastodonController.accountInfo!)).first
if let existing {
actions.append(UIContextualAction(style: .destructive, title: "Unsave", handler: { _, _, completion in
context.delete(existing)
try! context.save()
}))
}
if mastodonController.instanceFeatures.canFollowHashtags,
mastodonController.followedHashtags.contains(where: { $0.name.lowercased() == name }) {
actions.append(UIContextualAction(style: .destructive, title: "Unfollow", handler: { _, _, completion in
Task {
let success =
await ToggleFollowHashtagService(hashtagName: hashtag.name, presenter: self)
.toggleFollow()
completion(success)
}
}))
}
case let .savedInstance(url):
actions.append(UIContextualAction(style: .destructive, title: "Unsave", handler: { _, _, completion in
self.removeSavedInstance(url)
completion(true)
}))
default:
return nil
}
return UISwipeActionsConfiguration(actions: actions)
}
// 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)
2023-02-04 18:21:58 +00:00
case .favorites:
show(FavoritesViewController(mastodonController: mastodonController), sender: nil)
2022-04-02 15:36:45 +00:00
case .trends:
show(TrendsViewController(mastodonController: mastodonController), sender: nil)
2021-02-08 00:39:22 +00:00
2019-12-17 23:48:29 +00:00
case let .list(list):
show(ListTimelineViewController(for: list, mastodonController: mastodonController), sender: nil)
2019-12-18 02:18:32 +00:00
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()
2019-12-20 02:20:29 +00:00
case let .savedHashtag(hashtag):
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
2019-12-20 02:20:29 +00:00
case .addSavedHashtag:
collectionView.deselectItem(at: indexPath, animated: true)
let navController = UINavigationController(rootViewController: AddSavedHashtagViewController(mastodonController: mastodonController))
2019-12-20 02:20:29 +00:00
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
2021-02-06 19:54:35 +00:00
case discover
case lists
2019-12-20 02:20:29 +00:00
case savedHashtags
case savedInstances
var label: String? {
switch self {
case .bookmarks:
return nil
2021-02-06 19:54:35 +00:00
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
2023-02-04 18:21:58 +00:00
case favorites
case trends
2019-12-17 23:48:29 +00:00
case list(List)
2019-12-18 02:18:32 +00:00
case addList
2019-12-20 02:20:29 +00:00
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")
2023-02-04 18:21:58 +00:00
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"
2023-02-04 18:21:58 +00:00
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 {
2019-12-17 23:48:29 +00:00
switch (lhs, rhs) {
case (.bookmarks, .bookmarks):
return true
2023-02-04 18:21:58 +00:00
case (.favorites, .favorites):
2022-04-02 15:36:45 +00:00
return true
case (.trends, .trends):
2021-02-08 00:39:22 +00:00
return true
2019-12-17 23:48:29 +00:00
case let (.list(a), .list(b)):
return a.id == b.id && a.title == b.title
2019-12-18 02:18:32 +00:00
case (.addList, .addList):
return true
2019-12-20 02:20:29 +00:00
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
2019-12-17 23:48:29 +00:00
default:
return false
}
}
2019-12-17 23:48:29 +00:00
func hash(into hasher: inout Hasher) {
switch self {
case .bookmarks:
hasher.combine("bookmarks")
2023-02-04 18:21:58 +00:00
case .favorites:
hasher.combine("favorites")
case .trends:
hasher.combine("trends")
2019-12-17 23:48:29 +00:00
case let .list(list):
hasher.combine("list")
hasher.combine(list.id)
hasher.combine(list.title)
2019-12-18 02:18:32 +00:00
case .addList:
hasher.combine("addList")
2019-12-20 02:20:29 +00:00
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")
2019-12-17 23:48:29 +00:00
}
}
}
}
2023-02-23 02:38:12 +00:00
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:
2023-02-23 02:38:12 +00:00
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?
2022-04-02 14:39:03 +00:00
default:
return []
}
return [UIDragItem(itemProvider: provider)]
}
}
extension ExploreViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}