feat: add following list
This commit is contained in:
parent
0d39d061a1
commit
8ebb2e5347
|
@ -296,6 +296,11 @@
|
|||
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; };
|
||||
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; };
|
||||
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* StripProgressView.swift */; };
|
||||
DB5B7295273112B100081888 /* FollowingListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B7294273112B100081888 /* FollowingListViewController.swift */; };
|
||||
DB5B7298273112C800081888 /* FollowingListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B7297273112C800081888 /* FollowingListViewModel.swift */; };
|
||||
DB5B729A2731137900081888 /* FollowingListViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B72992731137900081888 /* FollowingListViewController+Provider.swift */; };
|
||||
DB5B729C273113C200081888 /* FollowingListViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */; };
|
||||
DB5B729E273113F300081888 /* FollowingListViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B729D273113F300081888 /* FollowingListViewModel+State.swift */; };
|
||||
DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */; };
|
||||
DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */; };
|
||||
DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */; };
|
||||
|
@ -1110,6 +1115,11 @@
|
|||
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; };
|
||||
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = "<group>"; };
|
||||
DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = "<group>"; };
|
||||
DB5B7294273112B100081888 /* FollowingListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingListViewController.swift; sourceTree = "<group>"; };
|
||||
DB5B7297273112C800081888 /* FollowingListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingListViewModel.swift; sourceTree = "<group>"; };
|
||||
DB5B72992731137900081888 /* FollowingListViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewController+Provider.swift"; sourceTree = "<group>"; };
|
||||
DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DB5B729D273113F300081888 /* FollowingListViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewController.swift; sourceTree = "<group>"; };
|
||||
DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewPagingViewController.swift; sourceTree = "<group>"; };
|
||||
DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerAnimatedTransitioning.swift; sourceTree = "<group>"; };
|
||||
|
@ -2453,6 +2463,18 @@
|
|||
path = View;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB5B7296273112B400081888 /* Following */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB5B7294273112B100081888 /* FollowingListViewController.swift */,
|
||||
DB5B72992731137900081888 /* FollowingListViewController+Provider.swift */,
|
||||
DB5B7297273112C800081888 /* FollowingListViewModel.swift */,
|
||||
DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */,
|
||||
DB5B729D273113F300081888 /* FollowingListViewModel+State.swift */,
|
||||
);
|
||||
path = Following;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB6180DE263919350018D199 /* MediaPreview */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2902,6 +2924,7 @@
|
|||
DBB5253B2611ECF5002F1F29 /* Timeline */,
|
||||
DBE3CDF1261C6B3100430CC6 /* Favorite */,
|
||||
DB6B74F0272FB55400C70B6E /* Follower */,
|
||||
DB5B7296273112B400081888 /* Following */,
|
||||
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */,
|
||||
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */,
|
||||
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */,
|
||||
|
@ -3918,6 +3941,7 @@
|
|||
DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */,
|
||||
5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */,
|
||||
DB1D843426579931000346B3 /* TableViewControllerNavigateable.swift in Sources */,
|
||||
DB5B729A2731137900081888 /* FollowingListViewController+Provider.swift in Sources */,
|
||||
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
|
||||
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
|
||||
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
|
||||
|
@ -3929,6 +3953,7 @@
|
|||
DB03A793272A7E5700EE37C5 /* SidebarListHeaderView.swift in Sources */,
|
||||
DB4FFC2B269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift in Sources */,
|
||||
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
|
||||
DB5B7298273112C800081888 /* FollowingListViewModel.swift in Sources */,
|
||||
2D7631B325C159F700929FB9 /* Item.swift in Sources */,
|
||||
5DF1054125F886D400D6C0D4 /* VideoPlaybackService.swift in Sources */,
|
||||
DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */,
|
||||
|
@ -4065,6 +4090,7 @@
|
|||
DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */,
|
||||
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
|
||||
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||
DB5B7295273112B100081888 /* FollowingListViewController.swift in Sources */,
|
||||
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
|
||||
DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */,
|
||||
DB4F097F26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift in Sources */,
|
||||
|
@ -4075,8 +4101,10 @@
|
|||
DB73BF47271199CA00781945 /* Instance.swift in Sources */,
|
||||
DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */,
|
||||
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
|
||||
DB5B729C273113C200081888 /* FollowingListViewModel+Diffable.swift in Sources */,
|
||||
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
|
||||
DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */,
|
||||
DB5B729E273113F300081888 /* FollowingListViewModel+State.swift in Sources */,
|
||||
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
|
||||
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
|
||||
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
|
||||
|
|
|
@ -7,12 +7,12 @@
|
|||
<key>AppShared.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>42</integer>
|
||||
<integer>36</integer>
|
||||
</dict>
|
||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>43</integer>
|
||||
<integer>35</integer>
|
||||
</dict>
|
||||
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -97,7 +97,7 @@
|
|||
<key>MastodonIntent.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>44</integer>
|
||||
<integer>38</integer>
|
||||
</dict>
|
||||
<key>MastodonIntents.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -117,7 +117,7 @@
|
|||
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>41</integer>
|
||||
<integer>37</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -179,6 +179,7 @@ extension SceneCoordinator {
|
|||
case profile(viewModel: ProfileViewModel)
|
||||
case favorite(viewModel: FavoriteViewModel)
|
||||
case follower(viewModel: FollowerListViewModel)
|
||||
case following(viewModel: FollowingListViewModel)
|
||||
|
||||
// setting
|
||||
case settings(viewModel: SettingsViewModel)
|
||||
|
@ -429,6 +430,10 @@ private extension SceneCoordinator {
|
|||
let _viewController = FollowerListViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .following(let viewModel):
|
||||
let _viewController = FollowingListViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .suggestionAccount(let viewModel):
|
||||
let _viewController = SuggestionAccountViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
|
|
|
@ -10,6 +10,7 @@ import CoreData
|
|||
|
||||
enum UserItem: Hashable {
|
||||
case follower(objectID: NSManagedObjectID)
|
||||
case following(objectID: NSManagedObjectID)
|
||||
case bottomLoader
|
||||
case bottomHeader(text: String)
|
||||
}
|
||||
|
|
|
@ -30,7 +30,8 @@ extension UserSection {
|
|||
] tableView, indexPath, item -> UITableViewCell? in
|
||||
guard let dependency = dependency else { return UITableViewCell() }
|
||||
switch item {
|
||||
case .follower(let objectID):
|
||||
case .follower(let objectID),
|
||||
.following(let objectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell
|
||||
managedObjectContext.performAndWait {
|
||||
let user = managedObjectContext.object(with: objectID) as! MastodonUser
|
||||
|
|
|
@ -37,7 +37,8 @@ extension FollowerListViewController: UserProvider {
|
|||
let managedObjectContext = self.viewModel.userFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||
|
||||
switch item {
|
||||
case .follower(let objectID):
|
||||
case .follower(let objectID),
|
||||
.following(let objectID):
|
||||
managedObjectContext.perform {
|
||||
let user = managedObjectContext.object(with: objectID) as? MastodonUser
|
||||
promise(.success(user))
|
||||
|
|
|
@ -7,11 +7,10 @@
|
|||
|
||||
import os.log
|
||||
import UIKit
|
||||
import AVKit
|
||||
import GameplayKit
|
||||
import Combine
|
||||
|
||||
final class FollowerListViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
final class FollowerListViewController: UIViewController, NeedsDependency {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
|
@ -19,9 +18,7 @@ final class FollowerListViewController: UIViewController, NeedsDependency, Media
|
|||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var viewModel: FollowerListViewModel!
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self))
|
||||
|
|
|
@ -18,16 +18,19 @@ extension FollowerListViewModel {
|
|||
managedObjectContext: userFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||
)
|
||||
|
||||
// workaround to append loader wrong animation issue
|
||||
// set empty section to make update animation top-to-bottom style
|
||||
var snapshot = NSDiffableDataSourceSnapshot<UserSection, UserItem>()
|
||||
snapshot.appendSections([.main])
|
||||
diffableDataSource?.apply(snapshot)
|
||||
|
||||
// workaround to append loader wrong animation issue
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
diffableDataSource?.apply(snapshot)
|
||||
if #available(iOS 15.0, *) {
|
||||
diffableDataSource?.applySnapshotUsingReloadData(snapshot, completion: nil)
|
||||
} else {
|
||||
// Fallback on earlier versions
|
||||
diffableDataSource?.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
userFetchedResultsController.objectIDs.removeDuplicates()
|
||||
userFetchedResultsController.objectIDs
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] objectIDs in
|
||||
guard let self = self else { return }
|
||||
|
@ -45,7 +48,8 @@ extension FollowerListViewModel {
|
|||
case is State.Idle, is State.Loading, is State.Fail:
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
case is State.NoMore:
|
||||
break
|
||||
let text = "Followers from other servers are not displayed."
|
||||
snapshot.appendItems([.bottomHeader(text: text)], toSection: .main)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
@ -179,18 +179,6 @@ extension FollowerListViewModel.State {
|
|||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let _ = stateMachine else { return }
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
var snapshot = diffableDataSource.snapshot()
|
||||
snapshot.deleteItems([.bottomLoader])
|
||||
let header = UserItem.bottomHeader(text: "Followers from other servers are not displayed")
|
||||
snapshot.appendItems([header], toSection: .main)
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
//
|
||||
// FollowingListViewController+Provider.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-11-2.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
|
||||
extension FollowingListViewController: UserProvider {
|
||||
|
||||
func mastodonUser() -> Future<MastodonUser?, Never> {
|
||||
Future { promise in
|
||||
promise(.success(nil))
|
||||
}
|
||||
}
|
||||
|
||||
func mastodonUser(for cell: UITableViewCell?) -> Future<MastodonUser?, Never> {
|
||||
Future { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
guard let cell = cell,
|
||||
let indexPath = self.tableView.indexPath(for: cell),
|
||||
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
|
||||
let managedObjectContext = self.viewModel.userFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||
|
||||
switch item {
|
||||
case .follower(let objectID),
|
||||
.following(let objectID):
|
||||
managedObjectContext.perform {
|
||||
let user = managedObjectContext.object(with: objectID) as? MastodonUser
|
||||
promise(.success(user))
|
||||
}
|
||||
case .bottomLoader, .bottomHeader:
|
||||
promise(.success(nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
//
|
||||
// FollowingListViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-11-2.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import GameplayKit
|
||||
import Combine
|
||||
|
||||
final class FollowingListViewController: UIViewController, NeedsDependency {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var viewModel: FollowingListViewModel!
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self))
|
||||
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||
tableView.register(TimelineFooterTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineFooterTableViewCell.self))
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.separatorStyle = .none
|
||||
tableView.backgroundColor = .clear
|
||||
return tableView
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FollowingListViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.view.backgroundColor = theme.secondarySystemBackgroundColor
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
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
|
||||
)
|
||||
// TODO: add UserTableViewCellDelegate
|
||||
|
||||
// trigger user timeline loading
|
||||
Publishers.CombineLatest(
|
||||
viewModel.domain.removeDuplicates().eraseToAnyPublisher(),
|
||||
viewModel.userID.removeDuplicates().eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.viewModel.stateMachine.enter(FollowingListViewModel.State.Reloading.self)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - LoadMoreConfigurableTableViewContainer
|
||||
extension FollowingListViewController: LoadMoreConfigurableTableViewContainer {
|
||||
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||
typealias LoadingState = FollowingListViewModel.State.Loading
|
||||
var loadMoreConfigurableTableView: UITableView { tableView }
|
||||
var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.stateMachine }
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
extension FollowingListViewController {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
handleScrollViewDidScroll(scrollView)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
extension FollowingListViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
handleTableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UserTableViewCellDelegate
|
||||
extension FollowingListViewController: UserTableViewCellDelegate { }
|
|
@ -0,0 +1,61 @@
|
|||
//
|
||||
// FollowingListViewModel+Diffable.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-11-2.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension FollowingListViewModel {
|
||||
func setupDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency
|
||||
) {
|
||||
diffableDataSource = UserSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: dependency,
|
||||
managedObjectContext: userFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||
)
|
||||
|
||||
// workaround to append loader wrong animation issue
|
||||
// set empty section to make update animation top-to-bottom style
|
||||
var snapshot = NSDiffableDataSourceSnapshot<UserSection, UserItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
if #available(iOS 15.0, *) {
|
||||
diffableDataSource?.applySnapshotUsingReloadData(snapshot, completion: nil)
|
||||
} else {
|
||||
// Fallback on earlier versions
|
||||
diffableDataSource?.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
userFetchedResultsController.objectIDs
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] objectIDs in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<UserSection, UserItem>()
|
||||
snapshot.appendSections([.main])
|
||||
let items: [UserItem] = objectIDs.map {
|
||||
UserItem.following(objectID: $0)
|
||||
}
|
||||
snapshot.appendItems(items, toSection: .main)
|
||||
|
||||
if let currentState = self.stateMachine.currentState {
|
||||
switch currentState {
|
||||
case is State.Idle, is State.Loading, is State.Fail:
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
case is State.NoMore:
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
diffableDataSource.apply(snapshot)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,196 @@
|
|||
//
|
||||
// FollowingListViewModel+State.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-11-2.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
|
||||
extension FollowingListViewModel {
|
||||
class State: GKState {
|
||||
weak var viewModel: FollowingListViewModel?
|
||||
|
||||
init(viewModel: FollowingListViewModel) {
|
||||
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 FollowingListViewModel.State {
|
||||
class Initial: FollowingListViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
guard let viewModel = viewModel else { return false }
|
||||
switch stateClass {
|
||||
case is Reloading.Type:
|
||||
return viewModel.userID.value != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Reloading: FollowingListViewModel.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.userFetchedResultsController.userIDs.value = []
|
||||
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: FollowingListViewModel.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: FollowingListViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Reloading.Type, is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: FollowingListViewModel.State {
|
||||
|
||||
var maxID: String?
|
||||
|
||||
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)
|
||||
|
||||
if previousState is Reloading {
|
||||
maxID = nil
|
||||
}
|
||||
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
viewModel.context.apiService.followers(
|
||||
userID: userID,
|
||||
maxID: maxID,
|
||||
authorizationBox: 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 hasNewAppend = false
|
||||
var userIDs = viewModel.userFetchedResultsController.userIDs.value
|
||||
for user in response.value {
|
||||
guard !userIDs.contains(user.id) else { continue }
|
||||
userIDs.append(user.id)
|
||||
hasNewAppend = true
|
||||
}
|
||||
|
||||
let maxID = response.link?.maxID
|
||||
|
||||
if hasNewAppend, maxID != nil {
|
||||
stateMachine.enter(Idle.self)
|
||||
} else {
|
||||
stateMachine.enter(NoMore.self)
|
||||
}
|
||||
self.maxID = maxID
|
||||
viewModel.userFetchedResultsController.userIDs.value = userIDs
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
} // end func didEnter
|
||||
}
|
||||
|
||||
class NoMore: FollowingListViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Reloading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let _ = stateMachine else { return }
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
var snapshot = diffableDataSource.snapshot()
|
||||
snapshot.deleteItems([.bottomLoader])
|
||||
let header = UserItem.bottomHeader(text: "Followers from other servers are not displayed")
|
||||
snapshot.appendItems([header], toSection: .main)
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
//
|
||||
// FollowingListViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-11-2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
|
||||
final class FollowingListViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let domain: CurrentValueSubject<String?, Never>
|
||||
let userID: CurrentValueSubject<String?, Never>
|
||||
let userFetchedResultsController: UserFetchedResultsController
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<UserSection, UserItem>?
|
||||
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, domain: String?, userID: String?) {
|
||||
self.context = context
|
||||
self.userFetchedResultsController = UserFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: domain,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
self.domain = CurrentValueSubject(domain)
|
||||
self.userID = CurrentValueSubject(userID)
|
||||
// super.init()
|
||||
|
||||
}
|
||||
}
|
|
@ -1001,8 +1001,19 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
|
|||
transition: .show
|
||||
)
|
||||
case .following:
|
||||
// TODO:
|
||||
break
|
||||
guard let domain = viewModel.domain.value,
|
||||
let userID = viewModel.userID.value
|
||||
else { return }
|
||||
let followingListViewModel = FollowingListViewModel(
|
||||
context: context,
|
||||
domain: domain,
|
||||
userID: userID
|
||||
)
|
||||
coordinator.present(
|
||||
scene: .following(viewModel: followingListViewModel),
|
||||
from: self,
|
||||
transition: .show
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue