feat: add following list

This commit is contained in:
CMK 2021-11-02 14:56:42 +08:00
parent 0d39d061a1
commit 8ebb2e5347
15 changed files with 536 additions and 31 deletions

View File

@ -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 */,

View File

@ -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>

View File

@ -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

View File

@ -10,6 +10,7 @@ import CoreData
enum UserItem: Hashable {
case follower(objectID: NSManagedObjectID)
case following(objectID: NSManagedObjectID)
case bottomLoader
case bottomHeader(text: String)
}

View File

@ -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

View File

@ -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))

View File

@ -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))

View File

@ -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
}

View File

@ -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)
}
}
}
}

View File

@ -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))
}
}
}
}

View File

@ -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 { }

View File

@ -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)
}
}

View File

@ -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)
}
}
}
}

View File

@ -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()
}
}

View File

@ -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
)
}
}