// // TimelineViewController.swift // Tusker // // Created by Shadowfacts on 9/20/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine // TODO: gonna need a thing to replicate all of EnhancedTableViewController class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, RefreshableViewController { let timeline: Timeline weak var mastodonController: MastodonController! private(set) var controller: TimelineLikeController! let confirmLoadMore = PassthroughSubject() private var newer: RequestRange? private var older: RequestRange? // stored separately because i don't want to query the snapshot every time the user scrolls private var isShowingTimelineDescription = false var collectionView: UICollectionView { view as! UICollectionView } private(set) var dataSource: UICollectionViewDiffableDataSource! init(for timeline: Timeline, mastodonController: MastodonController!) { self.timeline = timeline self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) self.controller = TimelineLikeController(delegate: self) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline")) } 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 = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0) config.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0) } return config } let layout = UICollectionViewCompositionalLayout.list(using: config) view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self registerTimelineLikeCells() collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription") dataSource = createDataSource() applyInitialSnapshot() #if !targetEnvironment(macCatalyst) collectionView.refreshControl = UIRefreshControl() collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) #endif } override func viewDidLoad() { super.viewDidLoad() } private func createDataSource() -> UICollectionViewDiffableDataSource { let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in guard case .status(id: let id, state: let state) = item, let status = mastodonController.persistentContainer.status(for: id) else { fatalError() } cell.mastodonController = mastodonController cell.delegate = self cell.updateUI(statusID: id, state: state) } let timelineDescriptionCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in guard case .public(let local) = timeline else { fatalError() } cell.mastodonController = self.mastodonController cell.local = local cell.didDismiss = { [unowned self] in self.removeTimelineDescriptionCell() } } return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .status(_, _): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: itemIdentifier) case .loadingIndicator: return loadingIndicatorCell(for: indexPath) case .confirmLoadMore: return confirmLoadMoreCell(for: indexPath) case .publicTimelineDescription: self.isShowingTimelineDescription = true return collectionView.dequeueConfiguredReusableCell(using: timelineDescriptionCell, for: indexPath, item: itemIdentifier) } } } private func applyInitialSnapshot() { if case .public(let local) = timeline, (local && !Preferences.shared.hasShownLocalTimelineDescription) || (!local && !Preferences.shared.hasShownFederatedTimelineDescription) { var snapshot = dataSource.snapshot() snapshot.appendSections([.header]) snapshot.appendItems([.publicTimelineDescription], toSection: .header) dataSource.apply(snapshot, animatingDifferences: false) } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: true) } Task { await controller.loadInitial() } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if isShowingTimelineDescription, case .public(let local) = timeline { if local { Preferences.shared.hasShownLocalTimelineDescription = true } else { Preferences.shared.hasShownFederatedTimelineDescription = true } } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) // pruneOffscreenRows() } private func removeTimelineDescriptionCell() { var snapshot = dataSource.snapshot() snapshot.deleteSections([.header]) dataSource.apply(snapshot, animatingDifferences: true) isShowingTimelineDescription = false } // private func pruneOffscreenRows() { // guard let lastVisibleIndexPath = collectionView.indexPathsForVisibleItems.last else { // return // } // var snapshot = dataSource.snapshot() // guard snapshot.indexOfSection(.statuses) != nil else { // return // } // let items = snapshot.itemIdentifiers(inSection: .statuses) // let pageSize = 20 // let numberOfPagesToPrune = (items.count - lastVisibleIndexPath.row - 1) / pageSize // if numberOfPagesToPrune > 0 { // let itemsToRemove = Array(items.suffix(numberOfPagesToPrune * pageSize)) // snapshot.deleteItems(itemsToRemove) // // dataSource.apply(snapshot, animatingDifferences: false) // // if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).last { // older = .before(id: id, count: nil) // } // } // } @objc func refresh() { Task { await controller.loadNewer() #if !targetEnvironment(macCatalyst) collectionView.refreshControl?.endRefreshing() #endif } } } extension TimelineViewController { enum Section: TimelineLikeCollectionViewSection { case header case statuses case footer static var entries: Self { .statuses } } enum Item: TimelineLikeCollectionViewItem { typealias TimelineItem = String // status ID case status(id: String, state: StatusState) case loadingIndicator case confirmLoadMore case publicTimelineDescription static func fromTimelineItem(_ id: String) -> Self { return .status(id: id, state: .unknown) } static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case let (.status(id: a, state: _), .status(id: b, state: _)): return a == b case (.loadingIndicator, .loadingIndicator): return true case (.confirmLoadMore, .confirmLoadMore): return true case (.publicTimelineDescription, .publicTimelineDescription): return true default: return false } } func hash(into hasher: inout Hasher) { switch self { case .status(id: let id, state: _): hasher.combine(0) hasher.combine(id) case .loadingIndicator: hasher.combine(1) case .confirmLoadMore: hasher.combine(2) case .publicTimelineDescription: hasher.combine(3) } } var hideSeparators: Bool { switch self { case .loadingIndicator, .publicTimelineDescription: return true default: return false } } var isSelectable: Bool { switch self { case .publicTimelineDescription, .status(id: _, state: _): return true default: return false } } } } // MARK: TimelineLikeControllerDelegate extension TimelineViewController { typealias TimelineItem = String // status ID func loadInitial() async throws -> [TimelineItem] { guard let mastodonController else { throw Error.noClient } let request = Client.getStatuses(timeline: timeline) 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 -> [TimelineItem] { guard let newer else { throw Error.noNewer } let request = Client.getStatuses(timeline: timeline, range: 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 -> [TimelineItem] { guard let older else { throw Error.noOlder } let request = Client.getStatuses(timeline: timeline, range: older) let (statuses, _) = try await mastodonController.run(request) if !statuses.isEmpty { 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 noClient case noNewer case noOlder case allCaughtUp } } extension TimelineViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section), case .status(_, _) = dataSource.itemIdentifier(for: indexPath) 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 let item = dataSource.itemIdentifier(for: indexPath) else { return } switch item { case .publicTimelineDescription: removeTimelineDescriptionCell() case .status(id: let id, state: let state): let status = mastodonController.persistentContainer.status(for: id)! // if the status in the timeline is a reblog, show the status that it is a reblog of selected(status: status.reblog?.id ?? id, state: state.copy()) case .loadingIndicator, .confirmLoadMore: fatalError("unreachable") } } 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) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { if isShowingTimelineDescription { removeTimelineDescriptionCell() } } } extension TimelineViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] } } extension TimelineViewController: TuskerNavigationDelegate { var apiController: MastodonController { mastodonController } } extension TimelineViewController: MenuActionProvider { } extension TimelineViewController: 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) } } }