diff --git a/Localization/app.json b/Localization/app.json index a7b98e0ee..7c72f7949 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -51,7 +51,9 @@ "preview": "Preview", "share": "Share", "share_user": "Share %s", - "open_in_safari": "Open in Safari" + "open_in_safari": "Open in Safari", + "find_people": "Find people to follow", + "manually_search": "Manually search instead" }, "status": { "user_reblogged": "%s reblogged", @@ -230,6 +232,10 @@ "Publishing": "Publishing post..." } }, + "suggestion_account": { + "title": "Find People to Follow", + "follow_explain": "When you follow someone, you’ll see their posts in your home feed." + }, "public_timeline": { "title": "Public" }, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 7b147991f..93ae2ba09 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -120,6 +120,9 @@ 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; + 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */; }; + 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */; }; + 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */; }; 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */; }; 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; }; 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; }; @@ -530,6 +533,9 @@ 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewController.swift; sourceTree = ""; }; + 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewModel.swift; sourceTree = ""; }; + 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewCell.swift; sourceTree = ""; }; 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = ""; }; 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; }; 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = ""; }; @@ -1182,6 +1188,24 @@ path = Decoration; sourceTree = ""; }; + 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */ = { + isa = PBXGroup; + children = ( + 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */, + 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */, + 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */, + ); + path = SuggestionAccount; + sourceTree = ""; + }; + 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */ = { + isa = PBXGroup; + children = ( + 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; 2DE0FAC62615F5D200CDF649 /* View */ = { isa = PBXGroup; children = ( @@ -1669,6 +1693,7 @@ 2D38F1D325CD463600561493 /* HomeTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, 0F2021F5261325ED000C64BF /* HashtagTimeline */, + 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */, DB9D6BEE25E4F5370051B173 /* Search */, 5B90C455262599800002E742 /* Settings */, DB9D6BFD25E4F57B0051B173 /* Notification */, @@ -2385,6 +2410,7 @@ DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, + 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, @@ -2425,6 +2451,7 @@ DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, + 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, @@ -2503,6 +2530,7 @@ 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, + 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c2608fe83..c0ad695f0 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -13,6 +13,7 @@ final public class SceneCoordinator { private weak var scene: UIScene! private weak var sceneDelegate: SceneDelegate! private weak var appContext: AppContext! + private weak var tabBarController: MainTabBarController! let id = UUID().uuidString @@ -61,6 +62,8 @@ extension SceneCoordinator { case profile(viewModel: ProfileViewModel) case favorite(viewModel: FavoriteViewModel) + // suggestion account + case suggestionAccount(viewModel: SuggestionAccountViewModel) // misc case safari(url: URL) case alertController(alertController: UIAlertController) @@ -93,6 +96,7 @@ extension SceneCoordinator { func setup() { let viewController = MainTabBarController(context: appContext, coordinator: self) sceneDelegate.window?.rootViewController = viewController + tabBarController = viewController } func setupOnboardingIfNeeds(animated: Bool) { @@ -187,6 +191,9 @@ extension SceneCoordinator { return viewController } + func switchToTabBar(tab: MainTabBarController.Tab) { + tabBarController.selectedIndex = tab.rawValue + } } private extension SceneCoordinator { @@ -246,6 +253,10 @@ private extension SceneCoordinator { let _viewController = FavoriteViewController() _viewController.viewModel = viewModel viewController = _viewController + case .suggestionAccount(let viewModel): + let _viewController = SuggestionAccountViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .safari(let url): guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift index 3ecd4e3b2..1732be29f 100644 --- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -29,4 +29,21 @@ extension RecommendAccountSection { return cell } } + + 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.contains(objectID) + cell.delegate = delegate + cell.config(with: user, isSelected: isSelected) + return cell + } + } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index b7bd3d0a8..aa841bbfc 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -72,6 +72,10 @@ internal enum L10n { internal static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done") /// Edit internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit") + /// Find people to follow + internal static let findPeople = L10n.tr("Localizable", "Common.Controls.Actions.FindPeople") + /// Manually search instead + internal static let manuallySearch = L10n.tr("Localizable", "Common.Controls.Actions.ManuallySearch") /// OK internal static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok") /// Open in Safari @@ -675,6 +679,12 @@ internal enum L10n { } } } + internal enum SuggestionAccount { + /// When you follow someone, you’ll see their posts in your home feed. + internal static let followExplain = L10n.tr("Localizable", "Scene.SuggestionAccount.FollowExplain") + /// Find People to Follow + internal static let title = L10n.tr("Localizable", "Scene.SuggestionAccount.Title") + } internal enum Thread { /// Post internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle") diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index b5f4dd32f..b64bfe79d 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -44,7 +44,8 @@ extension UserProviderFacade { return context.apiService.toggleFollow( for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + needFeedback: true ) } .switchToLatest() diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index cc7eef062..c90c013da 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -20,6 +20,8 @@ Please check your internet connection."; "Common.Controls.Actions.Discard" = "Discard"; "Common.Controls.Actions.Done" = "Done"; "Common.Controls.Actions.Edit" = "Edit"; +"Common.Controls.Actions.FindPeople" = "Find people to follow"; +"Common.Controls.Actions.ManuallySearch" = "Manually search instead"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; "Common.Controls.Actions.Preview" = "Preview"; @@ -220,6 +222,8 @@ any server."; "Scene.Settings.Section.Spicyzone.Signout" = "Sign Out"; "Scene.Settings.Section.Spicyzone.Title" = "The spicy zone"; "Scene.Settings.Title" = "Settings"; +"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed."; +"Scene.SuggestionAccount.Title" = "Find People to Follow"; "Scene.Thread.BackTitle" = "Post"; "Scene.Thread.Favorite.Multiple" = "%@ favorites"; "Scene.Thread.Favorite.Single" = "%@ favorite"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 53909b2df..60fa3c9c4 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -23,6 +23,15 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { var disposeBag = Set() private(set) lazy var viewModel = HomeTimelineViewModel(context: context) + lazy var emptyView: UIStackView = { + let emptyView = UIStackView() + emptyView.axis = .vertical + emptyView.distribution = .fill + emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20) + emptyView.isLayoutMarginsRelativeArrangement = true + return emptyView + }() + let titleView = HomeTimelineNavigationBarTitleView() let settingBarButtonItem: UIBarButtonItem = { @@ -142,6 +151,13 @@ extension HomeTimelineViewController { UIView.animate(withDuration: 0.5) { [weak self] in guard let self = self else { return } self.refreshControl.endRefreshing() + } completion: { [weak self] _ in + guard let self = self else { return } + if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty { + self.showEmptyView() + } else { + self.emptyView.removeFromSuperview() + } } } } @@ -217,6 +233,54 @@ extension HomeTimelineViewController { } extension HomeTimelineViewController { + func showEmptyView() { + if emptyView.superview != nil { + return + } + view.addSubview(emptyView) + emptyView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor) + ]) + + let findPeopleButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal) + button.addTarget(self, action: #selector(HomeTimelineViewController.findPeopleButtonPressed(_:)), for: .touchUpInside) + return button + }() + NSLayoutConstraint.activate([ + findPeopleButton.heightAnchor.constraint(equalToConstant: 46) + ]) + + let manuallySearchButton: HighlightDimmableButton = { + let button = HighlightDimmableButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle(L10n.Common.Controls.Actions.manuallySearch, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) + button.addTarget(self, action: #selector(HomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside) + return button + }() + + emptyView.addArrangedSubview(findPeopleButton) + emptyView.setCustomSpacing(17, after: findPeopleButton) + emptyView.addArrangedSubview(manuallySearchButton) + + } +} + +extension HomeTimelineViewController { + + @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) { + let viewModel = SuggestionAccountViewModel(context: context) + coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func manuallySearchButtonPressed(_ sender: UIButton) { + coordinator.switchToTabBar(tab: .search) + } @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index afdc1a9f4..1a1d87fc9 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -233,7 +233,7 @@ final class SearchViewModel: NSObject { promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) return } - self.context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) .sink { completion in switch completion { case .failure(let error): diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift new file mode 100644 index 000000000..16a916db4 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -0,0 +1,161 @@ +// +// SuggestionAccountViewController.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import OSLog +import UIKit + +class SuggestionAccountViewController: UIViewController, NeedsDependency { + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + + var viewModel: SuggestionAccountViewModel! + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(SuggestionAccountTableViewCell.self, forCellReuseIdentifier: String(describing: SuggestionAccountTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.tableFooterView = UIView() + tableView.separatorStyle = .singleLine + tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + return tableView + }() + + lazy var tableHeader: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + 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 avatarStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .equalSpacing + stackView.alignment = .center + stackView.spacing = 15 + return stackView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", (#file as NSString).lastPathComponent, #line, #function) + } +} + +extension SuggestionAccountViewController { + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.systemBackground.color + title = L10n.Scene.SuggestionAccount.title + navigationItem.rightBarButtonItem + = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, + target: self, + action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:))) + + 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.accounts + .receive(on: DispatchQueue.main) + .sink { [weak self] accounts in + guard let self = self else { return } + self.setupHeader(accounts: accounts) + } + .store(in: &disposeBag) + } + + 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), + ]) + + avatarStackView.translatesAutoresizingMaskIntoConstraints = false + tableHeader.addSubview(avatarStackView) + NSLayoutConstraint.activate([ + avatarStackView.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20), + avatarStackView.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), + avatarStackView.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor), + avatarStackView.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor), + ]) + let avatarImageViewHeight: Double = 56 + let avatarImageViewCount = Int(floor((Double(tableView.frame.width) - 20) / (avatarImageViewHeight + 15))) + let count = min(avatarImageViewCount, accounts.count) + for i in 0 ..< count { + let account = context.managedObjectContext.object(with: accounts[i]) as! MastodonUser + let imageView = UIImageView() + imageView.layer.cornerRadius = 6 + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), + imageView.heightAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), + ]) + if let url = account.avatarImageURL() { + imageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + avatarStackView.addArrangedSubview(imageView) + } + + tableView.tableHeaderView = tableHeader + } +} + +extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { + func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) { + let selected = !sender.isSelected + sender.isSelected = !sender.isSelected + if selected { + viewModel.selectedAccounts.append(objectID) + } else { + viewModel.selectedAccounts.removeAll { $0 == objectID } + } + } +} + +extension SuggestionAccountViewController { + @objc func doneButtonDidClick(_ sender: UIButton) { + dismiss(animated: true, completion: nil) + viewModel.followAction() + } +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift new file mode 100644 index 000000000..9a92b059e --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -0,0 +1,101 @@ +// +// SuggestionAccountViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK +import os.log +import UIKit + +final class SuggestionAccountViewModel: NSObject { + var disposeBag = Set() + + // input + let context: AppContext + let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + var selectedAccounts = [NSManagedObjectID]() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + + init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { + self.context = context + if let accounts = accounts { + self.accounts.value = accounts + } + super.init() + + self.accounts + .receive(on: DispatchQueue.main) + .sink { [weak self] accounts in + guard let dataSource = self?.diffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(accounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + .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 { completion in + switch completion { + case .failure(let error): + 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 + guard let self = self else { return } + let ids = response.value.map(\.account.id) + let users: [MastodonUser]? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) + request.returnsObjectsAsFaults = false + do { + return try context.managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + if let accounts = users?.map(\.objectID) { + self.accounts.value = accounts + } + } + .store(in: &disposeBag) + } + } + + func followAction() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + for objectID in selectedAccounts { + let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser + context.apiService.toggleFollow( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + needFeedback: false + ) + .sink { completion in + 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: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { _ in + } + .store(in: &disposeBag) + } + } +} diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift new file mode 100644 index 000000000..9dafaedb8 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -0,0 +1,149 @@ +// +// SuggestionAccountTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Foundation +import UIKit +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit + +protocol SuggestionAccountTableViewCellDelegate: AnyObject { + func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) +} + +final class SuggestionAccountTableViewCell: UITableViewCell { + + var disposeBag = Set() + weak var delegate: SuggestionAccountTableViewCellDelegate? + + let _imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.primary.color + imageView.layer.cornerRadius = 4 + imageView.clipsToBounds = true + return imageView + }() + + let titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.brandBlue.color + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let subTitleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .preferredFont(forTextStyle: .body) + return label + }() + + lazy var button: HighlightDimmableButton = { + let button = HighlightDimmableButton(type: .custom) + if let plusImage = UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { + button.setImage(plusImage, for: .normal) + } + if let minusImage = UIImage(systemName: "minus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { + button.setImage(minusImage, for: .selected) + } + button.publisher(for: \.isSelected) + .sink { isSelected in + if isSelected { + button.tintColor = Asset.Colors.danger.color + } else { + button.tintColor = Asset.Colors.Label.secondary.color + } + } + .store(in: &self.disposeBag) + return button + }() + override func prepareForReuse() { + super.prepareForReuse() + _imageView.af.cancelImageRequest() + _imageView.image = nil + disposeBag.removeAll() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SuggestionAccountTableViewCell { + private func configure() { + backgroundColor = .clear + + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.distribution = .fill + containerStackView.spacing = 12 + containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12) + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + _imageView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(_imageView) + NSLayoutConstraint.activate([ + _imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1), + _imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1) + ]) + + let textStackView = UIStackView() + textStackView.axis = .vertical + textStackView.distribution = .fill + textStackView.alignment = .leading + textStackView.translatesAutoresizingMaskIntoConstraints = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(titleLabel) + subTitleLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(subTitleLabel) + subTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) + + containerStackView.addArrangedSubview(textStackView) + textStackView.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + + button.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(button) + } + + func config(with account: MastodonUser, isSelected: Bool) { + if let url = account.avatarImageURL() { + _imageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + subTitleLabel.text = account.acct + button.isSelected = isSelected + button.publisher(for: .touchUpInside) + .sink { [weak self] sender in + guard let self = self else { return } + self.delegate?.accountButtonPressed(objectID: account.objectID, sender: self.button) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index cf19878d6..6db612942 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -24,10 +24,15 @@ extension APIService { /// - Returns: publisher for `Relationship` func toggleFollow( for mastodonUser: MastodonUser, - activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + needFeedback: Bool ) -> AnyPublisher, Error> { - let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + var impactFeedbackGenerator: UIImpactFeedbackGenerator? + var notificationFeedbackGenerator: UINotificationFeedbackGenerator? + if needFeedback { + impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + notificationFeedbackGenerator = UINotificationFeedbackGenerator() + } return followUpdateLocal( mastodonUserObjectID: mastodonUser.objectID, @@ -35,9 +40,9 @@ extension APIService { ) .receive(on: DispatchQueue.main) .handleEvents { _ in - impactFeedbackGenerator.prepare() + impactFeedbackGenerator?.prepare() } receiveOutput: { _ in - impactFeedbackGenerator.impactOccurred() + impactFeedbackGenerator?.impactOccurred() } receiveCompletion: { completion in switch completion { case .failure(let error): @@ -74,13 +79,13 @@ extension APIService { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) } receiveValue: { _ in // do nothing - notificationFeedbackGenerator.prepare() - notificationFeedbackGenerator.notificationOccurred(.error) + notificationFeedbackGenerator?.prepare() + notificationFeedbackGenerator?.notificationOccurred(.error) } .store(in: &self.disposeBag) case .finished: - notificationFeedbackGenerator.notificationOccurred(.success) + notificationFeedbackGenerator?.notificationOccurred(.success) os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) } }) diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift index 134d43fab..a3bcb3e35 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -13,7 +13,38 @@ import CoreDataStack import OSLog extension APIService { - func recommendAccount( + func suggestionAccount( + domain: String, + query: Mastodon.API.Suggestions.Query?, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + 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() + } + + func suggestionAccountV2( domain: String, query: Mastodon.API.Suggestions.Query?, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox @@ -44,7 +75,7 @@ extension APIService { } .eraseToAnyPublisher() } - + func recommendTrends( domain: String, query: Mastodon.API.Trends.Query?