From 5f410213e211f67355f0db692e97f2810f84e9bb Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 10 Oct 2022 21:01:03 -0400 Subject: [PATCH] Start converting profile statuses to collection view --- Tusker.xcodeproj/project.pbxproj | 4 + .../NewProfileStatusesViewController.swift | 327 ++++++++++++++++++ .../Timeline/TimelineViewController.swift | 22 +- Tusker/TuskerNavigationDelegate.swift | 3 +- .../Status/StatusCollectionViewCell.swift | 4 +- .../TimelineStatusCollectionViewCell.swift | 6 +- 6 files changed, 345 insertions(+), 21 deletions(-) create mode 100644 Tusker/Screens/Profile/NewProfileStatusesViewController.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index df8f8d62..9e3f378d 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */; }; D61ABEFC28F105DE00B29151 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D61ABEFB28F105DE00B29151 /* Pachyderm */; }; D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEFD28F1C92600B29151 /* FavoriteService.swift */; }; + D61ABEF828EFC3F900B29151 /* NewProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.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 */; }; @@ -390,6 +391,7 @@ D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = ""; }; D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = ""; }; D61ABEFD28F1C92600B29151 /* FavoriteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteService.swift; sourceTree = ""; }; + D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileStatusesViewController.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 = ""; }; @@ -944,6 +946,7 @@ D6412B0424B0227D00F5412E /* ProfileViewController.swift */, D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */, D6412B0824B0291E00F5412E /* MyProfileViewController.swift */, + D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */, ); path = Profile; sourceTree = ""; @@ -1950,6 +1953,7 @@ D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */, D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */, D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */, + D61ABEF828EFC3F900B29151 /* NewProfileStatusesViewController.swift in Sources */, D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */, D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */, D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */, diff --git a/Tusker/Screens/Profile/NewProfileStatusesViewController.swift b/Tusker/Screens/Profile/NewProfileStatusesViewController.swift new file mode 100644 index 00000000..1e430c56 --- /dev/null +++ b/Tusker/Screens/Profile/NewProfileStatusesViewController.swift @@ -0,0 +1,327 @@ +// +// NewProfileStatusesViewController.swift +// Tusker +// +// Created by Shadowfacts on 10/6/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm +import Combine + +class NewProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController { + + weak var mastodonController: MastodonController! + var accountID: String! + let kind: Kind + + private(set) var controller: TimelineLikeController! + let confirmLoadMore = PassthroughSubject() + private var newer: RequestRange? + private var older: RequestRange? + + var collectionView: UICollectionView { + view as! UICollectionView + } + private(set) var dataSource: UICollectionViewDiffableDataSource! + + init(accountID: String?, kind: Kind, mastodonController: MastodonController) { + self.accountID = accountID + self.kind = kind + self.mastodonController = mastodonController + + super.init(nibName: nil, bundle: nil) + + self.controller = TimelineLikeController(delegate: self) + + // TODO: refresh key command + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.leadingSwipeActionsConfigurationProvider = { [unowned self] in + (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() + } + config.trailingSwipeActionsConfigurationProvider = { [unowned self] in + (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() + } + // TODO: item separators + let layout = UICollectionViewCompositionalLayout.list(using: config) + view = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.delegate = self + // TODO: drag delegate + + registerTimelineLikeCells() + dataSource = createDataSource() + applyInitialSnapshot() + + // TODO: refresh control + } + + override func viewDidLoad() { + super.viewDidLoad() + + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in + cell.delegate = self + cell.showPinned = item.2 + cell.updateUI(statusID: item.0, state: item.1) + } + return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in + switch itemIdentifier { + case .status(id: let id, state: let state, pinned: let pinned): + return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned)) + case .loadingIndicator: + return loadingIndicatorCell(for: indexPath) + case .confirmLoadMore: + return confirmLoadMoreCell(for: indexPath) + } + } + } + + private func applyInitialSnapshot() { + // TODO: header + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + collectionView.indexPathsForSelectedItems?.forEach { + collectionView.deselectItem(at: $0, animated: true) + } + + Task { + await controller.loadInitial() + await tryLoadPinned() + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + // TODO: prune offscreen rows + } + + // TODO: refreshing + + private func tryLoadPinned() async { + do { + try await loadPinned() + } catch { + let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in + toast.dismissToast(animated: true) + Task { + await self.tryLoadPinned() + } + } + self.showToast(configuration: config, animated: true) + } + } + + private func loadPinned() async throws { + guard case .statuses = kind, + mastodonController.instanceFeatures.profilePinnedStatuses else { + return + } + + let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false) + let (statuses, _) = try await mastodonController.run(request) + + await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(statuses: statuses) { + continuation.resume() + } + } + + var snapshot = dataSource.snapshot() + if !snapshot.sectionIdentifiers.contains(.pinned) { + if snapshot.sectionIdentifiers.isEmpty { + snapshot.appendSections([.pinned]) + } else { + snapshot.insertSections([.pinned], beforeSection: snapshot.sectionIdentifiers.first!) + } + } + let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: true) } + snapshot.appendItems(items, toSection: .pinned) + await apply(snapshot, animatingDifferences: true) + } + +} + +extension NewProfileStatusesViewController { + enum Kind { + case statuses, withReplies, onlyMedia + } +} + +extension NewProfileStatusesViewController { + enum Section: TimelineLikeCollectionViewSection { + case pinned + case statuses + case footer + + static var entries: Self { .statuses } + } + enum Item: TimelineLikeCollectionViewItem { + typealias TimelineItem = String + + case status(id: String, state: StatusState, pinned: Bool) + case loadingIndicator + case confirmLoadMore + + static func fromTimelineItem(_ item: String) -> Self { + return .status(id: item, state: .unknown, pinned: false) + } + + static func ==(lhs: Item, rhs: Item) -> Bool { + switch (lhs, rhs) { + case let (.status(id: a, state: _, pinned: ap), .status(id: b, state: _, pinned: bp)): + return a == b && ap == bp + case (.loadingIndicator, .loadingIndicator): + return true + case (.confirmLoadMore, .confirmLoadMore): + return true + default: + return false + } + } + + func hash(into hasher: inout Hasher) { + switch self { + case .status(id: let id, state: _, pinned: let pinned): + hasher.combine(0) + hasher.combine(id) + hasher.combine(pinned) + case .loadingIndicator: + hasher.combine(1) + case .confirmLoadMore: + hasher.combine(2) + } + } + } +} + +extension NewProfileStatusesViewController: TimelineLikeControllerDelegate { + typealias TimelineItem = String // status ID + + private func request(for range: RequestRange = .default) -> Request<[Status]> { + switch kind { + case .statuses: + return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true) + case .withReplies: + return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false) + case .onlyMedia: + return Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false) + } + } + + func loadInitial() async throws -> [String] { + guard let mastodonController else { + throw Error.noClient + } + + let request = request() + 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 -> [String] { + guard let newer else { + throw Error.noNewer + } + + let request = request(for: 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 -> [String] { + guard let older else { + throw Error.noOlder + } + + let request = request(for: older) + let (statuses, _) = try await mastodonController.run(request) + + guard !statuses.isEmpty else { + return [] + } + + 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)) + } + } + } + + enum Error: TimelineLikeCollectionViewError { + case noClient + case noNewer + case noOlder + case allCaughtUp + } +} + +extension NewProfileStatusesViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else { + return + } + + let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) + if indexPath.row == itemsInSection - 1 { + Task { + await controller.loadOlder() + } + } + } + + // TODO: cell selection +} + +extension NewProfileStatusesViewController: TuskerNavigationDelegate { + var apiController: MastodonController { mastodonController } +} + +extension NewProfileStatusesViewController: MenuActionProvider { +} + +extension NewProfileStatusesViewController: StatusCollectionViewCellDelegate { + func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { + if let indexPath = collectionView.indexPath(for: cell) { + var snapshot = dataSource.snapshot() + snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) + dataSource.apply(snapshot, animatingDifferences: false, completion: completion) + } + } +} diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 1b343078..3f027050 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -87,14 +87,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } private func createDataSource() -> UICollectionViewDiffableDataSource { - let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in - guard case .status(id: let id, state: let state) = item, - let status = mastodonController.persistentContainer.status(for: id) else { - fatalError() - } - cell.mastodonController = mastodonController + let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self - cell.updateUI(statusID: id, state: state) + cell.updateUI(statusID: item.0, state: item.1) } let timelineDescriptionCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in guard case .public(let local) = timeline else { @@ -108,8 +103,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { - case .status(_, _): - return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: itemIdentifier) + case .status(id: let id, state: let state): + return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) case .loadingIndicator: return loadingIndicatorCell(for: indexPath) case .confirmLoadMore: @@ -326,10 +321,12 @@ extension TimelineViewController { 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) + guard !statuses.isEmpty else { + return [] } + 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)) @@ -347,8 +344,7 @@ extension TimelineViewController { 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 { + guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else { return } diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 14e334a4..8e8aa331 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -33,7 +33,8 @@ extension TuskerNavigationDelegate { return } - show(ProfileViewController(accountID: accountID, mastodonController: apiController), sender: self) +// show(ProfileViewController(accountID: accountID, mastodonController: apiController), sender: self) + show(NewProfileStatusesViewController(accountID: accountID, kind: .statuses, mastodonController: apiController), sender: self) } func selected(mention: Mention) { diff --git a/Tusker/Views/Status/StatusCollectionViewCell.swift b/Tusker/Views/Status/StatusCollectionViewCell.swift index 77713db3..f6a3b67f 100644 --- a/Tusker/Views/Status/StatusCollectionViewCell.swift +++ b/Tusker/Views/Status/StatusCollectionViewCell.swift @@ -29,8 +29,6 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate var reblogButton: UIButton { get } var moreButton: UIButton { get } - // TODO: why is one of these ! and the other ? - var mastodonController: MastodonController! { get } var delegate: StatusCollectionViewCellDelegate? { get } var showStatusAutomatically: Bool { get } @@ -50,6 +48,8 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate extension StatusCollectionViewCell { static var avatarImageViewSize: CGFloat { 50 } + var mastodonController: MastodonController! { delegate?.apiController } + func baseCreateObservers() { mastodonController.persistentContainer.statusSubject .receive(on: DispatchQueue.main) diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index 6402860f..1b49b116 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -216,7 +216,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti private var mainContainerBottomToActionsConstraint: NSLayoutConstraint! private var mainContainerBottomToSelfConstraint: NSLayoutConstraint! - weak var mastodonController: MastodonController! weak var delegate: StatusCollectionViewCellDelegate? var showStatusAutomatically: Bool { // TODO: needed once conversation controller refactored @@ -226,10 +225,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti // TODO: needed once conversation controller refactored true } - var showPinned: Bool { - // TODO: needed once profile controller refactored - false - } + var showPinned: Bool = false // alas these need to be internal so they're accessible from the protocol extensions var statusID: String!