Tusker/Tusker/Screens/Timeline/TimelineViewController.swift

420 lines
16 KiB
Swift

//
// TimelineViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/20/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
// TODO: gonna need a thing to replicate all of EnhancedTableViewController
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, RefreshableViewController {
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?
// 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<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)
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
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 = 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)
collectionView.delegate = self
collectionView.dragDelegate = self
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
}
override func viewDidLoad() {
super.viewDidLoad()
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Item> { [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()
}
cell.mastodonController = mastodonController
cell.delegate = self
cell.updateUI(statusID: id, state: state)
}
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [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)
collectionView.indexPathsForSelectedItems?.forEach {
collectionView.deselectItem(at: $0, animated: true)
}
Task {
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 {
await controller.loadNewer()
#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 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
}
}
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] {
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, shouldSelectItemAt indexPath: IndexPath) -> 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 .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)
}
}
}