feat: add follower list for user

This commit is contained in:
CMK 2021-11-01 19:54:07 +08:00
parent d66dfccad0
commit f0a570ea0c
21 changed files with 1028 additions and 42 deletions

View File

@ -333,6 +333,17 @@
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; };
DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; };
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; };
DB6B74EF272FB55000C70B6E /* FollowerListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */; };
DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74F1272FB67600C70B6E /* FollowerListViewModel.swift */; };
DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74F3272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift */; };
DB6B74F6272FBCDB00C70B6E /* FollowerListViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74F5272FBCDB00C70B6E /* FollowerListViewModel+State.swift */; };
DB6B74F8272FBFB100C70B6E /* FollowerListViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74F7272FBFB100C70B6E /* FollowerListViewController+Provider.swift */; };
DB6B74FA272FC2B500C70B6E /* APIService+Follower.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74F9272FC2B500C70B6E /* APIService+Follower.swift */; };
DB6B74FC272FF55800C70B6E /* UserSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74FB272FF55800C70B6E /* UserSection.swift */; };
DB6B74FE272FF59000C70B6E /* UserItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74FD272FF59000C70B6E /* UserItem.swift */; };
DB6B7500272FF73800C70B6E /* UserTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */; };
DB6B75022730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B75012730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift */; };
DB6B750427300B4000C70B6E /* TimelineFooterTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */; };
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; };
DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; };
DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */; };
@ -1132,6 +1143,17 @@
DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = "<group>"; };
DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = "<group>"; };
DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerListViewController.swift; sourceTree = "<group>"; };
DB6B74F1272FB67600C70B6E /* FollowerListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerListViewModel.swift; sourceTree = "<group>"; };
DB6B74F3272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewModel+Diffable.swift"; sourceTree = "<group>"; };
DB6B74F5272FBCDB00C70B6E /* FollowerListViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewModel+State.swift"; sourceTree = "<group>"; };
DB6B74F7272FBFB100C70B6E /* FollowerListViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewController+Provider.swift"; sourceTree = "<group>"; };
DB6B74F9272FC2B500C70B6E /* APIService+Follower.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follower.swift"; sourceTree = "<group>"; };
DB6B74FB272FF55800C70B6E /* UserSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSection.swift; sourceTree = "<group>"; };
DB6B74FD272FF59000C70B6E /* UserItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserItem.swift; sourceTree = "<group>"; };
DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTableViewCell.swift; sourceTree = "<group>"; };
DB6B75012730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserProviderFacade+UITableViewDelegate.swift"; sourceTree = "<group>"; };
DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFooterTableViewCell.swift; sourceTree = "<group>"; };
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; };
DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = "<group>"; };
DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = "<group>"; };
@ -1866,6 +1888,7 @@
2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */,
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */,
DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */,
DB6B74FB272FF55800C70B6E /* UserSection.swift */,
);
path = Section;
sourceTree = "<group>";
@ -1909,8 +1932,10 @@
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */,
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */,
DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */,
DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */,
DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */,
DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */,
);
path = TableviewCell;
sourceTree = "<group>";
@ -1919,6 +1944,7 @@
isa = PBXGroup;
children = (
2D7631B225C159F700929FB9 /* Item.swift */,
DB6B74FD272FF59000C70B6E /* UserItem.swift */,
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */,
2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */,
@ -2289,6 +2315,7 @@
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */,
DB6B74F9272FC2B500C70B6E /* APIService+Follower.swift */,
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */,
5B24BBE1262DB19100A9381B /* APIService+Report.swift */,
DBAE3F932616E28B004B8251 /* APIService+Follow.swift */,
@ -2508,6 +2535,18 @@
path = NavigationController;
sourceTree = "<group>";
};
DB6B74F0272FB55400C70B6E /* Follower */ = {
isa = PBXGroup;
children = (
DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */,
DB6B74F7272FBFB100C70B6E /* FollowerListViewController+Provider.swift */,
DB6B74F1272FB67600C70B6E /* FollowerListViewModel.swift */,
DB6B74F3272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift */,
DB6B74F5272FBCDB00C70B6E /* FollowerListViewModel+State.swift */,
);
path = Follower;
sourceTree = "<group>";
};
DB6C8C0525F0921200AAA452 /* MastodonSDK */ = {
isa = PBXGroup;
children = (
@ -2862,6 +2901,7 @@
DBB525462611ED57002F1F29 /* Header */,
DBB5253B2611ECF5002F1F29 /* Timeline */,
DBE3CDF1261C6B3100430CC6 /* Favorite */,
DB6B74F0272FB55400C70B6E /* Follower */,
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */,
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */,
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */,
@ -2982,6 +3022,7 @@
children = (
DBAE3F672615DD60004B8251 /* UserProvider.swift */,
DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */,
DB6B75012730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift */,
);
path = UserProvider;
sourceTree = "<group>";
@ -3965,6 +4006,7 @@
DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */,
0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */,
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
DB6B74FE272FF59000C70B6E /* UserItem.swift in Sources */,
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */,
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */,
@ -4094,6 +4136,9 @@
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */,
5D0393902612D259007FE196 /* WebViewController.swift in Sources */,
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
DB6B74FA272FC2B500C70B6E /* APIService+Follower.swift in Sources */,
DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */,
DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */,
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */,
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */,
DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */,
@ -4106,6 +4151,7 @@
DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */,
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */,
DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */,
DB6B750427300B4000C70B6E /* TimelineFooterTableViewCell.swift in Sources */,
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */,
DB852D1F26FB037800FC9D81 /* SidebarViewModel.swift in Sources */,
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */,
@ -4122,6 +4168,7 @@
DBF156E42702DB3F00EC00B7 /* HandleTapAction.swift in Sources */,
DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
DB6B74F6272FBCDB00C70B6E /* FollowerListViewModel+State.swift in Sources */,
DB4F096C269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift in Sources */,
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
DB9F58EF26EF491E00E7BBE9 /* AccountListViewModel.swift in Sources */,
@ -4139,6 +4186,7 @@
DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */,
DB73BF4B27140C0800781945 /* UITableViewDiffableDataSource.swift in Sources */,
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */,
DB6B74EF272FB55000C70B6E /* FollowerListViewController.swift in Sources */,
5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */,
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */,
DBBC24C426A544B900398BB9 /* Theme.swift in Sources */,
@ -4180,6 +4228,7 @@
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */,
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
DB6B75022730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift in Sources */,
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */,
2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */,
DB71C7CB271D5A0300BE3819 /* LineChartView.swift in Sources */,
@ -4245,6 +4294,7 @@
DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */,
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
DB6B74FC272FF55800C70B6E /* UserSection.swift in Sources */,
2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */,
DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */,
5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */,
@ -4261,6 +4311,7 @@
0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */,
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
DB6B7500272FF73800C70B6E /* UserTableViewCell.swift in Sources */,
DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */,
DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */,
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
@ -4291,6 +4342,7 @@
DB647C5726F1E97300F7F82C /* MainTabBarController+Wizard.swift in Sources */,
DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */,
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
DB6B74F8272FBFB100C70B6E /* FollowerListViewController+Provider.swift in Sources */,
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
DB4932B326F2054200EF46D4 /* CircleAvatarButton.swift in Sources */,
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,

View File

@ -7,12 +7,12 @@
<key>AppShared.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>43</integer>
<integer>35</integer>
</dict>
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>42</integer>
<integer>39</integer>
</dict>
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
<dict>
@ -97,7 +97,7 @@
<key>MastodonIntent.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>41</integer>
<integer>36</integer>
</dict>
<key>MastodonIntents.xcscheme_^#shared#^_</key>
<dict>
@ -117,7 +117,7 @@
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>44</integer>
<integer>37</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -178,6 +178,7 @@ extension SceneCoordinator {
case accountList
case profile(viewModel: ProfileViewModel)
case favorite(viewModel: FavoriteViewModel)
case follower(viewModel: FollowerListViewModel)
// setting
case settings(viewModel: SettingsViewModel)
@ -424,6 +425,10 @@ private extension SceneCoordinator {
let _viewController = FavoriteViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .follower(let viewModel):
let _viewController = FollowerListViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .suggestionAccount(let viewModel):
let _viewController = SuggestionAccountViewController()
_viewController.viewModel = viewModel

View File

@ -0,0 +1,15 @@
//
// UserItem.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-11-1.
//
import Foundation
import CoreData
enum UserItem: Hashable {
case follower(objectID: NSManagedObjectID)
case bottomLoader
case bottomHeader(text: String)
}

View File

@ -0,0 +1,63 @@
//
// UserSection.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-11-1.
//
import os.log
import UIKit
import CoreData
import CoreDataStack
import MetaTextKit
import MastodonMeta
enum UserSection: Hashable {
case main
}
extension UserSection {
static let logger = Logger(subsystem: "StatusSection", category: "logic")
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext
) -> UITableViewDiffableDataSource<UserSection, UserItem> {
UITableViewDiffableDataSource(tableView: tableView) { [
weak dependency
] tableView, indexPath, item -> UITableViewCell? in
guard let dependency = dependency else { return UITableViewCell() }
switch item {
case .follower(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
configure(cell: cell, user: user)
}
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
return cell
case .bottomHeader(let text):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineFooterTableViewCell.self), for: indexPath) as! TimelineFooterTableViewCell
cell.messageLabel.text = text
return cell
} // end switch
} // end UITableViewDiffableDataSource
} // end static func tableViewDiffableDataSource { }
}
extension UserSection {
static func configure(
cell: UserTableViewCell,
user: MastodonUser
) {
cell.configure(user: user)
}
}

View File

@ -0,0 +1,22 @@
//
// UserProviderFacade+UITableViewDelegate.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-11-1.
//
import Combine
import CoreDataStack
import MastodonSDK
import os.log
import UIKit
extension UserTableViewCellDelegate where Self: UserProvider {
func handleTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let cell = tableView.cellForRow(at: indexPath) else { return }
let user = self.mastodonUser(for: cell)
UserProviderFacade.coordinatorToUserProfileScene(provider: self, user: user)
}
}

