Compare commits

..

4 Commits

6 changed files with 389 additions and 177 deletions

View File

@ -35,6 +35,7 @@
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; };
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; };
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; };
@ -214,6 +215,7 @@
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
D6ACE1AC240C3BAD004EA8E2 /* Ambassador.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F613023AE99E000F3CFD3 /* Ambassador.framework */; };
D6ACE1AD240C3BAD004EA8E2 /* Embassy.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F612D23AE990C00F3CFD3 /* Embassy.framework */; };
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; };
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */; };
D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */; };
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */; };
@ -380,6 +382,7 @@
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = "<group>"; };
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; };
D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; };
D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; };
@ -558,6 +561,7 @@
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivity+Types.swift"; sourceTree = "<group>"; };
D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMesasgeActivity.swift; sourceTree = "<group>"; };
D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInSafariActivity.swift; sourceTree = "<group>"; };
@ -895,6 +899,7 @@
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
);
path = Timeline;
sourceTree = "<group>";
@ -1298,6 +1303,7 @@
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
@ -1886,6 +1892,7 @@
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */,
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */,
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */,
@ -1900,6 +1907,7 @@
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,

View File

@ -0,0 +1,62 @@
//
// PublicTimelineDescriptionCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 10/1/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class PublicTimelineDescriptionCollectionViewCell: UICollectionViewCell {
weak var mastodonController: MastodonController!
var local = false {
didSet {
updateLabel()
}
}
var didDismiss: (() -> Void)?
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .tintColor
label.font = .preferredFont(forTextStyle: .body)
label.numberOfLines = 0
label.textColor = .white
label.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalToSystemSpacingAfter: contentView.leadingAnchor, multiplier: 1),
contentView.trailingAnchor.constraint(equalToSystemSpacingAfter: label.trailingAnchor, multiplier: 1),
label.topAnchor.constraint(equalToSystemSpacingBelow: contentView.topAnchor, multiplier: 1),
contentView.bottomAnchor.constraint(equalToSystemSpacingBelow: label.bottomAnchor, multiplier: 1),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateLabel() {
let str = NSMutableAttributedString()
let instanceStr = NSAttributedString(string: mastodonController.instanceURL.host!, attributes: [
.font: UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
])
if local {
str.append(NSAttributedString(string: "The local timeline shows public posts from only "))
str.append(instanceStr)
str.append(NSAttributedString(string: "."))
} else {
str.append(NSAttributedString(string: "The federated timeline shows public posts from all users that "))
str.append(instanceStr)
str.append(NSAttributedString(string: " knows about."))
}
label.attributedText = str
}
}

View File

