// // TrendingLinksViewController.swift // Tusker // // Created by Shadowfacts on 4/2/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import WebURLFoundationExtras import SafariServices import Combine class TrendingLinksViewController: UIViewController, CollectionViewController { weak var mastodonController: MastodonController! var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var state = State.unloaded private let 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 Links", comment: "trending links screen title") let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in switch dataSource.sectionIdentifier(for: sectionIndex) { case nil: fatalError() case .loadingIndicator: var config = UICollectionLayoutListConfiguration(appearance: .grouped) config.backgroundColor = .appGroupedBackground config.showsSeparators = false return .list(using: config, layoutEnvironment: environment) case .links: let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(280)) let item = NSCollectionLayoutItem(layoutSize: size) let item2 = NSCollectionLayoutItem(layoutSize: size) let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item, item2]) group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) group.interItemSpacing = .fixed(16) let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = 16 section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0) return section } } collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.delegate = self collectionView.dragDelegate = self collectionView.backgroundColor = .appGroupedBackground collectionView.allowsFocus = true view.addSubview(collectionView) NSLayoutConstraint.activate([ collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), collectionView.topAnchor.constraint(equalTo: view.topAnchor), collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) dataSource = createDataSource() } private func createDataSource() -> UICollectionViewDiffableDataSource { let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in cell.indicator.startAnimating() } let linkCell = UICollectionView.CellRegistration(cellNib: UINib(nibName: "TrendingLinkCardCollectionViewCell", bundle: .main)) { cell, indexPath, item in cell.verticalSize = .compact cell.updateUI(card: item) } let confirmLoadMoreCell = UICollectionView.CellRegistration { cell, indexPath, isLoading in cell.confirmLoadMore = self.confirmLoadMore cell.isLoading = isLoading } return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .loadingIndicator: return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) case .link(let card): return collectionView.dequeueConfiguredReusableCell(using: linkCell, for: indexPath, item: card) case .confirmLoadMore(let loading): return collectionView.dequeueConfiguredReusableCell(using: confirmLoadMoreCell, for: indexPath, item: loading) } } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) Task { await loadInitial() } } @MainActor private func loadInitial() async { guard case .unloaded = state else { return } state = .loading var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.loadingIndicator]) snapshot.appendItems([.loadingIndicator]) await dataSource.apply(snapshot) do { let request = Client.getTrendingLinks() let (links, _) = try await mastodonController.run(request) snapshot.deleteSections([.loadingIndicator]) snapshot.appendSections([.links]) snapshot.appendItems(links.map { .link($0) }) state = .loaded await dataSource.apply(snapshot) } catch { await dataSource.apply(NSDiffableDataSourceSnapshot()) state = .unloaded let config = ToastConfiguration(from: error, with: "Error Loading Trending Links", 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.appendSections([.loadingIndicator]) snapshot.appendItems([.confirmLoadMore(false)], toSection: .loadingIndicator) await dataSource.apply(snapshot) for await _ in confirmLoadMore.values { break } snapshot.deleteItems([.confirmLoadMore(false)]) snapshot.appendItems([.confirmLoadMore(true)], toSection: .loadingIndicator) await dataSource.apply(snapshot, animatingDifferences: false) } else { snapshot.appendSections([.loadingIndicator]) snapshot.appendItems([.loadingIndicator], toSection: .loadingIndicator) await dataSource.apply(snapshot) } do { let request = Client.getTrendingLinks(offset: origSnapshot.itemIdentifiers.count) let (links, _) = try await mastodonController.run(request) var snapshot = origSnapshot snapshot.appendItems(links.map { .link($0) }, toSection: .links) await dataSource.apply(snapshot) } catch { await dataSource.apply(origSnapshot) let config = ToastConfiguration(from: error, with: "Erorr Loading More Links", in: self) { [weak self] toast in toast.dismissToast(animated: true) await self?.loadOlder() } self.showToast(configuration: config, animated: true) } } } extension TrendingLinksViewController { enum State { case unloaded case loading case loaded case loadingOlder } } extension TrendingLinksViewController { enum Section { case loadingIndicator case links } enum Item: Hashable { case loadingIndicator case link(Card) case confirmLoadMore(Bool) static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case (.loadingIndicator, .loadingIndicator): return true case (.link(let a), .link(let b)): return a.url == b.url case (.confirmLoadMore(let a), .confirmLoadMore(let b)): return a == b default: return false } } func hash(into hasher: inout Hasher) { switch self { case .loadingIndicator: hasher.combine(0) case .link(let card): hasher.combine(1) hasher.combine(card.url) case .confirmLoadMore(let loading): hasher.combine(2) hasher.combine(loading) } } var shouldSelect: Bool { if case .link(_) = self { return true } else { return false } } } } extension TrendingLinksViewController: 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 loadOlder() } } } func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { return dataSource.itemIdentifier(for: indexPath)?.shouldSelect ?? false } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard case .link(let card) = dataSource.itemIdentifier(for: indexPath), let url = URL(card.url) else { return } selected(url: url) } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard case .link(let card) = dataSource.itemIdentifier(for: indexPath), let url = URL(card.url), let cell = collectionView.cellForItem(at: indexPath) else { return nil } return UIContextMenuConfiguration { let vc = SFSafariViewController(url: url) vc.preferredControlTintColor = Preferences.shared.accentColor.color return vc } actionProvider: { _ in UIMenu(children: self.actionsForTrendingLink(card: card, source: .view(cell))) } } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) } } extension TrendingLinksViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard case .link(let card) = dataSource.itemIdentifier(for: indexPath), let url = URL(card.url) else { return [] } return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))] } } extension TrendingLinksViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension TrendingLinksViewController: ToastableViewController { } extension TrendingLinksViewController: MenuActionProvider { }