// // LocalPredicateStatusesViewController.swift // Tusker // // Created by Shadowfacts on 2/4/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import CoreData class LocalPredicateStatusesViewController: UIViewController, CollectionViewController, RefreshableViewController { private static let pageSize = 40 let mastodonController: MastodonController private let predicate: (StatusMO) -> Bool private let predicateTitle: String private let request: (RequestRange) -> Request<[Status]> var collectionView: UICollectionView! { view as? UICollectionView } private var dataSource: UICollectionViewDiffableDataSource! private var state = State.unloaded private var newer: RequestRange? private var older: RequestRange? init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[Status]>, mastodonController: MastodonController) { self.mastodonController = mastodonController self.predicate = predicate self.predicateTitle = predicateTitle self.request = request super.init(nibName: nil, bundle: nil) self.title = predicateTitle } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { var config = UICollectionLayoutListConfiguration(appearance: .plain) config.backgroundColor = .appBackground 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, sectionConfig in guard let item = dataSource.itemIdentifier(for: indexPath) else { return sectionConfig } var config = sectionConfig if item.hideSeparators { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } else { 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: .allow, precomputedContent: nil) } let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in cell.indicator.startAnimating() } return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .status(id: let id, state: let state, addedLocally: _): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) case .loadingIndicator: return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) } } } override func viewDidLoad() { super.viewDidLoad() #if !targetEnvironment(macCatalyst) collectionView.refreshControl = UIRefreshControl() collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) #endif addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh \(predicateTitle)")) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) if case .unloaded = state { Task { await loadInitial() } } } private func apply(snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool) async { await Task { @MainActor in self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences) }.value } @MainActor private func loadInitial() async { state = .loadingInitial var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) snapshot.appendItems([.loadingIndicator]) await apply(snapshot: snapshot, animatingDifferences: false) do { let req = request(.count(Self.pageSize)) let (statuses, pagination) = try await mastodonController.run(req) newer = pagination?.newer older = pagination?.older await mastodonController.persistentContainer.addAll(statuses: statuses) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, addedLocally: false) }) await apply(snapshot: snapshot, animatingDifferences: true) state = .loaded } catch { let config = ToastConfiguration(from: error, with: "Error Loading \(predicateTitle)", in: self) { [weak self] toast in toast.dismissToast(animated: true) await self?.loadInitial() } showToast(configuration: config, animated: true) await apply(snapshot: NSDiffableDataSourceSnapshot(), animatingDifferences: false) state = .unloaded } } @MainActor private func loadOlder() async { guard case .loaded = state, let older else { return } state = .loadingOlder var snapshot = dataSource.snapshot() snapshot.appendItems([.loadingIndicator]) await apply(snapshot: snapshot, animatingDifferences: false) do { let req = request(older.withCount(Self.pageSize)) let (statuses, pagination) = try await mastodonController.run(req) self.older = pagination?.older await mastodonController.persistentContainer.addAll(statuses: statuses) snapshot.deleteItems([.loadingIndicator]) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, addedLocally: false) }) await apply(snapshot: snapshot, animatingDifferences: true) } catch { let config = ToastConfiguration(from: error, with: "Error Loading Older \(predicateTitle)", in: self) { [weak self] toast in toast.dismissToast(animated: true) await self?.loadOlder() } showToast(configuration: config, animated: true) var snapshot = dataSource.snapshot() snapshot.deleteItems([.loadingIndicator]) await apply(snapshot: snapshot, animatingDifferences: false) } state = .loaded } @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, let accountID = mastodonController.accountInfo?.id, userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } var snapshot = dataSource.snapshot() let toDelete = statusIDs.map { id in Item.status(id: id, state: .unknown, addedLocally: false) }.filter { item in snapshot.itemIdentifiers.contains(item) } if !toDelete.isEmpty { snapshot.deleteItems(toDelete) Task { await apply(snapshot: snapshot, animatingDifferences: true) } } } @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) { // only perform local updates while the vc is idle // otherwise loading the bookmarks ends up inserting them out of order guard case .loaded = state else { return } var snapshot = dataSource.snapshot() func prepend(item: Item) { if let first = snapshot.itemIdentifiers.first { snapshot.insertItems([item], beforeItem: first) } else { snapshot.appendItems([item]) } } var hasChanges = false if let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set { for case let status as StatusMO in inserted where predicate(status) { prepend(item: .status(id: status.id, state: .unknown, addedLocally: true)) hasChanges = true } } if let updated = notification.userInfo?[NSUpdatedObjectsKey] as? Set { for case let status as StatusMO in updated { let item = Item.status(id: status.id, state: .unknown, addedLocally: true) let exists = snapshot.itemIdentifiers.contains(item) if predicate(status) && !exists { prepend(item: item) hasChanges = true } else if !predicate(status) && exists { snapshot.deleteItems([item]) hasChanges = true } } } if hasChanges { Task { await apply(snapshot: snapshot, animatingDifferences: true) } } } // MARK: Interaction @objc func refresh() { guard case .loaded = state, let newer else { #if !targetEnvironment(macCatalyst) collectionView.refreshControl!.endRefreshing() #endif return } state = .loadingNewer Task { do { let req = request(newer.withCount(Self.pageSize)) let (statuses, pagination) = try await mastodonController.run(req) self.newer = pagination?.newer await mastodonController.persistentContainer.addAll(statuses: statuses) var snapshot = dataSource.snapshot() let localItems: [String: CollapseState] = Dictionary(uniqueKeysWithValues: snapshot.itemIdentifiers.compactMap { if case .status(id: let id, state: let state, addedLocally: true) = $0 { return (id, state) } else { return nil } }) var newItems: [Item] = [] for status in statuses { let state: CollapseState if let existing = localItems[status.id] { state = existing snapshot.deleteItems([.status(id: status.id, state: existing, addedLocally: true)]) } else { state = .unknown } newItems.append(.status(id: status.id, state: state, addedLocally: false)) } if let first = snapshot.itemIdentifiers.first { snapshot.insertItems(newItems, beforeItem: first) } else { snapshot.appendItems(newItems) } await apply(snapshot: snapshot, animatingDifferences: true) } catch { let config = ToastConfiguration(from: error, with: "Error Refreshing \(predicateTitle)", in: self) { [weak self] toast in toast.dismissToast(animated: true) self?.refresh() } showToast(configuration: config, animated: true) } #if !targetEnvironment(macCatalyst) collectionView.refreshControl!.endRefreshing() #endif state = .loaded } } } extension LocalPredicateStatusesViewController { enum Section { case statuses } enum Item: Equatable, Hashable { case status(id: String, state: CollapseState, addedLocally: Bool) case loadingIndicator var hideSeparators: Bool { switch self { case .loadingIndicator: return true default: return false } } 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) } } } } extension LocalPredicateStatusesViewController { enum State { case unloaded case loadingInitial case loaded case loadingOlder case loadingNewer } } extension LocalPredicateStatusesViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { if indexPath.section == 0, indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 { Task { await self.loadOlder() } } } func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { if case .status(_, _, _) = dataSource.itemIdentifier(for: indexPath) { return true } else { return false } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if case .status(id: let id, state: let state, _) = dataSource.itemIdentifier(for: indexPath) { 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 LocalPredicateStatusesViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] } } extension LocalPredicateStatusesViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension LocalPredicateStatusesViewController: 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) { // filtering isn't supported here } } extension LocalPredicateStatusesViewController: TabBarScrollableViewController { func tabBarScrollToTop() { collectionView.scrollToTop() } } extension LocalPredicateStatusesViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { collectionView.scrollToTop() return .stop } }