@ -14,20 +14,21 @@ import SwiftSoup
// TODO: gonna need a thing to replicate all of EnhancedTableViewController
class TimelineViewController: UIViewController {
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController {
let timeline: Timeline
weak var mastodonController: MastodonController!
private var controller: TimelineLikeController<TimelineItem>!
private var confirmLoadMore = PassthroughSubject<Void, Never>()
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
private var collectionView: UICollectionView {
var collectionView: UICollectionView {
view as! UICollectionView
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline
@ -61,12 +62,14 @@ class TimelineViewController: UIViewController {
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
// TODO: delegates
collectionView.delegate = self
// collectionView.dragDelegate = self
// collectionView.dragDelegate = self
registerTimelineLikeCells()
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
dataSource = createDataSource()
applyInitialSnapshot()
#if !targetEnvironment(macCatalyst)
#if !targetEnvironment(macCatalyst)
let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction(handler: { [unowned self] _ in
Task {
await self.controller.loadNewer()
@ -74,7 +77,7 @@ class TimelineViewController: UIViewController {
}
}))
collectionView.refreshControl = refreshControl
#endif
#endif
}
override func viewDidLoad() {
@ -94,34 +97,40 @@ class TimelineViewController: UIViewController {
config.text = try! doc.text()
cell.contentConfiguration = config
}
collectionView.register(LoadingCollectionViewCell.self, forCellWithReuseIdentifier: "loadingIndicator")
collectionView.register(ConfirmLoadMoreCollectionViewCell.self, forCellWithReuseIdentifier: "confirmLoadMore")
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: listCell, for: indexPath, item: itemIdentifier)
case .loadingIndicator:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "loadingIndicator", for: indexPath) as! LoadingCollectionViewCell
cell.indicator.startAnimating()
return cell
return loadingIndicatorCell(for: indexPath)
case .confirmLoadMore:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
cell.confirmLoadMore = self.confirmLoadMore
Task {
if case .loadingOlder(_, _) = await controller.state {
cell.isLoading = true
} else {
cell.isLoading = false
}
}
return cell
return confirmLoadMoreCell(for: indexPath)
case .publicTimelineDescription:
self.isShowingTimelineDescription = true
return collectionView.dequeueConfiguredReusableCell(using: timelineDescriptionCell, for: indexPath, item: itemIdentifier)
}
}
}
private func applyInitialSnapshot() {
// TODO: this might not be necessary
// TODO: yes it is, for public timeline descriptions
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) {
@ -132,20 +141,34 @@ class TimelineViewController: UIViewController {
}
}
private func removeTimelineDescriptionCell() {
var snapshot = dataSource.snapshot()
snapshot.deleteSections([.header])
dataSource.apply(snapshot, animatingDifferences: true)
isShowingTimelineDescription = false
}
}
extension TimelineViewController {
enum Section: Hashable {
enum Section: TimelineLikeCollectionViewSection {
case header
case statuses
case footer
static var entries: Self { .statuses }
}
enum Item: Hashable {
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)
case publicTimelineDescription
static func fromTimelineItem(_ id: String) -> Self {
return .status(id: id, state: .unknown)
}
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
@ -155,8 +178,8 @@ extension TimelineViewController {
return true
case (.confirmLoadMore, .confirmLoadMore):
return true
// case let (.publicTimelineDescription(local: a), .publicTimelineDescription(local: b)):
// return a == b
case (.publicTimelineDescription, .publicTimelineDescription):
return true
default:
return false
}
@ -171,15 +194,14 @@ extension TimelineViewController {
hasher.combine(1)
case .confirmLoadMore:
hasher.combine(2)
// case .publicTimelineDescription(local: let local):
// hasher.combine(3)
// hasher.combine(local)
case .publicTimelineDescription:
hasher.combine(3)
}
}
var hideSeparators: Bool {
switch self {
case .loadingIndicator:
case .loadingIndicator, .publicTimelineDescription:
return true
default:
return false
@ -188,7 +210,8 @@ extension TimelineViewController {
}
}
extension TimelineViewController: TimelineLikeControllerDelegate {
// MARK: TimelineLikeControllerDelegate
extension TimelineViewController {
typealias TimelineItem = String // status ID
func loadInitial() async throws -> [TimelineItem] {
@ -196,8 +219,6 @@ extension TimelineViewController: TimelineLikeControllerDelegate {
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)
@ -239,8 +260,6 @@ extension TimelineViewController: TimelineLikeControllerDelegate {
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)
@ -255,120 +274,7 @@ extension TimelineViewController: TimelineLikeControllerDelegate {
}
}
func canLoadOlder() async -> Bool {
if Preferences.shared.disableInfiniteScrolling {
var snapshot = dataSource.snapshot()
if !snapshot.itemIdentifiers.contains(.confirmLoadMore) {
if !snapshot.sectionIdentifiers.contains(.footer) {
snapshot.appendSections([.footer])
}
snapshot.appendItems([.confirmLoadMore], toSection: .footer)
await dataSource.apply(snapshot, animatingDifferences: false)
}
for await _ in self.confirmLoadMore.values {
return true
}
fatalError("unreachable")
} else {
return true
}
}
func handleEvent(_ event: TimelineLikeController<TimelineItem>.Event) async {
switch event {
case .addLoadingIndicator:
var snapshot = dataSource.snapshot()
if !snapshot.sectionIdentifiers.contains(.footer) {
snapshot.appendSections([.footer])
}
if snapshot.itemIdentifiers.contains(.confirmLoadMore) {
snapshot.reconfigureItems([.confirmLoadMore])
} else {
snapshot.appendItems([.loadingIndicator], toSection: .footer)
}
await dataSource.apply(snapshot, animatingDifferences: false)
case .removeLoadingIndicator:
let oldContentOffset = collectionView.contentOffset
var snapshot = dataSource.snapshot()
snapshot.deleteSections([.footer])
await dataSource.apply(snapshot, animatingDifferences: false)
collectionView.contentOffset = oldContentOffset
case .loadAllError(let error, _):
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] (toast: ToastView) in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadInitial()
}
}
self.showToast(configuration: config, animated: true)
case .replaceAllItems(let ids, _):
var snapshot = dataSource.snapshot()
if snapshot.sectionIdentifiers.contains(.statuses) {
snapshot.deleteSections([.statuses])
}
snapshot.appendSections([.statuses])
snapshot.appendItems(ids.map { .status(id: $0, state: .unknown) }, toSection: .statuses)
await dataSource.apply(snapshot, animatingDifferences: false)
case .loadNewerError(Error.allCaughtUp, _):
var config = ToastConfiguration(title: "You're all caught up")
config.edge = .top
config.dismissAutomaticallyAfter = 2
config.action = { (toast) in
toast.dismissToast(animated: true)
}
self.showToast(configuration: config, animated: true)
case .loadNewerError(let error, _):
let config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] (toast: ToastView) in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadNewer()
}
}
self.showToast(configuration: config, animated: true)
case .prependItems(let ids, _):
var snapshot = dataSource.snapshot()
let items = ids.map { Item.status(id: $0, state: .unknown) }
let first = snapshot.itemIdentifiers(inSection: .statuses).first
if let first {
snapshot.insertItems(items, beforeItem: first)
} else {
snapshot.appendItems(items, toSection: .statuses)
}
await dataSource.apply(snapshot, animatingDifferences: false)
if let first,
let indexPath = dataSource.indexPath(for: first) {
// TODO: i can't tell if this actually works or not
// maintain the current position in the list (don't scroll to top)
collectionView.scrollToItem(at: indexPath, at: .top, animated: false)
}
case .loadOlderError(let error, _):
let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] (toast: ToastView) in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadOlder()
}
}
self.showToast(configuration: config, animated: true)
case .appendItems(let ids, _):
var snapshot = dataSource.snapshot()
if snapshot.itemIdentifiers.contains(.confirmLoadMore) {
snapshot.deleteItems([.confirmLoadMore])
}
snapshot.appendItems(ids.map { .status(id: $0, state: .unknown) }, toSection: .statuses)
await dataSource.apply(snapshot, animatingDifferences: false)
}
}
enum Error: Swift.Error {
enum Error: TimelineLikeCollectionViewError {
case noClient
case noNewer
case noOlder
@ -390,7 +296,24 @@ extension TimelineViewController: UICollectionViewDelegate {
}
}
}
}
extension TimelineViewController: ToastableViewController {
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()
}
}
}

View File

@ -22,10 +22,10 @@ class TimelinesPageViewController: SegmentedPageViewController {
let home = TimelineViewController(for: .home, mastodonController: mastodonController)
home.title = homeTitle
let federated = TimelineTableViewController(for: .public(local: false), mastodonController: mastodonController)
let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
federated.title = federatedTitle
let local = TimelineTableViewController(for: .public(local: true), mastodonController: mastodonController)
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
local.title = localTitle
super.init(titles: [

View File

@ -0,0 +1,223 @@
//
// TimelineLikeCollectionViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/24/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Combine
@MainActor
protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeControllerDelegate, ToastableViewController {
associatedtype Section: TimelineLikeCollectionViewSection
associatedtype Item: TimelineLikeCollectionViewItem where Item.TimelineItem == Self.TimelineItem
associatedtype Error: TimelineLikeCollectionViewError
// this needs to be an IUO because it can't be set until after the super init is called, so that self can be passed as the delegate param
var controller: TimelineLikeController<TimelineItem>! { get }
var confirmLoadMore: PassthroughSubject<Void, Never> { get }
var collectionView: UICollectionView { get }
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
}
protocol TimelineLikeCollectionViewSection: Hashable {
static var entries: Self { get }
static var footer: Self { get }
}
protocol TimelineLikeCollectionViewItem: Hashable {
associatedtype TimelineItem
static var loadingIndicator: Self { get }
static var confirmLoadMore: Self { get }
static func fromTimelineItem(_ item: TimelineItem) -> Self
}
// TODO: equatable might not be the best for this?
protocol TimelineLikeCollectionViewError: Error, Equatable {
static var allCaughtUp: Self { get }
}
// MARK: TimelineLikeControllerDelegate
extension TimelineLikeCollectionViewController {
func canLoadOlder() async -> Bool {
if Preferences.shared.disableInfiniteScrolling {
var snapshot = dataSource.snapshot()
if !snapshot.itemIdentifiers.contains(.confirmLoadMore) {
if !snapshot.sectionIdentifiers.contains(.footer) {
snapshot.appendSections([.footer])
}
snapshot.appendItems([.confirmLoadMore], toSection: .footer)
await apply(snapshot, animatingDifferences: false)
}
for await _ in confirmLoadMore.values {
return true
}
fatalError("unreachable")
} else {
return true
}
}
func handleEvent(_ event: TimelineLikeController<TimelineItem>.Event) async {
switch event {
case .addLoadingIndicator:
await handleAddLoadingIndicator()
case .removeLoadingIndicator:
await handleRemoveLoadingIndicator()
case .loadAllError(let error, _):
await handleLoadAllError(error)
case .replaceAllItems(let items, _):
await handleReplaceAllItems(items)
case .loadNewerError(let error, _):
await handleLoadNewerError(error)
case .prependItems(let items, _):
await handlePrependItems(items)
case .loadOlderError(let error, _):
await handleLoadOlderError(error)
case .appendItems(let items, _):
await handleAppendItems(items)
}
}
func handleAddLoadingIndicator() async {
var snapshot = dataSource.snapshot()
if !snapshot.sectionIdentifiers.contains(.footer) {
snapshot.appendSections([.footer])
}
if snapshot.itemIdentifiers.contains(.confirmLoadMore) {
snapshot.reconfigureItems([.confirmLoadMore])
} else {
snapshot.appendItems([.loadingIndicator], toSection: .footer)
}
await apply(snapshot, animatingDifferences: false)
}
func handleRemoveLoadingIndicator() async {
let oldContentOffset = collectionView.contentOffset
var snapshot = dataSource.snapshot()
snapshot.deleteSections([.footer])
await apply(snapshot, animatingDifferences: false)
// prevent the collection view from scrolling as we remove the loading indicator and add the timeline items
collectionView.contentOffset = oldContentOffset
}
func handleLoadAllError(_ error: Swift.Error) async {
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadInitial()
}
}
self.showToast(configuration: config, animated: true)
}
func handleReplaceAllItems(_ timelineItems: [TimelineItem]) async {
var snapshot = dataSource.snapshot()
if snapshot.sectionIdentifiers.contains(.entries) {
snapshot.deleteSections([.entries])
}
snapshot.appendSections([.entries])
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
await apply(snapshot, animatingDifferences: false)
}
func handleLoadNewerError(_ error: Swift.Error) async {
var config: ToastConfiguration
if let error = error as? Self.Error,
error == .allCaughtUp {
config = ToastConfiguration(title: "You're all caught up")
config.edge = .top
config.dismissAutomaticallyAfter = 2
config.action = { toast in
toast.dismissToast(animated: true)
}
} else {
config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadNewer()
}
}
}
self.showToast(configuration: config, animated: true)
}
func handlePrependItems(_ timelineItems: [TimelineItem]) async {
let items = timelineItems.map { Item.fromTimelineItem($0) }
var snapshot = dataSource.snapshot()
let first = snapshot.itemIdentifiers(inSection: .entries).first
if let first {
snapshot.insertItems(items, beforeItem: first)
} else {
snapshot.appendItems(items, toSection: .entries)
}
await apply(snapshot, animatingDifferences: false)
if let first,
let indexPath = dataSource.indexPath(for: first) {
// TODO: i can't tell if this actually works or not
// maintain the current scroll position in the list (don't scroll to top)
collectionView.scrollToItem(at: indexPath, at: .top, animated: false)
}
}
func handleLoadOlderError(_ error: Swift.Error) async {
let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadOlder()
}
}
self.showToast(configuration: config, animated: true)
}
func handleAppendItems(_ timelineItems: [TimelineItem]) async {
var snapshot = dataSource.snapshot()
// TODO: this might not be necessary, isn't the confirm item removed separately?
if snapshot.itemIdentifiers.contains(.confirmLoadMore) {
snapshot.deleteItems([.confirmLoadMore])
}
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
await apply(snapshot, animatingDifferences: false)
}
}
extension TimelineLikeCollectionViewController {
// apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods
// but we always want to update the data source on the main thread for consistency, so this method does that
func apply(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool) async {
let task = Task { @MainActor in
self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
await task.value
}
func registerTimelineLikeCells() {
collectionView.register(LoadingCollectionViewCell.self, forCellWithReuseIdentifier: "loadingIndicator")
collectionView.register(ConfirmLoadMoreCollectionViewCell.self, forCellWithReuseIdentifier: "confirmLoadMore")
}
func loadingIndicatorCell(for indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "loadingIndicator", for: indexPath) as! LoadingCollectionViewCell
cell.indicator.startAnimating()
return cell
}
func confirmLoadMoreCell(for indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
cell.confirmLoadMore = self.confirmLoadMore
Task {
if case .loadingOlder(_, _) = await controller.state {
cell.isLoading = true
} else {
cell.isLoading = false
}
}
return cell
}
}

