// // 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, TimelineLikeCollectionViewController { let timeline: Timeline weak var mastodonController: MastodonController! private(set) var controller: TimelineLikeController! let confirmLoadMore = PassthroughSubject() private var newer: RequestRange? private var older: RequestRange? 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) } 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 registerTimelineLikeCells() 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 } 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: return loadingIndicatorCell(for: indexPath) case .confirmLoadMore: return confirmLoadMoreCell(for: indexPath) } } } 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: 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 // // TODO: remove local param from this // case publicTimelineDescription(local: Bool) 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 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 } } } } // MARK: TimelineLikeControllerDelegate extension TimelineViewController { 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)) } } } 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() } } } }