Tusker/Tusker/Screens/Timeline/TimelineViewController.swift

938 lines
41 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// TimelineViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/20/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
let timeline: Timeline
weak var mastodonController: MastodonController!
let filterer: Filterer
private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>()
// stored separately because i don't want to query the snapshot every time the user scrolls
private var isShowingTimelineDescription = false
private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var contentOffsetObservation: NSKeyValueObservation?
private var activityToRestore: NSUserActivity?
init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline
self.mastodonController = mastodonController
let filterContext: FilterV1.Context
switch timeline {
case .home, .list(id: _):
filterContext = .home
default:
filterContext = .public
}
self.filterer = Filterer(mastodonController: mastodonController, context: filterContext)
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
super.init(nibName: nil, bundle: nil)
self.controller = TimelineLikeController(delegate: self)
self.navigationItem.title = timeline.title
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
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
} else if case .status(id: _, collapseState: _, filterState: let filterState) = item,
filterer.isKnownHide(state: filterState) {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
} else {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
return config
}
let layout = UICollectionViewCompositionalLayout.list(using: config)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
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
contentOffsetObservation = collectionView.observe(\.contentOffset) { [weak self] _, _ in
if let indexPath = self?.dataSource.indexPath(for: .gap),
let cell = self?.collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell {
cell.update()
}
}
filterer.filtersChanged = { [unowned self] actionsChanged in
self.reapplyFilters(actionsChanged: actionsChanged)
}
let jumpToPresentName = NSMutableAttributedString("Jump to Present")
// otherwise it pronounces it as 'pɹizˈənt'
// its IPA is also bad, this should be an alveolar approximant not a trill
jumpToPresentName.addAttribute(.accessibilitySpeechIPANotation, value: "ˈprɛ.zənt", range: NSRange(location: "Jump to ".count, length: "Present".count))
accessibilityCustomActions = [
UIAccessibilityCustomAction(attributedName: jumpToPresentName, actionHandler: { [unowned self] _ in
Task {
await self.checkPresent(jumpImmediately: true)
}
return true
})
]
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
}
// separate method because InstanceTimelineViewController needs to be able to customize it
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
cell.delegate = self
if case .home = timeline {
cell.showFollowedHashtags = true
} else {
cell.showFollowedHashtags = false
}
cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Filterer.Result, NSAttributedString?)> { [unowned self] cell, indexPath, item in
self.configureStatusCell(cell, id: item.0, state: item.1, filterResult: item.2, precomputedContent: item.3)
}
let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
}
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
cell.showsIndicator = false
}
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(id: let id, collapseState: let state, filterState: let filterState):
let (result, attributedString) = filterResult(state: filterState, statusID: id)
switch result {
case .allow, .warn(_):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result, attributedString))
case .hide:
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
}
case .gap:
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
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)
}
}
}
// non-private, because ListTimelineViewController needs to be able to reload it from scratch
func applyInitialSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
if case .public(let local) = timeline,
(local && !Preferences.shared.hasShownLocalTimelineDescription) ||
(!local && !Preferences.shared.hasShownFederatedTimelineDescription) {
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)
}
if case .notLoadedInitial = controller.state {
if doRestore() {
Task {
await checkPresent(jumpImmediately: false)
}
} else {
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
}
}
}
func stateRestorationActivity() -> NSUserActivity? {
guard isViewLoaded else {
return nil
}
let visible = collectionView.indexPathsForVisibleItems.sorted()
let snapshot = dataSource.snapshot()
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
let midPoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY)
guard let currentAccountID = mastodonController.accountInfo?.id,
!visible.isEmpty,
let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses),
let rawCenterVisible = collectionView.indexPathForItem(at: midPoint),
let centerVisible = visible.first(where: { $0.section == statusesSection && $0 >= rawCenterVisible }) else {
return nil
}
let allItems = snapshot.itemIdentifiers(inSection: .statuses)
let startIndex = max(0, centerVisible.row - 20)
let endIndex = min(allItems.count - 1, centerVisible.row + 20)
let centerVisibleItem: Item
var items = allItems[startIndex...endIndex]
if let gapIndex = items.firstIndex(of: .gap) {
// if the gap is above the top visible item, we take everything below the gap
// otherwise, we take everything above the gap
if gapIndex <= centerVisible.row {
items = allItems[(gapIndex + 1)...endIndex]
if gapIndex == centerVisible.row {
centerVisibleItem = allItems.first!
} else {
assert(items.indices.contains(centerVisible.row))
centerVisibleItem = allItems[centerVisible.row]
}
} else {
items = allItems[startIndex..<gapIndex]
centerVisibleItem = allItems[centerVisible.row]
}
} else {
centerVisibleItem = allItems[centerVisible.row]
}
let ids = items.map {
if case .status(id: let id, _, _) = $0 {
return id
} else {
fatalError()
}
}
let centerVisibleID: String
if case .status(id: let id, _, _) = centerVisibleItem {
centerVisibleID = id
} else {
fatalError()
}
stateRestorationLogger.debug("TimelineViewController: creating state restoration activity with topID \(centerVisibleID)")
let activity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: currentAccountID)!
activity.addUserInfoEntries(from: [
"statusIDs": ids,
"centerID": centerVisibleID,
])
activity.isEligibleForPrediction = false
return activity
}
func restoreActivity(_ activity: NSUserActivity) {
self.activityToRestore = activity
}
private func doRestore() -> Bool {
guard let activity = activityToRestore else {
return false
}
guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
return false
}
activityToRestore = nil
let existingStatuses = statusIDs.filter { mastodonController.persistentContainer.status(for: $0) != nil }
guard !existingStatuses.isEmpty else {
return false
}
loadViewIfNeeded()
controller.restoreInitial {
var snapshot = dataSource.snapshot()
snapshot.appendSections([.statuses])
let items = statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
snapshot.appendItems(items, toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false) {
if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String,
let index = statusIDs.firstIndex(of: centerID),
let indexPath = self.dataSource.indexPath(for: items[index]) {
// it sometimes takes multiple attempts to convert on the right scroll position
// since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop
var count = 0
while count < 5 {
count += 1
let origOffset = self.collectionView.contentOffset
self.collectionView.layoutIfNeeded()
self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
let newOffset = self.collectionView.contentOffset
if abs(origOffset.y - newOffset.y) <= 1 {
break
}
}
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerID)")
} else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
}
}
}
return true
}
private func removeTimelineDescriptionCell() {
var snapshot = dataSource.snapshot()
snapshot.deleteSections([.header])
dataSource.apply(snapshot, animatingDifferences: true)
isShowingTimelineDescription = false
}
private func filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) {
let status = {
let status = self.mastodonController.persistentContainer.status(for: statusID)!
// if the status is a reblog of another one, filter based on that one
return status.reblog ?? status
}
return filterer.resolve(state: state, status: status)
}
private func reapplyFilters(actionsChanged: Bool) {
let visible = collectionView.indexPathsForVisibleItems
let items = visible
.compactMap { dataSource.itemIdentifier(for: $0) }
.filter {
if case .status(_, _, _) = $0 {
return true
} else {
return false
}
}
guard !items.isEmpty else {
return
}
var snapshot = dataSource.snapshot()
if actionsChanged {
// need to reload not just reconfigure because hidden posts use a separate cell type
snapshot.reloadItems(items)
} else {
// reconfigure when possible to avoid the content offset jumping around
snapshot.reconfigureItems(items)
}
dataSource.apply(snapshot)
}
@objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) {
guard let scene = notification.object as? UIScene,
// view.window is nil when the VC is not on screen
view.window?.windowScene == scene else {
return
}
Task {
await checkPresent(jumpImmediately: false)
}
}
@objc func refresh() {
Task {
if case .notLoadedInitial = controller.state {
await controller.loadInitial()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing()
#endif
} else {
@MainActor
func loadNewerAndEndRefreshing() async {
await controller.loadNewer()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing()
#endif
}
// I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
let (_, presentItems) = await (loadNewerAndEndRefreshing(), try? loadInitial())
if let presentItems, !presentItems.isEmpty {
insertPresentItemsIfNecessary(presentItems)
}
}
}
}
private func checkPresent(jumpImmediately: Bool) async {
if case .idle = controller.state,
let presentItems = try? await loadInitial(),
!presentItems.isEmpty {
if jumpImmediately {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: true) {
UIAccessibility.post(notification: .screenChanged, argument: self.collectionView.cellForItem(at: IndexPath(row: 0, section: 0)))
}
} else {
insertPresentItemsIfNecessary(presentItems)
}
}
}
private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
let snapshot = dataSource.snapshot()
guard snapshot.indexOfSection(.statuses) != nil else {
return
}
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
if case .status(id: let firstID, _, _) = currentItems.first,
// if there's no overlap between presentItems and the existing items in the data source, prompt the user
!presentItems.contains(firstID) {
// create a new snapshot to reset the timeline to the "present" state
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
var config = ToastConfiguration(title: "Jump to present")
config.edge = .top
config.systemImageName = "arrow.up"
config.dismissAutomaticallyAfter = 4
config.action = { [unowned self] toast in
toast.dismissToast(animated: true)
let origSnapshot = self.dataSource.snapshot()
let origItemAtTop: (Item, CGFloat)?
if let statusesSection = origSnapshot.indexOfSection(.statuses),
let indexPath = self.collectionView.indexPathsForVisibleItems.sorted().first(where: { $0.section == statusesSection }),
let cell = self.collectionView.cellForItem(at: indexPath),
let item = self.dataSource.itemIdentifier(for: indexPath) {
origItemAtTop = (item, cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top)
} else {
origItemAtTop = nil
}
self.dataSource.apply(snapshot, animatingDifferences: true) {
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
var config = ToastConfiguration(title: "Go back")
config.edge = .top
config.systemImageName = "arrow.down"
config.dismissAutomaticallyAfter = 4
config.action = { [unowned self] toast in
toast.dismissToast(animated: true)
// todo: it would be nice if we could animate this, but that doesn't work with the screen-position-maintaining stuff
if let (item, offset) = origItemAtTop {
self.applySnapshot(snapshot, maintainingScreenPosition: offset, ofItem: item)
} else {
self.dataSource.apply(origSnapshot, animatingDifferences: false)
}
}
self.showToast(configuration: config, animated: true)
}
}
self.showToast(configuration: config, animated: true)
}
}
// NOTE: this only works when items are being inserted ABOVE the item to maintain
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingBottomRelativeScrollPositionOf itemToMaintain: Item) {
var firstItemAfterOriginalGapOffsetFromTop: CGFloat = 0
if let indexPath = dataSource.indexPath(for: itemToMaintain),
let cell = collectionView.cellForItem(at: indexPath) {
// subtract top safe area inset b/c scrollToItem at .top aligns the top of the cell to the top of the safe area
firstItemAfterOriginalGapOffsetFromTop = cell.convert(.zero, to: view).y - view.safeAreaInsets.top
}
applySnapshot(snapshot, maintainingScreenPosition: firstItemAfterOriginalGapOffsetFromTop, ofItem: itemToMaintain)
}
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingScreenPosition offsetFromTop: CGFloat, ofItem itemToMaintain: Item) {
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
snapshotView.layer.zPosition = 1000
snapshotView.frame = view.bounds
view.addSubview(snapshotView)
dataSource.apply(snapshot, animatingDifferences: false) {
if let indexPathOfItemToMaintain = self.dataSource.indexPath(for: itemToMaintain) {
// scroll up until we've accumulated enough MEASURED height that we can put the
// firstItemAfterOriginalGapCell at the top of the screen and then scroll down by
// firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area
var cur = indexPathOfItemToMaintain
var amountScrolledUp: CGFloat = 0
while true {
if cur.row <= 0 {
break
}
if let cell = self.collectionView.cellForItem(at: indexPathOfItemToMaintain),
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > offsetFromTop {
break
}
cur = IndexPath(row: cur.row - 1, section: cur.section)
self.collectionView.scrollToItem(at: cur, at: .top, animated: false)
self.collectionView.layoutIfNeeded()
let attrs = self.collectionView.layoutAttributesForItem(at: cur)!
amountScrolledUp += attrs.size.height
}
self.collectionView.contentOffset.y += amountScrolledUp
self.collectionView.contentOffset.y -= offsetFromTop
}
snapshotView.removeFromSuperview()
}
}
}
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, collapseState: CollapseState, filterState: FilterState)
case gap
case loadingIndicator
case confirmLoadMore
case publicTimelineDescription
static func fromTimelineItem(_ id: String) -> Self {
return .status(id: id, collapseState: .unknown, filterState: .unknown)
}
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case let (.status(id: a, _, _), .status(id: b, _, _)):
return a == b
case (.gap, .gap):
return true
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, _, _):
hasher.combine(0)
hasher.combine(id)
case .gap:
hasher.combine(1)
case .loadingIndicator:
hasher.combine(2)
case .confirmLoadMore:
hasher.combine(3)
case .publicTimelineDescription:
hasher.combine(4)
}
}
var hideSeparators: Bool {
switch self {
case .loadingIndicator, .publicTimelineDescription, .confirmLoadMore:
return true
default:
return false
}
}
var isSelectable: Bool {
switch self {
case .publicTimelineDescription, .gap, .status(_, _, _):
return true
default:
return false
}
}
}
}
// MARK: TimelineLikeControllerDelegate
extension TimelineViewController {
typealias TimelineItem = String // status ID
func loadInitial() async throws -> [TimelineItem] {
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
let request = Client.getStatuses(timeline: timeline)
let (statuses, _) = try await mastodonController.run(request)
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume()
}
}
return statuses.map(\.id)
}
func loadNewer() async throws -> [TimelineItem] {
let statusesSection = dataSource.snapshot().indexOfSection(.statuses)!
guard case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
throw Error.noNewer
}
let newer = RequestRange.after(id: id, count: nil)
let request = Client.getStatuses(timeline: timeline, range: newer)
let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else {
throw TimelineViewController.Error.allCaughtUp
}
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume()
}
}
return statuses.map(\.id)
}
func loadOlder() async throws -> [TimelineItem] {
let snapshot = dataSource.snapshot()
let statusesSection = snapshot.indexOfSection(.statuses)!
guard case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
throw Error.noNewer
}
let older = RequestRange.before(id: id, count: nil)
let request = Client.getStatuses(timeline: timeline, range: older)
let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else {
return []
}
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume()
}
}
return statuses.map(\.id)
}
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] {
guard let gapIndexPath = dataSource.indexPath(for: .gap) else {
throw Error.noGap
}
let statusItemsCount = collectionView.numberOfItems(inSection: gapIndexPath.section)
let range: RequestRange
switch direction {
case .above:
guard gapIndexPath.row > 0,
case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else {
// not really the right error but w/e
throw Error.noGap
}
range = .before(id: id, count: nil)
case .below:
guard gapIndexPath.row < statusItemsCount - 1,
case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else {
throw Error.noGap
}
range = .after(id: id, count: nil)
}
let request = Client.getStatuses(timeline: timeline, range: range)
let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else {
return []
}
// NOTE: closing the gap (if necessary) happens in handleFillGap
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume()
}
}
return statuses.map(\.id)
}
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async {
// TODO: better title, involving direction?
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
Task {
await self?.controller.fillGap(in: direction)
}
}
self.showToast(configuration: config, animated: true)
}
func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async {
var snapshot = dataSource.snapshot()
let addedItems: Bool
let statusItems = snapshot.itemIdentifiers(inSection: .statuses)
let gapIndex = statusItems.firstIndex(of: .gap)!
switch direction {
case .above:
// dropFirst to remove .gap item
let afterGap = statusItems[gapIndex...].dropFirst().prefix(20)
precondition(!afterGap.contains(.gap))
// if there is any overlap, the first overlapping item will be the first item below the gap
var indexOfFirstTimelineItemExistingBelowGap: Int?
if case .status(id: let id, _, _) = afterGap.first {
indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id)
}
// the end index of the range of timelineItems that don't yet exist in the data source
let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex
let toInsert = timelineItems[..<endIndex].map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
if toInsert.isEmpty {
addedItems = false
} else {
snapshot.insertItems(toInsert, beforeItem: .gap)
addedItems = true
}
// if there's any overlap between the items we loaded to insert above the gap
// and the items that already exist below the gap, we've completely filled the gap
if indexOfFirstTimelineItemExistingBelowGap != nil {
snapshot.deleteItems([.gap])
}
await apply(snapshot, animatingDifferences: !addedItems)
case .below:
let beforeGap = statusItems[..<gapIndex].suffix(20)
precondition(!beforeGap.contains(.gap))
// if there's any overlap, last overlapping item will be the last item below the gap
var indexOfLastTimelineItemExistingAboveGap: Int?
if case .status(id: let id, _, _) = beforeGap.last {
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
}
// the start index of the reange of timeline items that don't yet exist in the data source
let startIndex: Int
if let indexOfLastTimelineItemExistingAboveGap {
// index(after:) because the beginning of the range is inclusive, but we don't want the item at indexOfLastTimelineItemExistingAboveGap
startIndex = timelineItems.index(after: indexOfLastTimelineItemExistingAboveGap)
} else {
startIndex = timelineItems.startIndex
}
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
if toInsert.isEmpty {
addedItems = false
} else {
snapshot.insertItems(toInsert, afterItem: .gap)
addedItems = true
}
// if there's any overlap between the items we loaded to insert below the gap
// and the items that already exist above the gap, we've completely filled the gap
if indexOfLastTimelineItemExistingAboveGap != nil {
snapshot.deleteItems([.gap])
}
if addedItems {
let firstItemAfterOriginalGap = statusItems[gapIndex + 1]
applySnapshot(snapshot, maintainingBottomRelativeScrollPositionOf: firstItemAfterOriginalGap)
} else {
dataSource.apply(snapshot, animatingDifferences: true) {}
}
}
// if we didn't add any items, that implies the gap was removed, and we want to to make clear what's happening
if !addedItems {
var config = ToastConfiguration(title: "There's nothing in between!")
config.dismissAutomaticallyAfter = 2
showToast(configuration: config, animated: true)
}
}
enum Error: TimelineLikeCollectionViewError {
case noClient
case noNewer
case noOlder
case allCaughtUp
case noGap
}
}
extension TimelineViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) 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, collapseState: let collapseState, filterState: let filterState):
if filterState.isWarning {
filterer.setResult(.allow, for: filterState)
collectionView.deselectItem(at: indexPath, animated: true)
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([item])
dataSource.apply(snapshot, animatingDifferences: true)
} else {
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: collapseState.copy())
}
case .gap:
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
cell.showsIndicator = true
Task {
await controller.fillGap(in: cell.direction)
cell.showsIndicator = false
}
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)
}
}
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
if let indexPath = collectionView.indexPath(for: cell),
let item = dataSource.itemIdentifier(for: indexPath),
case .status(id: _, collapseState: _, filterState: let filterState) = item {
filterer.setResult(.allow, for: filterState)
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([item])
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
extension TimelineViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
collectionView.scrollToTop()
}
}
extension TimelineViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop
}
}