View File

@ -440,3 +440,25 @@ extension UserProviderFacade {
return activityViewController
}
}
extension UserProviderFacade {
static func coordinatorToUserProfileScene(provider: UserProvider, user: Future<MastodonUser?, Never>) {
user
.sink { [weak provider] mastodonUser in
guard let provider = provider else { return }
guard let mastodonUser = mastodonUser else { return }
let profileViewModel = CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser)
DispatchQueue.main.async {
if provider.navigationController == nil {
let from = provider.presentingViewController ?? provider
provider.dismiss(animated: true) {
provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: from, transition: .show)
}
} else {
provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show)
}
}
}
.store(in: &provider.disposeBag)
}
}

View File

@ -0,0 +1,50 @@
//
// FollowerListViewController+Provider.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-11-1.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
extension FollowerListViewController: 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):
managedObjectContext.perform {
let user = managedObjectContext.object(with: objectID) as? MastodonUser
promise(.success(user))
}
case .bottomLoader, .bottomHeader:
promise(.success(nil))
}
}
}
}

View File

@ -0,0 +1,111 @@
//
// FollowerListViewController.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-11-1.
//
import os.log
import UIKit
import AVKit
import GameplayKit
import Combine
final class FollowerListViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
var disposeBag = Set<AnyCancellable>()
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
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))
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 FollowerListViewController {
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(FollowerListViewModel.State.Reloading.self)
}
.store(in: &disposeBag)
}
}
// MARK: - LoadMoreConfigurableTableViewContainer
extension FollowerListViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = FollowerListViewModel.State.Loading
var loadMoreConfigurableTableView: UITableView { tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.stateMachine }
}
// MARK: - UIScrollViewDelegate
extension FollowerListViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
handleScrollViewDidScroll(scrollView)
}
}
// MARK: - UITableViewDelegate
extension FollowerListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
handleTableView(tableView, didSelectRowAt: indexPath)
}
}
// MARK: - UserTableViewCellDelegate
extension FollowerListViewController: UserTableViewCellDelegate { }

