// // ProfileStatusesViewController.swift // Tusker // // Created by Shadowfacts on 10/6/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController { unowned var owner: ProfileViewController var mastodonController: MastodonController { owner.mastodonController } private(set) var accountID: String! let kind: Kind var initialHeaderMode: HeaderMode? weak var profileHeaderDelegate: ProfileHeaderViewDelegate? private(set) var controller: TimelineLikeController! let confirmLoadMore = PassthroughSubject() private var newer: RequestRange? private var older: RequestRange? private var cancellables = Set() var collectionView: UICollectionView { view as! UICollectionView } private(set) var dataSource: UICollectionViewDiffableDataSource! private(set) var headerCell: ProfileHeaderCollectionViewCell? private var state: State = .unloaded init(accountID: String?, kind: Kind, owner: ProfileViewController) { self.accountID = accountID self.kind = kind self.owner = owner super.init(nibName: nil, bundle: nil) self.controller = TimelineLikeController(delegate: self) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile")) } 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.list(using: config) view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self registerTimelineLikeCells() dataSource = createDataSource() #if !targetEnvironment(macCatalyst) collectionView.refreshControl = UIRefreshControl() collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) #endif } override func viewDidLoad() { super.viewDidLoad() mastodonController.persistentContainer.accountSubject .receive(on: DispatchQueue.main) .filter { [unowned self] in $0 == self.accountID } .sink { [unowned self] id in switch state { case .unloaded: Task { await load() } case .loading: break case .loaded, .setupInitialSnapshot: var snapshot = dataSource.snapshot() snapshot.reconfigureItems([.header(id)]) dataSource.apply(snapshot, animatingDifferences: true) } } .store(in: &cancellables) } private func createDataSource() -> UICollectionViewDiffableDataSource { collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell") let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self cell.showPinned = item.2 cell.updateUI(statusID: item.0, state: item.1) } return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .header(let id): if let headerCell = self.headerCell { return headerCell } else { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "headerCell", for: indexPath) as! ProfileHeaderCollectionViewCell switch self.initialHeaderMode { case nil: fatalError("missing initialHeaderMode") case .createView: let view = ProfileHeaderView.create() view.delegate = self.profileHeaderDelegate view.updateUI(for: id) view.pagesSegmentedControl.selectedSegmentIndex = self.owner.currentIndex ?? 0 cell.addHeader(view) case .placeholder(height: let height): _ = cell.addConstraint(height: height) } self.headerCell = cell return cell } case .status(id: let id, state: let state, pinned: let pinned): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned)) case .loadingIndicator: return loadingIndicatorCell(for: indexPath) case .confirmLoadMore: return confirmLoadMoreCell(for: indexPath) } } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: true) } Task { await load() } } func setAccountID(_ id: String) { self.accountID = id // TODO: maybe this function should be async? Task { await load() } } private func load() async { guard isViewLoaded, let accountID, case .unloaded = state, mastodonController.persistentContainer.account(for: accountID) != nil else { return } state = .loading var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.header, .pinned, .statuses]) snapshot.appendItems([.header(accountID)], toSection: .header) await apply(snapshot, animatingDifferences: false) state = .setupInitialSnapshot await controller.loadInitial() await tryLoadPinned() state = .loaded } private func tryLoadPinned() async { do { try await loadPinned() } catch { let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in toast.dismissToast(animated: true) await self.tryLoadPinned() } self.showToast(configuration: config, animated: true) } } private func loadPinned() async throws { guard case .statuses = kind, mastodonController.instanceFeatures.profilePinnedStatuses else { return } let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false) let (statuses, _) = try await mastodonController.run(request) await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume() } } var snapshot = dataSource.snapshot() let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: true) } snapshot.appendItems(items, toSection: .pinned) await apply(snapshot, animatingDifferences: true) } @objc func refresh() { guard case .loaded = state else { #if !targetEnvironment(macCatalyst) collectionView.refreshControl?.endRefreshing() #endif return } Task { // TODO: coalesce these data source updates // TODO: refresh profile await controller.loadNewer() await tryLoadPinned() #if !targetEnvironment(macCatalyst) collectionView.refreshControl?.endRefreshing() #endif } } } extension ProfileStatusesViewController { enum State { case unloaded case loading case setupInitialSnapshot case loaded } } extension ProfileStatusesViewController { enum Kind { case statuses, withReplies, onlyMedia } enum HeaderMode { case createView, placeholder(height: CGFloat) } } extension ProfileStatusesViewController { enum Section: TimelineLikeCollectionViewSection { case header case pinned case statuses case footer static var entries: Self { .statuses } } enum Item: TimelineLikeCollectionViewItem { typealias TimelineItem = String case header(String) case status(id: String, state: StatusState, pinned: Bool) case loadingIndicator case confirmLoadMore static func fromTimelineItem(_ item: String) -> Self { return .status(id: item, state: .unknown, pinned: false) } static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case let (.header(a), .header(b)): return a == b case let (.status(id: a, state: _, pinned: ap), .status(id: b, state: _, pinned: bp)): return a == b && ap == bp case (.loadingIndicator, .loadingIndicator): return true case (.confirmLoadMore, .confirmLoadMore): return true default: return false } } func hash(into hasher: inout Hasher) { switch self { case .header(let id): hasher.combine(0) hasher.combine(id) case .status(id: let id, state: _, pinned: let pinned): hasher.combine(1) hasher.combine(id) hasher.combine(pinned) case .loadingIndicator: hasher.combine(2) case .confirmLoadMore: hasher.combine(3) } } var hideSeparators: Bool { switch self { case .loadingIndicator, .confirmLoadMore: return true default: return false } } var isSelectable: Bool { switch self { case .status(id: _, state: _, pinned: _): return true default: return false } } } } extension ProfileStatusesViewController: TimelineLikeControllerDelegate { typealias TimelineItem = String // status ID private func request(for range: RequestRange = .default) -> Request<[Status]> { switch kind { case .statuses: return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true) case .withReplies: return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false) case .onlyMedia: return Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false) } } func loadInitial() async throws -> [String] { let request = request() let (statuses, _) = try await mastodonController.run(request) if !statuses.isEmpty { newer = .after(id: statuses.first!.id, count: nil) older = .before(id: statuses.last!.id, count: nil) } return await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume(returning: statuses.map(\.id)) } } } func loadNewer() async throws -> [String] { guard let newer else { throw Error.noNewer } let request = request(for: newer) let (statuses, _) = try await mastodonController.run(request) guard !statuses.isEmpty else { throw Error.allCaughtUp } self.newer = .after(id: statuses.first!.id, count: nil) return await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume(returning: statuses.map(\.id)) } } } func loadOlder() async throws -> [String] { guard let older else { throw Error.noOlder } let request = request(for: older) let (statuses, _) = try await mastodonController.run(request) guard !statuses.isEmpty else { return [] } self.older = .before(id: statuses.last!.id, count: nil) return await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume(returning: statuses.map(\.id)) } } } enum Error: TimelineLikeCollectionViewError { case noNewer case noOlder case allCaughtUp } } extension ProfileStatusesViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else { return } let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) if indexPath.row == itemsInSection - 1 { Task { await controller.loadOlder() } } } 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, state: let state, pinned: _) = dataSource.itemIdentifier(for: indexPath) else { return } let status = mastodonController.persistentContainer.status(for: id)! selected(status: status.reblog?.id ?? 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 ProfileStatusesViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] } } extension ProfileStatusesViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension ProfileStatusesViewController: MenuActionProvider { } extension ProfileStatusesViewController: 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) } } } extension ProfileStatusesViewController: TabBarScrollableViewController { func tabBarScrollToTop() { collectionView.scrollToTop() } } extension ProfileStatusesViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { collectionView.scrollToTop() return .stop } }