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 */; }; D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; }; D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; };
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.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 */; }; D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; };
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; }; D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; }; 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 */; }; D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
D6ACE1AC240C3BAD004EA8E2 /* Ambassador.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F613023AE99E000F3CFD3 /* Ambassador.framework */; }; D6ACE1AC240C3BAD004EA8E2 /* Ambassador.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F613023AE99E000F3CFD3 /* Ambassador.framework */; };
D6ACE1AD240C3BAD004EA8E2 /* Embassy.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F612D23AE990C00F3CFD3 /* Embassy.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 */; }; D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */; };
D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */; }; D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */; };
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB422321685E00E5038B /* OpenInSafariActivity.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInSafariActivity.swift; sourceTree = "<group>"; };
@ -895,6 +899,7 @@
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */, D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */, D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */, D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
); );
path = Timeline; path = Timeline;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1298,6 +1303,7 @@
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */, D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */, D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */, D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */, D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */, D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */, D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
@ -1886,6 +1892,7 @@
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */, D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */,
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */, D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */, D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */, D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */,
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */, D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */,
@ -1900,6 +1907,7 @@
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */, D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */, D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */, D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */, D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D627FF76217E923E00CC0648 /* DraftsManager.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 // TODO: gonna need a thing to replicate all of EnhancedTableViewController
class TimelineViewController: UIViewController { class TimelineViewController: UIViewController, TimelineLikeCollectionViewController {
let timeline: Timeline let timeline: Timeline
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
private var controller: TimelineLikeController<TimelineItem>! private(set) var controller: TimelineLikeController<TimelineItem>!
private var confirmLoadMore = PassthroughSubject<Void, Never>() let confirmLoadMore = PassthroughSubject<Void, Never>()
private var newer: RequestRange? private var newer: RequestRange?
private var older: 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 view as! UICollectionView
} }
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(for timeline: Timeline, mastodonController: MastodonController!) { init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline self.timeline = timeline
@ -61,12 +62,14 @@ class TimelineViewController: UIViewController {
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)
// TODO: delegates // TODO: delegates
collectionView.delegate = self collectionView.delegate = self
// collectionView.dragDelegate = self // collectionView.dragDelegate = self
registerTimelineLikeCells()
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
dataSource = createDataSource() dataSource = createDataSource()
applyInitialSnapshot() applyInitialSnapshot()
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction(handler: { [unowned self] _ in let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction(handler: { [unowned self] _ in
Task { Task {
await self.controller.loadNewer() await self.controller.loadNewer()
@ -74,7 +77,7 @@ class TimelineViewController: UIViewController {
} }
})) }))
collectionView.refreshControl = refreshControl collectionView.refreshControl = refreshControl
#endif #endif
} }
override func viewDidLoad() { override func viewDidLoad() {
@ -94,34 +97,40 @@ class TimelineViewController: UIViewController {
config.text = try! doc.text() config.text = try! doc.text()
cell.contentConfiguration = config cell.contentConfiguration = config
} }
collectionView.register(LoadingCollectionViewCell.self, forCellWithReuseIdentifier: "loadingIndicator") let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
collectionView.register(ConfirmLoadMoreCollectionViewCell.self, forCellWithReuseIdentifier: "confirmLoadMore") 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 return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier { switch itemIdentifier {
case .status(_, _): case .status(_, _):
return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: itemIdentifier) return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: itemIdentifier)
case .loadingIndicator: case .loadingIndicator:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "loadingIndicator", for: indexPath) as! LoadingCollectionViewCell return loadingIndicatorCell(for: indexPath)
cell.indicator.startAnimating()
return cell
case .confirmLoadMore: case .confirmLoadMore:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell return confirmLoadMoreCell(for: indexPath)
cell.confirmLoadMore = self.confirmLoadMore case .publicTimelineDescription:
Task { self.isShowingTimelineDescription = true
if case .loadingOlder(_, _) = await controller.state { return collectionView.dequeueConfiguredReusableCell(using: timelineDescriptionCell, for: indexPath, item: itemIdentifier)
cell.isLoading = true
} else {
cell.isLoading = false
}
}
return cell
} }
} }
} }
private func applyInitialSnapshot() { private func applyInitialSnapshot() {
// TODO: this might not be necessary if case .public(let local) = timeline,
// TODO: yes it is, for public timeline descriptions (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) { 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 { extension TimelineViewController {
enum Section: Hashable { enum Section: TimelineLikeCollectionViewSection {
case header case header
case statuses case statuses
case footer 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 status(id: String, state: StatusState)
case loadingIndicator case loadingIndicator
case confirmLoadMore case confirmLoadMore
// // TODO: remove local param from this case publicTimelineDescription
// case publicTimelineDescription(local: Bool)
static func fromTimelineItem(_ id: String) -> Self {
return .status(id: id, state: .unknown)
}
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
@ -155,8 +178,8 @@ extension TimelineViewController {
return true return true
case (.confirmLoadMore, .confirmLoadMore): case (.confirmLoadMore, .confirmLoadMore):
return true return true
// case let (.publicTimelineDescription(local: a), .publicTimelineDescription(local: b)): case (.publicTimelineDescription, .publicTimelineDescription):
// return a == b return true
default: default:
return false return false
} }
@ -171,15 +194,14 @@ extension TimelineViewController {
hasher.combine(1) hasher.combine(1)
case .confirmLoadMore: case .confirmLoadMore:
hasher.combine(2) hasher.combine(2)
// case .publicTimelineDescription(local: let local): case .publicTimelineDescription:
// hasher.combine(3) hasher.combine(3)
// hasher.combine(local)
} }
} }
var hideSeparators: Bool { var hideSeparators: Bool {
switch self { switch self {
case .loadingIndicator: case .loadingIndicator, .publicTimelineDescription:
return true return true
default: default:
return false return false
@ -188,7 +210,8 @@ extension TimelineViewController {
} }
} }
extension TimelineViewController: TimelineLikeControllerDelegate { // MARK: TimelineLikeControllerDelegate
extension TimelineViewController {
typealias TimelineItem = String // status ID typealias TimelineItem = String // status ID
func loadInitial() async throws -> [TimelineItem] { func loadInitial() async throws -> [TimelineItem] {
@ -196,8 +219,6 @@ extension TimelineViewController: TimelineLikeControllerDelegate {
throw Error.noClient throw Error.noClient
} }
try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
let request = Client.getStatuses(timeline: timeline) let request = Client.getStatuses(timeline: timeline)
let (statuses, _) = try await mastodonController.run(request) let (statuses, _) = try await mastodonController.run(request)
@ -239,8 +260,6 @@ extension TimelineViewController: TimelineLikeControllerDelegate {
throw Error.noOlder throw Error.noOlder
} }
try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
let request = Client.getStatuses(timeline: timeline, range: older) let request = Client.getStatuses(timeline: timeline, range: older)
let (statuses, _) = try await mastodonController.run(request) let (statuses, _) = try await mastodonController.run(request)
@ -255,120 +274,7 @@ extension TimelineViewController: TimelineLikeControllerDelegate {
} }
} }
func canLoadOlder() async -> Bool { enum Error: TimelineLikeCollectionViewError {
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 {
case noClient case noClient
case noNewer case noNewer
case noOlder 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) let home = TimelineViewController(for: .home, mastodonController: mastodonController)
home.title = homeTitle home.title = homeTitle
let federated = TimelineTableViewController(for: .public(local: false), mastodonController: mastodonController) let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
federated.title = federatedTitle federated.title = federatedTitle
let local = TimelineTableViewController(for: .public(local: true), mastodonController: mastodonController) let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
local.title = localTitle local.title = localTitle
super.init(titles: [ 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> unowned var delegate: any TimelineLikeControllerDelegate<Item>
private(set) var state = State.idle { private(set) var state = State.notLoadedInitial {
willSet { willSet {
precondition(state.canTransition(to: newValue)) precondition(state.canTransition(to: newValue))
logger.debug("State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)") logger.debug("State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)")
@ -41,7 +41,7 @@ actor TimelineLikeController<Item> {
} }
func loadInitial() async { func loadInitial() async {
guard state == .idle else { guard state == .notLoadedInitial else {
return return
} }
let token = LoadAttemptToken() let token = LoadAttemptToken()
@ -86,11 +86,7 @@ actor TimelineLikeController<Item> {
return return
} }
let token = LoadAttemptToken() 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 { guard await delegate.canLoadOlder() else {
// state = .idle
return return
} }
state = .loadingOlder(token, hasAddedLoadingIndicator: false) state = .loadingOlder(token, hasAddedLoadingIndicator: false)
@ -120,22 +116,22 @@ actor TimelineLikeController<Item> {
} }
enum State: Equatable, CustomDebugStringConvertible { enum State: Equatable, CustomDebugStringConvertible {
case notLoadedInitial
case idle case idle
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool) case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
case loadingNewer(LoadAttemptToken) case loadingNewer(LoadAttemptToken)
// case waitingForLoadOlderPermission
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool) case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
var debugDescription: String { var debugDescription: String {
switch self { switch self {
case .notLoadedInitial:
return "notLoadedInitial"
case .idle: case .idle:
return "idle" return "idle"
case .loadingInitial(let token, let hasAddedLoadingIndicator): case .loadingInitial(let token, let hasAddedLoadingIndicator):
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))" return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
case .loadingNewer(let token): case .loadingNewer(let token):
return "loadingNewer(\(ObjectIdentifier(token)))" return "loadingNewer(\(ObjectIdentifier(token)))"
// case .waitingForLoadOlderPermission:
// return "waitingForLoadOlderPermission"
case .loadingOlder(let token, let hasAddedLoadingIndicator): case .loadingOlder(let token, let hasAddedLoadingIndicator):
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))" return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
} }
@ -143,9 +139,16 @@ actor TimelineLikeController<Item> {
func canTransition(to: State) -> Bool { func canTransition(to: State) -> Bool {
switch self { switch self {
case .notLoadedInitial:
switch to {
case .loadingInitial(_, hasAddedLoadingIndicator: _):
return true
default:
return false
}
case .idle: case .idle:
switch to { switch to {
case .loadingInitial(_, _), .loadingNewer(_)/*, .waitingForLoadOlderPermission*/, .loadingOlder(_, _): case .loadingNewer(_), .loadingOlder(_, _):
return true return true
default: default:
return false return false
@ -156,13 +159,6 @@ actor TimelineLikeController<Item> {
return to == .idle return to == .idle
case .loadingOlder(let token, let hasAddedLoadingIndicator): case .loadingOlder(let token, let hasAddedLoadingIndicator):
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true)) return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
// case .waitingForLoadOlderPermission:
// switch to {
// case .idle, .loadingOlder(_, _):
// return true
// default:
// return false
// }
} }
} }