View File

@ -0,0 +1,58 @@
//
// FollowerListViewModel+Diffable.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-11-1.
//
import UIKit
extension FollowerListViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency
) {
diffableDataSource = UserSection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency,
managedObjectContext: userFetchedResultsController.fetchedResultsController.managedObjectContext
)
// 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)
userFetchedResultsController.objectIDs.removeDuplicates()
.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.follower(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 @@
//
// FollowerListViewModel+State.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-11-1.
//
import os.log
import Foundation
import GameplayKit
import MastodonSDK
extension FollowerListViewModel {
class State: GKState {
weak var viewModel: FollowerListViewModel?
init(viewModel: FollowerListViewModel) {
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 FollowerListViewModel.State {
class Initial: FollowerListViewModel.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: FollowerListViewModel.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: FollowerListViewModel.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: FollowerListViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type, is Loading.Type:
return true
default:
return false
}
}
}
class Loading: FollowerListViewModel.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: FollowerListViewModel.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 @@
//
// FollowerListViewModel.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-11-1.
//
import Foundation
import Combine
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import MastodonSDK
final class FollowerListViewModel {
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

@ -17,9 +17,7 @@ protocol ProfileHeaderViewDelegate: AnyObject {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed followingDashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed followersDashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter)
}
final class ProfileHeaderView: UIView {
@ -443,6 +441,7 @@ extension ProfileHeaderView {
bringSubviewToFront(bannerContainerView)
bringSubviewToFront(nameContainerStackView)
statusDashboardView.delegate = self
bioMetaText.textView.delegate = self
bioMetaText.textView.linkDelegate = self
@ -549,19 +548,9 @@ extension ProfileHeaderView: MetaTextViewDelegate {
// MARK: - ProfileStatusDashboardViewDelegate
extension ProfileHeaderView: ProfileStatusDashboardViewDelegate {
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, postDashboardMeterViewDidPressed: dashboardMeterView)
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter) {
delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, dashboardMeterViewDidPressed: dashboardMeterView, meter: meter)
}
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followingDashboardMeterViewDidPressed: dashboardMeterView)
}
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followersDashboardMeterViewDidPressed: dashboardMeterView)
}
}
// MARK: - AvatarConfigurableView

View File

@ -9,9 +9,7 @@ import os.log
import UIKit
protocol ProfileStatusDashboardViewDelegate: AnyObject {
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter)
}
final class ProfileStatusDashboardView: UIView {
@ -34,6 +32,14 @@ final class ProfileStatusDashboardView: UIView {
}
extension ProfileStatusDashboardView {
enum Meter: Hashable {
case post
case following
case follower
}
}
extension ProfileStatusDashboardView {
private func _init() {
let containerStackView = UIStackView()
@ -67,7 +73,6 @@ extension ProfileStatusDashboardView {
tapGestureRecognizer.addTarget(self, action: #selector(ProfileStatusDashboardView.tapGestureRecognizerHandler(_:)))
meterView.addGestureRecognizer(tapGestureRecognizer)
}
}
}
@ -78,12 +83,15 @@ extension ProfileStatusDashboardView {
assertionFailure()
return
}
if sourceView === postDashboardMeterView {
delegate?.profileStatusDashboardView(self, postDashboardMeterViewDidPressed: sourceView)
} else if sourceView === followingDashboardMeterView {
delegate?.profileStatusDashboardView(self, followingDashboardMeterViewDidPressed: sourceView)
} else if sourceView === followersDashboardMeterView {
delegate?.profileStatusDashboardView(self, followersDashboardMeterViewDidPressed: sourceView)
switch sourceView {
case postDashboardMeterView:
delegate?.profileStatusDashboardView(self, dashboardMeterViewDidPressed: sourceView, meter: .post)
case followingDashboardMeterView:
delegate?.profileStatusDashboardView(self, dashboardMeterViewDidPressed: sourceView, meter: .following)
case followersDashboardMeterView:
delegate?.profileStatusDashboardView(self, dashboardMeterViewDidPressed: sourceView, meter: .follower)
default:
assertionFailure()
}
}
}

View File

@ -769,7 +769,6 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate {
// MARK: - ProfileHeaderViewDelegate
extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) {
guard let mastodonUser = viewModel.mastodonUser.value else { return }
guard let avatar = imageView.image else { return }
@ -982,15 +981,29 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
}
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed followingDashboardMeterView: ProfileStatusDashboardMeterView) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed followersDashboardMeterView: ProfileStatusDashboardMeterView) {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter) {
switch meter {
case .post:
// do nothing
break
case .follower:
guard let domain = viewModel.domain.value,
let userID = viewModel.userID.value
else { return }
let followerListViewModel = FollowerListViewModel(
context: context,
domain: domain,
userID: userID
)
coordinator.present(
scene: .follower(viewModel: followerListViewModel),
from: self,
transition: .show
)
case .following:
// TODO:
break
}
}
}

