From 426b31d46cdd12b53292f42fada59b52f3b1ee8f Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 24 Sep 2022 10:49:06 -0400 Subject: [PATCH] Initial TimelineLikeController + TimelineViewController implementation --- Pachyderm/Sources/Pachyderm/Client.swift | 4 + Tusker.xcodeproj/project.pbxproj | 20 +- .../Timeline/TimelineViewController.swift | 396 ++++++++++++++++++ .../TimelinesPageViewController.swift | 2 +- Tusker/TimelineLikeController.swift | 255 +++++++++++ .../ConfirmLoadMoreCollectionViewCell.swift | 67 +++ Tusker/Views/LoadingCollectionViewCell.swift | 29 ++ Tusker/Views/Toast/ToastConfiguration.swift | 40 +- 8 files changed, 793 insertions(+), 20 deletions(-) create mode 100644 Tusker/Screens/Timeline/TimelineViewController.swift create mode 100644 Tusker/TimelineLikeController.swift create mode 100644 Tusker/Views/Confirm Load More Cell/ConfirmLoadMoreCollectionViewCell.swift create mode 100644 Tusker/Views/LoadingCollectionViewCell.swift diff --git a/Pachyderm/Sources/Pachyderm/Client.swift b/Pachyderm/Sources/Pachyderm/Client.swift index afad5ecf..396b575e 100644 --- a/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Pachyderm/Sources/Pachyderm/Client.swift @@ -420,6 +420,10 @@ extension Client { public let requestEndpoint: Endpoint public let type: ErrorType + #if DEBUG + public static let debug = Error(request: Client.getStatuses(timeline: .home), type: .invalidResponse) + #endif + init(request: Request, type: ErrorType) { self.requestMethod = request.method self.requestEndpoint = request.endpoint diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 0b863d60..7c435e9b 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -33,6 +33,8 @@ D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1627F8BB210080E273 /* VersionTests.swift */; }; D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */; }; 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 */; }; 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 */; }; @@ -140,6 +142,7 @@ D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */; }; D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; }; D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; }; + D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; }; D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; }; D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; }; D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; }; @@ -162,6 +165,7 @@ D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; }; D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; }; D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */; }; + D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DE828D962C2006341DA /* TimelineLikeController.swift */; }; D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; }; D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; }; D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; }; @@ -374,6 +378,8 @@ D6114E1627F8BB210080E273 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = ""; }; D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = ""; }; D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = ""; }; + D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = ""; }; + D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = ""; }; D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = ""; }; D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = ""; }; D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = ""; }; @@ -483,6 +489,7 @@ D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.swift; sourceTree = ""; }; D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = ""; }; D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = ""; }; + D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = ""; }; D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = ""; }; D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = ""; }; @@ -505,6 +512,7 @@ D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = ""; }; D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = ""; }; D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = ""; }; + D6895DE828D962C2006341DA /* TimelineLikeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeController.swift; sourceTree = ""; }; D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = ""; }; D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = ""; }; D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = ""; }; @@ -882,10 +890,11 @@ D641C781213DD7DD004B4513 /* Timeline */ = { isa = PBXGroup; children = ( - D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */, - D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */, D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */, D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */, + D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */, + D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */, + D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */, ); path = Timeline; sourceTree = ""; @@ -1241,6 +1250,7 @@ D6DD2A44273D6C5700386A6C /* GIFImageView.swift */, D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */, D620483323D3801D008A63EF /* LinkTextView.swift */, + D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */, D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */, D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */, D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */, @@ -1347,6 +1357,7 @@ D6B81F432560390300F6E31D /* MenuController.swift */, D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, + D6895DE828D962C2006341DA /* TimelineLikeController.swift */, D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */, D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */, D63D8DF32850FE7A008D95E1 /* ViewTags.swift */, @@ -1399,6 +1410,7 @@ children = ( D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */, D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */, + D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */, ); path = "Confirm Load More Cell"; sourceTree = ""; @@ -1790,6 +1802,7 @@ D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */, D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */, D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, + D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, @@ -1837,6 +1850,7 @@ D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */, D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */, D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */, + D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */, D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */, D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */, D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */, @@ -1869,6 +1883,7 @@ D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */, D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */, D627943523A5525100D38C68 /* StatusActivity.swift in Sources */, + D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */, D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */, D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, @@ -1882,6 +1897,7 @@ D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */, D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */, D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */, + D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */, D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */, D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift new file mode 100644 index 00000000..d41e9cb9 --- /dev/null +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -0,0 +1,396 @@ +// +// TimelineViewController.swift +// Tusker +// +// Created by Shadowfacts on 9/20/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm +import Combine + +import SwiftSoup + +// TODO: gonna need a thing to replicate all of EnhancedTableViewController + +class TimelineViewController: UIViewController { + + let timeline: Timeline + weak var mastodonController: MastodonController! + + private var controller: TimelineLikeController! + private var confirmLoadMore = PassthroughSubject() + private var newer: RequestRange? + private var older: RequestRange? + + private var collectionView: UICollectionView { + view as! UICollectionView + } + private var dataSource: UICollectionViewDiffableDataSource! + + init(for timeline: Timeline, mastodonController: MastodonController!) { + self.timeline = timeline + self.mastodonController = mastodonController + + super.init(nibName: nil, bundle: nil) + + self.controller = TimelineLikeController(delegate: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + var config = UICollectionLayoutListConfiguration(appearance: .plain) + // TODO: swipe actions + // config.trailingSwipeActionsConfigurationProvider = + config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in + if let item = self.dataSource.itemIdentifier(for: indexPath), + item.hideSeparators { + var config = sectionSeparatorConfiguration + config.topSeparatorVisibility = .hidden + config.bottomSeparatorVisibility = .hidden + return config + } else { + return sectionSeparatorConfiguration + } + } + let layout = UICollectionViewCompositionalLayout.list(using: config) + view = UICollectionView(frame: .zero, collectionViewLayout: layout) + // TODO: delegates + collectionView.delegate = self +// collectionView.dragDelegate = self + + dataSource = createDataSource() + applyInitialSnapshot() + + #if !targetEnvironment(macCatalyst) + let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction(handler: { [unowned self] _ in + Task { + await self.controller.loadNewer() + self.collectionView.refreshControl!.endRefreshing() + } + })) + collectionView.refreshControl = refreshControl + #endif + } + + override func viewDidLoad() { + super.viewDidLoad() + + // TODO: refresh key command + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let listCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in + guard case .status(id: let id, state: _) = item, + let status = mastodonController.persistentContainer.status(for: id) else { + fatalError() + } + var config = cell.defaultContentConfiguration() + let doc = try! SwiftSoup.parseBodyFragment(status.content) + config.text = try! doc.text() + cell.contentConfiguration = config + } + collectionView.register(LoadingCollectionViewCell.self, forCellWithReuseIdentifier: "loadingIndicator") + collectionView.register(ConfirmLoadMoreCollectionViewCell.self, forCellWithReuseIdentifier: "confirmLoadMore") + 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 + 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 + } + } + } + + private func applyInitialSnapshot() { + // TODO: this might not be necessary + // TODO: yes it is, for public timeline descriptions + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + Task { + await controller.loadInitial() + } + } + +} + +extension TimelineViewController { + enum Section: Hashable { + case header + case statuses + case footer + } + enum Item: Hashable { + case status(id: String, state: StatusState) + case loadingIndicator + case confirmLoadMore +// // TODO: remove local param from this +// case publicTimelineDescription(local: Bool) + + static func ==(lhs: Item, rhs: Item) -> Bool { + switch (lhs, rhs) { + case let (.status(id: a, state: _), .status(id: b, state: _)): + return a == b + case (.loadingIndicator, .loadingIndicator): + return true + case (.confirmLoadMore, .confirmLoadMore): + return true +// case let (.publicTimelineDescription(local: a), .publicTimelineDescription(local: b)): +// return a == b + default: + return false + } + } + + func hash(into hasher: inout Hasher) { + switch self { + case .status(id: let id, state: _): + hasher.combine(0) + hasher.combine(id) + case .loadingIndicator: + hasher.combine(1) + case .confirmLoadMore: + hasher.combine(2) +// case .publicTimelineDescription(local: let local): +// hasher.combine(3) +// hasher.combine(local) + } + } + + var hideSeparators: Bool { + switch self { + case .loadingIndicator: + return true + default: + return false + } + } + } +} + +extension TimelineViewController: TimelineLikeControllerDelegate { + typealias TimelineItem = String // status ID + + func loadInitial() async throws -> [TimelineItem] { + guard let mastodonController else { + 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) + + if !statuses.isEmpty { + newer = .after(id: statuses.first!.id, count: nil) + older = .before(id: statuses.last!.id, count: nil) + } + + return await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(statuses: statuses) { + continuation.resume(returning: statuses.map(\.id)) + } + } + } + + func loadNewer() async throws -> [TimelineItem] { + guard let newer else { + throw Error.noNewer + } + + let request = Client.getStatuses(timeline: timeline, range: newer) + let (statuses, _) = try await mastodonController.run(request) + + guard !statuses.isEmpty else { + throw Error.allCaughtUp + } + + self.newer = .after(id: statuses.first!.id, count: nil) + + return await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(statuses: statuses) { + continuation.resume(returning: statuses.map(\.id)) + } + } + } + + func loadOlder() async throws -> [TimelineItem] { + guard let older else { + throw Error.noOlder + } + + try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) + + let request = Client.getStatuses(timeline: timeline, range: older) + let (statuses, _) = try await mastodonController.run(request) + + if !statuses.isEmpty { + self.older = .before(id: statuses.last!.id, count: nil) + } + + return await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(statuses: statuses) { + continuation.resume(returning: statuses.map(\.id)) + } + } + } + + 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.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 noNewer + case noOlder + case allCaughtUp + } +} + +extension TimelineViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section), + case .status(_, _) = dataSource.itemIdentifier(for: indexPath) else { + return + } + + let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) + if indexPath.row == itemsInSection - 1 { + Task { + await controller.loadOlder() + } + } + } +} + +extension TimelineViewController: ToastableViewController { +} diff --git a/Tusker/Screens/Timeline/TimelinesPageViewController.swift b/Tusker/Screens/Timeline/TimelinesPageViewController.swift index 19fb6878..05fb4e8e 100644 --- a/Tusker/Screens/Timeline/TimelinesPageViewController.swift +++ b/Tusker/Screens/Timeline/TimelinesPageViewController.swift @@ -19,7 +19,7 @@ class TimelinesPageViewController: SegmentedPageViewController { init(mastodonController: MastodonController) { self.mastodonController = mastodonController - let home = TimelineTableViewController(for: .home, mastodonController: mastodonController) + let home = TimelineViewController(for: .home, mastodonController: mastodonController) home.title = homeTitle let federated = TimelineTableViewController(for: .public(local: false), mastodonController: mastodonController) diff --git a/Tusker/TimelineLikeController.swift b/Tusker/TimelineLikeController.swift new file mode 100644 index 00000000..be89f61d --- /dev/null +++ b/Tusker/TimelineLikeController.swift @@ -0,0 +1,255 @@ +// +// TimelineLikeController.swift +// Tusker +// +// Created by Shadowfacts on 9/19/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import OSLog + +protocol TimelineLikeControllerDelegate: AnyObject { + associatedtype TimelineItem + + func loadInitial() async throws -> [TimelineItem] + + func loadNewer() async throws -> [TimelineItem] + + func loadOlder() async throws -> [TimelineItem] + + func canLoadOlder() async -> Bool + + func handleEvent(_ event: TimelineLikeController.Event) async +} + +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController") + +actor TimelineLikeController { + + unowned var delegate: any TimelineLikeControllerDelegate + + private(set) var state = State.idle { + willSet { + precondition(state.canTransition(to: newValue)) + logger.debug("State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)") + } + } + + init(delegate: any TimelineLikeControllerDelegate) { + self.delegate = delegate + } + + func loadInitial() async { + guard state == .idle else { + return + } + let token = LoadAttemptToken() + state = .loadingInitial(token, hasAddedLoadingIndicator: false) + let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingInitial(token, hasAddedLoadingIndicator: true)) + do { + let items = try await delegate.loadInitial() + guard case .loadingInitial(token, _) = state else { + return + } + await loadingIndicator.end() + await emit(event: .replaceAllItems(items, token)) + state = .idle + } catch { + await loadingIndicator.end() + await emit(event: .loadAllError(error, token)) + state = .idle + } + } + + func loadNewer() async { + guard state == .idle else { + return + } + let token = LoadAttemptToken() + state = .loadingNewer(token) + do { + let items = try await delegate.loadNewer() + guard case .loadingNewer(token) = state else { + return + } + await emit(event: .prependItems(items, token)) + state = .idle + } catch { + await emit(event: .loadNewerError(error, token)) + state = .idle + } + } + + func loadOlder() async { + guard state == .idle else { + 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) + let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingOlder(token, hasAddedLoadingIndicator: true)) + do { + let items = try await delegate.loadOlder() + guard case .loadingOlder(token, _) = state else { + return + } + await loadingIndicator.end() + await emit(event: .appendItems(items, token)) + state = .idle + } catch { + await loadingIndicator.end() + await emit(event: .loadOlderError(error, token)) + state = .idle + } + } + + private func transition(to newState: State) { + self.state = newState + } + + private func emit(event: Event) async { + precondition(state.canEmit(event: event)) + await delegate.handleEvent(event) + } + + enum State: Equatable, CustomDebugStringConvertible { + case idle + case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool) + case loadingNewer(LoadAttemptToken) +// case waitingForLoadOlderPermission + case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool) + + var debugDescription: String { + switch self { + 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))" + } + } + + func canTransition(to: State) -> Bool { + switch self { + case .idle: + switch to { + case .loadingInitial(_, _), .loadingNewer(_)/*, .waitingForLoadOlderPermission*/, .loadingOlder(_, _): + return true + default: + return false + } + case .loadingInitial(let token, let hasAddedLoadingIndicator): + return to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true)) + case .loadingNewer(_): + 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 +// } + } + } + + func canEmit(event: Event) -> Bool { + switch event { + case .addLoadingIndicator: + switch self { + case .loadingInitial(_, _), .loadingOlder(_, _): + return true + default: + return false + } + case .removeLoadingIndicator: + switch self { + case .loadingInitial(_, hasAddedLoadingIndicator: true), .loadingOlder(_, hasAddedLoadingIndicator: true): + return true + default: + return false + } + case .loadAllError(_, let token), .replaceAllItems(_, let token): + switch self { + case .loadingInitial(token, _): + return true + default: + return false + } + case .loadNewerError(_, let token), .prependItems(_, let token): + switch self { + case .loadingNewer(token): + return true + default: + return false + } + case .loadOlderError(_, let token), .appendItems(_, let token): + switch self { + case .loadingOlder(token, _): + return true + default: + return false + } + } + } + } + + enum Event { + case addLoadingIndicator + case removeLoadingIndicator + case loadAllError(Error, LoadAttemptToken) + case replaceAllItems([Item], LoadAttemptToken) + case loadNewerError(Error, LoadAttemptToken) + case prependItems([Item], LoadAttemptToken) + case loadOlderError(Error, LoadAttemptToken) + case appendItems([Item], LoadAttemptToken) + } + + class LoadAttemptToken: Equatable { + static func ==(lhs: LoadAttemptToken, rhs: LoadAttemptToken) -> Bool { + return lhs === rhs + } + } + + class DeferredLoadingIndicator { + private let owner: TimelineLikeController + private let addedIndicatorState: State + private let task: Task + + init(owner: TimelineLikeController, state: State, addedIndicatorState: State) { + self.owner = owner + self.addedIndicatorState = addedIndicatorState + self.task = Task { + try await Task.sleep(nanoseconds: 150 * NSEC_PER_MSEC) + guard await state == owner.state else { + return + } + await owner.emit(event: .addLoadingIndicator) + await owner.transition(to: addedIndicatorState) + } + } + + func end() async { + let state = await owner.state + if state == addedIndicatorState { + await owner.emit(event: .removeLoadingIndicator) + } else { + task.cancel() + } + } + } + +} diff --git a/Tusker/Views/Confirm Load More Cell/ConfirmLoadMoreCollectionViewCell.swift b/Tusker/Views/Confirm Load More Cell/ConfirmLoadMoreCollectionViewCell.swift new file mode 100644 index 00000000..04eacb2d --- /dev/null +++ b/Tusker/Views/Confirm Load More Cell/ConfirmLoadMoreCollectionViewCell.swift @@ -0,0 +1,67 @@ +// +// ConfirmLoadMoreCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 9/21/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Combine + +class ConfirmLoadMoreCollectionViewCell: UICollectionViewCell { + + var confirmLoadMore: PassthroughSubject? + var isLoading: Bool { + get { + button.configuration?.showsActivityIndicator ?? false + } + set { + var config = button.configuration! + config.showsActivityIndicator = newValue + button.configuration = config + } + } + + private var button: UIButton! + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .secondarySystemBackground + + let label = UILabel() + label.text = "Infinite scrolling is off. Do you want to keep going?" + label.textColor = .secondaryLabel + label.textAlignment = .natural + label.numberOfLines = 0 + + var config = UIButton.Configuration.tinted() + config.title = "Load More" + config.imagePadding = 4 + button = UIButton(configuration: config, primaryAction: UIAction(handler: { [unowned self] _ in + self.confirmLoadMore?.send() + })) + + let stack = UIStackView(arrangedSubviews: [ + label, + button, + ]) + stack.axis = .vertical + stack.distribution = .fill + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Tusker/Views/LoadingCollectionViewCell.swift b/Tusker/Views/LoadingCollectionViewCell.swift new file mode 100644 index 00000000..5d99f15d --- /dev/null +++ b/Tusker/Views/LoadingCollectionViewCell.swift @@ -0,0 +1,29 @@ +// +// LoadingCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 9/24/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit + +class LoadingCollectionViewCell: UICollectionViewCell { + let indicator = UIActivityIndicatorView(style: .medium) + + override init(frame: CGRect) { + super.init(frame: frame) + + indicator.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(indicator) + NSLayoutConstraint.activate([ + indicator.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + indicator.topAnchor.constraint(equalToSystemSpacingBelow: contentView.topAnchor, multiplier: 1), + contentView.bottomAnchor.constraint(equalToSystemSpacingBelow: indicator.bottomAnchor, multiplier: 1), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Tusker/Views/Toast/ToastConfiguration.swift b/Tusker/Views/Toast/ToastConfiguration.swift index 3a187946..cf113e32 100644 --- a/Tusker/Views/Toast/ToastConfiguration.swift +++ b/Tusker/Views/Toast/ToastConfiguration.swift @@ -35,30 +35,36 @@ struct ToastConfiguration { } extension ToastConfiguration { - init(from error: Client.Error, with title: String, in viewController: UIViewController, retryAction: ((ToastView) -> Void)?) { + init(from error: Error, with title: String, in viewController: UIViewController, retryAction: ((ToastView) -> Void)?) { self.init(title: title) - self.subtitle = error.localizedDescription - self.systemImageName = error.systemImageName + // localizedDescription is statically dispatched, so we need to call it after the downcast + if let error = error as? Client.Error { + self.subtitle = error.localizedDescription + self.systemImageName = error.systemImageName + self.longPressAction = { [unowned viewController] toast in + toast.dismissToast(animated: true) + let text = """ + \(title): + \(error.requestMethod.name) \(error.requestEndpoint) + + \(error.type) + """ + let reporter = IssueReporterViewController.create(reportText: text, dismiss: { [unowned viewController] in + viewController.dismiss(animated: true) + }) + viewController.present(reporter, animated: true) + } + } else { + self.subtitle = error.localizedDescription + self.systemImageName = "exclamationmark.triangle" + } if let retryAction = retryAction { self.actionTitle = "Retry" self.action = retryAction } - self.longPressAction = { [unowned viewController] toast in - toast.dismissToast(animated: true) - let text = """ - \(title): - \(error.requestMethod.name) \(error.requestEndpoint) - - \(error.type) - """ - let reporter = IssueReporterViewController.create(reportText: text, dismiss: { [unowned viewController] in - viewController.dismiss(animated: true) - }) - viewController.present(reporter, animated: true) - } } - init(from error: Client.Error, with title: String, in viewController: UIViewController, retryAction: @escaping @MainActor (ToastView) async -> Void) { + init(from error: Error, with title: String, in viewController: UIViewController, retryAction: @escaping @MainActor (ToastView) async -> Void) { self.init(from: error, with: title, in: viewController) { toast in Task { await retryAction(toast)