View File

@ -29,7 +29,7 @@ actor TimelineLikeController<Item> {
unowned var delegate: any TimelineLikeControllerDelegate<Item>
private(set) var state = State.idle {
private(set) var state = State.notLoadedInitial {
willSet {
precondition(state.canTransition(to: newValue))
logger.debug("State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)")
@ -41,7 +41,7 @@ actor TimelineLikeController<Item> {
}
func loadInitial() async {
guard state == .idle else {
guard state == .notLoadedInitial else {
return
}
let token = LoadAttemptToken()
@ -86,11 +86,7 @@ actor TimelineLikeController<Item> {
return
}
let token = LoadAttemptToken()
// TODO: does the waiting state need to include the token?
// TODO: does this even need to be a separate state? maybe we should just await the delegate's permission, since it can suspend until user input. then the prompt could appear, and the user could scroll back to the top and still be able to refresh
// state = .waitingForLoadOlderPermission
guard await delegate.canLoadOlder() else {
// state = .idle
return
}
state = .loadingOlder(token, hasAddedLoadingIndicator: false)
@ -120,22 +116,22 @@ actor TimelineLikeController<Item> {
}
enum State: Equatable, CustomDebugStringConvertible {
case notLoadedInitial
case idle
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
case loadingNewer(LoadAttemptToken)
// case waitingForLoadOlderPermission
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
var debugDescription: String {
switch self {
case .notLoadedInitial:
return "notLoadedInitial"
case .idle:
return "idle"
case .loadingInitial(let token, let hasAddedLoadingIndicator):
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
case .loadingNewer(let token):
return "loadingNewer(\(ObjectIdentifier(token)))"
// case .waitingForLoadOlderPermission:
// return "waitingForLoadOlderPermission"
case .loadingOlder(let token, let hasAddedLoadingIndicator):
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
}
@ -143,9 +139,16 @@ actor TimelineLikeController<Item> {
func canTransition(to: State) -> Bool {
switch self {
case .notLoadedInitial:
switch to {
case .loadingInitial(_, hasAddedLoadingIndicator: _):
return true
default:
return false
}
case .idle:
switch to {
case .loadingInitial(_, _), .loadingNewer(_)/*, .waitingForLoadOlderPermission*/, .loadingOlder(_, _):
case .loadingNewer(_), .loadingOlder(_, _):
return true
default:
return false
@ -156,13 +159,6 @@ actor TimelineLikeController<Item> {
return to == .idle
case .loadingOlder(let token, let hasAddedLoadingIndicator):
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
// case .waitingForLoadOlderPermission:
// switch to {
// case .idle, .loadingOlder(_, _):
// return true
// default:
// return false
// }
}
}