View File

@ -12,7 +12,6 @@ import Combine
import CoreDataStack
import GameplayKit
// TODO: adopt MediaPreviewableViewController
final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }

View File

@ -0,0 +1,51 @@
//
// TimelineFooterTableViewCell.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-11-1.
//
import UIKit
final class TimelineFooterTableViewCell: UITableViewCell {
let messageLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 17)
label.textAlignment = .center
label.textColor = Asset.Colors.Label.secondary.color
label.text = "info"
label.numberOfLines = 0
return label
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension TimelineFooterTableViewCell {
private func _init() {
selectionStyle = .none
backgroundColor = .clear
messageLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(messageLabel)
NSLayoutConstraint.activate([
messageLabel.topAnchor.constraint(equalTo: contentView.topAnchor),
messageLabel.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
messageLabel.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
messageLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
messageLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 68).priority(.required - 1), // same height to bottom loader
])
}
}

View File

@ -0,0 +1,131 @@
//
// UserTableViewCell.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-11-1.
//
import CoreData
import CoreDataStack
import MastodonSDK
import UIKit
import MetaTextKit
import MastodonMeta
import FLAnimatedImage
protocol UserTableViewCellDelegate: AnyObject { }
final class UserTableViewCell: UITableViewCell {
weak var delegate: UserTableViewCellDelegate?
let avatarImageView: AvatarImageView = {
let imageView = AvatarImageView()
imageView.tintColor = Asset.Colors.Label.primary.color
imageView.layer.cornerRadius = 4
imageView.clipsToBounds = true
return imageView
}()
let nameLabel = MetaLabel(style: .statusName)
let usernameLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.Label.secondary.color
label.font = .preferredFont(forTextStyle: .body)
return label
}()
let separatorLine = UIView.separatorLine
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension UserTableViewCell {
private func _init() {
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
containerStackView.distribution = .fill
containerStackView.spacing = 12
containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(avatarImageView)
NSLayoutConstraint.activate([
avatarImageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1),
avatarImageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1),
])
let textStackView = UIStackView()
textStackView.axis = .vertical
textStackView.distribution = .fill
textStackView.translatesAutoresizingMaskIntoConstraints = false
nameLabel.translatesAutoresizingMaskIntoConstraints = false
textStackView.addArrangedSubview(nameLabel)
usernameLabel.translatesAutoresizingMaskIntoConstraints = false
textStackView.addArrangedSubview(usernameLabel)
usernameLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
containerStackView.addArrangedSubview(textStackView)
separatorLine.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(separatorLine)
NSLayoutConstraint.activate([
separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)),
])
nameLabel.isUserInteractionEnabled = false
usernameLabel.isUserInteractionEnabled = false
avatarImageView.isUserInteractionEnabled = false
}
}
// MARK: - AvatarStackedImageView
extension UserTableViewCell: AvatarConfigurableView {
static var configurableAvatarImageSize: CGSize { CGSize(width: 42, height: 42) }
static var configurableAvatarImageCornerRadius: CGFloat { 4 }
var configurableAvatarImageView: FLAnimatedImageView? { avatarImageView }
}
extension UserTableViewCell {
func configure(user: MastodonUser) {
// avatar
configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: user.avatarImageURL()))
// name
let name = user.displayNameWithFallback
do {
let mastodonContent = MastodonContent(content: name, emojis: user.emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
nameLabel.configure(content: metaContent)
} catch {
let metaContent = PlaintextMetaContent(string: name)
nameLabel.configure(content: metaContent)
}
// username
usernameLabel.text = "@" + user.acct
}
}

