// // TimelineViewController.swift // Tusker // // Created by Shadowfacts on 9/20/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine import SwiftSoup // TODO: gonna need a thing to replicate all of EnhancedTableViewController class TimelineViewController: UIViewController { let timeline: Timeline weak var mastodonController: MastodonController! private var controller: TimelineLikeController! private var confirmLoadMore = PassthroughSubject() private var newer: RequestRange? private var older: RequestRange? private var collectionView: UICollectionView { view as! UICollectionView } private 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) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { var config = UICollectionLayoutListConfiguration(appearance: .plain) // TODO: swipe actions // config.trailingSwipeActionsConfigurationProvider = config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in if let item = self.dataSource.itemIdentifier(for: indexPath), item.hideSeparators { var config = sectionSeparatorConfiguration config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden return config } else { return sectionSeparatorConfiguration } } let layout = UICollectionViewCompositionalLayout.list(using: config) view = UICollectionView(frame: .zero, collectionViewLayout: layout) // TODO: delegates collectionView.delegate = self // collectionView.dragDelegate = self dataSource = createDataSource() applyInitialSnapshot() #if !targetEnvironment(macCatalyst) let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction(handler: { [unowned self] _ in Task { await self.controller.loadNewer() self.collectionView.refreshControl!.endRefreshing() } })) collectionView.refreshControl = refreshControl #endif } override func viewDidLoad() { super.viewDidLoad() // TODO: refresh key command } private func createDataSource() -> UICollectionViewDiffableDataSource { let listCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in guard case .status(id: let id, state: _) = item, let status = mastodonController.persistentContainer.status(for: id) else { fatalError() } var config = cell.defaultContentConfiguration() let doc = try! SwiftSoup.parseBodyFragment(status.content) config.text = try! doc.text() cell.contentConfiguration = config } collectionView.register(LoadingCollectionViewCell.self, forCellWithReuseIdentifier: "loadingIndicator") collectionView.register(ConfirmLoadMoreCollectionViewCell.self, forCellWithReuseIdentifier: "confirmLoadMore") return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .status(_, _): return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: itemIdentifier) case .loadingIndicator: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "loadingIndicator", for: indexPath) as! LoadingCollectionViewCell cell.indicator.startAnimating() return cell case .confirmLoadMore: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell cell.confirmLoadMore = self.confirmLoadMore Task { if case .loadingOlder(_, _) = await controller.state { cell.isLoading = true } else { cell.isLoading = false } } return cell } } } private func applyInitialSnapshot() { // TODO: this might not be necessary // TODO: yes it is, for public timeline descriptions } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) Task { await controller.loadInitial() } } } extension TimelineViewController { enum Section: Hashable { case header case statuses case footer } enum Item: Hashable { case status(id: String, state: StatusState) case loadingIndicator case confirmLoadMore // // TODO: remove local param from this // case publicTimelineDescription(local: Bool) 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 let (.publicTimelineDescription(local: a), .publicTimelineDescription(local: b)): // return a == b 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(local: let local): // hasher.combine(3) // hasher.combine(local) } } var hideSeparators: Bool { switch self { case .loadingIndicator: return true default: return false } } } } extension TimelineViewController: TimelineLikeControllerDelegate { typealias TimelineItem = String // status ID func loadInitial() async throws -> [TimelineItem] { guard let mastodonController else { throw Error.noClient } try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) 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 } try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) 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)) } } } func canLoadOlder() async -> Bool { if Preferences.shared.disableInfiniteScrolling { var snapshot = dataSource.snapshot() if !snapshot.itemIdentifiers.contains(.confirmLoadMore) { if !snapshot.sectionIdentifiers.contains(.footer) { snapshot.appendSections([.footer]) } snapshot.appendItems([.confirmLoadMore], toSection: .footer) await dataSource.apply(snapshot, animatingDifferences: false) } for await _ in self.confirmLoadMore.values { return true } fatalError("unreachable") } else { return true } } func handleEvent(_ event: TimelineLikeController.Event) async { switch event { case .addLoadingIndicator: var snapshot = dataSource.snapshot() if !snapshot.sectionIdentifiers.contains(.footer) { snapshot.appendSections([.footer]) } if snapshot.itemIdentifiers.contains(.confirmLoadMore) { snapshot.reconfigureItems([.confirmLoadMore]) } else { snapshot.appendItems([.loadingIndicator], toSection: .footer) } await dataSource.apply(snapshot, animatingDifferences: false) case .removeLoadingIndicator: let oldContentOffset = collectionView.contentOffset var snapshot = dataSource.snapshot() snapshot.deleteSections([.footer]) await dataSource.apply(snapshot, animatingDifferences: false) collectionView.contentOffset = oldContentOffset case .loadAllError(let error, _): let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] (toast: ToastView) in toast.dismissToast(animated: true) Task { await self?.controller.loadInitial() } } self.showToast(configuration: config, animated: true) case .replaceAllItems(let ids, _): var snapshot = dataSource.snapshot() if snapshot.sectionIdentifiers.contains(.statuses) { snapshot.deleteSections([.statuses]) } snapshot.appendSections([.statuses]) snapshot.appendItems(ids.map { .status(id: $0, state: .unknown) }, toSection: .statuses) await dataSource.apply(snapshot, animatingDifferences: false) case .loadNewerError(Error.allCaughtUp, _): var config = ToastConfiguration(title: "You're all caught up") config.edge = .top config.dismissAutomaticallyAfter = 2 config.action = { (toast) in toast.dismissToast(animated: true) } self.showToast(configuration: config, animated: true) case .loadNewerError(let error, _): let config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] (toast: ToastView) in toast.dismissToast(animated: true) Task { await self?.controller.loadNewer() } } self.showToast(configuration: config, animated: true) case .prependItems(let ids, _): var snapshot = dataSource.snapshot() let items = ids.map { Item.status(id: $0, state: .unknown) } let first = snapshot.itemIdentifiers(inSection: .statuses).first if let first { snapshot.insertItems(items, beforeItem: first) } else { snapshot.appendItems(items, toSection: .statuses) } await dataSource.apply(snapshot, animatingDifferences: false) if let first, let indexPath = dataSource.indexPath(for: first) { // TODO: i can't tell if this actually works or not // maintain the current position in the list (don't scroll to top) collectionView.scrollToItem(at: indexPath, at: .top, animated: false) } case .loadOlderError(let error, _): let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] (toast: ToastView) in toast.dismissToast(animated: true) Task { await self?.controller.loadOlder() } } self.showToast(configuration: config, animated: true) case .appendItems(let ids, _): var snapshot = dataSource.snapshot() if snapshot.itemIdentifiers.contains(.confirmLoadMore) { snapshot.deleteItems([.confirmLoadMore]) } snapshot.appendItems(ids.map { .status(id: $0, state: .unknown) }, toSection: .statuses) await dataSource.apply(snapshot, animatingDifferences: false) } } enum Error: Swift.Error { 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() } } } } extension TimelineViewController: ToastableViewController { }