feature: suggestion account scene

This commit is contained in:
sunxiaojian 2021-04-21 14:46:31 +08:00
parent e7cd130bf1
commit c8474c6a7f
14 changed files with 601 additions and 13 deletions

View File

@ -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, youll see their posts in your home feed."
},
"public_timeline": {
"title": "Public"
},

View File

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

View File

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

View File

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

View File

@ -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, youll 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")

View File

@ -44,7 +44,8 @@ extension UserProviderFacade {
return context.apiService.toggleFollow(
for: mastodonUser,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
needFeedback: true
)
}
.switchToLatest()

View File

@ -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, youll 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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
@ -44,7 +75,7 @@ extension APIService {
}
.eraseToAnyPublisher()
}
func recommendTrends(
domain: String,
query: Mastodon.API.Trends.Query?