feat: add favorite scene
This commit is contained in:
parent
e4b15cc951
commit
b6269c7643
|
@ -313,6 +313,12 @@
|
|||
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; };
|
||||
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; };
|
||||
DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */; };
|
||||
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */; };
|
||||
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */; };
|
||||
DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */; };
|
||||
DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; };
|
||||
DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */; };
|
||||
DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; };
|
||||
DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; };
|
||||
DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
@ -684,6 +690,12 @@
|
|||
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = "<group>"; };
|
||||
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.swift; sourceTree = "<group>"; };
|
||||
DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewController.swift; sourceTree = "<group>"; };
|
||||
DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = "<group>"; };
|
||||
DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+StatusProvider.swift"; sourceTree = "<group>"; };
|
||||
DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = "<group>"; };
|
||||
DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = "<group>"; };
|
||||
DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = "<group>"; };
|
||||
EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
|
@ -971,6 +983,7 @@
|
|||
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */,
|
||||
2D38F20725CD491300561493 /* DisposeBagCollectable.swift */,
|
||||
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */,
|
||||
DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */,
|
||||
);
|
||||
path = Protocol;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1602,6 +1615,7 @@
|
|||
DBB525132611EBB1002F1F29 /* Segmented */,
|
||||
DBB525462611ED57002F1F29 /* Header */,
|
||||
DBB5253B2611ECF5002F1F29 /* Timeline */,
|
||||
DBE3CDF1261C6B3100430CC6 /* Favorite */,
|
||||
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */,
|
||||
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */,
|
||||
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */,
|
||||
|
@ -1739,6 +1753,18 @@
|
|||
path = Register;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBE3CDF1261C6B3100430CC6 /* Favorite */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */,
|
||||
DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */,
|
||||
DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */,
|
||||
DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */,
|
||||
DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */,
|
||||
);
|
||||
path = Favorite;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXHeadersBuildPhase section */
|
||||
|
@ -2158,6 +2184,7 @@
|
|||
0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */,
|
||||
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
|
||||
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
|
||||
DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */,
|
||||
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */,
|
||||
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
|
||||
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
|
||||
|
@ -2172,6 +2199,7 @@
|
|||
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
|
||||
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
|
||||
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
|
||||
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
|
||||
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
|
||||
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
|
||||
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
|
||||
|
@ -2181,15 +2209,18 @@
|
|||
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */,
|
||||
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
|
||||
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
|
||||
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
|
||||
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
|
||||
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */,
|
||||
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */,
|
||||
DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */,
|
||||
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
|
||||
DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */,
|
||||
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
|
||||
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
|
||||
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */,
|
||||
DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */,
|
||||
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
|
||||
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
|
||||
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
|
||||
|
@ -2353,6 +2384,7 @@
|
|||
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
|
||||
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
|
||||
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,
|
||||
DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */,
|
||||
DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>12</integer>
|
||||
<integer>10</integer>
|
||||
</dict>
|
||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
|
|
@ -56,6 +56,7 @@ extension SceneCoordinator {
|
|||
|
||||
// profile
|
||||
case profile(viewModel: ProfileViewModel)
|
||||
case favorite(viewModel: FavoriteViewModel)
|
||||
|
||||
// misc
|
||||
case alertController(alertController: UIAlertController)
|
||||
|
@ -232,6 +233,10 @@ private extension SceneCoordinator {
|
|||
let _viewController = ProfileViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .favorite(let viewModel):
|
||||
let _viewController = FavoriteViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .alertController(let alertController):
|
||||
if let popoverPresentationController = alertController.popoverPresentationController {
|
||||
assert(
|
||||
|
|
|
@ -173,7 +173,7 @@ extension StatusProviderFacade {
|
|||
return (status.objectID, favoriteKind)
|
||||
}
|
||||
.map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in
|
||||
return context.apiService.like(
|
||||
return context.apiService.favorite(
|
||||
statusObjectID: statusObjectID,
|
||||
mastodonUserObjectID: mastodonUserObjectID,
|
||||
favoriteKind: favoriteKind
|
||||
|
@ -201,7 +201,7 @@ extension StatusProviderFacade {
|
|||
}
|
||||
}
|
||||
.map { statusID, favoriteKind in
|
||||
return context.apiService.like(
|
||||
return context.apiService.favorite(
|
||||
statusID: statusID,
|
||||
favoriteKind: favoriteKind,
|
||||
mastodonAuthenticationBox: activeMastodonAuthenticationBox
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
//
|
||||
// StatusTableViewControllerAspect.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-7.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import AVKit
|
||||
|
||||
protocol StatusTableViewControllerAspect: UIViewController {
|
||||
var tableView: UITableView { get }
|
||||
}
|
||||
|
||||
// MARK: - UIViewController
|
||||
|
||||
// StatusTableViewControllerAspect.aspectViewWillAppear(_:)
|
||||
extension StatusTableViewControllerAspect {
|
||||
func aspectViewWillAppear(_ animated: Bool) {
|
||||
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusTableViewControllerAspect where Self: NeedsDependency {
|
||||
func aspectViewDidDisappear(_ animated: Bool) {
|
||||
context.videoPlaybackService.viewDidDisappear(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
// aspectTableView(_:estimatedHeightForRowAt:)
|
||||
extension StatusTableViewControllerAspect where Self: LoadMoreConfigurableTableViewContainer {
|
||||
func aspectScrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
handleScrollViewDidScroll(scrollView)
|
||||
}
|
||||
}
|
||||
|
||||
// aspectTableView(_:estimatedHeightForRowAt:)
|
||||
extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer {
|
||||
func aspectTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
handleTableView(tableView, estimatedHeightForRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
// StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:)
|
||||
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
|
||||
func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer & StatusProvider {
|
||||
func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & TableViewCellHeightCacheableContainer & StatusProvider {
|
||||
func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
(self as StatusTableViewCellDelegate & StatusProvider).handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||
(self as TableViewCellHeightCacheableContainer & StatusProvider).handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPlayerViewControllerDelegate & NeedsDependency
|
||||
|
||||
// aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:)
|
||||
extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency {
|
||||
func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
// aspectPlayerViewController(_:willEndFullScreenPresentationWithAnimationCoordinator:)
|
||||
extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency {
|
||||
func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,30 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
protocol TableViewCellHeightCacheableContainer: UIViewController {
|
||||
// TODO:
|
||||
protocol TableViewCellHeightCacheableContainer: StatusProvider {
|
||||
var cellFrameCache: NSCache<NSNumber, NSValue> { get }
|
||||
}
|
||||
|
||||
extension TableViewCellHeightCacheableContainer {
|
||||
|
||||
func handleTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard let item = item(for: nil, indexPath: indexPath) else { return }
|
||||
|
||||
let key = item.hashValue
|
||||
let frame = cell.frame
|
||||
cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
|
||||
}
|
||||
|
||||
func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
guard let item = item(for: nil, indexPath: indexPath) else { return 200 }
|
||||
guard let frame = cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
|
||||
if case .bottomLoader = item {
|
||||
return TimelineLoaderTableViewCell.cellHeight
|
||||
} else {
|
||||
return 200
|
||||
}
|
||||
}
|
||||
|
||||
return ceil(frame.height)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
//
|
||||
// FavoriteViewController+StatusProvider.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-7.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
|
||||
// MARK: - StatusProvider
|
||||
extension FavoriteViewController: StatusProvider {
|
||||
|
||||
func status() -> Future<Status?, Never> {
|
||||
return Future { promise in promise(.success(nil)) }
|
||||
}
|
||||
|
||||
func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Status?, Never> {
|
||||
return Future { promise in
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
|
||||
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
|
||||
switch item {
|
||||
case .status(let objectID, _):
|
||||
let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||
managedObjectContext.perform {
|
||||
let status = managedObjectContext.object(with: objectID) as? Status
|
||||
promise(.success(status))
|
||||
}
|
||||
default:
|
||||
promise(.success(nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
|
||||
return Future { promise in promise(.success(nil)) }
|
||||
}
|
||||
|
||||
var managedObjectContext: NSManagedObjectContext {
|
||||
return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||
}
|
||||
|
||||
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
|
||||
return viewModel.diffableDataSource
|
||||
}
|
||||
|
||||
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
|
||||
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func items(indexPaths: [IndexPath]) -> [Item] {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
return []
|
||||
}
|
||||
|
||||
var items: [Item] = []
|
||||
for indexPath in indexPaths {
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
|
||||
items.append(item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
//
|
||||
// FavoriteViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-6.
|
||||
//
|
||||
|
||||
// Note: Prefer use US favorite then EN favourite in coding
|
||||
// to following the text checker auto-correct behavior
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import AVKit
|
||||
import Combine
|
||||
import GameplayKit
|
||||
|
||||
final class FavoriteViewController: UIViewController, NeedsDependency {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: FavoriteViewModel!
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
||||
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.separatorStyle = .none
|
||||
tableView.backgroundColor = .clear
|
||||
return tableView
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FavoriteViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(tableView)
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
tableView.delegate = self
|
||||
viewModel.setupDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: self,
|
||||
statusTableViewCellDelegate: self
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
aspectViewWillAppear(animated)
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
aspectViewDidDisappear(animated)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - StatusTableViewControllerAspect
|
||||
extension FavoriteViewController: StatusTableViewControllerAspect { }
|
||||
|
||||
// MARK: - TableViewCellHeightCacheableContainer
|
||||
extension FavoriteViewController: TableViewCellHeightCacheableContainer {
|
||||
var cellFrameCache: NSCache<NSNumber, NSValue> {
|
||||
return viewModel.cellFrameCache
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
extension FavoriteViewController {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
aspectScrollViewDidScroll(scrollView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
extension FavoriteViewController: UITableViewDelegate {
|
||||
|
||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - AVPlayerViewControllerDelegate
|
||||
extension FavoriteViewController: AVPlayerViewControllerDelegate {
|
||||
|
||||
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
|
||||
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: - TimelinePostTableViewCellDelegate
|
||||
extension FavoriteViewController: StatusTableViewCellDelegate {
|
||||
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
|
||||
func parent() -> UIViewController { return self }
|
||||
}
|
||||
|
||||
// MARK: - LoadMoreConfigurableTableViewContainer
|
||||
extension FavoriteViewController: LoadMoreConfigurableTableViewContainer {
|
||||
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||
typealias LoadingState = FavoriteViewModel.State.Loading
|
||||
|
||||
var loadMoreConfigurableTableView: UITableView { return tableView }
|
||||
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine }
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// FavoriteViewModel+Diffable.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-7.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension FavoriteViewModel {
|
||||
|
||||
func setupDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency,
|
||||
statusTableViewCellDelegate: StatusTableViewCellDelegate
|
||||
) {
|
||||
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.share()
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: dependency,
|
||||
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil
|
||||
)
|
||||
|
||||
// set empty section to make update animation top-to-bottom style
|
||||
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
diffableDataSource?.apply(snapshot)
|
||||
|
||||
stateMachine.enter(State.Reloading.self)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
//
|
||||
// FavoriteViewModel+State.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-7.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
|
||||
extension FavoriteViewModel {
|
||||
class State: GKState {
|
||||
weak var viewModel: FavoriteViewModel?
|
||||
|
||||
init(viewModel: FavoriteViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FavoriteViewModel.State {
|
||||
class Initial: FavoriteViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
guard let viewModel = viewModel else { return false }
|
||||
switch stateClass {
|
||||
case is Reloading.Type:
|
||||
return viewModel.activeMastodonAuthenticationBox.value != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Reloading: FavoriteViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
// reset
|
||||
viewModel.statusFetchedResultsController.statusIDs.value = []
|
||||
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: FavoriteViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let _ = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Idle: FavoriteViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Reloading.Type, is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: FavoriteViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Fail.Type:
|
||||
return true
|
||||
case is Idle.Type:
|
||||
return true
|
||||
case is NoMore.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last
|
||||
|
||||
viewModel.context.apiService.favoritedStatuses(
|
||||
maxID: maxID,
|
||||
mastodonAuthenticationBox: activeMastodonAuthenticationBox
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
stateMachine.enter(Fail.self)
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { response in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
var hasNewStatusesAppend = false
|
||||
var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
|
||||
for status in response.value {
|
||||
guard !statusIDs.contains(status.id) else { continue }
|
||||
statusIDs.append(status.id)
|
||||
hasNewStatusesAppend = true
|
||||
}
|
||||
|
||||
if hasNewStatusesAppend {
|
||||
stateMachine.enter(Idle.self)
|
||||
} else {
|
||||
stateMachine.enter(NoMore.self)
|
||||
}
|
||||
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
class NoMore: FavoriteViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Reloading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
//
|
||||
// FavoriteViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-6.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import GameplayKit
|
||||
|
||||
final class FavoriteViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let activeMastodonAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
|
||||
private(set) lazy var stateMachine: GKStateMachine = {
|
||||
let stateMachine = GKStateMachine(states: [
|
||||
State.Initial(viewModel: self),
|
||||
State.Reloading(viewModel: self),
|
||||
State.Fail(viewModel: self),
|
||||
State.Idle(viewModel: self),
|
||||
State.Loading(viewModel: self),
|
||||
State.NoMore(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(State.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: nil,
|
||||
additionalTweetPredicate: Status.notDeleted()
|
||||
)
|
||||
|
||||
context.authenticationService.activeMastodonAuthenticationBox
|
||||
.assign(to: \.value, on: activeMastodonAuthenticationBox)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
activeMastodonAuthenticationBox
|
||||
.map { $0?.domain }
|
||||
.assign(to: \.value, on: statusFetchedResultsController.domain)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
statusFetchedResultsController.objectIDs
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] objectIDs in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
|
||||
var items: [Item] = []
|
||||
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
|
||||
defer {
|
||||
// not animate when empty items fix loader first appear layout issue
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
|
||||
}
|
||||
|
||||
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
|
||||
let oldSnapshot = diffableDataSource.snapshot()
|
||||
for item in oldSnapshot.itemIdentifiers {
|
||||
guard case let .status(objectID, attribute) = item else { continue }
|
||||
oldSnapshotAttributeDict[objectID] = attribute
|
||||
}
|
||||
|
||||
for objectID in objectIDs {
|
||||
let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute()
|
||||
items.append(.status(objectID: objectID, attribute: attribute))
|
||||
}
|
||||
snapshot.appendItems(items, toSection: .main)
|
||||
|
||||
if let currentState = self.stateMachine.currentState {
|
||||
switch currentState {
|
||||
case is State.Reloading, is State.Loading, is State.Idle, is State.Fail:
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
case is State.NoMore:
|
||||
break
|
||||
// TODO: handle other states
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
|
@ -22,7 +22,7 @@ final class MeProfileViewModel: ProfileViewModel {
|
|||
|
||||
self.currentMastodonUser
|
||||
.sink { [weak self] currentMastodonUser in
|
||||
os_log("%{public}s[%{public}ld], %{public}s: current active twitter user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "<nil>")
|
||||
os_log("%{public}s[%{public}ld], %{public}s: current active mastodon user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "<nil>")
|
||||
|
||||
guard let self = self else { return }
|
||||
self.mastodonUser.value = currentMastodonUser
|
||||
|
|
|
@ -18,6 +18,24 @@ final class ProfileViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: ProfileViewModel!
|
||||
|
||||
private(set) lazy var settingBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "gear"), style: .plain, target: self, action: #selector(ProfileViewController.settingBarButtonItemPressed(_:)))
|
||||
barButtonItem.tintColor = .white
|
||||
return barButtonItem
|
||||
}()
|
||||
|
||||
private(set) lazy var shareBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), style: .plain, target: self, action: #selector(ProfileViewController.shareBarButtonItemPressed(_:)))
|
||||
barButtonItem.tintColor = .white
|
||||
return barButtonItem
|
||||
}()
|
||||
|
||||
private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "star"), style: .plain, target: self, action: #selector(ProfileViewController.favoriteBarButtonItemPressed(_:)))
|
||||
barButtonItem.tintColor = .white
|
||||
return barButtonItem
|
||||
}()
|
||||
|
||||
private(set) lazy var replyBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:)))
|
||||
barButtonItem.tintColor = .white
|
||||
|
@ -118,25 +136,32 @@ extension ProfileViewController {
|
|||
|
||||
navigationItem.titleView = UIView()
|
||||
|
||||
Publishers.CombineLatest(
|
||||
Publishers.CombineLatest3(
|
||||
viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(),
|
||||
viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(),
|
||||
viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in
|
||||
.sink { [weak self] isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in
|
||||
guard let self = self else { return }
|
||||
var items: [UIBarButtonItem] = []
|
||||
defer {
|
||||
self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil
|
||||
}
|
||||
|
||||
guard isMeBarButtonItemsHidden else {
|
||||
items.append(self.settingBarButtonItem)
|
||||
items.append(self.shareBarButtonItem)
|
||||
items.append(self.favoriteBarButtonItem)
|
||||
return
|
||||
}
|
||||
|
||||
if !isReplyBarButtonItemHidden {
|
||||
items.append(self.replyBarButtonItem)
|
||||
}
|
||||
if !isMoreMenuBarButtonItemHidden {
|
||||
items.append(self.moreMenuBarButtonItem)
|
||||
}
|
||||
guard !items.isEmpty else {
|
||||
self.navigationItem.rightBarButtonItems = nil
|
||||
return
|
||||
}
|
||||
self.navigationItem.rightBarButtonItems = items
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
@ -392,6 +417,21 @@ extension ProfileViewController {
|
|||
|
||||
extension ProfileViewController {
|
||||
|
||||
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
}
|
||||
|
||||
@objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
@objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
let favoriteViewModel = FavoriteViewModel(context: context)
|
||||
coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show)
|
||||
}
|
||||
|
||||
@objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let mastodonUser = viewModel.mastodonUser.value else { return }
|
||||
|
|
|
@ -53,6 +53,7 @@ class ProfileViewModel: NSObject {
|
|||
let isRelationshipActionButtonHidden = CurrentValueSubject<Bool, Never>(true)
|
||||
let isReplyBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
|
||||
let isMoreMenuBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
|
||||
let isMeBarButtonItemsHidden = CurrentValueSubject<Bool, Never>(true)
|
||||
|
||||
init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) {
|
||||
self.context = context
|
||||
|
@ -240,6 +241,7 @@ extension ProfileViewModel {
|
|||
// set bar button item state
|
||||
self.isReplyBarButtonItemHidden.value = true
|
||||
self.isMoreMenuBarButtonItemHidden.value = true
|
||||
self.isMeBarButtonItemsHidden.value = true
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -248,6 +250,7 @@ extension ProfileViewModel {
|
|||
// set bar button item state
|
||||
self.isReplyBarButtonItemHidden.value = true
|
||||
self.isMoreMenuBarButtonItemHidden.value = true
|
||||
self.isMeBarButtonItemsHidden.value = false
|
||||
} else {
|
||||
// set with follow action default
|
||||
var relationshipActionSet = RelationshipActionOptionSet([.follow])
|
||||
|
@ -294,6 +297,7 @@ extension ProfileViewModel {
|
|||
// set bar button item state
|
||||
self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy
|
||||
self.isMoreMenuBarButtonItemHidden.value = false
|
||||
self.isMeBarButtonItemsHidden.value = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -80,21 +80,31 @@ extension UserTimelineViewController {
|
|||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||
aspectViewWillAppear(animated)
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
context.videoPlaybackService.viewDidDisappear(from: self)
|
||||
aspectViewDidDisappear(animated)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - StatusTableViewControllerAspect
|
||||
extension UserTimelineViewController: StatusTableViewControllerAspect { }
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
extension UserTimelineViewController {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
handleScrollViewDidScroll(scrollView)
|
||||
aspectScrollViewDidScroll(scrollView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TableViewCellHeightCacheableContainer
|
||||
extension UserTimelineViewController: TableViewCellHeightCacheableContainer {
|
||||
var cellFrameCache: NSCache<NSNumber, NSValue> {
|
||||
return viewModel.cellFrameCache
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -102,28 +112,11 @@ extension UserTimelineViewController {
|
|||
extension UserTimelineViewController: UITableViewDelegate {
|
||||
|
||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
|
||||
|
||||
guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
|
||||
if case .bottomLoader = item {
|
||||
return TimelineLoaderTableViewCell.cellHeight
|
||||
} else {
|
||||
return 200
|
||||
}
|
||||
}
|
||||
// os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
|
||||
|
||||
return ceil(frame.height)
|
||||
aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
let key = item.hashValue
|
||||
let frame = cell.frame
|
||||
viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
|
||||
aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -132,11 +125,11 @@ extension UserTimelineViewController: UITableViewDelegate {
|
|||
extension UserTimelineViewController: AVPlayerViewControllerDelegate {
|
||||
|
||||
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
|
||||
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -159,7 +152,7 @@ extension UserTimelineViewController: ScrollViewContainer {
|
|||
// MARK: - LoadMoreConfigurableTableViewContainer
|
||||
extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer {
|
||||
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||
typealias LoadingState = UserTimelineViewModel.State.LoadingMore
|
||||
typealias LoadingState = UserTimelineViewModel.State.Loading
|
||||
|
||||
var loadMoreConfigurableTableView: UITableView { return tableView }
|
||||
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine }
|
||||
|
|
|
@ -40,11 +40,7 @@ extension UserTimelineViewModel.State {
|
|||
class Reloading: UserTimelineViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Fail.Type:
|
||||
return true
|
||||
case is Idle.Type:
|
||||
return true
|
||||
case is NoMore.Type:
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
@ -57,69 +53,38 @@ extension UserTimelineViewModel.State {
|
|||
|
||||
// reset
|
||||
viewModel.statusFetchedResultsController.statusIDs.value = []
|
||||
|
||||
guard let userID = viewModel.userID.value, !userID.isEmpty else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
let domain = activeMastodonAuthenticationBox.domain
|
||||
let queryFilter = viewModel.queryFilter.value
|
||||
|
||||
viewModel.context.apiService.userTimeline(
|
||||
domain: domain,
|
||||
accountID: userID,
|
||||
maxID: nil,
|
||||
sinceID: nil,
|
||||
excludeReplies: queryFilter.excludeReplies,
|
||||
excludeReblogs: queryFilter.excludeReblogs,
|
||||
onlyMedia: queryFilter.onlyMedia,
|
||||
authorizationBox: activeMastodonAuthenticationBox
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
|
||||
} receiveValue: { response in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
var hasNewStatusesAppend = false
|
||||
var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
|
||||
for status in response.value {
|
||||
guard !statusIDs.contains(status.id) else { continue }
|
||||
statusIDs.append(status.id)
|
||||
hasNewStatusesAppend = true
|
||||
}
|
||||
|
||||
if hasNewStatusesAppend {
|
||||
stateMachine.enter(Idle.self)
|
||||
} else {
|
||||
stateMachine.enter(NoMore.self)
|
||||
}
|
||||
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: UserTimelineViewModel.State {
|
||||
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Reloading.Type, is LoadingMore.Type:
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let _ = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Idle: UserTimelineViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Reloading.Type, is LoadingMore.Type:
|
||||
case is Reloading.Type, is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
@ -127,7 +92,7 @@ extension UserTimelineViewModel.State {
|
|||
}
|
||||
}
|
||||
|
||||
class LoadingMore: UserTimelineViewModel.State {
|
||||
class Loading: UserTimelineViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Fail.Type:
|
||||
|
@ -145,10 +110,7 @@ extension UserTimelineViewModel.State {
|
|||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
guard let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last
|
||||
|
||||
guard let userID = viewModel.userID.value, !userID.isEmpty else {
|
||||
stateMachine.enter(Fail.self)
|
||||
|
@ -177,6 +139,7 @@ extension UserTimelineViewModel.State {
|
|||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
stateMachine.enter(Fail.self)
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
|
|
|
@ -12,9 +12,8 @@ import Combine
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
import AlamofireImage
|
||||
|
||||
class UserTimelineViewModel: NSObject {
|
||||
final class UserTimelineViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
|
@ -37,7 +36,7 @@ class UserTimelineViewModel: NSObject {
|
|||
State.Reloading(viewModel: self),
|
||||
State.Fail(viewModel: self),
|
||||
State.Idle(viewModel: self),
|
||||
State.LoadingMore(viewModel: self),
|
||||
State.Loading(viewModel: self),
|
||||
State.NoMore(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(State.Initial.self)
|
||||
|
@ -54,7 +53,7 @@ class UserTimelineViewModel: NSObject {
|
|||
self.domain = CurrentValueSubject(domain)
|
||||
self.userID = CurrentValueSubject(userID)
|
||||
self.queryFilter = CurrentValueSubject(queryFilter)
|
||||
super.init()
|
||||
// super.init()
|
||||
|
||||
self.domain
|
||||
.assign(to: \.value, on: statusFetchedResultsController.domain)
|
||||
|
@ -105,7 +104,7 @@ class UserTimelineViewModel: NSObject {
|
|||
|
||||
if let currentState = self.stateMachine.currentState {
|
||||
switch currentState {
|
||||
case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail:
|
||||
case is State.Reloading, is State.Loading, is State.Idle, is State.Fail:
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
case is State.NoMore:
|
||||
break
|
||||
|
@ -114,8 +113,6 @@ class UserTimelineViewModel: NSObject {
|
|||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
@ -144,4 +141,3 @@ extension UserTimelineViewModel {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ import CommonOSLog
|
|||
extension APIService {
|
||||
|
||||
// make local state change only
|
||||
func like(
|
||||
func favorite(
|
||||
statusObjectID: NSManagedObjectID,
|
||||
mastodonUserObjectID: NSManagedObjectID,
|
||||
favoriteKind: Mastodon.API.Favorites.FavoriteKind
|
||||
|
@ -50,7 +50,7 @@ extension APIService {
|
|||
}
|
||||
|
||||
// send favorite request to remote
|
||||
func like(
|
||||
func favorite(
|
||||
statusID: Mastodon.Entity.Status.ID,
|
||||
favoriteKind: Mastodon.API.Favorites.FavoriteKind,
|
||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||
|
@ -128,16 +128,20 @@ extension APIService {
|
|||
}
|
||||
|
||||
extension APIService {
|
||||
func likeList(
|
||||
func favoritedStatuses(
|
||||
limit: Int = onceRequestStatusMaxCount,
|
||||
userID: String,
|
||||
maxID: String? = nil,
|
||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
|
||||
|
||||
let requestMastodonUserID = mastodonAuthenticationBox.userID
|
||||
let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID)
|
||||
return Mastodon.API.Favorites.favoritedStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query)
|
||||
let query = Mastodon.API.Favorites.FavoriteStatusesQuery(limit: limit, minID: nil, maxID: maxID)
|
||||
return Mastodon.API.Favorites.favoritedStatus(
|
||||
domain: mastodonAuthenticationBox.domain,
|
||||
session: session,
|
||||
authorization: mastodonAuthenticationBox.userAuthorization,
|
||||
query: query
|
||||
)
|
||||
.map { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
||||
let log = OSLog.api
|
||||
|
||||
|
|
|
@ -234,8 +234,8 @@ extension APIService.Persist {
|
|||
let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in
|
||||
return next.statusProcessType == .create ? result + 1 : result
|
||||
})
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge)
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: status: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge)
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
@ -14,78 +14,6 @@ extension Mastodon.API.Favorites {
|
|||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites")
|
||||
}
|
||||
|
||||
static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL {
|
||||
let pathComponent = "statuses/" + statusID + "/favourited_by"
|
||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
|
||||
}
|
||||
|
||||
static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL {
|
||||
var actionString: String
|
||||
switch favoriteKind {
|
||||
case .create:
|
||||
actionString = "/favourite"
|
||||
case .destroy:
|
||||
actionString = "/unfavourite"
|
||||
}
|
||||
let pathComponent = "statuses/" + statusID + actionString
|
||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
|
||||
}
|
||||
|
||||
/// Favourite / Undo Favourite
|
||||
///
|
||||
/// Add a status to your favourites list / Remove a status from your favourites list
|
||||
///
|
||||
/// - Since: 0.0.0
|
||||
/// - Version: 3.3.0
|
||||
/// # Last Update
|
||||
/// 2021/3/3
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/statuses/)
|
||||
/// - Parameters:
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - statusID: Mastodon status id
|
||||
/// - session: `URLSession`
|
||||
/// - authorization: User token
|
||||
/// - Returns: `AnyPublisher` contains `Server` nested in the response
|
||||
public static func favorites(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, favoriteKind: FavoriteKind) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
|
||||
let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind)
|
||||
var request = Mastodon.API.post(url: url, query: nil, authorization: authorization)
|
||||
request.httpMethod = "POST"
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Favourited by
|
||||
///
|
||||
/// View who favourited a given status.
|
||||
///
|
||||
/// - Since: 0.0.0
|
||||
/// - Version: 3.3.0
|
||||
/// # Last Update
|
||||
/// 2021/3/3
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/statuses/)
|
||||
/// - Parameters:
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - statusID: Mastodon status id
|
||||
/// - session: `URLSession`
|
||||
/// - authorization: User token
|
||||
/// - Returns: `AnyPublisher` contains `Server` nested in the response
|
||||
public static func favoriteBy(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
|
||||
let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID)
|
||||
let request = Mastodon.API.get(url: url, query: nil, authorization: authorization)
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Favourited statuses
|
||||
///
|
||||
/// Using this endpoint to view the favourited list for user
|
||||
|
@ -101,7 +29,12 @@ extension Mastodon.API.Favorites {
|
|||
/// - session: `URLSession`
|
||||
/// - authorization: User token
|
||||
/// - Returns: `AnyPublisher` contains `Server` nested in the response
|
||||
public static func favoritedStatus(domain: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, query: Mastodon.API.Favorites.ListQuery) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
|
||||
public static func favoritedStatus(
|
||||
domain: String,
|
||||
session: URLSession,
|
||||
authorization: Mastodon.API.OAuth.Authorization,
|
||||
query: Mastodon.API.Favorites.FavoriteStatusesQuery
|
||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
|
||||
let url = favoritesStatusesEndpointURL(domain: domain)
|
||||
let request = Mastodon.API.get(url: url, query: query, authorization: authorization)
|
||||
return session.dataTaskPublisher(for: request)
|
||||
|
@ -112,16 +45,7 @@ extension Mastodon.API.Favorites {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Mastodon.API.Favorites {
|
||||
|
||||
public enum FavoriteKind {
|
||||
case create
|
||||
case destroy
|
||||
}
|
||||
|
||||
public struct ListQuery: GetQuery, PagedQueryType {
|
||||
public struct FavoriteStatusesQuery: GetQuery,TimelineQueryType {
|
||||
|
||||
public var limit: Int?
|
||||
public var minID: String?
|
||||
|
@ -155,3 +79,99 @@ extension Mastodon.API.Favorites {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension Mastodon.API.Favorites {
|
||||
|
||||
static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL {
|
||||
var actionString: String
|
||||
switch favoriteKind {
|
||||
case .create:
|
||||
actionString = "/favourite"
|
||||
case .destroy:
|
||||
actionString = "/unfavourite"
|
||||
}
|
||||
let pathComponent = "statuses/" + statusID + actionString
|
||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
|
||||
}
|
||||
|
||||
/// Favourite / Undo Favourite
|
||||
///
|
||||
/// Add a status to your favourites list / Remove a status from your favourites list
|
||||
///
|
||||
/// - Since: 0.0.0
|
||||
/// - Version: 3.3.0
|
||||
/// # Last Update
|
||||
/// 2021/3/3
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/statuses/)
|
||||
/// - Parameters:
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - statusID: Mastodon status id
|
||||
/// - session: `URLSession`
|
||||
/// - authorization: User token
|
||||
/// - Returns: `AnyPublisher` contains `Server` nested in the response
|
||||
public static func favorites(
|
||||
domain: String,
|
||||
statusID: String,
|
||||
session: URLSession,
|
||||
authorization: Mastodon.API.OAuth.Authorization,
|
||||
favoriteKind: FavoriteKind
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
|
||||
let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind)
|
||||
var request = Mastodon.API.post(url: url, query: nil, authorization: authorization)
|
||||
request.httpMethod = "POST"
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public enum FavoriteKind {
|
||||
case create
|
||||
case destroy
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Mastodon.API.Favorites {
|
||||
|
||||
static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL {
|
||||
let pathComponent = "statuses/" + statusID + "/favourited_by"
|
||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
|
||||
}
|
||||
|
||||
/// Favourited by
|
||||
///
|
||||
/// View who favourited a given status.
|
||||
///
|
||||
/// - Since: 0.0.0
|
||||
/// - Version: 3.3.0
|
||||
/// # Last Update
|
||||
/// 2021/3/3
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/statuses/)
|
||||
/// - Parameters:
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - statusID: Mastodon status id
|
||||
/// - session: `URLSession`
|
||||
/// - authorization: User token
|
||||
/// - Returns: `AnyPublisher` contains `Server` nested in the response
|
||||
public static func favoriteBy(
|
||||
domain: String,
|
||||
statusID: String,
|
||||
session: URLSession,
|
||||
authorization: Mastodon.API.OAuth.Authorization
|
||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
|
||||
let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID)
|
||||
let request = Mastodon.API.get(url: url, query: nil, authorization: authorization)
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue