diff --git a/Localization/app.json b/Localization/app.json index bd1b29fe5..eda508e48 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -51,7 +51,9 @@ "preview": "Preview", "share": "Share", "share_user": "Share %s", - "open_in_safari": "Open in Safari" + "open_in_safari": "Open in Safari", + "find_people": "Find people to follow", + "manually_search": "Manually search instead" }, "status": { "user_reblogged": "%s reblogged", @@ -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", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0f8236e62..d9af89e1b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = ""; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = ""; }; + 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountCollectionViewCell.swift; sourceTree = ""; }; + 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountSection.swift; sourceTree = ""; }; + 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountItem.swift; sourceTree = ""; }; 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarProgressView.swift; sourceTree = ""; }; 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = ""; }; 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = ""; }; @@ -532,6 +541,9 @@ 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewController.swift; sourceTree = ""; }; + 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewModel.swift; sourceTree = ""; }; + 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewCell.swift; sourceTree = ""; }; 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = ""; }; 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; }; 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = ""; }; @@ -1018,6 +1030,14 @@ path = Button; sourceTree = ""; }; + 2D4AD89A2631659400613EFC /* CollectionViewCell */ = { + isa = PBXGroup; + children = ( + 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */, + ); + path = CollectionViewCell; + sourceTree = ""; + }; 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 = ""; }; + 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */ = { + isa = PBXGroup; + children = ( + 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */, + 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */, + 2D4AD89A2631659400613EFC /* CollectionViewCell */, + 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */, + ); + path = SuggestionAccount; + sourceTree = ""; + }; + 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */ = { + isa = PBXGroup; + children = ( + 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; 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 */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c2608fe83..c0ad695f0 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -13,6 +13,7 @@ final public class SceneCoordinator { private weak var scene: UIScene! private weak var sceneDelegate: SceneDelegate! private weak var appContext: AppContext! + private weak var tabBarController: MainTabBarController! let id = UUID().uuidString @@ -61,6 +62,8 @@ extension SceneCoordinator { case profile(viewModel: ProfileViewModel) case favorite(viewModel: FavoriteViewModel) + // suggestion account + case suggestionAccount(viewModel: SuggestionAccountViewModel) // misc case safari(url: URL) case alertController(alertController: UIAlertController) @@ -93,6 +96,7 @@ extension SceneCoordinator { func setup() { let viewController = MainTabBarController(context: appContext, coordinator: self) sceneDelegate.window?.rootViewController = viewController + tabBarController = viewController } func setupOnboardingIfNeeds(animated: Bool) { @@ -187,6 +191,9 @@ extension SceneCoordinator { return viewController } + func switchToTabBar(tab: MainTabBarController.Tab) { + tabBarController.selectedIndex = tab.rawValue + } } private extension SceneCoordinator { @@ -246,6 +253,10 @@ private extension SceneCoordinator { let _viewController = FavoriteViewController() _viewController.viewModel = viewModel viewController = _viewController + case .suggestionAccount(let viewModel): + let _viewController = SuggestionAccountViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .safari(let url): guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { diff --git a/Mastodon/Diffiable/Item/SelectedAccountItem.swift b/Mastodon/Diffiable/Item/SelectedAccountItem.swift new file mode 100644 index 000000000..dbfe25cea --- /dev/null +++ b/Mastodon/Diffiable/Item/SelectedAccountItem.swift @@ -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) + } + } +} diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift index 3ecd4e3b2..64019e580 100644 --- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -29,4 +29,21 @@ extension RecommendAccountSection { return cell } } + + static func tableViewDiffableDataSource( + for tableView: UITableView, + managedObjectContext: NSManagedObjectContext, + viewModel: SuggestionAccountViewModel, + delegate: SuggestionAccountTableViewCellDelegate + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel, weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in + guard let viewModel = viewModel else { return nil } + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell + let user = managedObjectContext.object(with: objectID) as! MastodonUser + let isSelected = viewModel.selectedAccounts.value.contains(objectID) + cell.delegate = delegate + cell.config(with: user, isSelected: isSelected) + return cell + } + } } diff --git a/Mastodon/Diffiable/Section/SelectedAccountSection.swift b/Mastodon/Diffiable/Section/SelectedAccountSection.swift new file mode 100644 index 000000000..4f18ef873 --- /dev/null +++ b/Mastodon/Diffiable/Section/SelectedAccountSection.swift @@ -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 { + 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 + } + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index ce9e33e2b..35111dde1 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -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") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 2fd529279..776d69d50 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -72,6 +72,10 @@ internal enum L10n { internal static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done") /// Edit internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit") + /// Find people to follow + internal static let findPeople = L10n.tr("Localizable", "Common.Controls.Actions.FindPeople") + /// Manually search instead + internal static let manuallySearch = L10n.tr("Localizable", "Common.Controls.Actions.ManuallySearch") /// OK internal static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok") /// Open in Safari @@ -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") diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index b5f4dd32f..b64bfe79d 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -44,7 +44,8 @@ extension UserProviderFacade { return context.apiService.toggleFollow( for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + needFeedback: true ) } .switchToLatest() diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json new file mode 100644 index 000000000..d4f558bfd --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json @@ -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 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 736ef10db..5d27c43b1 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -20,6 +20,8 @@ Please check your internet connection."; "Common.Controls.Actions.Discard" = "Discard"; "Common.Controls.Actions.Done" = "Done"; "Common.Controls.Actions.Edit" = "Edit"; +"Common.Controls.Actions.FindPeople" = "Find people to follow"; +"Common.Controls.Actions.ManuallySearch" = "Manually search instead"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; "Common.Controls.Actions.Preview" = "Preview"; @@ -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."; \ No newline at end of file diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index b43b67143..54acbf4fe 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -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 diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 8bbf9436e..e338aa09f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -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) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 53909b2df..bd559eed5 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -23,6 +23,15 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { var disposeBag = Set() private(set) lazy var viewModel = HomeTimelineViewModel(context: context) + lazy var emptyView: UIStackView = { + let emptyView = UIStackView() + emptyView.axis = .vertical + emptyView.distribution = .fill + emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20) + emptyView.isLayoutMarginsRelativeArrangement = true + return emptyView + }() + let titleView = HomeTimelineNavigationBarTitleView() let settingBarButtonItem: UIBarButtonItem = { @@ -142,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) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 640d9df3b..425eb9aa0 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -107,6 +107,7 @@ extension HomeTimelineViewModel.LoadLatestState { viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() } } + viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 1c0ddf71b..717519464 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -34,6 +34,8 @@ final class HomeTimelineViewModel: NSObject { weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + let timelineIsEmpty = CurrentValueSubject(false) + let homeTimelineNeedRefresh = PassthroughSubject() // 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 { } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index c74560386..948d22b0f 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -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 } diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index 9d6bbedc5..289583aec 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -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 diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index abcd9d08d..002929510 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -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) { diff --git a/Mastodon/Scene/Search/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift index f394f09f1..3425ac193 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recommend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift @@ -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)) + } } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 770fb1da7..3731f118b 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -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 } diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift index b486df774..4fe68e47d 100644 --- a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift @@ -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 { diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 27c322c88..e10b04c9e 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -26,7 +26,7 @@ final class SearchViewModel: NSObject { // output let searchText = CurrentValueSubject("") - let searchScope = CurrentValueSubject(Mastodon.API.Search.SearchType.default) + let searchScope = CurrentValueSubject(Mastodon.API.V2.Search.SearchType.default) let isSearching = CurrentValueSubject(false) @@ -34,6 +34,7 @@ final class SearchViewModel: NSObject { var recommendHashTags = [Mastodon.Entity.Tag]() var recommendAccounts = [NSManagedObjectID]() + var recommendAccountsFallback = PassthroughSubject() var hashtagDiffableDataSource: UICollectionViewDiffableDataSource? var accountDiffableDataSource: UICollectionViewDiffableDataSource? @@ -86,16 +87,16 @@ final class SearchViewModel: NSObject { } .flatMap { (text, scope) -> AnyPublisher, 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() - 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 { + 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 { 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() + 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 { diff --git a/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift new file mode 100644 index 000000000..a973e1c52 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift @@ -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), + ]) + } +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift new file mode 100644 index 000000000..80cd73cc1 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -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() + + 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() + } + } +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift new file mode 100644 index 000000000..d5ef6f6c7 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -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 { get } +} + +final class SuggestionAccountViewModel: NSObject { + var disposeBag = Set() + + // input + let context: AppContext + + let currentMastodonUser = CurrentValueSubject(nil) + weak var delegate: SuggestionAccountViewModelDelegate? + // output + let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + var selectedAccounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + + var headerPlaceholderCount = CurrentValueSubject(nil) + var suggestionAccountsFallback = PassthroughSubject() + + var viewWillAppear = PassthroughSubject() + + var diffableDataSource: UITableViewDiffableDataSource? { + didSet(value) { + if !accounts.value.isEmpty { + applyTableViewDataSource(accounts: accounts.value) + } + } + } + + var collectionDiffableDataSource: UICollectionViewDiffableDataSource? + + init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { + 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() + 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() + 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, 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 + } +} diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift new file mode 100644 index 000000000..db56d63ca --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -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() + 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 + } +} diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index cf19878d6..6db612942 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -24,10 +24,15 @@ extension APIService { /// - Returns: publisher for `Relationship` func toggleFollow( for mastodonUser: MastodonUser, - activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + needFeedback: Bool ) -> AnyPublisher, Error> { - let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + var impactFeedbackGenerator: UIImpactFeedbackGenerator? + var notificationFeedbackGenerator: UINotificationFeedbackGenerator? + if needFeedback { + impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + notificationFeedbackGenerator = UINotificationFeedbackGenerator() + } return followUpdateLocal( mastodonUserObjectID: mastodonUser.objectID, @@ -35,9 +40,9 @@ extension APIService { ) .receive(on: DispatchQueue.main) .handleEvents { _ in - impactFeedbackGenerator.prepare() + impactFeedbackGenerator?.prepare() } receiveOutput: { _ in - impactFeedbackGenerator.impactOccurred() + impactFeedbackGenerator?.impactOccurred() } receiveCompletion: { completion in switch completion { case .failure(let error): @@ -74,13 +79,13 @@ extension APIService { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) } receiveValue: { _ in // do nothing - notificationFeedbackGenerator.prepare() - notificationFeedbackGenerator.notificationOccurred(.error) + notificationFeedbackGenerator?.prepare() + notificationFeedbackGenerator?.notificationOccurred(.error) } .store(in: &self.disposeBag) case .finished: - notificationFeedbackGenerator.notificationOccurred(.success) + notificationFeedbackGenerator?.notificationOccurred(.success) os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) } }) diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift index 1c58fc575..a3bcb3e35 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -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, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.V2.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + return self.backgroundManagedObjectContext.performChanges { + response.value.forEach { suggestionAccount in + let user = suggestionAccount.account + let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log) + let flag = isCreated ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username) + } + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + func recommendTrends( domain: String, query: Mastodon.API.Trends.Query? diff --git a/Mastodon/Service/APIService/APIService+Search.swift b/Mastodon/Service/APIService/APIService+Search.swift index ba40aa5de..986e3d931 100644 --- a/Mastodon/Service/APIService/APIService+Search.swift +++ b/Mastodon/Service/APIService/APIService+Search.swift @@ -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, 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) } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift similarity index 96% rename from MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift rename to MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift index be8bb2607..c0a687f17 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift @@ -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, 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 diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift new file mode 100644 index 000000000..9e6876b41 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift @@ -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, 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() + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 1a4496ed3..921cc9ed3 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -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( diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Suggestion.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Suggestion.swift new file mode 100644 index 000000000..98d67d5fa --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Suggestion.swift @@ -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 + } + } +}