forked from zelo72/mastodon-ios
Merge pull request #110 from tootsuite/feature/suggestion
Feature/suggestion
This commit is contained in:
commit
bcdb55f9f0
|
@ -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",
|
||||
|
@ -231,6 +233,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"
|
||||
},
|
||||
|
@ -330,7 +336,7 @@
|
|||
},
|
||||
"favorite": {
|
||||
"title": "Your Favorites"
|
||||
},
|
||||
},
|
||||
"notification": {
|
||||
"title": {
|
||||
"Everything": "Everything",
|
||||
|
@ -342,6 +348,7 @@
|
|||
"reblog": "rebloged your post",
|
||||
"poll": "Your poll has ended",
|
||||
"mention": "mentioned you"
|
||||
},
|
||||
},
|
||||
"thread": {
|
||||
"back_title": "Post",
|
||||
|
|
|
@ -74,6 +74,9 @@
|
|||
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; };
|
||||
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; };
|
||||
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; };
|
||||
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */; };
|
||||
2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */; };
|
||||
2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */; };
|
||||
2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */; };
|
||||
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; };
|
||||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; };
|
||||
|
@ -120,6 +123,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 */; };
|
||||
|
@ -488,6 +494,9 @@
|
|||
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
|
||||
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; };
|
||||
2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountSection.swift; sourceTree = "<group>"; };
|
||||
2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountItem.swift; sourceTree = "<group>"; };
|
||||
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarProgressView.swift; sourceTree = "<group>"; };
|
||||
2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = "<group>"; };
|
||||
2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -532,6 +541,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>"; };
|
||||
|
@ -1018,6 +1030,14 @@
|
|||
path = Button;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D4AD89A2631659400613EFC /* CollectionViewCell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */,
|
||||
);
|
||||
path = CollectionViewCell;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D59819925E4A55C000FB903 /* ConfirmEmail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1116,6 +1136,7 @@
|
|||
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
|
||||
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
|
||||
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
|
||||
2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */,
|
||||
2D35237926256D920031AF25 /* NotificationSection.swift */,
|
||||
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
|
||||
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
|
||||
|
@ -1170,6 +1191,7 @@
|
|||
children = (
|
||||
2D7631B225C159F700929FB9 /* Item.swift */,
|
||||
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
|
||||
2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */,
|
||||
2D7867182625B77500211898 /* NotificationItem.swift */,
|
||||
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */,
|
||||
DB1E347725F519300079D7DF /* PickServerItem.swift */,
|
||||
|
@ -1188,6 +1210,25 @@
|
|||
path = Decoration;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */,
|
||||
2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */,
|
||||
2D4AD89A2631659400613EFC /* CollectionViewCell */,
|
||||
2DAC9E43262FC9DE0062E1A6 /* TableViewCell */,
|
||||
);
|
||||
path = SuggestionAccount;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2DAC9E43262FC9DE0062E1A6 /* TableViewCell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */,
|
||||
);
|
||||
path = TableViewCell;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2DE0FAC62615F5D200CDF649 /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1675,6 +1716,7 @@
|
|||
2D38F1D325CD463600561493 /* HomeTimeline */,
|
||||
2D76316325C14BAC00929FB9 /* PublicTimeline */,
|
||||
0F2021F5261325ED000C64BF /* HashtagTimeline */,
|
||||
2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */,
|
||||
DB9D6BEE25E4F5370051B173 /* Search */,
|
||||
5B90C455262599800002E742 /* Settings */,
|
||||
DB9D6BFD25E4F57B0051B173 /* Notification */,
|
||||
|
@ -2391,6 +2433,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 */,
|
||||
|
@ -2407,6 +2450,7 @@
|
|||
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
|
||||
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
|
||||
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
|
||||
2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */,
|
||||
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
|
||||
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
|
||||
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */,
|
||||
|
@ -2431,6 +2475,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 */,
|
||||
|
@ -2509,6 +2554,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 */,
|
||||
|
@ -2532,6 +2578,7 @@
|
|||
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */,
|
||||
2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */,
|
||||
DB084B5725CBC56C00F898ED /* Status.swift in Sources */,
|
||||
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */,
|
||||
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
|
||||
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
|
||||
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
|
||||
|
@ -2597,6 +2644,7 @@
|
|||
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
|
||||
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
|
||||
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
|
||||
2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */,
|
||||
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */,
|
||||
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */,
|
||||
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.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 {
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// SelectedAccountItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/22.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
enum SelectedAccountItem {
|
||||
case accountObjectID(accountObjectID: NSManagedObjectID)
|
||||
case placeHolder(uuid: UUID)
|
||||
}
|
||||
|
||||
extension SelectedAccountItem: Equatable {
|
||||
static func == (lhs: SelectedAccountItem, rhs: SelectedAccountItem) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.accountObjectID(let idLeft), .accountObjectID(let idRight)):
|
||||
return idLeft == idRight
|
||||
case (.placeHolder(let uuidLeft), .placeHolder(let uuidRight)):
|
||||
return uuidLeft == uuidRight
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SelectedAccountItem: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .accountObjectID(let id):
|
||||
hasher.combine(id)
|
||||
case .placeHolder(let id):
|
||||
hasher.combine(id.uuidString)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.value.contains(objectID)
|
||||
cell.delegate = delegate
|
||||
cell.config(with: user, isSelected: isSelected)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// SelectedAccountSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/22.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
|
||||
enum SelectedAccountSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension SelectedAccountSection {
|
||||
static func collectionViewDiffableDataSource(
|
||||
for collectionView: UICollectionView,
|
||||
managedObjectContext: NSManagedObjectContext
|
||||
) -> UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem> {
|
||||
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self), for: indexPath) as! SuggestionAccountCollectionViewCell
|
||||
switch item {
|
||||
case .accountObjectID(let objectID):
|
||||
let user = managedObjectContext.object(with: objectID) as! MastodonUser
|
||||
cell.config(with: user)
|
||||
case .placeHolder:
|
||||
cell.configAsPlaceHolder()
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
|
@ -69,6 +69,7 @@ internal enum Asset {
|
|||
internal static let highlight = ColorAsset(name: "Colors/Label/highlight")
|
||||
internal static let primary = ColorAsset(name: "Colors/Label/primary")
|
||||
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
|
||||
internal static let tertiary = ColorAsset(name: "Colors/Label/tertiary")
|
||||
}
|
||||
internal enum Notification {
|
||||
internal static let favourite = ColorAsset(name: "Colors/Notification/favourite")
|
||||
|
|
|
@ -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
|
||||
|
@ -679,6 +683,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()
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "0.300",
|
||||
"blue" : "67",
|
||||
"green" : "60",
|
||||
"red" : "60"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
@ -221,6 +223,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";
|
||||
|
@ -228,4 +232,4 @@ any server.";
|
|||
"Scene.Thread.Reblog.Single" = "%@ reblog";
|
||||
"Scene.Thread.Title" = "Post from %@";
|
||||
"Scene.Welcome.Slogan" = "Social networking
|
||||
back in your hands.";
|
||||
back in your hands.";
|
|
@ -84,7 +84,7 @@ final class HashtagTimelineViewModel: NSObject {
|
|||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
let query = Mastodon.API.Search.Query(q: hashtag, type: .hashtags)
|
||||
let query = Mastodon.API.V2.Search.Query(q: hashtag, type: .hashtags)
|
||||
context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
.sink { _ in
|
||||
|
||||
|
|
|
@ -25,6 +25,14 @@ extension HomeTimelineViewController {
|
|||
guard let self = self else { return }
|
||||
self.showWelcomeAction(action)
|
||||
},
|
||||
UIAction(title: "Show Or Remove EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
if self.emptyView.superview != nil {
|
||||
self.emptyView.removeFromSuperview()
|
||||
} else {
|
||||
self.showEmptyView()
|
||||
}
|
||||
},
|
||||
UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.showPublicTimelineAction(action)
|
||||
|
|
|
@ -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,7 +151,7 @@ extension HomeTimelineViewController {
|
|||
UIView.animate(withDuration: 0.5) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.refreshControl.endRefreshing()
|
||||
}
|
||||
} completion: { _ in }
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
@ -173,6 +182,17 @@ extension HomeTimelineViewController {
|
|||
self.publishProgressView.setProgress(progress, animated: true)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.timelineIsEmpty
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isEmpty in
|
||||
if isEmpty {
|
||||
self?.showEmptyView()
|
||||
} else {
|
||||
self?.emptyView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
|
@ -182,6 +202,10 @@ extension HomeTimelineViewController {
|
|||
|
||||
// needs trigger manually after onboarding dismiss
|
||||
setNeedsStatusBarAppearanceUpdate()
|
||||
|
||||
if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty {
|
||||
viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
|
@ -217,6 +241,58 @@ 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)
|
||||
])
|
||||
|
||||
if emptyView.arrangedSubviews.count > 0 {
|
||||
return
|
||||
}
|
||||
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)
|
||||
viewModel.delegate = self.viewModel
|
||||
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)
|
||||
|
|
|
@ -107,6 +107,7 @@ extension HomeTimelineViewModel.LoadLatestState {
|
|||
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
|
||||
}
|
||||
}
|
||||
viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
}
|
||||
|
|
|
@ -34,6 +34,8 @@ final class HomeTimelineViewModel: NSObject {
|
|||
weak var tableView: UITableView?
|
||||
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
|
||||
|
||||
let timelineIsEmpty = CurrentValueSubject<Bool, Never>(false)
|
||||
let homeTimelineNeedRefresh = PassthroughSubject<Void, Never>()
|
||||
// output
|
||||
// top loader
|
||||
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
|
||||
|
@ -122,6 +124,12 @@ final class HomeTimelineViewModel: NSObject {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
homeTimelineNeedRefresh
|
||||
.sink { [weak self] _ in
|
||||
self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
@ -129,3 +137,5 @@ final class HomeTimelineViewModel: NSObject {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { }
|
||||
|
|
|
@ -9,7 +9,7 @@ import UIKit
|
|||
|
||||
final class ProfileRelationshipActionButton: RoundedEdgesButton {
|
||||
|
||||
let actvityIndicatorView: UIActivityIndicatorView = {
|
||||
let activityIndicatorView: UIActivityIndicatorView = {
|
||||
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
activityIndicatorView.color = .white
|
||||
return activityIndicatorView
|
||||
|
@ -31,15 +31,15 @@ extension ProfileRelationshipActionButton {
|
|||
private func _init() {
|
||||
titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
|
||||
actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(actvityIndicatorView)
|
||||
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(activityIndicatorView)
|
||||
NSLayoutConstraint.activate([
|
||||
actvityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
actvityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
])
|
||||
|
||||
actvityIndicatorView.hidesWhenStopped = true
|
||||
actvityIndicatorView.stopAnimating()
|
||||
activityIndicatorView.hidesWhenStopped = true
|
||||
activityIndicatorView.stopAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -52,13 +52,13 @@ extension ProfileRelationshipActionButton {
|
|||
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
|
||||
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled)
|
||||
|
||||
actvityIndicatorView.stopAnimating()
|
||||
activityIndicatorView.stopAnimating()
|
||||
|
||||
if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended {
|
||||
isEnabled = false
|
||||
} else if actionOptionSet.contains(.updating) {
|
||||
isEnabled = false
|
||||
actvityIndicatorView.startAnimating()
|
||||
activityIndicatorView.startAnimating()
|
||||
} else {
|
||||
isEnabled = true
|
||||
}
|
||||
|
|
|
@ -98,10 +98,7 @@ extension SearchRecommendAccountsCollectionViewCell {
|
|||
headerImageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor
|
||||
applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0)
|
||||
}
|
||||
override open func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
followButton.layer.cornerRadius = followButton.frame.height/2
|
||||
}
|
||||
|
||||
private func configure() {
|
||||
headerImageView.backgroundColor = Asset.Colors.brandBlue.color
|
||||
layer.cornerRadius = 10
|
||||
|
|
|
@ -83,7 +83,6 @@ extension SearchRecommendTagsCollectionViewCell {
|
|||
let containerStackView = UIStackView()
|
||||
containerStackView.axis = .vertical
|
||||
containerStackView.distribution = .fill
|
||||
containerStackView.spacing = 6
|
||||
containerStackView.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16)
|
||||
containerStackView.isLayoutMarginsRelativeArrangement = true
|
||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -113,6 +112,7 @@ extension SearchRecommendTagsCollectionViewCell {
|
|||
peopleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
|
||||
containerStackView.addArrangedSubview(peopleLabel)
|
||||
containerStackView.setCustomSpacing(SearchViewController.hashtagPeopleTalkingLabelTop, after: horizontalStackView)
|
||||
}
|
||||
|
||||
func config(with tag: Mastodon.Entity.Tag) {
|
||||
|
|
|
@ -26,7 +26,7 @@ extension SearchViewController {
|
|||
hashtagCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addArrangedSubview(hashtagCollectionView)
|
||||
NSLayoutConstraint.activate([
|
||||
hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130)
|
||||
hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.hashtagCardHeight))
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -43,7 +43,7 @@ extension SearchViewController {
|
|||
accountsCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addArrangedSubview(accountsCollectionView)
|
||||
NSLayoutConstraint.activate([
|
||||
accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202)
|
||||
accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.accountCardHeight))
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -91,9 +91,9 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout {
|
|||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
if collectionView == hashtagCollectionView {
|
||||
return CGSize(width: 228, height: 130)
|
||||
return CGSize(width: 228, height: SearchViewController.hashtagCardHeight)
|
||||
} else {
|
||||
return CGSize(width: 257, height: 202)
|
||||
return CGSize(width: 257, height: SearchViewController.accountCardHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -101,5 +101,11 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout {
|
|||
extension SearchViewController {
|
||||
@objc func hashtagSeeAllButtonPressed(_ sender: UIButton) {}
|
||||
|
||||
@objc func accountSeeAllButtonPressed(_ sender: UIButton) {}
|
||||
@objc func accountSeeAllButtonPressed(_ sender: UIButton) {
|
||||
if self.viewModel.recommendAccounts.isEmpty {
|
||||
return
|
||||
}
|
||||
let viewModel = SuggestionAccountViewModel(context: context, accounts: self.viewModel.recommendAccounts)
|
||||
coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,26 @@ import MastodonSDK
|
|||
import UIKit
|
||||
|
||||
final class SearchViewController: UIViewController, NeedsDependency {
|
||||
|
||||
public static var hashtagCardHeight: CGFloat {
|
||||
get {
|
||||
if UIScreen.main.bounds.size.height > 736 {
|
||||
return 186
|
||||
}
|
||||
return 130
|
||||
}
|
||||
}
|
||||
|
||||
public static var hashtagPeopleTalkingLabelTop: CGFloat {
|
||||
get {
|
||||
if UIScreen.main.bounds.size.height > 736 {
|
||||
return 18
|
||||
}
|
||||
return 6
|
||||
}
|
||||
}
|
||||
public static let accountCardHeight = 202
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
|
@ -49,9 +69,6 @@ final class SearchViewController: UIViewController, NeedsDependency {
|
|||
let stackView = UIStackView()
|
||||
stackView.axis = .vertical
|
||||
stackView.distribution = .fill
|
||||
stackView.spacing = 0
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 68, right: 0)
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
return stackView
|
||||
}()
|
||||
|
||||
|
@ -130,6 +147,8 @@ extension SearchViewController {
|
|||
setupSearchingTableView()
|
||||
setupDataSource()
|
||||
setupSearchHeader()
|
||||
view.bringSubviewToFront(searchBar)
|
||||
view.bringSubviewToFront(statusBar)
|
||||
}
|
||||
|
||||
func setupSearchBar() {
|
||||
|
@ -148,23 +167,27 @@ extension SearchViewController {
|
|||
statusBar.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
statusBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 3),
|
||||
statusBar.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 3),
|
||||
])
|
||||
}
|
||||
|
||||
func setupScrollView() {
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// scrollView
|
||||
view.addSubview(scrollView)
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
|
||||
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor),
|
||||
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: searchBar.frame.height),
|
||||
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
|
||||
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
|
||||
])
|
||||
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// stackview
|
||||
scrollView.addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
||||
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
||||
|
@ -217,11 +240,11 @@ extension SearchViewController: UISearchBarDelegate {
|
|||
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
|
||||
switch selectedScope {
|
||||
case 0:
|
||||
viewModel.searchScope.value = Mastodon.API.Search.SearchType.default
|
||||
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.default
|
||||
case 1:
|
||||
viewModel.searchScope.value = Mastodon.API.Search.SearchType.accounts
|
||||
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.accounts
|
||||
case 2:
|
||||
viewModel.searchScope.value = Mastodon.API.Search.SearchType.hashtags
|
||||
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.hashtags
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
@ -53,14 +53,14 @@ extension SearchViewModel.LoadOldestState {
|
|||
}
|
||||
var offset = 0
|
||||
switch viewModel.searchScope.value {
|
||||
case Mastodon.API.Search.SearchType.accounts:
|
||||
case Mastodon.API.V2.Search.SearchType.accounts:
|
||||
offset = oldSearchResult.accounts.count
|
||||
case Mastodon.API.Search.SearchType.hashtags:
|
||||
case Mastodon.API.V2.Search.SearchType.hashtags:
|
||||
offset = oldSearchResult.hashtags.count
|
||||
default:
|
||||
return
|
||||
}
|
||||
let query = Mastodon.API.Search.Query(q: viewModel.searchText.value,
|
||||
let query = Mastodon.API.V2.Search.Query(q: viewModel.searchText.value,
|
||||
type: viewModel.searchScope.value,
|
||||
accountID: nil,
|
||||
maxID: nil,
|
||||
|
@ -82,7 +82,7 @@ extension SearchViewModel.LoadOldestState {
|
|||
}
|
||||
} receiveValue: { result in
|
||||
switch viewModel.searchScope.value {
|
||||
case Mastodon.API.Search.SearchType.accounts:
|
||||
case Mastodon.API.V2.Search.SearchType.accounts:
|
||||
if result.value.accounts.isEmpty {
|
||||
stateMachine.enter(NoMore.self)
|
||||
} else {
|
||||
|
@ -93,7 +93,7 @@ extension SearchViewModel.LoadOldestState {
|
|||
viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags)
|
||||
stateMachine.enter(Idle.self)
|
||||
}
|
||||
case Mastodon.API.Search.SearchType.hashtags:
|
||||
case Mastodon.API.V2.Search.SearchType.hashtags:
|
||||
if result.value.hashtags.isEmpty {
|
||||
stateMachine.enter(NoMore.self)
|
||||
} else {
|
||||
|
|
|
@ -26,7 +26,7 @@ final class SearchViewModel: NSObject {
|
|||
|
||||
// output
|
||||
let searchText = CurrentValueSubject<String, Never>("")
|
||||
let searchScope = CurrentValueSubject<Mastodon.API.Search.SearchType, Never>(Mastodon.API.Search.SearchType.default)
|
||||
let searchScope = CurrentValueSubject<Mastodon.API.V2.Search.SearchType, Never>(Mastodon.API.V2.Search.SearchType.default)
|
||||
|
||||
let isSearching = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
|
@ -34,6 +34,7 @@ final class SearchViewModel: NSObject {
|
|||
|
||||
var recommendHashTags = [Mastodon.Entity.Tag]()
|
||||
var recommendAccounts = [NSManagedObjectID]()
|
||||
var recommendAccountsFallback = PassthroughSubject<Void, Never>()
|
||||
|
||||
var hashtagDiffableDataSource: UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag>?
|
||||
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
|
||||
|
@ -86,16 +87,16 @@ final class SearchViewModel: NSObject {
|
|||
}
|
||||
.flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
|
||||
|
||||
let query = Mastodon.API.Search.Query(q: text,
|
||||
type: scope,
|
||||
accountID: nil,
|
||||
maxID: nil,
|
||||
minID: nil,
|
||||
excludeUnreviewed: nil,
|
||||
resolve: nil,
|
||||
limit: nil,
|
||||
offset: nil,
|
||||
following: nil)
|
||||
let query = Mastodon.API.V2.Search.Query(q: text,
|
||||
type: scope,
|
||||
accountID: nil,
|
||||
maxID: nil,
|
||||
minID: nil,
|
||||
excludeUnreviewed: nil,
|
||||
resolve: nil,
|
||||
limit: nil,
|
||||
offset: nil,
|
||||
following: nil)
|
||||
return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
}
|
||||
.sink { _ in
|
||||
|
@ -130,8 +131,8 @@ final class SearchViewModel: NSObject {
|
|||
snapshot.appendSections([.mixed])
|
||||
|
||||
searchHistories.forEach { searchHistory in
|
||||
let containsAccount = scope == Mastodon.API.Search.SearchType.accounts || scope == Mastodon.API.Search.SearchType.default
|
||||
let containsHashTag = scope == Mastodon.API.Search.SearchType.hashtags || scope == Mastodon.API.Search.SearchType.default
|
||||
let containsAccount = scope == Mastodon.API.V2.Search.SearchType.accounts || scope == Mastodon.API.V2.Search.SearchType.default
|
||||
let containsHashTag = scope == Mastodon.API.V2.Search.SearchType.hashtags || scope == Mastodon.API.V2.Search.SearchType.default
|
||||
if let mastodonUser = searchHistory.account, containsAccount {
|
||||
let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID)
|
||||
snapshot.appendItems([item], toSection: .mixed)
|
||||
|
@ -142,7 +143,6 @@ final class SearchViewModel: NSObject {
|
|||
}
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
@ -161,21 +161,31 @@ final class SearchViewModel: NSObject {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
requestRecommendAccounts()
|
||||
.receive(on: DispatchQueue.main)
|
||||
requestRecommendAccountsV2()
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
if !self.recommendAccounts.isEmpty {
|
||||
guard let dataSource = self.accountDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(self.recommendAccounts, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
self.applyDataSource()
|
||||
}
|
||||
} receiveValue: { _ in
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
recommendAccountsFallback
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.requestRecommendAccounts()
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
if !self.recommendAccounts.isEmpty {
|
||||
self.applyDataSource()
|
||||
}
|
||||
} receiveValue: { _ in
|
||||
}
|
||||
.store(in: &self.disposeBag)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
searchResult
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] searchResult in
|
||||
|
@ -186,7 +196,7 @@ final class SearchViewModel: NSObject {
|
|||
snapshot.appendSections([.account])
|
||||
let items = accounts.compactMap { SearchResultItem.account(account: $0) }
|
||||
snapshot.appendItems(items, toSection: .account)
|
||||
if self.searchScope.value == Mastodon.API.Search.SearchType.accounts, !items.isEmpty {
|
||||
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.accounts, !items.isEmpty {
|
||||
snapshot.appendItems([.bottomLoader], toSection: .account)
|
||||
}
|
||||
}
|
||||
|
@ -194,7 +204,7 @@ final class SearchViewModel: NSObject {
|
|||
snapshot.appendSections([.hashtag])
|
||||
let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) }
|
||||
snapshot.appendItems(items, toSection: .hashtag)
|
||||
if self.searchScope.value == Mastodon.API.Search.SearchType.hashtags, !items.isEmpty {
|
||||
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.hashtags, !items.isEmpty {
|
||||
snapshot.appendItems([.bottomLoader], toSection: .hashtag)
|
||||
}
|
||||
}
|
||||
|
@ -227,13 +237,43 @@ final class SearchViewModel: NSObject {
|
|||
}
|
||||
}
|
||||
|
||||
func requestRecommendAccountsV2() -> Future<Void, Error> {
|
||||
Future { promise in
|
||||
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
|
||||
return
|
||||
}
|
||||
self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
.sink { [weak self] completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
if let apiError = error as? Mastodon.API.Error {
|
||||
if apiError.httpResponseStatus == .notFound {
|
||||
self?.recommendAccountsFallback.send()
|
||||
}
|
||||
}
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||
promise(.failure(error))
|
||||
case .finished:
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function)
|
||||
promise(.success(()))
|
||||
}
|
||||
} receiveValue: { [weak self] accounts in
|
||||
guard let self = self else { return }
|
||||
let ids = accounts.value.compactMap({$0.account.id})
|
||||
self.receiveAccounts(ids: ids)
|
||||
}
|
||||
.store(in: &self.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
func requestRecommendAccounts() -> Future<Void, Error> {
|
||||
Future { promise in
|
||||
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
|
||||
return
|
||||
}
|
||||
self.context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
self.context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
|
@ -246,27 +286,47 @@ final class SearchViewModel: NSObject {
|
|||
} receiveValue: { [weak self] accounts in
|
||||
guard let self = self else { return }
|
||||
let ids = accounts.value.compactMap({$0.id})
|
||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||
let mastodonUsers: [MastodonUser]? = {
|
||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||
userFetchRequest.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try self.context.managedObjectContext.fetch(userFetchRequest)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
if let users = mastodonUsers {
|
||||
self.recommendAccounts = users.map(\.objectID)
|
||||
}
|
||||
self.receiveAccounts(ids: ids)
|
||||
}
|
||||
.store(in: &self.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
func applyDataSource() {
|
||||
DispatchQueue.main.async {
|
||||
guard let dataSource = self.accountDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(self.recommendAccounts, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveAccounts(ids: [String]) {
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||
let mastodonUsers: [MastodonUser]? = {
|
||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||
userFetchRequest.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try self.context.managedObjectContext.fetch(userFetchRequest)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
if let users = mastodonUsers {
|
||||
let sortedUsers = users.sorted { (user1, user2) -> Bool in
|
||||
(ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0)
|
||||
}
|
||||
recommendAccounts = sortedUsers.map(\.objectID)
|
||||
}
|
||||
}
|
||||
|
||||
func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) {
|
||||
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
|
||||
DispatchQueue.main.async {
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
//
|
||||
// SuggestionAccountCollectionViewCell.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/22.
|
||||
//
|
||||
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class SuggestionAccountCollectionViewCell: UICollectionViewCell {
|
||||
let imageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.tintColor = Asset.Colors.Label.tertiary.color
|
||||
imageView.layer.cornerRadius = 4
|
||||
imageView.clipsToBounds = true
|
||||
imageView.image = UIImage.placeholder(color: .systemFill)
|
||||
return imageView
|
||||
}()
|
||||
|
||||
func configAsPlaceHolder() {
|
||||
imageView.tintColor = Asset.Colors.Label.tertiary.color
|
||||
imageView.image = UIImage.placeholder(color: .systemFill)
|
||||
}
|
||||
|
||||
func config(with mastodonUser: MastodonUser) {
|
||||
imageView.af.setImage(
|
||||
withURL: URL(string: mastodonUser.avatar)!,
|
||||
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||
imageTransition: .crossDissolve(0.2)
|
||||
)
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: .zero)
|
||||
configure()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
configure()
|
||||
}
|
||||
}
|
||||
|
||||
extension SuggestionAccountCollectionViewCell {
|
||||
private func configure() {
|
||||
contentView.addSubview(imageView)
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
|
@ -0,0 +1,213 @@
|
|||
//
|
||||
// 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 selectedCollectionView: UICollectionView = {
|
||||
let flowLayout = UICollectionViewFlowLayout()
|
||||
flowLayout.scrollDirection = .horizontal
|
||||
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
view.register(SuggestionAccountCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self))
|
||||
view.backgroundColor = .clear
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.layer.masksToBounds = false
|
||||
return view
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", (#file as NSString).lastPathComponent, #line, #function)
|
||||
}
|
||||
}
|
||||
|
||||
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.delegate = self
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(tableView)
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
viewModel.diffableDataSource = RecommendAccountSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
viewModel: viewModel,
|
||||
delegate: self
|
||||
)
|
||||
|
||||
viewModel.collectionDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource(for: selectedCollectionView, managedObjectContext: context.managedObjectContext)
|
||||
|
||||
viewModel.accounts
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] accounts in
|
||||
guard let self = self else { return }
|
||||
self.setupHeader(accounts: accounts)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||
viewModel.checkAccountsFollowState()
|
||||
}
|
||||
|
||||
override func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
let avatarImageViewHeight: Double = 56
|
||||
let avatarImageViewCount = Int(floor((Double(view.frame.width) - 20) / (avatarImageViewHeight + 15)))
|
||||
viewModel.headerPlaceholderCount.value = avatarImageViewCount
|
||||
}
|
||||
|
||||
func setupHeader(accounts: [NSManagedObjectID]) {
|
||||
if accounts.isEmpty {
|
||||
return
|
||||
}
|
||||
followExplainLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableHeader.addSubview(followExplainLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
followExplainLabel.topAnchor.constraint(equalTo: tableHeader.topAnchor, constant: 20),
|
||||
followExplainLabel.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20),
|
||||
tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20),
|
||||
])
|
||||
|
||||
selectedCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableHeader.addSubview(selectedCollectionView)
|
||||
NSLayoutConstraint.activate([
|
||||
selectedCollectionView.frameLayoutGuide.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20),
|
||||
selectedCollectionView.frameLayoutGuide.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20),
|
||||
selectedCollectionView.frameLayoutGuide.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor),
|
||||
selectedCollectionView.frameLayoutGuide.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor),
|
||||
])
|
||||
selectedCollectionView.delegate = self
|
||||
|
||||
tableView.tableHeaderView = tableHeader
|
||||
}
|
||||
}
|
||||
|
||||
extension SuggestionAccountViewController: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
|
||||
15
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
CGSize(width: 56, height: 56)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let diffableDataSource = viewModel.collectionDiffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
switch item {
|
||||
case .accountObjectID(let accountObjectID):
|
||||
let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SuggestionAccountViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let objectID = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser
|
||||
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate {
|
||||
func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) {
|
||||
let selected = !viewModel.selectedAccounts.value.contains(objectID)
|
||||
cell.startAnimating()
|
||||
viewModel.followAction(objectID: objectID)?
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
cell.stopAnimating()
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||
case .finished:
|
||||
var selectedAccounts = self.viewModel.selectedAccounts.value
|
||||
if selected {
|
||||
selectedAccounts.append(objectID)
|
||||
} else {
|
||||
selectedAccounts.removeAll { $0 == objectID }
|
||||
}
|
||||
cell.button.isSelected = selected
|
||||
self.viewModel.selectedAccounts.value = selectedAccounts
|
||||
}
|
||||
}, receiveValue: { _ in
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
extension SuggestionAccountViewController {
|
||||
@objc func doneButtonDidClick(_ sender: UIButton) {
|
||||
dismiss(animated: true, completion: nil)
|
||||
if viewModel.selectedAccounts.value.count > 0 {
|
||||
viewModel.delegate?.homeTimelineNeedRefresh.send()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,222 @@
|
|||
//
|
||||
// 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
|
||||
|
||||
protocol SuggestionAccountViewModelDelegate: AnyObject {
|
||||
var homeTimelineNeedRefresh: PassthroughSubject<Void, Never> { get }
|
||||
}
|
||||
|
||||
final class SuggestionAccountViewModel: NSObject {
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
|
||||
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
|
||||
weak var delegate: SuggestionAccountViewModelDelegate?
|
||||
// output
|
||||
let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([])
|
||||
var selectedAccounts = CurrentValueSubject<[NSManagedObjectID], Never>([])
|
||||
|
||||
var headerPlaceholderCount = CurrentValueSubject<Int?, Never>(nil)
|
||||
var suggestionAccountsFallback = PassthroughSubject<Void, Never>()
|
||||
|
||||
var viewWillAppear = PassthroughSubject<Void, Never>()
|
||||
|
||||
var diffableDataSource: UITableViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>? {
|
||||
didSet(value) {
|
||||
if !accounts.value.isEmpty {
|
||||
applyTableViewDataSource(accounts: accounts.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var collectionDiffableDataSource: UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem>?
|
||||
|
||||
init(context: AppContext, accounts: [NSManagedObjectID]? = nil) {
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
|
||||
Publishers.CombineLatest(self.accounts,self.selectedAccounts)
|
||||
.sink { [weak self] accounts,selectedAccounts in
|
||||
self?.applyTableViewDataSource(accounts: accounts)
|
||||
self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(self.selectedAccounts,self.headerPlaceholderCount)
|
||||
.sink { [weak self] selectedAccount,count in
|
||||
self?.applySelectedCollectionViewDataSource(accounts: selectedAccount)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewWillAppear
|
||||
.sink { [weak self] _ in
|
||||
self?.checkAccountsFollowState()
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
if let accounts = accounts {
|
||||
self.accounts.value = accounts
|
||||
}
|
||||
|
||||
context.authenticationService.activeMastodonAuthentication
|
||||
.sink { [weak self] activeMastodonAuthentication in
|
||||
guard let self = self else { return }
|
||||
guard let activeMastodonAuthentication = activeMastodonAuthentication else {
|
||||
self.currentMastodonUser.value = nil
|
||||
return
|
||||
}
|
||||
self.currentMastodonUser.value = activeMastodonAuthentication.user
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
if accounts == nil || (accounts ?? []).isEmpty {
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||
|
||||
context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
.sink { [weak self] completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
if let apiError = error as? Mastodon.API.Error {
|
||||
if apiError.httpResponseStatus == .notFound {
|
||||
self?.suggestionAccountsFallback.send()
|
||||
}
|
||||
}
|
||||
os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||
case .finished:
|
||||
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||
break
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
let ids = response.value.map(\.account.id)
|
||||
self?.receiveAccounts(ids: ids)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
suggestionAccountsFallback
|
||||
.sink(receiveValue: { [weak self] _ in
|
||||
self?.requestSuggestionAccount()
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
func requestSuggestionAccount() {
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||
context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccount failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||
case .finished:
|
||||
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||
break
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
let ids = response.value.map(\.id)
|
||||
self?.receiveAccounts(ids: ids)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
func applyTableViewDataSource(accounts: [NSManagedObjectID]) {
|
||||
guard let dataSource = diffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(accounts, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
|
||||
func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) {
|
||||
guard let count = headerPlaceholderCount.value else { return }
|
||||
guard let dataSource = collectionDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SelectedAccountSection, SelectedAccountItem>()
|
||||
snapshot.appendSections([.main])
|
||||
let placeholderCount = count - accounts.count
|
||||
let accountItems = accounts.map { SelectedAccountItem.accountObjectID(accountObjectID: $0) }
|
||||
snapshot.appendItems(accountItems, toSection: .main)
|
||||
|
||||
if placeholderCount > 0 {
|
||||
for _ in 0 ..< placeholderCount {
|
||||
snapshot.appendItems([SelectedAccountItem.placeHolder(uuid: UUID())], toSection: .main)
|
||||
}
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
|
||||
func receiveAccounts(ids: [String]) {
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||
let mastodonUsers: [MastodonUser]? = {
|
||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||
userFetchRequest.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try self.context.managedObjectContext.fetch(userFetchRequest)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
if let users = mastodonUsers {
|
||||
let sortedUsers = users.sorted { (user1, user2) -> Bool in
|
||||
(ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0)
|
||||
}
|
||||
accounts.value = sortedUsers.map(\.objectID)
|
||||
}
|
||||
}
|
||||
|
||||
func followAction(objectID: NSManagedObjectID) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error>? {
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil }
|
||||
|
||||
let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser
|
||||
return context.apiService.toggleFollow(
|
||||
for: mastodonUser,
|
||||
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
|
||||
needFeedback: false
|
||||
)
|
||||
}
|
||||
|
||||
func checkAccountsFollowState() {
|
||||
guard let currentMastodonUser = currentMastodonUser.value else {
|
||||
return
|
||||
}
|
||||
let users: [MastodonUser] = accounts.value.compactMap {
|
||||
guard let user = context.managedObjectContext.object(with: $0) as? MastodonUser else {
|
||||
return nil
|
||||
}
|
||||
let isBlock = user.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
|
||||
let isDomainBlock = user.domainBlockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
|
||||
if isBlock || isDomainBlock {
|
||||
return nil
|
||||
} else {
|
||||
return user
|
||||
}
|
||||
}
|
||||
accounts.value = users.map(\.objectID)
|
||||
|
||||
let followingUsers = users.filter { user -> Bool in
|
||||
let isFollowing = user.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
|
||||
let isPending = user.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false
|
||||
return isFollowing || isPending
|
||||
}.map(\.objectID)
|
||||
|
||||
selectedAccounts.value = followingUsers
|
||||
}
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
//
|
||||
// SuggestionAccountTableViewCell.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/21.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
|
||||
protocol SuggestionAccountTableViewCellDelegate: AnyObject {
|
||||
func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell)
|
||||
}
|
||||
|
||||
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
|
||||
}()
|
||||
|
||||
let buttonContainer: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
return view
|
||||
}()
|
||||
|
||||
let 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)
|
||||
}
|
||||
return button
|
||||
}()
|
||||
|
||||
let activityIndicatorView: UIActivityIndicatorView = {
|
||||
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
activityIndicatorView.hidesWhenStopped = true
|
||||
return activityIndicatorView
|
||||
}()
|
||||
|
||||
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)
|
||||
|
||||
buttonContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.addArrangedSubview(buttonContainer)
|
||||
NSLayoutConstraint.activate([
|
||||
buttonContainer.widthAnchor.constraint(equalToConstant: 24).priority(.required - 1),
|
||||
buttonContainer.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1),
|
||||
])
|
||||
buttonContainer.setContentHuggingPriority(.required - 1, for: .horizontal)
|
||||
|
||||
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
buttonContainer.addSubview(button)
|
||||
buttonContainer.addSubview(activityIndicatorView)
|
||||
NSLayoutConstraint.activate([
|
||||
buttonContainer.centerXAnchor.constraint(equalTo: activityIndicatorView.centerXAnchor),
|
||||
buttonContainer.centerYAnchor.constraint(equalTo: activityIndicatorView.centerYAnchor),
|
||||
buttonContainer.centerXAnchor.constraint(equalTo: button.centerXAnchor),
|
||||
buttonContainer.centerYAnchor.constraint(equalTo: button.centerYAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
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] _ in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.accountButtonPressed(objectID: account.objectID, cell: self)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
button.publisher(for: \.isSelected)
|
||||
.sink { [weak self] isSelected in
|
||||
if isSelected {
|
||||
self?.button.tintColor = Asset.Colors.danger.color
|
||||
} else {
|
||||
self?.button.tintColor = Asset.Colors.Label.secondary.color
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
activityIndicatorView.publisher(for: \.isHidden)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isHidden in
|
||||
self?.button.isHidden = !isHidden
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
func startAnimating() {
|
||||
activityIndicatorView.isHidden = false
|
||||
activityIndicatorView.startAnimating()
|
||||
}
|
||||
|
||||
func stopAnimating() {
|
||||
activityIndicatorView.stopAnimating()
|
||||
activityIndicatorView.isHidden = true
|
||||
}
|
||||
}
|
|
@ -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,7 @@ import CoreDataStack
|
|||
import OSLog
|
||||
|
||||
extension APIService {
|
||||
func recommendAccount(
|
||||
func suggestionAccount(
|
||||
domain: String,
|
||||
query: Mastodon.API.Suggestions.Query?,
|
||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||
|
@ -44,6 +44,38 @@ extension APIService {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func suggestionAccountV2(
|
||||
domain: String,
|
||||
query: Mastodon.API.Suggestions.Query?,
|
||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> {
|
||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||
|
||||
return Mastodon.API.V2.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization)
|
||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> in
|
||||
let log = OSLog.api
|
||||
return self.backgroundManagedObjectContext.performChanges {
|
||||
response.value.forEach { suggestionAccount in
|
||||
let user = suggestionAccount.account
|
||||
let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log)
|
||||
let flag = isCreated ? "+" : "-"
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username)
|
||||
}
|
||||
}
|
||||
.setFailureType(to: Error.self)
|
||||
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]> in
|
||||
switch result {
|
||||
case .success:
|
||||
return response
|
||||
case .failure(let error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func recommendTrends(
|
||||
domain: String,
|
||||
query: Mastodon.API.Trends.Query?
|
||||
|
|
|
@ -13,11 +13,11 @@ extension APIService {
|
|||
|
||||
func search(
|
||||
domain: String,
|
||||
query: Mastodon.API.Search.Query,
|
||||
query: Mastodon.API.V2.Search.Query,
|
||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
|
||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||
|
||||
return Mastodon.API.Search.search(session: session, domain: domain, query: query, authorization: authorization)
|
||||
return Mastodon.API.V2.Search.search(session: session, domain: domain, query: query, authorization: authorization)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
|
||||
extension Mastodon.API.Search {
|
||||
extension Mastodon.API.V2.Search {
|
||||
static func searchURL(domain: String) -> URL {
|
||||
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("search")
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ extension Mastodon.API.Search {
|
|||
public static func search(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: Mastodon.API.Search.Query,
|
||||
query: Mastodon.API.V2.Search.Query,
|
||||
authorization: Mastodon.API.OAuth.Authorization
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
|
||||
let request = Mastodon.API.get(
|
||||
|
@ -49,7 +49,7 @@ extension Mastodon.API.Search {
|
|||
}
|
||||
}
|
||||
|
||||
extension Mastodon.API.Search {
|
||||
extension Mastodon.API.V2.Search {
|
||||
|
||||
public struct Query: Codable, GetQuery {
|
||||
public init(q: String,
|
||||
|
@ -105,7 +105,7 @@ extension Mastodon.API.Search {
|
|||
}
|
||||
}
|
||||
|
||||
public extension Mastodon.API.Search {
|
||||
public extension Mastodon.API.V2.Search {
|
||||
enum SearchType: String, Codable {
|
||||
case accounts
|
||||
case hashtags
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// Mastodon+API+V2+Suggestions.swift
|
||||
//
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/20.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
extension Mastodon.API.V2.Suggestions {
|
||||
static func suggestionsURL(domain: String) -> URL {
|
||||
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("suggestions")
|
||||
}
|
||||
|
||||
/// Follow suggestions, No document for now
|
||||
/// - Parameters:
|
||||
/// - session: `URLSession`
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - query: query
|
||||
/// - authorization: User token.
|
||||
/// - Returns: `AnyPublisher` contains `AccountsSuggestion` nested in the response
|
||||
public static func get(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: Mastodon.API.Suggestions.Query?,
|
||||
authorization: Mastodon.API.OAuth.Authorization
|
||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> {
|
||||
let request = Mastodon.API.get(
|
||||
url: suggestionsURL(domain: domain),
|
||||
query: query,
|
||||
authorization: authorization
|
||||
)
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: [Mastodon.Entity.V2.SuggestionAccount].self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
|
@ -99,6 +99,7 @@ extension Mastodon.API {
|
|||
}
|
||||
|
||||
extension Mastodon.API {
|
||||
public enum V2 { }
|
||||
public enum Account { }
|
||||
public enum App { }
|
||||
public enum CustomEmojis { }
|
||||
|
@ -111,13 +112,17 @@ extension Mastodon.API {
|
|||
public enum Reblog { }
|
||||
public enum Statuses { }
|
||||
public enum Timeline { }
|
||||
public enum Search { }
|
||||
public enum Trends { }
|
||||
public enum Suggestions { }
|
||||
public enum Notifications { }
|
||||
public enum Subscriptions { }
|
||||
}
|
||||
|
||||
extension Mastodon.API.V2 {
|
||||
public enum Search { }
|
||||
public enum Suggestions { }
|
||||
}
|
||||
|
||||
extension Mastodon.API {
|
||||
|
||||
static func get(
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
//
|
||||
// Mastodon+Entity+Suggestion.swift
|
||||
//
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Mastodon.Entity.V2 {
|
||||
|
||||
public struct SuggestionAccount: Codable {
|
||||
|
||||
public let source: String
|
||||
public let account: Mastodon.Entity.Account
|
||||
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case source
|
||||
case account
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue