// // TimelineViewController.swift // Tusker // // Created by Shadowfacts on 9/20/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, RefreshableViewController { let timeline: Timeline weak var mastodonController: MastodonController! private(set) var controller: TimelineLikeController! let confirmLoadMore = PassthroughSubject() // stored separately because i don't want to query the snapshot every time the user scrolls private var isShowingTimelineDescription = false private(set) var collectionView: 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) self.navigationItem.title = timeline.title addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline")) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() 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) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self collectionView.translatesAutoresizingMaskIntoConstraints = false 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), ]) 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 #if DEBUG // navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), primaryAction: UIAction(handler: { [unowned self] _ in // var snapshot = self.dataSource.snapshot() // let statuses = snapshot.itemIdentifiers(inSection: .statuses) // if statuses.count > 20 { // let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10)) // if !toRemove.isEmpty { // print("REMOVING MIDDLE \(toRemove.count) STATUSES") // snapshot.insertItems([.gap], beforeItem: toRemove.first!) // snapshot.deleteItems(toRemove) // self.dataSource.apply(snapshot) // } // } // })) navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), primaryAction: UIAction(handler: { [unowned self] _ in var snapshot = self.dataSource.snapshot() let statuses = snapshot.itemIdentifiers(inSection: .statuses) if statuses.count > 20 { let toRemove = Array(statuses.dropLast(20)) snapshot.deleteItems(toRemove) self.dataSource.apply(snapshot) // if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).first { // self.controller.dataSource.state.newer = .after(id: id, count: nil) // } } })) #else #error("remove me") #endif } // separate method because InstanceTimelineViewController needs to be able to customize it func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) { cell.delegate = self cell.updateUI(statusID: id, state: state) } private func createDataSource() -> UICollectionViewDiffableDataSource { let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in self.configureStatusCell(cell, id: item.0, state: item.1) } let gapCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, _ in cell.fillGap = { [unowned self] direction in Task { await self.controller.fillGap(in: direction) } } } 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(id: let id, state: let state): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) case .gap: return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ()) 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) } } } // non-private, because ListTimelineViewController needs to be able to reload it from scratch func applyInitialSnapshot() { var snapshot = NSDiffableDataSourceSnapshot() if case .public(let local) = timeline, (local && !Preferences.shared.hasShownLocalTimelineDescription) || (!local && !Preferences.shared.hasShownFederatedTimelineDescription) { 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 { if case .notLoadedInitial = controller.state { 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 { if case .notLoadedInitial = controller.state { await controller.loadInitial() } else { // I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController let (_, presentItems) = await (controller.loadNewer(), try? loadInitial()) if let presentItems, case .status(id: let id, state: _) = dataSource.snapshot().itemIdentifiers(inSection: .statuses).first { // if there's no overlap between presentItems and the existing items in the data source, prompt the user to scroll to present if !presentItems.contains(id) { var snapshot = self.dataSource.snapshot() let currentItems = snapshot.itemIdentifiers(inSection: .statuses) guard let item = currentItems.first, case .status(id: id, state: _) = item else { return } // remove any existing gap if there is one if let index = currentItems.lastIndex(of: .gap) { snapshot.deleteItems(Array(currentItems[index...])) } snapshot.insertItems([.gap], beforeItem: item) snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap) // use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)! snapshotView.layer.zPosition = 1000 snapshotView.frame = view.bounds view.addSubview(snapshotView) let bottomOffset = collectionView.contentSize.height - collectionView.contentOffset.y self.dataSource.apply(snapshot, animatingDifferences: false) { self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset) snapshotView.removeFromSuperview() } var config = ToastConfiguration(title: "Jump to present") config.edge = .top config.systemImageName = "arrow.up" config.dismissAutomaticallyAfter = 2 config.action = { [unowned self] toast in toast.dismissToast(animated: true) self.collectionView.scrollToTop() } self.showToast(configuration: config, animated: true) } } } #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 gap 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 (.gap, .gap): return true 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 .gap: hasher.combine(1) case .loadingIndicator: hasher.combine(2) case .confirmLoadMore: hasher.combine(3) case .publicTimelineDescription: hasher.combine(4) } } var hideSeparators: Bool { switch self { case .loadingIndicator, .publicTimelineDescription, .confirmLoadMore: 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] { try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC) let request = Client.getStatuses(timeline: timeline) let (statuses, _) = try await mastodonController.run(request) await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume() } } return statuses.map(\.id) } func loadNewer() async throws -> [TimelineItem] { let statusesSection = dataSource.snapshot().indexOfSection(.statuses)! guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else { throw Error.noNewer } let newer = RequestRange.after(id: id, count: nil) let request = Client.getStatuses(timeline: timeline, range: newer) let (statuses, _) = try await mastodonController.run(request) guard !statuses.isEmpty else { throw TimelineViewController.Error.allCaughtUp } await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume() } } return statuses.map(\.id) } func loadOlder() async throws -> [TimelineItem] { let snapshot = dataSource.snapshot() let statusesSection = snapshot.indexOfSection(.statuses)! guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else { throw Error.noNewer } let older = RequestRange.before(id: id, count: nil) let request = Client.getStatuses(timeline: timeline, range: older) let (statuses, _) = try await mastodonController.run(request) guard !statuses.isEmpty else { return [] } await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume() } } return statuses.map(\.id) } func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] { guard let gapIndexPath = dataSource.indexPath(for: .gap) else { throw Error.noGap } let statusItemsCount = collectionView.numberOfItems(inSection: gapIndexPath.section) let range: RequestRange switch direction { case .above: guard gapIndexPath.row > 0, case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else { // not really the right error but w/e throw Error.noGap } range = .before(id: id, count: nil) case .below: guard gapIndexPath.row < statusItemsCount - 1, case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else { throw Error.noGap } range = .after(id: id, count: nil) } let request = Client.getStatuses(timeline: timeline, range: range) let (statuses, _) = try await mastodonController.run(request) guard !statuses.isEmpty else { return [] } // NOTE: closing the gap (if necessary) happens in handleFillGap await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { continuation.resume() } } return statuses.map(\.id) } func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async { // TODO: better title, involving direction? let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in toast.dismissToast(animated: true) Task { await self?.controller.fillGap(in: direction) } } self.showToast(configuration: config, animated: true) } func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async { var snapshot = dataSource.snapshot() let addedItems: Bool let statusItems = snapshot.itemIdentifiers(inSection: .statuses) let gapIndex = statusItems.firstIndex(of: .gap)! switch direction { case .above: // dropFirst to remove .gap item let afterGap = statusItems[gapIndex...].dropFirst().prefix(20) precondition(!afterGap.contains(.gap)) // if there is any overlap, the first overlapping item will be the first item below the gap var indexOfFirstTimelineItemExistingBelowGap: Int? if case .status(id: let id, state: _) = afterGap.first { indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id) } // the end index of the range of timelineItems that don't yet exist in the data source let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex let toInsert = timelineItems[.. 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 .gap, .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) } } } extension TimelineViewController: TabBarScrollableViewController { func tabBarScrollToTop() { collectionView.scrollToTop() } } extension TimelineViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { collectionView.scrollToTop() return .stop } }