View File

@ -0,0 +1,65 @@
//
// APIService+Follower.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-11-1.
//
import UIKit
import Combine
import CoreData
import CoreDataStack
import CommonOSLog
import MastodonSDK
extension APIService {
func followers(
userID: Mastodon.Entity.Account.ID,
maxID: String?,
authorizationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
let domain = authorizationBox.domain
let authorization = authorizationBox.userAuthorization
let requestMastodonUserID = authorizationBox.userID
return Mastodon.API.Account.followers(
session: session,
domain: domain,
userID: userID,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> in
let managedObjectContext = self.backgroundManagedObjectContext
return managedObjectContext.performChanges {
let requestMastodonUserRequest = MastodonUser.sortedFetchRequest
requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
requestMastodonUserRequest.fetchLimit = 1
guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return }
for entity in response.value {
_ = APIService.CoreData.createOrMergeMastodonUser(
into: managedObjectContext,
for: requestMastodonUser,
in: domain,
entity: entity,
userCache: nil,
networkDate: response.networkDate,
log: .api
)
}
}
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -12,13 +12,15 @@ import Combine
extension Mastodon.API.Account {
static func acceptFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests")
return Mastodon.API.endpointURL(domain: domain)
.appendingPathComponent("follow_requests")
.appendingPathComponent(userID)
.appendingPathComponent("authorize")
}
static func rejectFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests")
return Mastodon.API.endpointURL(domain: domain)
.appendingPathComponent("follow_requests")
.appendingPathComponent(userID)
.appendingPathComponent("reject")
}

View File

@ -0,0 +1,81 @@
//
// Mastodon+API+Account+Followers.swift
//
//
// Created by Cirno MainasuK on 2021-11-1.
//
import Foundation
import Combine
extension Mastodon.API.Account {
static func followersEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL {
return Mastodon.API.endpointURL(domain: domain)
.appendingPathComponent("accounts")
.appendingPathComponent(userID)
.appendingPathComponent("followers")
}
/// Followers
///
/// Accounts which follow the given account, if network is not hidden by the account owner.
///
/// - Since: 0.0.0
/// - Version: 3.4.1
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - userID: ID of the account in the database
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `[Account]` nested in the response
public static func followers(
session: URLSession,
domain: String,
userID: Mastodon.Entity.Account.ID,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
let request = Mastodon.API.get(
url: followersEndpointURL(domain: domain, userID: userID),
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()
}
public struct FollowerQuery: Codable, GetQuery {
public let maxID: String?
public let limit: Int? // default 40
enum CodingKeys: String, CodingKey {
case maxID = "max_id"
case limit
}
public init(
maxID: String?,
limit: Int?
) {
self.maxID = maxID
self.limit = limit
}
var queryItems: [URLQueryItem]? {
var items: [URLQueryItem] = []
maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) }
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
guard !items.isEmpty else { return nil }
return items
}
}
}