// // TrendingHashtagsViewController.swift // Tusker // // Created by Shadowfacts on 2/6/21. // Copyright © 2021 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import WebURLFoundationExtras import Combine class TrendingHashtagsViewController: UIViewController { private let mastodonController: MastodonController private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var state = State.unloaded private var confirmLoadMore = PassthroughSubject() 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() title = NSLocalizedString("Trending Hashtags", comment: "trending hashtags screen title") view.backgroundColor = .appGroupedBackground var config = UICollectionLayoutListConfiguration(appearance: .grouped) config.backgroundColor = .appGroupedBackground config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in guard let item = dataSource.itemIdentifier(for: indexPath) else { return sectionConfig } var config = sectionConfig if item.hideSeparators { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } return config } let layout = UICollectionViewCompositionalLayout.list(using: config) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] collectionView.delegate = self collectionView.dragDelegate = self collectionView.allowsFocus = true view.addSubview(collectionView) let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in cell.indicator.startAnimating() } let hashtagCell = UICollectionView.CellRegistration { cell, indexPath, hashtag in cell.updateUI(hashtag: hashtag) } let confirmLoadMoreCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, isLoading in cell.confirmLoadMore = self.confirmLoadMore cell.isLoading = isLoading } dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, item) in switch item { case .loadingIndicator: return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) case .tag(let hashtag): return collectionView.dequeueConfiguredReusableCell(using: hashtagCell, for: indexPath, item: hashtag) case .confirmLoadMore(let loading): return collectionView.dequeueConfiguredReusableCell(using: confirmLoadMoreCell, for: indexPath, item: loading) } } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) Task { await loadInitial() } } private func request(offset: Int?) -> Request<[Hashtag]> { if mastodonController.instanceFeatures.hasMastodonVersion(3, 5, 0) { return Client.getTrendingHashtags(offset: offset) } else { return Client.getTrendingHashtagsDeprecated(offset: offset) } } @MainActor private func loadInitial() async { guard case .unloaded = state else { return } state = .loadingInitial var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.trendingTags]) snapshot.appendItems([.loadingIndicator]) await MainActor.run { dataSource.apply(snapshot) } do { let request = self.request(offset: nil) let (hashtags, _) = try await mastodonController.run(request) snapshot.deleteItems([.loadingIndicator]) snapshot.appendItems(hashtags.map { .tag($0) }) state = .loaded await MainActor.run { dataSource.apply(snapshot) } } catch { snapshot.deleteItems([.loadingIndicator]) await MainActor.run { dataSource.apply(snapshot) } state = .unloaded let config = ToastConfiguration(from: error, with: "Error Loading Trending Tags", in: self) { [weak self] toast in toast.dismissToast(animated: true) await self?.loadInitial() } self.showToast(configuration: config, animated: true) } } @MainActor private func loadOlder() async { guard case .loaded = state else { return } state = .loadingOlder let origSnapshot = dataSource.snapshot() var snapshot = origSnapshot if Preferences.shared.disableInfiniteScrolling { snapshot.appendItems([.confirmLoadMore(false)]) await MainActor.run { dataSource.apply(snapshot) } for await _ in confirmLoadMore.values { break } snapshot.deleteItems([.confirmLoadMore(false)]) snapshot.appendItems([.confirmLoadMore(true)]) await MainActor.run { dataSource.apply(snapshot, animatingDifferences: false) } } else { snapshot.appendItems([.loadingIndicator]) await MainActor.run { dataSource.apply(snapshot) } } do { let request = self.request(offset: snapshot.itemIdentifiers.count - 1) let (hashtags, _) = try await mastodonController.run(request) var snapshot = origSnapshot snapshot.appendItems(hashtags.map { .tag($0) }) await MainActor.run { dataSource.apply(snapshot) } } catch { await MainActor.run { dataSource.apply(origSnapshot) } let config = ToastConfiguration(from: error, with: "Error Loading More Tags", in: self) { [weak self] toast in toast.dismissToast(animated: true) await self?.loadOlder() } self.showToast(configuration: config, animated: true) } state = .loaded } } extension TrendingHashtagsViewController { enum State { case unloaded case loadingInitial case loaded case loadingOlder } } extension TrendingHashtagsViewController { enum Section { case trendingTags } enum Item: Hashable { case loadingIndicator case tag(Hashtag) case confirmLoadMore(Bool) var hideSeparators: Bool { switch self { case .loadingIndicator: return true case .tag(_): return false case .confirmLoadMore(_): return false } } var shouldSelect: Bool { if case .tag(_) = self { return true } else { return false } } } } extension TrendingHashtagsViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { if indexPath.section == collectionView.numberOfSections - 1, indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 { Task { await self.loadOlder() } } } func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { return dataSource.itemIdentifier(for: indexPath)?.shouldSelect ?? false } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath), case let .tag(hashtag) = item else { return } show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil) } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let item = dataSource.itemIdentifier(for: indexPath), case let .tag(hashtag) = item else { return nil } return UIContextMenuConfiguration(identifier: nil) { HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController) } actionProvider: { (_) in UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self.collectionView.cellForItem(at: indexPath)))) } } } extension TrendingHashtagsViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard let item = dataSource.itemIdentifier(for: indexPath), case let .tag(hashtag) = item, let url = URL(hashtag.url) else { return [] } let provider = NSItemProvider(object: url as NSURL) if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) { activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) } return [UIDragItem(itemProvider: provider)] } } extension TrendingHashtagsViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension TrendingHashtagsViewController: ToastableViewController { } extension TrendingHashtagsViewController: MenuActionProvider { }