Tusker/Tusker/Screens/Timeline/TimelineViewController.swift

277 lines
9.3 KiB
Swift
Raw Normal View History

//
// 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<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>()
private var newer: RequestRange?
private var older: RequestRange?
var collectionView: UICollectionView {
view as! UICollectionView
}
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
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<Section, Item> {
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { [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()
}
}
}
}