Compare commits
4 Commits
426b31d46c
...
a38c89a17f
Author | SHA1 | Date |
---|---|---|
Shadowfacts | a38c89a17f | |
Shadowfacts | 253fb8d27d | |
Shadowfacts | a682c8f5cc | |
Shadowfacts | d18a4b3c42 |
|
@ -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 */,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
@ -63,6 +64,8 @@ class TimelineViewController: UIViewController {
|
|||
collectionView.delegate = self
|
||||
// collectionView.dragDelegate = self
|
||||
|
||||
registerTimelineLikeCells()
|
||||
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
|
||||
dataSource = createDataSource()
|
||||
applyInitialSnapshot()
|
||||
|
||||
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelineViewController: ToastableViewController {
|
||||
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
if isShowingTimelineDescription {
|
||||
removeTimelineDescriptionCell()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue