// // 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? // 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) } 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 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) // TODO: delegates collectionView.delegate = self // collectionView.dragDelegate = self registerTimelineLikeCells() collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription") 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 // } 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() } // TODO: update cell } 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) Task { await controller.loadInitial() } } private func removeTimelineDescriptionCell() { var snapshot = dataSource.snapshot() snapshot.deleteSections([.header]) dataSource.apply(snapshot, animatingDifferences: true) isShowingTimelineDescription = false } } 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 } } } } // 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, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } switch item { case .publicTimelineDescription: removeTimelineDescriptionCell() default: // TODO: cell selection break } } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { if isShowingTimelineDescription { removeTimelineDescriptionCell() } } }