// // TrendingStatusesViewController.swift // Tusker // // Created by Shadowfacts on 4/1/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm class TrendingStatusesViewController: UIViewController, CollectionViewController { private let mastodonController: MastodonController let filterer: Filterer var collectionView: UICollectionView! { view as? UICollectionView } private var dataSource: UICollectionViewDiffableDataSource! private var loaded = false init(mastodonController: MastodonController) { self.mastodonController = mastodonController self.filterer = Filterer(mastodonController: mastodonController, context: .public, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter) super.init(nibName: nil, bundle: nil) title = NSLocalizedString("Trending Posts", comment: "trending posts screen title") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { var config = UICollectionLayoutListConfiguration(appearance: .plain) config.leadingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() } config.trailingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() } config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return sectionSeparatorConfiguration } var config = sectionSeparatorConfiguration if item.hideSeparators { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } if case .status(_, _, _) = item { config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets } return config } let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) section.readableContentInset(in: environment) return section } view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self collectionView.allowsFocus = true dataSource = createDataSource() } private func createDataSource() -> UICollectionViewDiffableDataSource { let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self cell.updateUI(statusID: item.0, state: item.1, filterResult: item.2, precomputedContent: item.3) } let zeroHeightCell = UICollectionView.CellRegistration { _, _, _ in } let loadingCell = UICollectionView.CellRegistration { cell, _, _ in cell.indicator.startAnimating() } return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .status(id: let id, let collapseState, let filterState): let (result, attributedString) = self.filterer.resolve(state: filterState, status: { (mastodonController.persistentContainer.status(for: id)!, false) }) switch result { case .allow, .warn(_): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, collapseState, result, attributedString)) case .hide: return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ()) } case .loadingIndicator: return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) } } } override func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) if !loaded { loaded = true var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) snapshot.appendItems([.loadingIndicator]) dataSource.apply(snapshot, animatingDifferences: false) Task { await loadTrendingStatuses() } } } private func loadTrendingStatuses() async { let statuses: [Status] do { statuses = try await mastodonController.run(Client.getTrendingStatuses()).0 } catch { let snapshot = NSDiffableDataSourceSnapshot() await MainActor.run { dataSource.apply(snapshot) } let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in toast.dismissToast(animated: true) await self.loadTrendingStatuses() } showToast(configuration: config, animated: true) return } await mastodonController.persistentContainer.addAll(statuses: statuses) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) snapshot.appendItems(statuses.map { .status(id: $0.id, collapseState: .unknown, filterState: .unknown) }) await MainActor.run { dataSource.apply(snapshot) } } @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, let statusIDs = userInfo["statusIDs"] as? [String] else { return } var snapshot = self.dataSource.snapshot() let toDelete = statusIDs .map { id in Item.status(id: id, collapseState: .unknown, filterState: .unknown) } .filter { item in snapshot.itemIdentifiers.contains(item) } if !toDelete.isEmpty { snapshot.deleteItems(toDelete) self.dataSource.apply(snapshot, animatingDifferences: true) } } } extension TrendingStatusesViewController { enum Section { case statuses } enum Item: Hashable { case status(id: String, collapseState: CollapseState, filterState: FilterState) case loadingIndicator static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case (.status(id: let a, _, _), .status(id: let b, _, _)): return a == b case (.loadingIndicator, .loadingIndicator): return true default: return false } } func hash(into hasher: inout Hasher) { switch self { case .status(id: let id, _, _): hasher.combine(0) hasher.combine(id) case .loadingIndicator: hasher.combine(1) } } var hideSeparators: Bool { if case .loadingIndicator = self { return true } else { return false } } var isSelectable: Bool { if case .status(id: _, _, _) = self { return true } else { return false } } } } extension TrendingStatusesViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard case .status(id: let id, collapseState: let state, _) = dataSource.itemIdentifier(for: indexPath) else { return } selected(status: id, state: state.copy()) } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) } } extension TrendingStatusesViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] } } extension TrendingStatusesViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension TrendingStatusesViewController: ToastableViewController { } extension TrendingStatusesViewController: MenuActionProvider { } extension TrendingStatusesViewController: StatusCollectionViewCellDelegate { func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { if let indexPath = collectionView.indexPath(for: cell) { var snapshot = dataSource.snapshot() snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) dataSource.apply(snapshot, animatingDifferences: animated, completion: completion) } } func statusCellShowFiltered(_ cell: StatusCollectionViewCell) { fatalError() } } extension TrendingStatusesViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { collectionView.scrollToTop() return .stop } }