// // TrendsViewController.swift // Tusker // // Created by Shadowfacts on 2/5/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import SafariServices class TrendsViewController: UIViewController, CollectionViewController { let mastodonController: MastodonController var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var loadTask: Task? private var isShowingTrends = false private var shouldShowTrends: Bool { mastodonController.instanceFeatures.trends && !Preferences.shared.hideTrends } init(mastodonController: MastodonController) { self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) title = "Trends" } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in let sectionIdentifier = self.dataSource.snapshot().sectionIdentifiers[sectionIndex] switch sectionIdentifier { case .trendingHashtags: var listConfig = UICollectionLayoutListConfiguration(appearance: .grouped) listConfig.headerMode = .supplementary return .list(using: listConfig, layoutEnvironment: environment) case .trendingLinks: let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(280)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(250), heightDimension: .estimated(280)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) let section = NSCollectionLayoutSection(group: group) section.orthogonalScrollingBehavior = .groupPaging section.boundarySupplementaryItems = [ NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading) ] section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0) return section case .profileSuggestions: let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(250)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(350), heightDimension: .absolute(250)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) let section = NSCollectionLayoutSection(group: group) section.orthogonalScrollingBehavior = .groupPaging section.boundarySupplementaryItems = [ NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading) ] section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0) return section case .trendingStatuses: var listConfig = UICollectionLayoutListConfiguration(appearance: .grouped) listConfig.headerMode = .supplementary return .list(using: listConfig, layoutEnvironment: environment) } } collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] collectionView.delegate = self collectionView.dragDelegate = self collectionView.backgroundColor = .secondarySystemBackground collectionView.allowsFocus = true view.addSubview(collectionView) dataSource = createDataSource() NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) } private func createDataSource() -> UICollectionViewDiffableDataSource { let sectionHeaderCell = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] (headerView, collectionView, indexPath) in let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] var config = UIListContentConfiguration.groupedHeader() config.text = section.title headerView.contentConfiguration = config } let trendingHashtagCell = UICollectionView.CellRegistration { (cell, indexPath, hashtag) in cell.updateUI(hashtag: hashtag) } let trendingLinkCell = UICollectionView.CellRegistration(cellNib: UINib(nibName: "TrendingLinkCardCollectionViewCell", bundle: .main)) { (cell, indexPath, card) in cell.updateUI(card: card) } let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self // TODO: filter trends cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil) } let accountCell = UICollectionView.CellRegistration(cellNib: UINib(nibName: "SuggestedProfileCardCollectionViewCell", bundle: .main)) { [unowned self] cell, indexPath, item in cell.delegate = self cell.updateUI(accountID: item.0, source: item.1) } let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in switch item { case let .tag(hashtag): return collectionView.dequeueConfiguredReusableCell(using: trendingHashtagCell, for: indexPath, item: hashtag) case let .link(card): return collectionView.dequeueConfiguredReusableCell(using: trendingLinkCell, for: indexPath, item: card) case let .status(id, state): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) case let .account(id, source): return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: (id, source)) } } dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in if elementKind == UICollectionView.elementKindSectionHeader { return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath) } else { return nil } } return dataSource } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) if loadTask == nil { loadTask = Task(priority: .userInitiated) { if (try? await mastodonController.getOwnInstance()) != nil { await loadTrends() } } } } @MainActor private func loadTrends() async { guard isShowingTrends != shouldShowTrends else { return } isShowingTrends = shouldShowTrends guard shouldShowTrends else { await dataSource.apply(NSDiffableDataSourceSnapshot()) return } var snapshot = NSDiffableDataSourceSnapshot() let hashtagsReq = Client.getTrendingHashtags(limit: 5) let hashtags = try? await mastodonController.run(hashtagsReq).0 if let hashtags { snapshot.appendSections([.trendingHashtags]) snapshot.appendItems(hashtags.map { .tag($0) }, toSection: .trendingHashtags) } if mastodonController.instanceFeatures.profileSuggestions { let req = Client.getSuggestions(limit: 10) let suggestions = try? await mastodonController.run(req).0 if let suggestions { snapshot.appendSections([.profileSuggestions]) await mastodonController.persistentContainer.addAll(accounts: suggestions.map(\.account)) snapshot.appendItems(suggestions.map { .account($0.account.id, $0.source) }, toSection: .profileSuggestions) } } if mastodonController.instanceFeatures.trendingStatusesAndLinks { let linksReq = Client.getTrendingLinks(limit: 10) async let links = try? mastodonController.run(linksReq).0 let statusesReq = Client.getTrendingStatuses(limit: 10) async let statuses = try? mastodonController.run(statusesReq).0 if let links = await links { if snapshot.sectionIdentifiers.contains(.profileSuggestions) { snapshot.insertSections([.trendingLinks], beforeSection: .profileSuggestions) } else { snapshot.appendSections([.trendingLinks]) } snapshot.appendItems(links.map { .link($0) }, toSection: .trendingLinks) } if let statuses = await statuses { await mastodonController.persistentContainer.addAll(statuses: statuses) snapshot.appendSections([.trendingStatuses]) snapshot.appendItems(statuses.map { .status($0.id, .unknown) }, toSection: .trendingStatuses) } } if !Task.isCancelled { await apply(snapshot: snapshot) } } @objc private func preferencesChanged() { if isShowingTrends != shouldShowTrends { loadTask?.cancel() loadTask = Task { await loadTrends() } } } private func apply(snapshot: NSDiffableDataSourceSnapshot) async { await Task { @MainActor in self.dataSource.apply(snapshot) }.value } @MainActor private func removeProfileSuggestion(accountID: String) async { let req = Suggestion.remove(accountID: accountID) do { _ = try await mastodonController.run(req) var snapshot = dataSource.snapshot() // the source here doesn't matter, since it's ignored by the equatable and hashable impls snapshot.deleteItems([.account(accountID, .global)]) await apply(snapshot: snapshot) } catch { let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: self) { [unowned self] toast in toast.dismissToast(animated: true) _ = await self.removeProfileSuggestion(accountID: accountID) } self.showToast(configuration: config, animated: true) } } } extension TrendsViewController { enum Section { case trendingHashtags case trendingLinks case profileSuggestions case trendingStatuses var title: String { switch self { case .trendingHashtags: return "Trending Hashtags" case .trendingLinks: return "Trending Links" case .trendingStatuses: return "Trending Posts" case .profileSuggestions: return "Suggested Accounts" } } } enum Item: Equatable, Hashable { case status(String, CollapseState) case tag(Hashtag) case link(Card) case account(String, Suggestion.Source) static func == (lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case let (.status(a, _), .status(b, _)): return a == b case let (.tag(a), .tag(b)): return a == b case let (.link(a), .link(b)): return a.url == b.url case let (.account(a, _), .account(b, _)): return a == b default: return false } } func hash(into hasher: inout Hasher) { switch self { case let .status(id, _): hasher.combine("status") hasher.combine(id) case let .tag(tag): hasher.combine("tag") hasher.combine(tag.name) case let .link(card): hasher.combine("link") hasher.combine(card.url) case let .account(id, _): hasher.combine("account") hasher.combine(id) } } } } extension TrendsViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } switch item { case let .tag(hashtag): show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil) case let .link(card): if let url = URL(card.url) { selected(url: url) } case let .status(id, state): selected(status: id, state: state.copy()) case let .account(id, _): selected(account: id) } } @available(iOS, obsoleted: 16.0) func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let item = dataSource.itemIdentifier(for: indexPath) else { return nil } switch item { case let .tag(hashtag): return UIContextMenuConfiguration(identifier: nil) { HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController) } actionProvider: { (_) in UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self.collectionView.cellForItem(at: indexPath)))) } case let .link(card): guard let url = URL(card.url) 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)) } case let .status(id, state): guard let status = mastodonController.persistentContainer.status(for: id) else { return nil } let cell = collectionView.cellForItem(at: indexPath)! return UIContextMenuConfiguration { ConversationViewController(for: id, state: state.copy(), mastodonController: self.mastodonController) } actionProvider: { _ in UIMenu(children: self.actionsForStatus(status, source: .view(cell))) } case let .account(id, _): let cell = collectionView.cellForItem(at: indexPath)! return UIContextMenuConfiguration { ProfileViewController(accountID: id, mastodonController: self.mastodonController) } actionProvider: { _ in let dismiss = UIAction(title: "Remove Suggestion", image: UIImage(systemName: "trash"), attributes: .destructive) { [unowned self] _ in Task { await self.removeProfileSuggestion(accountID: id) } } return UIMenu(children: [UIMenu(options: .displayInline, children: [dismiss])] + self.actionsForProfile(accountID: id, source: .view(cell))) } } } // implementing the highlightPreviewForItemAt method seems to prevent the old, single IndexPath variant of this method from being called on iOS 16 @available(iOS 16.0, *) func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? { guard indexPaths.count == 1 else { return nil } return self.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPaths[0], point: point) } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) } func collectionView(_ collectionView: UICollectionView, contextMenuConfiguration configuration: UIContextMenuConfiguration, highlightPreviewForItemAt indexPath: IndexPath) -> UITargetedPreview? { switch dataSource.itemIdentifier(for: indexPath) { case .link(_), .account(_, _): guard let cell = collectionView.cellForItem(at: indexPath) else { return nil } let params = UIPreviewParameters() params.visiblePath = UIBezierPath(roundedRect: cell.bounds, cornerRadius: cell.contentView.layer.cornerRadius) return UITargetedPreview(view: cell, parameters: params) default: return nil } } func collectionView(_ collectionView: UICollectionView, contextMenuConfiguration configuration: UIContextMenuConfiguration, dismissalPreviewForItemAt indexPath: IndexPath) -> UITargetedPreview? { return self.collectionView(collectionView, contextMenuConfiguration: configuration, highlightPreviewForItemAt: indexPath) } } extension TrendsViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard let item = dataSource.itemIdentifier(for: indexPath) else { return [] } switch item { case let .tag(hashtag): guard 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)] case let .link(card): guard let url = URL(card.url) else { return [] } return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))] case let .status(id, _): guard let status = mastodonController.persistentContainer.status(for: id), let url = status.url else { return [] } let provider = NSItemProvider(object: url as NSURL) let activity = UserActivityManager.showConversationActivity(mainStatusID: id, accountID: mastodonController.accountInfo!.id) activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) return [UIDragItem(itemProvider: provider)] case let .account(id, _): guard let account = mastodonController.persistentContainer.account(for: id) else { return [] } let provider = NSItemProvider(object: account.url as NSURL) let activity = UserActivityManager.showProfileActivity(id: id, accountID: mastodonController.accountInfo!.id) activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) return [UIDragItem(itemProvider: provider)] } } } extension TrendsViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension TrendsViewController: ToastableViewController { } extension TrendsViewController: MenuActionProvider { } extension TrendsViewController: 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) { // TODO: filtering } }