feature: suggestion account scene
This commit is contained in:
parent
e7cd130bf1
commit
c8474c6a7f
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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 = "<group>"; };
|
||||
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
||||
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
|
||||
2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewController.swift; sourceTree = "<group>"; };
|
||||
2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewModel.swift; sourceTree = "<group>"; };
|
||||
2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = "<group>"; };
|
||||
2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = "<group>"; };
|
||||
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = "<group>"; };
|
||||
|
@ -1182,6 +1188,24 @@
|
|||
path = Decoration;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */,
|
||||
2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */,
|
||||
2DAC9E43262FC9DE0062E1A6 /* TableViewCell */,
|
||||
);
|
||||
path = SuggestionAccount;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2DAC9E43262FC9DE0062E1A6 /* TableViewCell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */,
|
||||
);
|
||||
path = TableViewCell;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -29,4 +29,21 @@ extension RecommendAccountSection {
|
|||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
viewModel: SuggestionAccountViewModel,
|
||||
delegate: SuggestionAccountTableViewCellDelegate
|
||||
) -> UITableViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -44,7 +44,8 @@ extension UserProviderFacade {
|
|||
|
||||
return context.apiService.toggleFollow(
|
||||
for: mastodonUser,
|
||||
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox
|
||||
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
|
||||
needFeedback: true
|
||||
)
|
||||
}
|
||||
.switchToLatest()
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -23,6 +23,15 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([])
|
||||
var selectedAccounts = [NSManagedObjectID]()
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
|
||||
|
||||
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<RecommendAccountSection, NSManagedObjectID>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
|
@ -24,10 +24,15 @@ extension APIService {
|
|||
/// - Returns: publisher for `Relationship`
|
||||
func toggleFollow(
|
||||
for mastodonUser: MastodonUser,
|
||||
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
|
||||
needFeedback: Bool
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
|
||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||
|
||||
return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization)
|
||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, 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
|
||||
|
|
Loading…
Reference in New Issue