diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 1bc305a6..987f7b6c 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -483,6 +483,8 @@ DBB45B5927B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */; }; DBB45B5B27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */; }; DBB45B5E27B4EB22002DC5A7 /* AdaptiveMarginStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5D27B4EB22002DC5A7 /* AdaptiveMarginStatusTableViewCell.swift */; }; + DBB45B6027B50A4F002DC5A7 /* RecommendAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */; }; + DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; @@ -1227,6 +1229,8 @@ DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewModel.swift; sourceTree = ""; }; DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionViewController.swift; sourceTree = ""; }; DBB45B5D27B4EB22002DC5A7 /* AdaptiveMarginStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveMarginStatusTableViewCell.swift; sourceTree = ""; }; + DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountItem.swift; sourceTree = ""; }; + DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountViewModel+Diffable.swift"; sourceTree = ""; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; @@ -1756,6 +1760,7 @@ children = ( 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */, 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */, + DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */, 2D4AD89A2631659400613EFC /* CollectionViewCell */, 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */, ); @@ -2012,6 +2017,7 @@ isa = PBXGroup; children = ( 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, + DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */, ); path = RecommandAccount; sourceTree = ""; @@ -3845,6 +3851,7 @@ DB03A795272A981400EE37C5 /* ContentSplitViewController.swift in Sources */, DBBC24DE26A54BCB00398BB9 /* MastodonMetricFormatter.swift in Sources */, DB06180A2785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift in Sources */, + DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */, DBB3BA2A26A81C020004F2D4 /* FLAnimatedImageView.swift in Sources */, DB6746ED278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift in Sources */, DB0618032785A7100030EE79 /* RegisterSection.swift in Sources */, @@ -4068,6 +4075,7 @@ DB697DD1278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift in Sources */, DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, + DBB45B6027B50A4F002DC5A7 /* RecommendAccountItem.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */, DB0FCB922796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift in Sources */, diff --git a/Mastodon/Diffiable/Account/SelectedAccountItem.swift b/Mastodon/Diffiable/Account/SelectedAccountItem.swift index dbfe25ce..05ecdae8 100644 --- a/Mastodon/Diffiable/Account/SelectedAccountItem.swift +++ b/Mastodon/Diffiable/Account/SelectedAccountItem.swift @@ -7,32 +7,9 @@ import CoreData import Foundation +import CoreDataStack -enum SelectedAccountItem { - case accountObjectID(accountObjectID: NSManagedObjectID) +enum SelectedAccountItem: Hashable { + case account(ManagedObjectRecord) case placeHolder(uuid: UUID) } - -extension SelectedAccountItem: Equatable { - static func == (lhs: SelectedAccountItem, rhs: SelectedAccountItem) -> Bool { - switch (lhs, rhs) { - case (.accountObjectID(let idLeft), .accountObjectID(let idRight)): - return idLeft == idRight - case (.placeHolder(let uuidLeft), .placeHolder(let uuidRight)): - return uuidLeft == uuidRight - default: - return false - } - } -} - -extension SelectedAccountItem: Hashable { - func hash(into hasher: inout Hasher) { - switch self { - case .accountObjectID(let id): - hasher.combine(id) - case .placeHolder(let id): - hasher.combine(id.uuidString) - } - } -} diff --git a/Mastodon/Diffiable/Account/SelectedAccountSection.swift b/Mastodon/Diffiable/Account/SelectedAccountSection.swift index 4f18ef87..6c02d705 100644 --- a/Mastodon/Diffiable/Account/SelectedAccountSection.swift +++ b/Mastodon/Diffiable/Account/SelectedAccountSection.swift @@ -17,15 +17,17 @@ enum SelectedAccountSection: Equatable, Hashable { extension SelectedAccountSection { static func collectionViewDiffableDataSource( - for collectionView: UICollectionView, - managedObjectContext: NSManagedObjectContext + collectionView: UICollectionView, + context: AppContext ) -> UICollectionViewDiffableDataSource { UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self), for: indexPath) as! SuggestionAccountCollectionViewCell switch item { - case .accountObjectID(let objectID): - let user = managedObjectContext.object(with: objectID) as! MastodonUser - cell.config(with: user) + case .account(let record): + context.managedObjectContext.performAndWait { + guard let user = record.object(in: context.managedObjectContext) else { return } + cell.config(with: user) + } case .placeHolder: cell.configAsPlaceHolder() } diff --git a/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift index b49e89a2..2230e04b 100644 --- a/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift @@ -20,15 +20,15 @@ final class UserFetchedResultsController: NSObject { let fetchedResultsController: NSFetchedResultsController // input - let domain = CurrentValueSubject(nil) - let userIDs = CurrentValueSubject<[Mastodon.Entity.Account.ID], Never>([]) + @Published var domain: String? = nil + @Published var userIDs: [Mastodon.Entity.Account.ID] = [] // output let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) @Published var records: [ManagedObjectRecord] = [] init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) { - self.domain.value = domain ?? "" + self.domain = domain ?? "" self.fetchedResultsController = { let fetchRequest = MastodonUser.sortedFetchRequest fetchRequest.predicate = MastodonUser.predicate(domain: domain ?? "", ids: []) @@ -54,8 +54,8 @@ final class UserFetchedResultsController: NSObject { fetchedResultsController.delegate = self Publishers.CombineLatest( - self.domain.removeDuplicates(), - self.userIDs.removeDuplicates() + self.$domain.removeDuplicates(), + self.$userIDs.removeDuplicates() ) .receive(on: DispatchQueue.main) .sink { [weak self] domain, ids in @@ -79,11 +79,11 @@ final class UserFetchedResultsController: NSObject { extension UserFetchedResultsController { public func append(userIDs: [Mastodon.Entity.Account.ID]) { - var result = self.userIDs.value + var result = self.userIDs for userID in userIDs where !result.contains(userID) { result.append(userID) } - self.userIDs.value = result + self.userIDs = result } } @@ -93,7 +93,7 @@ extension UserFetchedResultsController: NSFetchedResultsControllerDelegate { func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let indexes = userIDs.value + let indexes = userIDs let objects = fetchedResultsController.fetchedObjects ?? [] let items: [NSManagedObjectID] = objects diff --git a/Mastodon/Diffiable/RecommandAccount/RecommendAccountItem.swift b/Mastodon/Diffiable/RecommandAccount/RecommendAccountItem.swift new file mode 100644 index 00000000..998f2f3e --- /dev/null +++ b/Mastodon/Diffiable/RecommandAccount/RecommendAccountItem.swift @@ -0,0 +1,13 @@ +// +// RecommendAccountItem.swift +// Mastodon +// +// Created by MainasuK on 2022-2-10. +// + +import Foundation +import CoreDataStack + +enum RecommendAccountItem: Hashable { + case account(ManagedObjectRecord) +} diff --git a/Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift b/Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift index d4943d32..c6d4897a 100644 --- a/Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift @@ -129,22 +129,29 @@ enum RecommendAccountSection: Equatable, Hashable { // //} // -//extension RecommendAccountSection { -// -// static func tableViewDiffableDataSource( -// for tableView: UITableView, -// managedObjectContext: NSManagedObjectContext, -// viewModel: SuggestionAccountViewModel, -// delegate: SuggestionAccountTableViewCellDelegate -// ) -> UITableViewDiffableDataSource { -// UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel, weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in -// guard let viewModel = viewModel else { return nil } -// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell -// let user = managedObjectContext.object(with: objectID) as! MastodonUser -// let isSelected = viewModel.selectedAccounts.value.contains(objectID) -// cell.delegate = delegate -// cell.config(with: user, isSelected: isSelected) -// return cell -// } -// } -//} +extension RecommendAccountSection { + + struct Configuration { + weak var suggestionAccountTableViewCellDelegate: SuggestionAccountTableViewCellDelegate? + } + + static func tableViewDiffableDataSource( + tableView: UITableView, + context: AppContext, + configuration: Configuration + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell + switch item { + case .account(let record): + context.managedObjectContext.performAndWait { + guard let user = record.object(in: context.managedObjectContext) else { return } + cell.config(with: user) + } + } + cell.delegate = configuration.suggestionAccountTableViewCellDelegate + return cell + } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index bbeebb72..b57c4325 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -74,6 +74,15 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showThreadAction(action) }, + UIAction(title: "Account Recommend", image: UIImage(systemName: "human"), attributes: []) { [weak self] action in + guard let self = self else { return } + let suggestionAccountViewModel = SuggestionAccountViewModel(context: self.context) + self.coordinator.present( + scene: .suggestionAccount(viewModel: suggestionAccountViewModel), + from: self, + transition: .modal(animated: true, completion: nil) + ) + }, UIAction(title: "Store Rating", image: UIImage(systemName: "star.fill"), attributes: []) { [weak self] action in guard let self = self else { return } guard let windowScene = self.view.window?.windowScene else { return } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 74a42cde..5985194e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -383,10 +383,13 @@ extension HomeTimelineViewController { extension HomeTimelineViewController { @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) { - // TODO: -// let viewModel = SuggestionAccountViewModel(context: context) -// viewModel.delegate = self.viewModel -// coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) + let suggestionAccountViewModel = SuggestionAccountViewModel(context: context) + suggestionAccountViewModel.delegate = viewModel + coordinator.present( + scene: .suggestionAccount(viewModel: suggestionAccountViewModel), + from: self, + transition: .modal(animated: true, completion: nil) + ) } @objc private func manuallySearchButtonPressed(_ sender: UIButton) { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 262c995e..488964ed 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -119,8 +119,6 @@ final class HomeTimelineViewModel: NSObject { } -//extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { } - extension HomeTimelineViewModel { struct ScrollPositionRecord { let item: StatusItem @@ -197,3 +195,9 @@ extension HomeTimelineViewModel { } } + +// MARK: - SuggestionAccountViewModelDelegate +extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { + +} + diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift index c6af90d5..a2958de3 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift @@ -72,7 +72,7 @@ extension FollowerListViewModel.State { guard let viewModel = viewModel, let stateMachine = stateMachine else { return } // reset - viewModel.userFetchedResultsController.userIDs.value = [] + viewModel.userFetchedResultsController.userIDs = [] stateMachine.enter(Loading.self) } @@ -158,7 +158,7 @@ extension FollowerListViewModel.State { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch \(response.value.count) followers") var hasNewAppend = false - var userIDs = viewModel.userFetchedResultsController.userIDs.value + var userIDs = viewModel.userFetchedResultsController.userIDs for user in response.value { guard !userIDs.contains(user.id) else { continue } userIDs.append(user.id) @@ -174,7 +174,7 @@ extension FollowerListViewModel.State { } self.maxID = maxID - viewModel.userFetchedResultsController.userIDs.value = userIDs + viewModel.userFetchedResultsController.userIDs = userIDs } catch { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch follower fail: \(error.localizedDescription)") diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift b/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift index 560c62d0..c01a9c8c 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift @@ -72,7 +72,7 @@ extension FollowingListViewModel.State { guard let viewModel = viewModel, let stateMachine = stateMachine else { return } // reset - viewModel.userFetchedResultsController.userIDs.value = [] + viewModel.userFetchedResultsController.userIDs = [] stateMachine.enter(Loading.self) } @@ -159,7 +159,7 @@ extension FollowingListViewModel.State { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch \(response.value.count)") var hasNewAppend = false - var userIDs = viewModel.userFetchedResultsController.userIDs.value + var userIDs = viewModel.userFetchedResultsController.userIDs for user in response.value { guard !userIDs.contains(user.id) else { continue } userIDs.append(user.id) @@ -174,7 +174,7 @@ extension FollowingListViewModel.State { await enter(state: NoMore.self) } self.maxID = maxID - viewModel.userFetchedResultsController.userIDs.value = userIDs + viewModel.userFetchedResultsController.userIDs = userIDs } catch { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch following fail: \(error.localizedDescription)") diff --git a/Mastodon/Scene/Profile/MeProfileViewModel.swift b/Mastodon/Scene/Profile/MeProfileViewModel.swift index d1c0cb49..cee6d5e4 100644 --- a/Mastodon/Scene/Profile/MeProfileViewModel.swift +++ b/Mastodon/Scene/Profile/MeProfileViewModel.swift @@ -20,12 +20,12 @@ final class MeProfileViewModel: ProfileViewModel { optionalMastodonUser: context.authenticationService.activeMastodonAuthentication.value?.user ) - self.currentMastodonUser - .sink { [weak self] currentMastodonUser in - os_log("%{public}s[%{public}ld], %{public}s: current active mastodon user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "") + $me + .sink { [weak self] me in + os_log("%{public}s[%{public}ld], %{public}s: current active mastodon user: %s", ((#file as NSString).lastPathComponent), #line, #function, me?.username ?? "") guard let self = self else { return } - self.mastodonUser.value = currentMastodonUser + self.user = me } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index c1d07459..8fe8ad2b 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -577,7 +577,7 @@ extension ProfileViewController { private func bindProfileRelationship() { Publishers.CombineLatest( - viewModel.mastodonUser, + viewModel.$user, viewModel.relationshipActionOptionSet ) .asyncMap { [weak self] user, relationshipSet -> UIMenu? in @@ -725,7 +725,7 @@ extension ProfileViewController { @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - guard let user = viewModel.mastodonUser.value else { return } + guard let user = viewModel.user else { return } let record: ManagedObjectRecord = .init(objectID: user.objectID) Task { let _activityViewController = try await DataSourceFacade.createActivityViewController( @@ -754,7 +754,7 @@ extension ProfileViewController { @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let mastodonUser = viewModel.mastodonUser.value else { return } + guard let mastodonUser = viewModel.user else { return } let composeViewModel = ComposeViewModel( context: context, composeKind: .mention(user: .init(objectID: mastodonUser.objectID)), @@ -849,7 +849,7 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate { // MARK: - ProfileHeaderViewDelegate extension ProfileViewController: ProfileHeaderViewDelegate { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) { - guard let user = viewModel.mastodonUser.value else { return } + guard let user = viewModel.user else { return } let record: ManagedObjectRecord = .init(objectID: user.objectID) Task { @@ -865,7 +865,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { - guard let user = viewModel.mastodonUser.value else { return } + guard let user = viewModel.user else { return } let record: ManagedObjectRecord = .init(objectID: user.objectID) Task { @@ -956,7 +956,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { case .none: break case .follow, .request, .pending, .following: - guard let user = viewModel.mastodonUser.value else { return } + guard let user = viewModel.user else { return } let reocrd = ManagedObjectRecord(objectID: user.objectID) guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } Task { @@ -968,7 +968,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } case .muting: guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let user = viewModel.mastodonUser.value else { return } + guard let user = viewModel.user else { return } let name = user.displayNameWithFallback let alertController = UIAlertController( @@ -993,7 +993,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { present(alertController, animated: true, completion: nil) case .blocking: guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let user = viewModel.mastodonUser.value else { return } + guard let user = viewModel.user else { return } let name = user.displayNameWithFallback let alertController = UIAlertController( @@ -1077,7 +1077,7 @@ extension ProfileViewController: ProfileAboutViewControllerDelegate { extension ProfileViewController: MastodonMenuDelegate { func menuAction(_ action: MastodonMenu.Action) { guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let user = viewModel.mastodonUser.value else { return } + guard let user = viewModel.user else { return } let userRecord: ManagedObjectRecord = .init(objectID: user.objectID) diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index b3973472..588651c8 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -28,8 +28,8 @@ class ProfileViewModel: NSObject { // input let context: AppContext - let mastodonUser: CurrentValueSubject - let currentMastodonUser = CurrentValueSubject(nil) + @Published var me: MastodonUser? + @Published var user: MastodonUser? let viewDidAppear = PassthroughSubject() // output @@ -73,7 +73,7 @@ class ProfileViewModel: NSObject { init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { self.context = context - self.mastodonUser = CurrentValueSubject(mastodonUser) + self.user = mastodonUser self.domain = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value?.domain) self.userID = CurrentValueSubject(mastodonUser?.id) self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) @@ -98,21 +98,21 @@ class ProfileViewModel: NSObject { .store(in: &disposeBag) // bind active authentication - context.authenticationService.activeMastodonAuthentication - .sink { [weak self] activeMastodonAuthentication in + context.authenticationService.activeMastodonAuthenticationBox + .sink { [weak self] authenticationBox in guard let self = self else { return } - guard let activeMastodonAuthentication = activeMastodonAuthentication else { + guard let authenticationBox = authenticationBox else { self.domain.value = nil - self.currentMastodonUser.value = nil + self.me = nil return } - self.domain.value = activeMastodonAuthentication.domain - self.currentMastodonUser.value = activeMastodonAuthentication.user + self.domain.value = authenticationBox.domain + self.me = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user } .store(in: &disposeBag) // query relationship - let userRecord = self.mastodonUser.map { user -> ManagedObjectRecord? in + let userRecord = $user.map { user -> ManagedObjectRecord? in user.flatMap { ManagedObjectRecord(objectID: $0.objectID) } } let pendingRetryPublisher = CurrentValueSubject(1) @@ -176,18 +176,18 @@ class ProfileViewModel: NSObject { extension ProfileViewModel { private func setup() { Publishers.CombineLatest( - mastodonUser.eraseToAnyPublisher(), - currentMastodonUser.eraseToAnyPublisher() + $user, + $me ) .receive(on: DispatchQueue.main) - .sink { [weak self] mastodonUser, currentMastodonUser in + .sink { [weak self] user, me in guard let self = self else { return } // Update view model attribute - self.update(mastodonUser: mastodonUser) - self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + self.update(mastodonUser: user) + self.update(mastodonUser: user, currentMastodonUser: me) // Setup observer for user - if let mastodonUser = mastodonUser { + if let mastodonUser = user { // setup observer self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser) .sink { completion in @@ -203,7 +203,7 @@ extension ProfileViewModel { switch changeType { case .update: self.update(mastodonUser: mastodonUser) - self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + self.update(mastodonUser: mastodonUser, currentMastodonUser: me) case .delete: // TODO: break @@ -215,7 +215,7 @@ extension ProfileViewModel { } // Setup observer for user - if let currentMastodonUser = currentMastodonUser { + if let currentMastodonUser = me { // setup observer self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser) .sink { completion in @@ -230,7 +230,7 @@ extension ProfileViewModel { guard let changeType = change.changeType else { return } switch changeType { case .update: - self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + self.update(mastodonUser: user, currentMastodonUser: currentMastodonUser) case .delete: // TODO: break @@ -347,13 +347,14 @@ extension ProfileViewModel { // fetch profile info before edit func fetchEditProfileInfo() -> AnyPublisher, Error> { - guard let currentMastodonUser = currentMastodonUser.value, - let mastodonAuthentication = currentMastodonUser.mastodonAuthentication else { + guard let me = me, + let mastodonAuthentication = me.mastodonAuthentication + else { return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() } let authorization = Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.userAccessToken) - return context.apiService.accountVerifyCredentials(domain: currentMastodonUser.domain, authorization: authorization) + return context.apiService.accountVerifyCredentials(domain: me.domain, authorization: authorization) } private func updateRelationship( diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift index 8e31050d..472af089 100644 --- a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift +++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift @@ -48,7 +48,7 @@ final class RemoteProfileViewModel: ProfileViewModel { assertionFailure() return } - self.mastodonUser.value = mastodonUser + self.user = mastodonUser } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift index 598f5df4..5e143a33 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift @@ -153,7 +153,7 @@ extension SearchDetailViewController { assertionFailure() break case .people: - viewController.viewModel.userFetchedResultsController.userIDs.value = allSearchScopeViewController.viewModel.userFetchedResultsController.userIDs.value + viewController.viewModel.userFetchedResultsController.userIDs = allSearchScopeViewController.viewModel.userFetchedResultsController.userIDs case .hashtags: viewController.viewModel.hashtags = allSearchScopeViewController.viewModel.hashtags case .posts: diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift index 1c0e5aa0..b763547b 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift @@ -155,7 +155,7 @@ extension SearchResultViewModel.State { // reset data source when the search is refresh if offset == nil { - viewModel.userFetchedResultsController.userIDs.value = [] + viewModel.userFetchedResultsController.userIDs = [] viewModel.statusFetchedResultsController.statusIDs.value = [] viewModel.hashtags = [] } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift index c5656ac0..7641bc0c 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift @@ -63,7 +63,7 @@ final class SearchResultViewModel { context.authenticationService.activeMastodonAuthenticationBox .map { $0?.domain } - .assign(to: \.value, on: userFetchedResultsController.domain) + .assign(to: \.domain, on: userFetchedResultsController) .store(in: &disposeBag) context.authenticationService.activeMastodonAuthenticationBox diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift index 13c2efb3..20449fdf 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -15,12 +15,43 @@ import MastodonAsset import MastodonLocalization class SuggestionAccountViewController: UIViewController, NeedsDependency { + + static let collectionViewHeight: CGFloat = 24 + 64 + 24 + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() - var viewModel: SuggestionAccountViewModel! + + private static func createCollectionViewLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(64), heightDimension: .absolute(64)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 24, leading: 0, bottom: 24, trailing: 0) + section.orthogonalScrollingBehavior = .continuous + section.contentInsetsReference = .readableContent + section.interGroupSpacing = 16 + + return UICollectionViewCompositionalLayout(section: section) + } + + let collectionView: UICollectionView = { + let collectionViewLayout = SuggestionAccountViewController.createCollectionViewLayout() + let view = ControlContainableCollectionView( + frame: .zero, + collectionViewLayout: collectionViewLayout + ) + view.register(SuggestionAccountCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self)) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + view.layer.masksToBounds = false + return view + }() let tableView: UITableView = { let tableView = ControlContainableTableView() @@ -32,34 +63,6 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency { return tableView }() - lazy var tableHeader: UIView = { - let view = UIView() - view.backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor - view.frame = CGRect(origin: .zero, size: CGSize(width: tableView.frame.width, height: 156)) - return view - }() - - let followExplainLabel: UILabel = { - let label = UILabel() - label.text = L10n.Scene.SuggestionAccount.followExplain - label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) - label.numberOfLines = 0 - return label - }() - - let selectedCollectionView: UICollectionView = { - let flowLayout = UICollectionViewFlowLayout() - flowLayout.scrollDirection = .horizontal - let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) - view.register(SuggestionAccountCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self)) - view.backgroundColor = .clear - view.showsHorizontalScrollIndicator = false - view.showsVerticalScrollIndicator = false - view.layer.masksToBounds = false - return view - }() - deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", (#file as NSString).lastPathComponent, #line, #function) } @@ -68,164 +71,135 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency { extension SuggestionAccountViewController { override func viewDidLoad() { super.viewDidLoad() - - fatalError() -// setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) -// ThemeService.shared.currentTheme -// .receive(on: RunLoop.main) -// .sink { [weak self] theme in -// guard let self = self else { return } -// self.setupBackgroundColor(theme: theme) -// } -// .store(in: &disposeBag) -// -// title = L10n.Scene.SuggestionAccount.title -// navigationItem.rightBarButtonItem -// = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, -// target: self, -// action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:))) -// -// tableView.delegate = self -// 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), -// ]) -// viewModel.diffableDataSource = RecommendAccountSection.tableViewDiffableDataSource( -// for: tableView, -// managedObjectContext: context.managedObjectContext, -// viewModel: viewModel, -// delegate: self -// ) -// -// viewModel.collectionDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource(for: selectedCollectionView, managedObjectContext: context.managedObjectContext) -// -// viewModel.accounts -// .receive(on: DispatchQueue.main) -// .sink { [weak self] accounts in -// guard let self = self else { return } -// self.setupHeader(accounts: accounts) -// } -// .store(in: &disposeBag) + setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) + ThemeService.shared.currentTheme + .receive(on: RunLoop.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.setupBackgroundColor(theme: theme) + } + .store(in: &disposeBag) + + title = L10n.Scene.SuggestionAccount.title + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: UIBarButtonItem.SystemItem.done, + target: self, + action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:)) + ) + + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.heightAnchor.constraint(equalToConstant: SuggestionAccountViewController.collectionViewHeight), + ]) + defer { view.bringSubviewToFront(collectionView) } + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: collectionView.bottomAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + collectionView.delegate = self + viewModel.setupDiffableDataSource( + collectionView: collectionView + ) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + tableView: tableView, + suggestionAccountTableViewCellDelegate: self + ) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) - viewModel.checkAccountsFollowState() - } - - override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - let avatarImageViewHeight: Double = 56 - let avatarImageViewCount = Int(floor((Double(view.frame.width) - 20) / (avatarImageViewHeight + 15))) - viewModel.headerPlaceholderCount.value = avatarImageViewCount - } - - func setupHeader(accounts: [NSManagedObjectID]) { - if accounts.isEmpty { - return - } - followExplainLabel.translatesAutoresizingMaskIntoConstraints = false - tableHeader.addSubview(followExplainLabel) - NSLayoutConstraint.activate([ - followExplainLabel.topAnchor.constraint(equalTo: tableHeader.topAnchor, constant: 20), - followExplainLabel.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), - tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20), - ]) - - selectedCollectionView.translatesAutoresizingMaskIntoConstraints = false - tableHeader.addSubview(selectedCollectionView) - NSLayoutConstraint.activate([ - selectedCollectionView.frameLayoutGuide.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20), - selectedCollectionView.frameLayoutGuide.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), - selectedCollectionView.frameLayoutGuide.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor), - selectedCollectionView.frameLayoutGuide.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor), - ]) - selectedCollectionView.delegate = self - - tableView.tableHeaderView = tableHeader } private func setupBackgroundColor(theme: Theme) { view.backgroundColor = theme.systemBackgroundColor - tableHeader.backgroundColor = theme.systemGroupedBackgroundColor + collectionView.backgroundColor = theme.systemGroupedBackgroundColor } } -extension SuggestionAccountViewController: UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { - 15 - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - CGSize(width: 56, height: 56) - } +// MARK: - UICollectionViewDelegateFlowLayout +extension SuggestionAccountViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.collectionDiffableDataSource else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - switch item { - case .accountObjectID(let accountObjectID): - let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser - let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) - DispatchQueue.main.async { - self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) - } - default: - break - } +// guard let diffableDataSource = viewModel.collectionDiffableDataSource else { return } +// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } +// switch item { +// case .accountObjectID(let accountObjectID): +// let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser +// let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) +// DispatchQueue.main.async { +// self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) +// } +// default: +// break +// } } } +// MARK: - UITableViewDelegate extension SuggestionAccountViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let objectID = diffableDataSource.itemIdentifier(for: indexPath) else { return } - let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser - let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) - DispatchQueue.main.async { - self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) + guard let tableViewDiffableDataSource = viewModel.tableViewDiffableDataSource else { return } + guard let item = tableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .account(let record): + guard let account = record.object(in: context.managedObjectContext) else { return } + let cachedProfileViewModel = CachedProfileViewModel(context: context, mastodonUser: account) + coordinator.present( + scene: .profile(viewModel: cachedProfileViewModel), + from: self, + transition: .show + ) } } } extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) { - let selected = !viewModel.selectedAccounts.value.contains(objectID) - cell.startAnimating() - viewModel.followAction(objectID: objectID)? - .sink(receiveCompletion: { [weak self] completion in - guard let self = self else { return } - cell.stopAnimating() - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - var selectedAccounts = self.viewModel.selectedAccounts.value - if selected { - selectedAccounts.append(objectID) - } else { - selectedAccounts.removeAll { $0 == objectID } - } - cell.button.isSelected = selected - self.viewModel.selectedAccounts.value = selectedAccounts - } - }, receiveValue: { _ in - }) - .store(in: &disposeBag) +// let selected = !viewModel.selectedAccounts.value.contains(objectID) +// cell.startAnimating() +// viewModel.followAction(objectID: objectID)? +// .sink(receiveCompletion: { [weak self] completion in +// guard let self = self else { return } +// cell.stopAnimating() +// switch completion { +// case .failure(let error): +// os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) +// case .finished: +// var selectedAccounts = self.viewModel.selectedAccounts.value +// if selected { +// selectedAccounts.append(objectID) +// } else { +// selectedAccounts.removeAll { $0 == objectID } +// } +// cell.button.isSelected = selected +// self.viewModel.selectedAccounts.value = selectedAccounts +// } +// }, receiveValue: { _ in +// }) +// .store(in: &disposeBag) } } extension SuggestionAccountViewController { @objc func doneButtonDidClick(_ sender: UIButton) { dismiss(animated: true, completion: nil) - if viewModel.selectedAccounts.value.count > 0 { - viewModel.delegate?.homeTimelineNeedRefresh.send() - } +// if viewModel.selectedAccounts.value.count > 0 { +// viewModel.delegate?.homeTimelineNeedRefresh.send() +// } } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel+Diffable.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel+Diffable.swift new file mode 100644 index 00000000..49f38c88 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel+Diffable.swift @@ -0,0 +1,74 @@ +// +// SuggestionAccountViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-2-10. +// + +import UIKit + +extension SuggestionAccountViewModel { + + func setupDiffableDataSource( + tableView: UITableView, + suggestionAccountTableViewCellDelegate: SuggestionAccountTableViewCellDelegate + ) { + tableViewDiffableDataSource = RecommendAccountSection.tableViewDiffableDataSource( + tableView: tableView, + context: context, + configuration: RecommendAccountSection.Configuration( + suggestionAccountTableViewCellDelegate: suggestionAccountTableViewCellDelegate + ) + ) + + userFetchedResultsController.$records + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let tableViewDiffableDataSource = self.tableViewDiffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let items: [RecommendAccountItem] = records.map { RecommendAccountItem.account($0) } + snapshot.appendItems(items, toSection: .main) + + if #available(iOS 15.0, *) { + tableViewDiffableDataSource.applySnapshotUsingReloadData(snapshot, completion: nil) + } else { + // Fallback on earlier versions + tableViewDiffableDataSource.applySnapshot(snapshot, animated: false, completion: nil) + } + } + .store(in: &disposeBag) + } + + func setupDiffableDataSource( + collectionView: UICollectionView + ) { + collectionViewDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource( + collectionView: collectionView, + context: context + ) + + userFetchedResultsController.$records + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let collectionViewDiffableDataSource = self.collectionViewDiffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let items: [SelectedAccountItem] = records.map { SelectedAccountItem.account($0) } + snapshot.appendItems(items, toSection: .main) + + if #available(iOS 15.0, *) { + collectionViewDiffableDataSource.applySnapshotUsingReloadData(snapshot, completion: nil) + } else { + // Fallback on earlier versions + collectionViewDiffableDataSource.applySnapshot(snapshot, animated: false, completion: nil) + } + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index a6786adf..5a79f14d 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -20,177 +20,175 @@ protocol SuggestionAccountViewModelDelegate: AnyObject { final class SuggestionAccountViewModel: NSObject { var disposeBag = Set() + weak var delegate: SuggestionAccountViewModelDelegate? + // input let context: AppContext + let userFetchedResultsController: UserFetchedResultsController - let currentMastodonUser = CurrentValueSubject(nil) - weak var delegate: SuggestionAccountViewModelDelegate? - // output - let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) - var selectedAccounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + var viewWillAppear = PassthroughSubject() + // output + var collectionViewDiffableDataSource: UICollectionViewDiffableDataSource? + var tableViewDiffableDataSource: UITableViewDiffableDataSource? + + @Published var selectedAccounts: [ManagedObjectRecord] = [] var headerPlaceholderCount = CurrentValueSubject(nil) var suggestionAccountsFallback = PassthroughSubject() - var viewWillAppear = PassthroughSubject() - var diffableDataSource: UITableViewDiffableDataSource? { - didSet(value) { - if !accounts.value.isEmpty { - applyTableViewDataSource(accounts: accounts.value) - } - } - } - - var collectionDiffableDataSource: UICollectionViewDiffableDataSource? - - init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { + init( + context: AppContext + ) { self.context = context - + self.userFetchedResultsController = UserFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalTweetPredicate: nil + ) super.init() - Publishers.CombineLatest( - self.accounts, - self.selectedAccounts - ) - .receive(on: RunLoop.main) - .sink { [weak self] accounts,selectedAccounts in - self?.applyTableViewDataSource(accounts: accounts) - self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts) - } - .store(in: &disposeBag) +// Publishers.CombineLatest( +// $accounts, +// $selectedAccounts +// ) +// .receive(on: RunLoop.main) +// .sink { [weak self] accounts,selectedAccounts in +// self?.applyTableViewDataSource(accounts: accounts) +// self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts) +// } +// .store(in: &disposeBag) - Publishers.CombineLatest( - self.selectedAccounts, - self.headerPlaceholderCount - ) - .receive(on: RunLoop.main) - .sink { [weak self] selectedAccount,count in - self?.applySelectedCollectionViewDataSource(accounts: selectedAccount) - } - .store(in: &disposeBag) +// Publishers.CombineLatest( +// self.selectedAccounts, +// self.headerPlaceholderCount +// ) +// .receive(on: RunLoop.main) +// .sink { [weak self] selectedAccount,count in +// self?.applySelectedCollectionViewDataSource(accounts: selectedAccount) +// } +// .store(in: &disposeBag) +// +// viewWillAppear +// .sink { [weak self] _ in +// self?.checkAccountsFollowState() +// } +// .store(in: &disposeBag) +// +// context.authenticationService.activeMastodonAuthentication +// .sink { [weak self] activeMastodonAuthentication in +// guard let self = self else { return } +// guard let activeMastodonAuthentication = activeMastodonAuthentication else { +// self.currentMastodonUser.value = nil +// return +// } +// self.currentMastodonUser.value = activeMastodonAuthentication.user +// } +// .store(in: &disposeBag) - viewWillAppear - .sink { [weak self] _ in - self?.checkAccountsFollowState() - } - .store(in: &disposeBag) - - if let accounts = accounts { - self.accounts.value = accounts - } - - context.authenticationService.activeMastodonAuthentication - .sink { [weak self] activeMastodonAuthentication in - guard let self = self else { return } - guard let activeMastodonAuthentication = activeMastodonAuthentication else { - self.currentMastodonUser.value = nil - return - } - self.currentMastodonUser.value = activeMastodonAuthentication.user - } - .store(in: &disposeBag) - - if accounts == nil || (accounts ?? []).isEmpty { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - - context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { [weak self] completion in - switch completion { - case .failure(let error): - if let apiError = error as? Mastodon.API.Error { - if apiError.httpResponseStatus == .notFound { - self?.suggestionAccountsFallback.send() - } - } - os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - } receiveValue: { [weak self] response in - let ids = response.value.map(\.account.id) - self?.receiveAccounts(ids: ids) - } - .store(in: &disposeBag) - - suggestionAccountsFallback - .sink(receiveValue: { [weak self] _ in - self?.requestSuggestionAccount() - }) - .store(in: &disposeBag) - } - } - - func requestSuggestionAccount() { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { completion in - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccount failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - } receiveValue: { [weak self] response in - let ids = response.value.map(\.id) - self?.receiveAccounts(ids: ids) - } - .store(in: &disposeBag) - } - - func applyTableViewDataSource(accounts: [NSManagedObjectID]) { - assert(Thread.isMainThread) - guard let dataSource = diffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(accounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - - func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) { - assert(Thread.isMainThread) - guard let count = headerPlaceholderCount.value else { return } - guard let dataSource = collectionDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - let placeholderCount = count - accounts.count - let accountItems = accounts.map { SelectedAccountItem.accountObjectID(accountObjectID: $0) } - snapshot.appendItems(accountItems, toSection: .main) - - if placeholderCount > 0 { - for _ in 0 ..< placeholderCount { - snapshot.appendItems([SelectedAccountItem.placeHolder(uuid: UUID())], toSection: .main) - } - } - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - - func receiveAccounts(ids: [String]) { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let userFetchRequest = MastodonUser.sortedFetchRequest - userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - let mastodonUsers: [MastodonUser]? = { - let userFetchRequest = MastodonUser.sortedFetchRequest - userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - userFetchRequest.returnsObjectsAsFaults = false + userFetchedResultsController.domain = authenticationBox.domain + + Task { + var userIDs: [MastodonUser.ID] = [] do { - return try self.context.managedObjectContext.fetch(userFetchRequest) + let response = try await context.apiService.suggestionAccountV2( + query: nil, + authenticationBox: authenticationBox + ) + userIDs = response.value.map { $0.account.id } + } catch let error as Mastodon.API.Error where error.httpResponseStatus == .notFound { + let response = try await context.apiService.suggestionAccount( + query: nil, + authenticationBox: authenticationBox + ) + userIDs = response.value.map { $0.id } } catch { - assertionFailure(error.localizedDescription) - return nil + os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) } - }() - if let users = mastodonUsers { - let sortedUsers = users.sorted { (user1, user2) -> Bool in - (ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0) - } - accounts.value = sortedUsers.map(\.objectID) + + guard !userIDs.isEmpty else { return } + userFetchedResultsController.userIDs = userIDs } + +// .sink { [weak self] completion in +// switch completion { +// case .failure(let error): +// if let apiError = error as? Mastodon.API.Error { +// if apiError.httpResponseStatus == .notFound { +// self?.suggestionAccountsFallback.send() +// } +// } +// case .finished: +// // handle isFetchingLatestTimeline in fetch controller delegate +// break +// } +// } receiveValue: { [weak self] response in +// let ids = response.value.map(\.account.id) +// self?.receiveAccounts(ids: ids) +// } +// .store(in: &disposeBag) +// +// suggestionAccountsFallback +// .sink(receiveValue: { [weak self] _ in +// self?.requestSuggestionAccount() +// }) +// .store(in: &disposeBag) } + +// func applyTableViewDataSource(accounts: [NSManagedObjectID]) { +// assert(Thread.isMainThread) +// guard let dataSource = diffableDataSource else { return } +// var snapshot = NSDiffableDataSourceSnapshot() +// snapshot.appendSections([.main]) +// snapshot.appendItems(accounts, toSection: .main) +// dataSource.apply(snapshot, animatingDifferences: false, completion: nil) +// } +// +// func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) { +// assert(Thread.isMainThread) +// guard let count = headerPlaceholderCount.value else { return } +// guard let dataSource = collectionDiffableDataSource else { return } +// var snapshot = NSDiffableDataSourceSnapshot() +// snapshot.appendSections([.main]) +// let placeholderCount = count - accounts.count +// let accountItems = accounts.map { SelectedAccountItem.accountObjectID(accountObjectID: $0) } +// snapshot.appendItems(accountItems, toSection: .main) +// +// if placeholderCount > 0 { +// for _ in 0 ..< placeholderCount { +// snapshot.appendItems([SelectedAccountItem.placeHolder(uuid: UUID())], toSection: .main) +// } +// } +// dataSource.apply(snapshot, animatingDifferences: false, completion: nil) +// } + +// func receiveAccounts(userIDs: [MastodonUser.ID]) { +// guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { +// return +// } +// let request = MastodonUser.sortedFetchRequest +// request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: userIDs) +// let mastodonUsers: [MastodonUser]? = { +// let userFetchRequest = MastodonUser.sortedFetchRequest +// userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) +// userFetchRequest.returnsObjectsAsFaults = false +// do { +// return try self.context.managedObjectContext.fetch(userFetchRequest) +// } catch { +// assertionFailure(error.localizedDescription) +// return nil +// } +// }() +// if let users = mastodonUsers { +// let sortedUsers = users.sorted { (user1, user2) -> Bool in +// (ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0) +// } +// accounts.value = sortedUsers.map(\.objectID) +// } +// } func followAction(objectID: NSManagedObjectID) -> AnyPublisher, Error>? { fatalError() @@ -203,8 +201,8 @@ final class SuggestionAccountViewModel: NSObject { // ) } - func checkAccountsFollowState() { - fatalError() +// func checkAccountsFollowState() { +// fatalError() // guard let currentMastodonUser = currentMastodonUser.value else { // return // } @@ -229,5 +227,5 @@ final class SuggestionAccountViewModel: NSObject { // }.map(\.objectID) // // selectedAccounts.value = followingUsers - } +// } } diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index 5f679a2c..5e3a2e1f 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -141,7 +141,7 @@ extension SuggestionAccountTableViewCell { ]) } - func config(with account: MastodonUser, isSelected: Bool) { + func config(with account: MastodonUser) { if let url = account.avatarImageURL() { _imageView.af.setImage( withURL: url, diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift index 7c24fdbf..cb195b60 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -14,68 +14,62 @@ import OSLog extension APIService { func suggestionAccount( - domain: String, query: Mastodon.API.Suggestions.Query?, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher, Error> { - fatalError() -// let authorization = mastodonAuthenticationBox.userAuthorization -// -// return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) -// .flatMap { response -> AnyPublisher, Error> in -// let log = OSLog.api -// return self.backgroundManagedObjectContext.performChanges { -// response.value.forEach { user in -// let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log) -// let flag = isCreated ? "+" : "-" -// os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username) -// } -// } -// .setFailureType(to: Error.self) -// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in -// switch result { -// case .success: -// return response -// case .failure(let error): -// throw error -// } -// } -// .eraseToAnyPublisher() -// } -// .eraseToAnyPublisher() + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { + + let response = try await Mastodon.API.Suggestions.get( + session: session, + domain: authenticationBox.domain, + query: query, + authorization: authenticationBox.userAuthorization + ).singleOutput() + + let managedObjectContext = backgroundManagedObjectContext + try await managedObjectContext.performChanges { + for entity in response.value { + _ = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: authenticationBox.domain, + entity: entity, + cache: nil, + networkDate: response.networkDate + ) + ) + } // end for … in + } + + return response } func suggestionAccountV2( - domain: String, query: Mastodon.API.Suggestions.Query?, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher, Error> { - fatalError() -// let authorization = mastodonAuthenticationBox.userAuthorization -// -// return Mastodon.API.V2.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) -// .flatMap { response -> AnyPublisher, Error> in -// let log = OSLog.api -// return self.backgroundManagedObjectContext.performChanges { -// response.value.forEach { suggestionAccount in -// let user = suggestionAccount.account -// let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log) -// let flag = isCreated ? "+" : "-" -// os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username) -// } -// } -// .setFailureType(to: Error.self) -// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]> in -// switch result { -// case .success: -// return response -// case .failure(let error): -// throw error -// } -// } -// .eraseToAnyPublisher() -// } -// .eraseToAnyPublisher() + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]> { + let response = try await Mastodon.API.V2.Suggestions.get( + session: session, + domain: authenticationBox.domain, + query: query, + authorization: authenticationBox.userAuthorization + ).singleOutput() + + let managedObjectContext = backgroundManagedObjectContext + try await managedObjectContext.performChanges { + for entity in response.value { + _ = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: authenticationBox.domain, + entity: entity.account, + cache: nil, + networkDate: response.networkDate + ) + ) + } // end for … in + } + + return response } }