From 731b49aaa03065f799a4d2dc0e0e6b2b0dd11672 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 20 Apr 2021 15:40:10 +0800 Subject: [PATCH 01/16] chore: suggestion use v2 api --- .../HashtagTimelineViewModel.swift | 2 +- .../Scene/Search/SearchViewController.swift | 6 +-- .../SearchViewModel+LoadOldestState.swift | 10 ++--- Mastodon/Scene/Search/SearchViewModel.swift | 14 +++---- .../APIService/APIService+Recommend.swift | 11 ++--- .../APIService/APIService+Search.swift | 4 +- ...rch.swift => Mastodon+API+V2+Search.swift} | 8 ++-- .../API/Mastodon+API+V2+Suggestions.swift | 41 +++++++++++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 7 +++- .../Entity/Mastodon+Entity+Suggestion.swift | 23 +++++++++++ 10 files changed, 98 insertions(+), 28 deletions(-) rename MastodonSDK/Sources/MastodonSDK/API/{Mastodon+API+Search.swift => Mastodon+API+V2+Search.swift} (96%) create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Suggestion.swift 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/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 770fb1da7..357f142c8 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -217,11 +217,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..afdc1a9f4 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) @@ -86,7 +86,7 @@ final class SearchViewModel: NSObject { } .flatMap { (text, scope) -> AnyPublisher, Error> in - let query = Mastodon.API.Search.Query(q: text, + let query = Mastodon.API.V2.Search.Query(q: text, type: scope, accountID: nil, maxID: nil, @@ -130,8 +130,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) @@ -186,7 +186,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 +194,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) } } @@ -245,7 +245,7 @@ final class SearchViewModel: NSObject { } } receiveValue: { [weak self] accounts in guard let self = self else { return } - let ids = accounts.value.compactMap({$0.id}) + let ids = accounts.value.compactMap({$0.account.id}) let userFetchRequest = MastodonUser.sortedFetchRequest userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) let mastodonUsers: [MastodonUser]? = { diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift index 1c58fc575..134d43fab 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -17,21 +17,22 @@ extension APIService { domain: String, query: Mastodon.API.Suggestions.Query?, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization - return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) - .flatMap { response -> AnyPublisher, Error> in + 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 { user in + 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.Account]> in + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]> in switch result { case .success: return response 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 + } + } +} From e7cd130bf13cc00fb84aeddea4257be3f255f960 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 20 Apr 2021 16:46:04 +0800 Subject: [PATCH 02/16] fix: L18n repair --- Localization/app.json | 3 ++- Mastodon/Resources/en.lproj/Localizable.strings | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 0aa622718..a7b98e0ee 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -329,7 +329,7 @@ }, "favorite": { "title": "Your Favorites" - }, + }, "notification": { "title": { "Everything": "Everything", @@ -341,6 +341,7 @@ "reblog": "rebloged your post", "poll": "Your poll has ended", "mention": "mentioned you" + }, }, "thread": { "back_title": "Post", diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 253b65d95..cc7eef062 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -227,4 +227,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 From c8474c6a7fe5c82f842ac359f86e94f9e3fc2bb9 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 21 Apr 2021 14:46:31 +0800 Subject: [PATCH 03/16] feature: suggestion account scene --- Localization/app.json | 8 +- Mastodon.xcodeproj/project.pbxproj | 28 +++ Mastodon/Coordinator/SceneCoordinator.swift | 11 ++ .../Section/RecommendAccountSection.swift | 17 ++ Mastodon/Generated/Strings.swift | 10 ++ .../UserProvider/UserProviderFacade.swift | 3 +- .../Resources/en.lproj/Localizable.strings | 4 + .../HomeTimelineViewController.swift | 64 +++++++ Mastodon/Scene/Search/SearchViewModel.swift | 2 +- .../SuggestionAccountViewController.swift | 161 ++++++++++++++++++ .../SuggestionAccountViewModel.swift | 101 +++++++++++ .../SuggestionAccountTableViewCell.swift | 149 ++++++++++++++++ .../APIService/APIService+Follow.swift | 21 ++- .../APIService/APIService+Recommend.swift | 35 +++- 14 files changed, 601 insertions(+), 13 deletions(-) create mode 100644 Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift create mode 100644 Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift create mode 100644 Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift diff --git a/Localization/app.json b/Localization/app.json index a7b98e0ee..7c72f7949 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -51,7 +51,9 @@ "preview": "Preview", "share": "Share", "share_user": "Share %s", - "open_in_safari": "Open in Safari" + "open_in_safari": "Open in Safari", + "find_people": "Find people to follow", + "manually_search": "Manually search instead" }, "status": { "user_reblogged": "%s reblogged", @@ -230,6 +232,10 @@ "Publishing": "Publishing post..." } }, + "suggestion_account": { + "title": "Find People to Follow", + "follow_explain": "When you follow someone, you’ll see their posts in your home feed." + }, "public_timeline": { "title": "Public" }, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 7b147991f..93ae2ba09 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -120,6 +120,9 @@ 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; + 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */; }; + 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */; }; + 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */; }; 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */; }; 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; }; 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; }; @@ -530,6 +533,9 @@ 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewController.swift; sourceTree = ""; }; + 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewModel.swift; sourceTree = ""; }; + 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewCell.swift; sourceTree = ""; }; 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = ""; }; 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; }; 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = ""; }; @@ -1182,6 +1188,24 @@ path = Decoration; sourceTree = ""; }; + 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */ = { + isa = PBXGroup; + children = ( + 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */, + 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */, + 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */, + ); + path = SuggestionAccount; + sourceTree = ""; + }; + 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */ = { + isa = PBXGroup; + children = ( + 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; 2DE0FAC62615F5D200CDF649 /* View */ = { isa = PBXGroup; children = ( @@ -1669,6 +1693,7 @@ 2D38F1D325CD463600561493 /* HomeTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, 0F2021F5261325ED000C64BF /* HashtagTimeline */, + 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */, DB9D6BEE25E4F5370051B173 /* Search */, 5B90C455262599800002E742 /* Settings */, DB9D6BFD25E4F57B0051B173 /* Notification */, @@ -2385,6 +2410,7 @@ DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, + 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, @@ -2425,6 +2451,7 @@ DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, + 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, @@ -2503,6 +2530,7 @@ 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, + 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c2608fe83..c0ad695f0 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -13,6 +13,7 @@ final public class SceneCoordinator { private weak var scene: UIScene! private weak var sceneDelegate: SceneDelegate! private weak var appContext: AppContext! + private weak var tabBarController: MainTabBarController! let id = UUID().uuidString @@ -61,6 +62,8 @@ extension SceneCoordinator { case profile(viewModel: ProfileViewModel) case favorite(viewModel: FavoriteViewModel) + // suggestion account + case suggestionAccount(viewModel: SuggestionAccountViewModel) // misc case safari(url: URL) case alertController(alertController: UIAlertController) @@ -93,6 +96,7 @@ extension SceneCoordinator { func setup() { let viewController = MainTabBarController(context: appContext, coordinator: self) sceneDelegate.window?.rootViewController = viewController + tabBarController = viewController } func setupOnboardingIfNeeds(animated: Bool) { @@ -187,6 +191,9 @@ extension SceneCoordinator { return viewController } + func switchToTabBar(tab: MainTabBarController.Tab) { + tabBarController.selectedIndex = tab.rawValue + } } private extension SceneCoordinator { @@ -246,6 +253,10 @@ private extension SceneCoordinator { let _viewController = FavoriteViewController() _viewController.viewModel = viewModel viewController = _viewController + case .suggestionAccount(let viewModel): + let _viewController = SuggestionAccountViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .safari(let url): guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift index 3ecd4e3b2..1732be29f 100644 --- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -29,4 +29,21 @@ extension RecommendAccountSection { return cell } } + + static func tableViewDiffableDataSource( + for tableView: UITableView, + managedObjectContext: NSManagedObjectContext, + viewModel: SuggestionAccountViewModel, + delegate: SuggestionAccountTableViewCellDelegate + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel,weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in + guard let viewModel = viewModel else { return nil } + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell + let user = managedObjectContext.object(with: objectID) as! MastodonUser + let isSelected = viewModel.selectedAccounts.contains(objectID) + cell.delegate = delegate + cell.config(with: user, isSelected: isSelected) + return cell + } + } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index b7bd3d0a8..aa841bbfc 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -72,6 +72,10 @@ internal enum L10n { internal static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done") /// Edit internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit") + /// Find people to follow + internal static let findPeople = L10n.tr("Localizable", "Common.Controls.Actions.FindPeople") + /// Manually search instead + internal static let manuallySearch = L10n.tr("Localizable", "Common.Controls.Actions.ManuallySearch") /// OK internal static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok") /// Open in Safari @@ -675,6 +679,12 @@ internal enum L10n { } } } + internal enum SuggestionAccount { + /// When you follow someone, you’ll see their posts in your home feed. + internal static let followExplain = L10n.tr("Localizable", "Scene.SuggestionAccount.FollowExplain") + /// Find People to Follow + internal static let title = L10n.tr("Localizable", "Scene.SuggestionAccount.Title") + } internal enum Thread { /// Post internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle") diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index b5f4dd32f..b64bfe79d 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -44,7 +44,8 @@ extension UserProviderFacade { return context.apiService.toggleFollow( for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + needFeedback: true ) } .switchToLatest() diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index cc7eef062..c90c013da 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -20,6 +20,8 @@ Please check your internet connection."; "Common.Controls.Actions.Discard" = "Discard"; "Common.Controls.Actions.Done" = "Done"; "Common.Controls.Actions.Edit" = "Edit"; +"Common.Controls.Actions.FindPeople" = "Find people to follow"; +"Common.Controls.Actions.ManuallySearch" = "Manually search instead"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; "Common.Controls.Actions.Preview" = "Preview"; @@ -220,6 +222,8 @@ any server."; "Scene.Settings.Section.Spicyzone.Signout" = "Sign Out"; "Scene.Settings.Section.Spicyzone.Title" = "The spicy zone"; "Scene.Settings.Title" = "Settings"; +"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed."; +"Scene.SuggestionAccount.Title" = "Find People to Follow"; "Scene.Thread.BackTitle" = "Post"; "Scene.Thread.Favorite.Multiple" = "%@ favorites"; "Scene.Thread.Favorite.Single" = "%@ favorite"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 53909b2df..60fa3c9c4 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -23,6 +23,15 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { var disposeBag = Set() private(set) lazy var viewModel = HomeTimelineViewModel(context: context) + lazy var emptyView: UIStackView = { + let emptyView = UIStackView() + emptyView.axis = .vertical + emptyView.distribution = .fill + emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20) + emptyView.isLayoutMarginsRelativeArrangement = true + return emptyView + }() + let titleView = HomeTimelineNavigationBarTitleView() let settingBarButtonItem: UIBarButtonItem = { @@ -142,6 +151,13 @@ extension HomeTimelineViewController { UIView.animate(withDuration: 0.5) { [weak self] in guard let self = self else { return } self.refreshControl.endRefreshing() + } completion: { [weak self] _ in + guard let self = self else { return } + if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty { + self.showEmptyView() + } else { + self.emptyView.removeFromSuperview() + } } } } @@ -217,6 +233,54 @@ extension HomeTimelineViewController { } extension HomeTimelineViewController { + func showEmptyView() { + if emptyView.superview != nil { + return + } + view.addSubview(emptyView) + emptyView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor) + ]) + + let findPeopleButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal) + button.addTarget(self, action: #selector(HomeTimelineViewController.findPeopleButtonPressed(_:)), for: .touchUpInside) + return button + }() + NSLayoutConstraint.activate([ + findPeopleButton.heightAnchor.constraint(equalToConstant: 46) + ]) + + let manuallySearchButton: HighlightDimmableButton = { + let button = HighlightDimmableButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle(L10n.Common.Controls.Actions.manuallySearch, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) + button.addTarget(self, action: #selector(HomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside) + return button + }() + + emptyView.addArrangedSubview(findPeopleButton) + emptyView.setCustomSpacing(17, after: findPeopleButton) + emptyView.addArrangedSubview(manuallySearchButton) + + } +} + +extension HomeTimelineViewController { + + @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) { + let viewModel = SuggestionAccountViewModel(context: context) + coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func manuallySearchButtonPressed(_ sender: UIButton) { + coordinator.switchToTabBar(tab: .search) + } @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index afdc1a9f4..1a1d87fc9 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -233,7 +233,7 @@ final class SearchViewModel: NSObject { promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) return } - self.context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) .sink { completion in switch completion { case .failure(let error): diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift new file mode 100644 index 000000000..16a916db4 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -0,0 +1,161 @@ +// +// SuggestionAccountViewController.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import OSLog +import UIKit + +class SuggestionAccountViewController: UIViewController, NeedsDependency { + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + + var viewModel: SuggestionAccountViewModel! + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(SuggestionAccountTableViewCell.self, forCellReuseIdentifier: String(describing: SuggestionAccountTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.tableFooterView = UIView() + tableView.separatorStyle = .singleLine + tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + return tableView + }() + + lazy var tableHeader: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.frame = CGRect(origin: .zero, size: CGSize(width: tableView.frame.width, height: 156)) + return view + }() + + let followExplainLabel: UILabel = { + let label = UILabel() + label.text = L10n.Scene.SuggestionAccount.followExplain + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + label.numberOfLines = 0 + return label + }() + + let avatarStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .equalSpacing + stackView.alignment = .center + stackView.spacing = 15 + return stackView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", (#file as NSString).lastPathComponent, #line, #function) + } +} + +extension SuggestionAccountViewController { + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.systemBackground.color + title = L10n.Scene.SuggestionAccount.title + navigationItem.rightBarButtonItem + = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, + target: self, + action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:))) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + viewModel.diffableDataSource = RecommendAccountSection.tableViewDiffableDataSource( + for: tableView, + managedObjectContext: context.managedObjectContext, + viewModel: viewModel, + delegate: self + ) + + viewModel.accounts + .receive(on: DispatchQueue.main) + .sink { [weak self] accounts in + guard let self = self else { return } + self.setupHeader(accounts: accounts) + } + .store(in: &disposeBag) + } + + func setupHeader(accounts: [NSManagedObjectID]) { + if accounts.isEmpty { + return + } + followExplainLabel.translatesAutoresizingMaskIntoConstraints = false + tableHeader.addSubview(followExplainLabel) + NSLayoutConstraint.activate([ + followExplainLabel.topAnchor.constraint(equalTo: tableHeader.topAnchor, constant: 20), + followExplainLabel.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), + tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20), + ]) + + avatarStackView.translatesAutoresizingMaskIntoConstraints = false + tableHeader.addSubview(avatarStackView) + NSLayoutConstraint.activate([ + avatarStackView.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20), + avatarStackView.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), + avatarStackView.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor), + avatarStackView.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor), + ]) + let avatarImageViewHeight: Double = 56 + let avatarImageViewCount = Int(floor((Double(tableView.frame.width) - 20) / (avatarImageViewHeight + 15))) + let count = min(avatarImageViewCount, accounts.count) + for i in 0 ..< count { + let account = context.managedObjectContext.object(with: accounts[i]) as! MastodonUser + let imageView = UIImageView() + imageView.layer.cornerRadius = 6 + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), + imageView.heightAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), + ]) + if let url = account.avatarImageURL() { + imageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + avatarStackView.addArrangedSubview(imageView) + } + + tableView.tableHeaderView = tableHeader + } +} + +extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { + func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) { + let selected = !sender.isSelected + sender.isSelected = !sender.isSelected + if selected { + viewModel.selectedAccounts.append(objectID) + } else { + viewModel.selectedAccounts.removeAll { $0 == objectID } + } + } +} + +extension SuggestionAccountViewController { + @objc func doneButtonDidClick(_ sender: UIButton) { + dismiss(animated: true, completion: nil) + viewModel.followAction() + } +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift new file mode 100644 index 000000000..9a92b059e --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -0,0 +1,101 @@ +// +// SuggestionAccountViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK +import os.log +import UIKit + +final class SuggestionAccountViewModel: NSObject { + var disposeBag = Set() + + // input + let context: AppContext + let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + var selectedAccounts = [NSManagedObjectID]() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + + init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { + self.context = context + if let accounts = accounts { + self.accounts.value = accounts + } + super.init() + + self.accounts + .receive(on: DispatchQueue.main) + .sink { [weak self] accounts in + guard let dataSource = self?.diffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(accounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + .store(in: &disposeBag) + + if accounts == nil || (accounts ?? []).isEmpty { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let ids = response.value.map(\.account.id) + let users: [MastodonUser]? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) + request.returnsObjectsAsFaults = false + do { + return try context.managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + if let accounts = users?.map(\.objectID) { + self.accounts.value = accounts + } + } + .store(in: &disposeBag) + } + } + + func followAction() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + for objectID in selectedAccounts { + let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser + context.apiService.toggleFollow( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + needFeedback: false + ) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { _ in + } + .store(in: &disposeBag) + } + } +} diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift new file mode 100644 index 000000000..9dafaedb8 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -0,0 +1,149 @@ +// +// SuggestionAccountTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Foundation +import UIKit +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit + +protocol SuggestionAccountTableViewCellDelegate: AnyObject { + func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) +} + +final class SuggestionAccountTableViewCell: UITableViewCell { + + var disposeBag = Set() + weak var delegate: SuggestionAccountTableViewCellDelegate? + + let _imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.primary.color + imageView.layer.cornerRadius = 4 + imageView.clipsToBounds = true + return imageView + }() + + let titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.brandBlue.color + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let subTitleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .preferredFont(forTextStyle: .body) + return label + }() + + lazy var button: HighlightDimmableButton = { + let button = HighlightDimmableButton(type: .custom) + if let plusImage = UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { + button.setImage(plusImage, for: .normal) + } + if let minusImage = UIImage(systemName: "minus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { + button.setImage(minusImage, for: .selected) + } + button.publisher(for: \.isSelected) + .sink { isSelected in + if isSelected { + button.tintColor = Asset.Colors.danger.color + } else { + button.tintColor = Asset.Colors.Label.secondary.color + } + } + .store(in: &self.disposeBag) + return button + }() + override func prepareForReuse() { + super.prepareForReuse() + _imageView.af.cancelImageRequest() + _imageView.image = nil + disposeBag.removeAll() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SuggestionAccountTableViewCell { + private func configure() { + backgroundColor = .clear + + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.distribution = .fill + containerStackView.spacing = 12 + containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12) + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + _imageView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(_imageView) + NSLayoutConstraint.activate([ + _imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1), + _imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1) + ]) + + let textStackView = UIStackView() + textStackView.axis = .vertical + textStackView.distribution = .fill + textStackView.alignment = .leading + textStackView.translatesAutoresizingMaskIntoConstraints = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(titleLabel) + subTitleLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(subTitleLabel) + subTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) + + containerStackView.addArrangedSubview(textStackView) + textStackView.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + + button.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(button) + } + + func config(with account: MastodonUser, isSelected: Bool) { + if let url = account.avatarImageURL() { + _imageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + subTitleLabel.text = account.acct + button.isSelected = isSelected + button.publisher(for: .touchUpInside) + .sink { [weak self] sender in + guard let self = self else { return } + self.delegate?.accountButtonPressed(objectID: account.objectID, sender: self.button) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index cf19878d6..6db612942 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -24,10 +24,15 @@ extension APIService { /// - Returns: publisher for `Relationship` func toggleFollow( for mastodonUser: MastodonUser, - activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + needFeedback: Bool ) -> AnyPublisher, Error> { - let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + var impactFeedbackGenerator: UIImpactFeedbackGenerator? + var notificationFeedbackGenerator: UINotificationFeedbackGenerator? + if needFeedback { + impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + notificationFeedbackGenerator = UINotificationFeedbackGenerator() + } return followUpdateLocal( mastodonUserObjectID: mastodonUser.objectID, @@ -35,9 +40,9 @@ extension APIService { ) .receive(on: DispatchQueue.main) .handleEvents { _ in - impactFeedbackGenerator.prepare() + impactFeedbackGenerator?.prepare() } receiveOutput: { _ in - impactFeedbackGenerator.impactOccurred() + impactFeedbackGenerator?.impactOccurred() } receiveCompletion: { completion in switch completion { case .failure(let error): @@ -74,13 +79,13 @@ extension APIService { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) } receiveValue: { _ in // do nothing - notificationFeedbackGenerator.prepare() - notificationFeedbackGenerator.notificationOccurred(.error) + notificationFeedbackGenerator?.prepare() + notificationFeedbackGenerator?.notificationOccurred(.error) } .store(in: &self.disposeBag) case .finished: - notificationFeedbackGenerator.notificationOccurred(.success) + notificationFeedbackGenerator?.notificationOccurred(.success) os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) } }) diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift index 134d43fab..a3bcb3e35 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -13,7 +13,38 @@ import CoreDataStack import OSLog extension APIService { - func recommendAccount( + func suggestionAccount( + domain: String, + query: Mastodon.API.Suggestions.Query?, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + return self.backgroundManagedObjectContext.performChanges { + response.value.forEach { user in + let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log) + let flag = isCreated ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username) + } + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func suggestionAccountV2( domain: String, query: Mastodon.API.Suggestions.Query?, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox @@ -44,7 +75,7 @@ extension APIService { } .eraseToAnyPublisher() } - + func recommendTrends( domain: String, query: Mastodon.API.Trends.Query? From 776263aaf2f37179f19b6b2d49a60e46d38b7902 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 21 Apr 2021 17:58:56 +0800 Subject: [PATCH 04/16] chore: compatible with the old server --- .../HomeTimelineViewController.swift | 5 + ...hRecommendAccountsCollectionViewCell.swift | 5 +- .../SearchViewController+Recommend.swift | 8 +- Mastodon/Scene/Search/SearchViewModel.swift | 125 +++++++++++++----- .../SuggestionAccountViewController.swift | 18 +-- .../SuggestionAccountViewModel.swift | 101 ++++++++++---- .../SuggestionAccountTableViewCell.swift | 7 +- 7 files changed, 189 insertions(+), 80 deletions(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 60fa3c9c4..74a6ae004 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -159,6 +159,8 @@ extension HomeTimelineViewController { self.emptyView.removeFromSuperview() } } + } else { + self.emptyView.removeFromSuperview() } } .store(in: &disposeBag) @@ -245,6 +247,9 @@ extension HomeTimelineViewController { 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) 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/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift index f394f09f1..b5ff0e54b 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recommend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift @@ -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/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 1a1d87fc9..04a977e0d 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -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? @@ -87,15 +88,15 @@ final class SearchViewModel: NSObject { .flatMap { (text, scope) -> AnyPublisher, Error> in 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) + 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 @@ -142,7 +143,6 @@ final class SearchViewModel: NSObject { } } dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } .store(in: &disposeBag) @@ -161,21 +161,33 @@ final class SearchViewModel: NSObject { } .store(in: &disposeBag) - requestRecommendAccounts() + requestRecommendAccountsV2() .receive(on: DispatchQueue.main) .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() + .receive(on: DispatchQueue.main) + .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 @@ -227,13 +239,43 @@ final class SearchViewModel: NSObject { } } - func requestRecommendAccounts() -> Future { + 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.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) .sink { completion in switch completion { case .failure(let error): @@ -245,28 +287,43 @@ final class SearchViewModel: NSObject { } } receiveValue: { [weak self] accounts in guard let self = self else { return } - let ids = accounts.value.compactMap({$0.account.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) - } + let ids = accounts.value.compactMap({$0.id}) + self.receiveAccounts(ids: ids) } .store(in: &self.disposeBag) } } + func applyDataSource() { + guard let dataSource = accountDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(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 { + recommendAccounts = users.map(\.objectID) + } + } + func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) { let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) DispatchQueue.main.async { diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift index 16a916db4..9cc6f33a2 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -15,11 +15,11 @@ 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)) @@ -29,14 +29,14 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency { 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 @@ -45,7 +45,7 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency { label.numberOfLines = 0 return label }() - + let avatarStackView: UIStackView = { let stackView = UIStackView() stackView.axis = .horizontal @@ -84,7 +84,7 @@ extension SuggestionAccountViewController { viewModel: viewModel, delegate: self ) - + viewModel.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in @@ -105,7 +105,7 @@ extension SuggestionAccountViewController { followExplainLabel.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20), ]) - + avatarStackView.translatesAutoresizingMaskIntoConstraints = false tableHeader.addSubview(avatarStackView) NSLayoutConstraint.activate([ @@ -136,7 +136,7 @@ extension SuggestionAccountViewController { } avatarStackView.addArrangedSubview(imageView) } - + tableView.tableHeaderView = tableHeader } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index 9a92b059e..33313bade 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -18,64 +18,111 @@ final class SuggestionAccountViewModel: NSObject { // input let context: AppContext - let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) - var selectedAccounts = [NSManagedObjectID]() // output - var diffableDataSource: UITableViewDiffableDataSource? + let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + var selectedAccounts = [NSManagedObjectID]() + var suggestionAccountsFallback = PassthroughSubject() + + var diffableDataSource: UITableViewDiffableDataSource? { + didSet(value) { + if !accounts.value.isEmpty { + applyDataSource(accounts: accounts.value) + } + } + } init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { self.context = context - if let accounts = accounts { - self.accounts.value = accounts - } + super.init() self.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in - guard let dataSource = self?.diffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(accounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + self?.applyDataSource(accounts: accounts) } .store(in: &disposeBag) + if let accounts = accounts { + self.accounts.value = accounts + } + if accounts == nil || (accounts ?? []).isEmpty { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { completion in + .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 - guard let self = self else { return } let ids = response.value.map(\.account.id) - let users: [MastodonUser]? = { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - request.returnsObjectsAsFaults = false - do { - return try context.managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - if let accounts = users?.map(\.objectID) { - self.accounts.value = accounts - } + 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 applyDataSource(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 receiveAccounts(ids: [String]) { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let users: [MastodonUser]? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) + request.returnsObjectsAsFaults = false + do { + return try context.managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + if let accounts = users?.map(\.objectID) { + self.accounts.value = accounts + } + } + func followAction() { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } for objectID in selectedAccounts { diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index 9dafaedb8..256b7babd 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -5,8 +5,6 @@ // Created by sxiaojian on 2021/4/21. // -import Foundation -import UIKit import Combine import CoreData import CoreDataStack @@ -19,7 +17,6 @@ protocol SuggestionAccountTableViewCellDelegate: AnyObject { } final class SuggestionAccountTableViewCell: UITableViewCell { - var disposeBag = Set() weak var delegate: SuggestionAccountTableViewCellDelegate? @@ -65,6 +62,7 @@ final class SuggestionAccountTableViewCell: UITableViewCell { .store(in: &self.disposeBag) return button }() + override func prepareForReuse() { super.prepareForReuse() _imageView.af.cancelImageRequest() @@ -139,11 +137,10 @@ extension SuggestionAccountTableViewCell { subTitleLabel.text = account.acct button.isSelected = isSelected button.publisher(for: .touchUpInside) - .sink { [weak self] sender in + .sink { [weak self] _ in guard let self = self else { return } self.delegate?.accountButtonPressed(objectID: account.objectID, sender: self.button) } .store(in: &disposeBag) } - } From 9402dab97f4c8ab008451e57c4e27e04fe03becf Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 21 Apr 2021 18:08:07 +0800 Subject: [PATCH 05/16] fix: suggestion button tintColor --- .../SuggestionAccountTableViewCell.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index 256b7babd..8f564ec31 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -51,15 +51,6 @@ final class SuggestionAccountTableViewCell: UITableViewCell { if let minusImage = UIImage(systemName: "minus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { button.setImage(minusImage, for: .selected) } - button.publisher(for: \.isSelected) - .sink { isSelected in - if isSelected { - button.tintColor = Asset.Colors.danger.color - } else { - button.tintColor = Asset.Colors.Label.secondary.color - } - } - .store(in: &self.disposeBag) return button }() @@ -142,5 +133,14 @@ extension SuggestionAccountTableViewCell { self.delegate?.accountButtonPressed(objectID: account.objectID, sender: self.button) } .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: &self.disposeBag) } } From 106a5cc71a0e6f00cea042613c34ce144dc946a8 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 21 Apr 2021 18:52:09 +0800 Subject: [PATCH 06/16] fix: homeTimeline refresh after follow people --- .../Scene/HomeTimeline/HomeTimelineViewController.swift | 5 +++++ Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift | 6 ++++++ .../SuggestionAccount/SuggestionAccountViewModel.swift | 6 +++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 74a6ae004..2734b33e1 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -200,6 +200,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) { @@ -280,6 +284,7 @@ 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)) } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 1c0ddf71b..c154b0508 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -129,3 +129,9 @@ final class HomeTimelineViewModel: NSObject { } } + +extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { + func homeTimelineNeedRefresh() { + loadLatestStateMachine.enter(LoadLatestState.Loading.self) + } +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index 33313bade..ec52120dc 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -13,12 +13,16 @@ import MastodonSDK import os.log import UIKit +protocol SuggestionAccountViewModelDelegate: AnyObject { + func homeTimelineNeedRefresh() +} final class SuggestionAccountViewModel: NSObject { var disposeBag = Set() // input let context: AppContext + weak var delegate: SuggestionAccountViewModelDelegate? // output let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) var selectedAccounts = [NSManagedObjectID]() @@ -137,7 +141,7 @@ final class SuggestionAccountViewModel: NSObject { case .failure(let error): os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate + self.delegate?.homeTimelineNeedRefresh() break } } receiveValue: { _ in From 46fe59c92016b835bdf5544033adb5d6c4613e0d Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 10:11:19 +0800 Subject: [PATCH 07/16] chore: add debounce for refresh --- .../Scene/HomeTimeline/HomeTimelineViewModel.swift | 14 +++++++++----- .../SuggestionAccountViewModel.swift | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index c154b0508..a81056d0f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -34,6 +34,7 @@ final class HomeTimelineViewModel: NSObject { weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + let homeTimelineNeedRefresh = PassthroughSubject() // output // top loader private(set) lazy var loadLatestStateMachine: GKStateMachine = { @@ -122,6 +123,13 @@ final class HomeTimelineViewModel: NSObject { } .store(in: &disposeBag) + homeTimelineNeedRefresh + .debounce(for: 0.3, scheduler: DispatchQueue.main) + .sink { [weak self] _ in + self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) + } + .store(in: &disposeBag) + } deinit { @@ -130,8 +138,4 @@ final class HomeTimelineViewModel: NSObject { } -extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { - func homeTimelineNeedRefresh() { - loadLatestStateMachine.enter(LoadLatestState.Loading.self) - } -} +extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index ec52120dc..494b00293 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -14,7 +14,7 @@ import os.log import UIKit protocol SuggestionAccountViewModelDelegate: AnyObject { - func homeTimelineNeedRefresh() + var homeTimelineNeedRefresh: PassthroughSubject { get } } final class SuggestionAccountViewModel: NSObject { var disposeBag = Set() @@ -141,7 +141,7 @@ final class SuggestionAccountViewModel: NSObject { 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: - self.delegate?.homeTimelineNeedRefresh() + self.delegate?.homeTimelineNeedRefresh.send() break } } receiveValue: { _ in From 7f6e9fb90703587690c3568db1698ee18a9f655a Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 10:29:53 +0800 Subject: [PATCH 08/16] fix: suggestions account order --- Mastodon/Scene/Search/SearchViewModel.swift | 5 +++- .../SuggestionAccountViewModel.swift | 25 ++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 04a977e0d..5f1ff5f46 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -320,7 +320,10 @@ final class SearchViewModel: NSObject { } }() if let users = mastodonUsers { - recommendAccounts = users.map(\.objectID) + let sortedUsers = users.sorted { (user1, user2) -> Bool in + (ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0) + } + recommendAccounts = sortedUsers.map(\.objectID) } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index 494b00293..ac8803188 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -16,6 +16,7 @@ import UIKit protocol SuggestionAccountViewModelDelegate: AnyObject { var homeTimelineNeedRefresh: PassthroughSubject { get } } + final class SuggestionAccountViewModel: NSObject { var disposeBag = Set() @@ -110,20 +111,27 @@ final class SuggestionAccountViewModel: NSObject { } func receiveAccounts(ids: [String]) { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let users: [MastodonUser]? = { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - request.returnsObjectsAsFaults = false + 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 context.managedObjectContext.fetch(request) + return try self.context.managedObjectContext.fetch(userFetchRequest) } catch { assertionFailure(error.localizedDescription) return nil } }() - if let accounts = users?.map(\.objectID) { - self.accounts.value = accounts + 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) } } @@ -142,7 +150,6 @@ final class SuggestionAccountViewModel: NSObject { os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: self.delegate?.homeTimelineNeedRefresh.send() - break } } receiveValue: { _ in } From d4f4a3e08671c2cd79b4b2fbfb2d022388caebd7 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 14:36:29 +0800 Subject: [PATCH 09/16] fix: search scene UI update --- ...earchRecommendTagsCollectionViewCell.swift | 2 +- .../SearchViewController+Recommend.swift | 8 ++-- .../Scene/Search/SearchViewController.swift | 45 ++++++++++++++----- 3 files changed, 39 insertions(+), 16 deletions(-) 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 b5ff0e54b..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) } } } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 357f142c8..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), From f84fb723df3ca3c2eea695f0dcc27144639aae31 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 14:36:29 +0800 Subject: [PATCH 10/16] fix: search scene UI update --- ...earchRecommendTagsCollectionViewCell.swift | 2 +- .../SearchViewController+Recommend.swift | 8 ++-- .../Scene/Search/SearchViewController.swift | 45 ++++++++++++++----- 3 files changed, 39 insertions(+), 16 deletions(-) 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..7d0b0eca4 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) } } } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 770fb1da7..e11a0e4c2 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), From 326aea36cdd2b894c996ceb2caebadc8be1b18b4 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 15:45:32 +0800 Subject: [PATCH 11/16] fix: The empty view should not display when the user just sign-in the first time --- .../Section/RecommendAccountSection.swift | 2 +- .../HomeTimelineViewController.swift | 22 ++++++++++--------- ...omeTimelineViewModel+LoadLatestState.swift | 1 + .../HomeTimeline/HomeTimelineViewModel.swift | 1 + 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift index 1732be29f..92def9156 100644 --- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -36,7 +36,7 @@ extension RecommendAccountSection { viewModel: SuggestionAccountViewModel, delegate: SuggestionAccountTableViewCellDelegate ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel,weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in + 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 diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 2734b33e1..bd559eed5 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -151,16 +151,7 @@ extension HomeTimelineViewController { UIView.animate(withDuration: 0.5) { [weak self] in guard let self = self else { return } self.refreshControl.endRefreshing() - } completion: { [weak self] _ in - guard let self = self else { return } - if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty { - self.showEmptyView() - } else { - self.emptyView.removeFromSuperview() - } - } - } else { - self.emptyView.removeFromSuperview() + } completion: { _ in } } } .store(in: &disposeBag) @@ -191,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) { 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 a81056d0f..26ef485ee 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -34,6 +34,7 @@ final class HomeTimelineViewModel: NSObject { weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + let timelineIsEmpty = CurrentValueSubject(false) let homeTimelineNeedRefresh = PassthroughSubject() // output // top loader From 64b4247706484db6bf7c6757423aeecc217a66ea Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 15:52:10 +0800 Subject: [PATCH 12/16] chore: add a debug menu entry for testing EmptyView --- .../HomeTimelineViewController+DebugAction.swift | 8 ++++++++ 1 file changed, 8 insertions(+) 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) From e664722b13ac7f6a61ba03ff75b5fd33b21101da Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 19:58:42 +0800 Subject: [PATCH 13/16] chore: update UI/UX of suggestion account --- Mastodon.xcodeproj/project.pbxproj | 20 +++ .../Diffiable/Item/SelectedAccountItem.swift | 38 +++++ .../Section/SelectedAccountSection.swift | 35 +++++ Mastodon/Generated/Assets.swift | 1 + .../Label/tertiary.colorset/Contents.json | 20 +++ .../HomeTimeline/HomeTimelineViewModel.swift | 1 - .../ProfileRelationshipActionButton.swift | 18 +-- .../SuggestionAccountCollectionViewCell.swift | 60 ++++++++ .../SuggestionAccountViewController.swift | 138 ++++++++++++------ .../SuggestionAccountViewModel.swift | 64 +++++--- .../SuggestionAccountTableViewCell.swift | 57 +++++++- 11 files changed, 370 insertions(+), 82 deletions(-) create mode 100644 Mastodon/Diffiable/Item/SelectedAccountItem.swift create mode 100644 Mastodon/Diffiable/Section/SelectedAccountSection.swift create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json create mode 100644 Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 93ae2ba09..acafa5fb2 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 */; }; @@ -489,6 +492,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 = ""; }; @@ -1020,6 +1026,14 @@ path = Button; sourceTree = ""; }; + 2D4AD89A2631659400613EFC /* CollectionViewCell */ = { + isa = PBXGroup; + children = ( + 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */, + ); + path = CollectionViewCell; + sourceTree = ""; + }; 2D59819925E4A55C000FB903 /* ConfirmEmail */ = { isa = PBXGroup; children = ( @@ -1116,6 +1130,7 @@ DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */, 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, + 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */, 2D35237926256D920031AF25 /* NotificationSection.swift */, 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, @@ -1170,6 +1185,7 @@ children = ( 2D7631B225C159F700929FB9 /* Item.swift */, 2D198642261BF09500F0B013 /* SearchResultItem.swift */, + 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */, 2D7867182625B77500211898 /* NotificationItem.swift */, DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, DB1E347725F519300079D7DF /* PickServerItem.swift */, @@ -1193,6 +1209,7 @@ children = ( 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */, 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */, + 2D4AD89A2631659400613EFC /* CollectionViewCell */, 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */, ); path = SuggestionAccount; @@ -2427,6 +2444,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 */, @@ -2554,6 +2572,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 */, @@ -2618,6 +2637,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/Diffiable/Item/SelectedAccountItem.swift b/Mastodon/Diffiable/Item/SelectedAccountItem.swift new file mode 100644 index 000000000..2e85efc16 --- /dev/null +++ b/Mastodon/Diffiable/Item/SelectedAccountItem.swift @@ -0,0 +1,38 @@ +// +// SelectedAccountItem.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import Foundation +import CoreData + +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/SelectedAccountSection.swift b/Mastodon/Diffiable/Section/SelectedAccountSection.swift new file mode 100644 index 000000000..0efd9aebc --- /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/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/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 26ef485ee..717519464 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -125,7 +125,6 @@ final class HomeTimelineViewModel: NSObject { .store(in: &disposeBag) homeTimelineNeedRefresh - .debounce(for: 0.3, scheduler: DispatchQueue.main) .sink { [weak self] _ in self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) } 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/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift new file mode 100644 index 000000000..bb4e422a4 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift @@ -0,0 +1,60 @@ +// +// SuggestionAccountCollectionViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import Foundation +import UIKit +import CoreDataStack + +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 index 9cc6f33a2..f36dc8da9 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -46,13 +46,16 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency { return label }() - let avatarStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .equalSpacing - stackView.alignment = .center - stackView.spacing = 15 - return stackView + 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 { @@ -70,6 +73,7 @@ extension SuggestionAccountViewController { target: self, action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:))) + tableView.delegate = self tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -85,6 +89,8 @@ extension SuggestionAccountViewController { delegate: self ) + viewModel.collectionDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource(for: selectedCollectionView, managedObjectContext: context.managedObjectContext) + viewModel.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in @@ -94,6 +100,17 @@ extension SuggestionAccountViewController { .store(in: &disposeBag) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + let avatarImageViewHeight: Double = 56 + let avatarImageViewCount = Int(floor((Double(view.frame.width) - 20) / (avatarImageViewHeight + 15))) + viewModel.headerPlaceholderCount = avatarImageViewCount + viewModel.applySelectedCollectionViewDataSource(accounts: []) + } func setupHeader(accounts: [NSManagedObjectID]) { if accounts.isEmpty { return @@ -106,56 +123,89 @@ extension SuggestionAccountViewController { tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20), ]) - avatarStackView.translatesAutoresizingMaskIntoConstraints = false - tableHeader.addSubview(avatarStackView) + selectedCollectionView.translatesAutoresizingMaskIntoConstraints = false + tableHeader.addSubview(selectedCollectionView) NSLayoutConstraint.activate([ - avatarStackView.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20), - avatarStackView.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), - avatarStackView.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor), - avatarStackView.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor), + 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), ]) - let avatarImageViewHeight: Double = 56 - let avatarImageViewCount = Int(floor((Double(tableView.frame.width) - 20) / (avatarImageViewHeight + 15))) - let count = min(avatarImageViewCount, accounts.count) - for i in 0 ..< count { - let account = context.managedObjectContext.object(with: accounts[i]) as! MastodonUser - let imageView = UIImageView() - imageView.layer.cornerRadius = 6 - imageView.clipsToBounds = true - imageView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), - imageView.heightAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), - ]) - if let url = account.avatarImageURL() { - imageView.af.setImage( - withURL: url, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) - } - avatarStackView.addArrangedSubview(imageView) - } + selectedCollectionView.delegate = self tableView.tableHeaderView = tableHeader } } -extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { - func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) { - let selected = !sender.isSelected - sender.isSelected = !sender.isSelected - if selected { - viewModel.selectedAccounts.append(objectID) - } else { - viewModel.selectedAccounts.removeAll { $0 == objectID } +extension SuggestionAccountViewController: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + return 15 + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return 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.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: + if selected { + self.viewModel.selectedAccounts.append(objectID) + } else { + self.viewModel.selectedAccounts.removeAll { $0 == objectID } + } + cell.button.isSelected = selected + self.viewModel.selectedAccountsDidChange.send() + } + }, receiveValue: { relationShip in + }) + .store(in: &disposeBag) + } +} + extension SuggestionAccountViewController { @objc func doneButtonDidClick(_ sender: UIButton) { dismiss(animated: true, completion: nil) - viewModel.followAction() + if viewModel.selectedAccounts.count > 0 { + viewModel.delegate?.homeTimelineNeedRefresh.send() + } } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index ac8803188..e08e820e4 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -27,16 +27,20 @@ final class SuggestionAccountViewModel: NSObject { // output let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) var selectedAccounts = [NSManagedObjectID]() + let selectedAccountsDidChange = PassthroughSubject() + var headerPlaceholderCount: Int? var suggestionAccountsFallback = PassthroughSubject() var diffableDataSource: UITableViewDiffableDataSource? { didSet(value) { if !accounts.value.isEmpty { - applyDataSource(accounts: accounts.value) + applyTableViewDataSource(accounts: accounts.value) } } } + var collectionDiffableDataSource: UICollectionViewDiffableDataSource? + init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { self.context = context @@ -45,7 +49,8 @@ final class SuggestionAccountViewModel: NSObject { self.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in - self?.applyDataSource(accounts: accounts) + self?.applyTableViewDataSource(accounts: accounts) + self?.applySelectedCollectionViewDataSource(accounts: []) } .store(in: &disposeBag) @@ -53,6 +58,13 @@ final class SuggestionAccountViewModel: NSObject { self.accounts.value = accounts } + selectedAccountsDidChange + .sink { [weak self] _ in + if let selectedAccout = self?.selectedAccounts { + self?.applySelectedCollectionViewDataSource(accounts: selectedAccout) + } + } + .store(in: &disposeBag) if accounts == nil || (accounts ?? []).isEmpty { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } @@ -102,13 +114,30 @@ final class SuggestionAccountViewModel: NSObject { .store(in: &disposeBag) } - func applyDataSource(accounts: [NSManagedObjectID]) { + 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 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 { @@ -135,25 +164,14 @@ final class SuggestionAccountViewModel: NSObject { } } - func followAction() { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - for objectID in selectedAccounts { - let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser - context.apiService.toggleFollow( - for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - needFeedback: false - ) - .sink { completion in - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - self.delegate?.homeTimelineNeedRefresh.send() - } - } receiveValue: { _ in - } - .store(in: &disposeBag) - } + 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 + ) } } diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index 8f564ec31..d81d8e0e0 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -13,7 +13,7 @@ import MastodonSDK import UIKit protocol SuggestionAccountTableViewCellDelegate: AnyObject { - func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) + func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) } final class SuggestionAccountTableViewCell: UITableViewCell { @@ -43,7 +43,13 @@ final class SuggestionAccountTableViewCell: UITableViewCell { return label }() - lazy var button: HighlightDimmableButton = { + 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) @@ -53,6 +59,13 @@ final class SuggestionAccountTableViewCell: UITableViewCell { } return button }() + + let activityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.color = .white + activityIndicatorView.hidesWhenStopped = true + return activityIndicatorView + }() override func prepareForReuse() { super.prepareForReuse() @@ -112,8 +125,25 @@ extension SuggestionAccountTableViewCell { 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 - containerStackView.addArrangedSubview(button) + 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) { @@ -130,7 +160,7 @@ extension SuggestionAccountTableViewCell { button.publisher(for: .touchUpInside) .sink { [weak self] _ in guard let self = self else { return } - self.delegate?.accountButtonPressed(objectID: account.objectID, sender: self.button) + self.delegate?.accountButtonPressed(objectID: account.objectID, cell: self) } .store(in: &disposeBag) button.publisher(for: \.isSelected) @@ -141,6 +171,23 @@ extension SuggestionAccountTableViewCell { self?.button.tintColor = Asset.Colors.Label.secondary.color } } - .store(in: &self.disposeBag) + .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 } } From 67f813a9466c74757d946f803df33da6b75a0545 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 20:32:54 +0800 Subject: [PATCH 14/16] fix: update followState when view will appear --- .../Diffiable/Item/SelectedAccountItem.swift | 2 +- .../Section/SelectedAccountSection.swift | 2 +- .../SuggestionAccountCollectionViewCell.swift | 11 ++++--- .../SuggestionAccountViewController.swift | 14 ++++---- .../SuggestionAccountViewModel.swift | 32 +++++++++++++++++-- .../SuggestionAccountTableViewCell.swift | 5 ++- 6 files changed, 47 insertions(+), 19 deletions(-) diff --git a/Mastodon/Diffiable/Item/SelectedAccountItem.swift b/Mastodon/Diffiable/Item/SelectedAccountItem.swift index 2e85efc16..dbfe25cea 100644 --- a/Mastodon/Diffiable/Item/SelectedAccountItem.swift +++ b/Mastodon/Diffiable/Item/SelectedAccountItem.swift @@ -5,8 +5,8 @@ // Created by sxiaojian on 2021/4/22. // -import Foundation import CoreData +import Foundation enum SelectedAccountItem { case accountObjectID(accountObjectID: NSManagedObjectID) diff --git a/Mastodon/Diffiable/Section/SelectedAccountSection.swift b/Mastodon/Diffiable/Section/SelectedAccountSection.swift index 0efd9aebc..4f18ef873 100644 --- a/Mastodon/Diffiable/Section/SelectedAccountSection.swift +++ b/Mastodon/Diffiable/Section/SelectedAccountSection.swift @@ -26,7 +26,7 @@ extension SelectedAccountSection { case .accountObjectID(let objectID): let user = managedObjectContext.object(with: objectID) as! MastodonUser cell.config(with: user) - case .placeHolder( _): + case .placeHolder: cell.configAsPlaceHolder() } return cell diff --git a/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift index bb4e422a4..a973e1c52 100644 --- a/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift @@ -5,9 +5,9 @@ // Created by sxiaojian on 2021/4/22. // +import CoreDataStack import Foundation import UIKit -import CoreDataStack class SuggestionAccountCollectionViewCell: UICollectionViewCell { let imageView: UIImageView = { @@ -18,11 +18,12 @@ class SuggestionAccountCollectionViewCell: UICollectionViewCell { 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)!, @@ -30,15 +31,16 @@ class SuggestionAccountCollectionViewCell: UICollectionViewCell { 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() @@ -46,7 +48,6 @@ class SuggestionAccountCollectionViewCell: UICollectionViewCell { } extension SuggestionAccountCollectionViewCell { - private func configure() { contentView.addSubview(imageView) imageView.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift index f36dc8da9..017c1ee3d 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -90,7 +90,7 @@ extension SuggestionAccountViewController { ) viewModel.collectionDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource(for: selectedCollectionView, managedObjectContext: context.managedObjectContext) - + viewModel.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in @@ -103,7 +103,9 @@ extension SuggestionAccountViewController { 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 @@ -111,6 +113,7 @@ extension SuggestionAccountViewController { viewModel.headerPlaceholderCount = avatarImageViewCount viewModel.applySelectedCollectionViewDataSource(accounts: []) } + func setupHeader(accounts: [NSManagedObjectID]) { if accounts.isEmpty { return @@ -138,15 +141,14 @@ extension SuggestionAccountViewController { } extension SuggestionAccountViewController: UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { - return 15 + 15 } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - return CGSize(width: 56, height: 56) + 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 } @@ -195,7 +197,7 @@ extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegat cell.button.isSelected = selected self.viewModel.selectedAccountsDidChange.send() } - }, receiveValue: { relationShip in + }, receiveValue: { _ in }) .store(in: &disposeBag) } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index e08e820e4..3a89d1431 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -23,6 +23,7 @@ final class SuggestionAccountViewModel: NSObject { // input let context: AppContext + let currentMastodonUser = CurrentValueSubject(nil) weak var delegate: SuggestionAccountViewModelDelegate? // output let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) @@ -60,11 +61,23 @@ final class SuggestionAccountViewModel: NSObject { selectedAccountsDidChange .sink { [weak self] _ in - if let selectedAccout = self?.selectedAccounts { - self?.applySelectedCollectionViewDataSource(accounts: selectedAccout) - } + guard let self = self else { return } + self.applyTableViewDataSource(accounts: self.accounts.value) + self.applySelectedCollectionViewDataSource(accounts: self.selectedAccounts) } .store(in: &disposeBag) + + 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 } @@ -174,4 +187,17 @@ final class SuggestionAccountViewModel: NSObject { needFeedback: false ) } + + func checkAccountsFollowState() { + guard let currentMastodonUser = currentMastodonUser.value else { + return + } + let users = accounts.value.compactMap { context.managedObjectContext.object(with: $0) as? MastodonUser } + let followingUsers = users.filter { user -> Bool in + let isFollowing = user.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + return isFollowing + }.map(\.objectID) + selectedAccounts = followingUsers + selectedAccountsDidChange.send() + } } diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index d81d8e0e0..a550fd889 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -101,14 +101,14 @@ extension SuggestionAccountTableViewCell { containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + 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) + _imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), ]) let textStackView = UIStackView() @@ -178,7 +178,6 @@ extension SuggestionAccountTableViewCell { self?.button.isHidden = !isHidden } .store(in: &disposeBag) - } func startAnimating() { From 61a26fbe66162bde496de42c50e44b34123a22c9 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 23 Apr 2021 10:25:08 +0800 Subject: [PATCH 15/16] fix: refresh block state when view will appear --- .../Section/RecommendAccountSection.swift | 2 +- .../SuggestionAccountViewController.swift | 14 ++--- .../SuggestionAccountViewModel.swift | 59 ++++++++++++------- 3 files changed, 47 insertions(+), 28 deletions(-) diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift index 92def9156..64019e580 100644 --- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -40,7 +40,7 @@ extension RecommendAccountSection { guard let viewModel = viewModel else { return nil } let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell let user = managedObjectContext.object(with: objectID) as! MastodonUser - let isSelected = viewModel.selectedAccounts.contains(objectID) + let isSelected = viewModel.selectedAccounts.value.contains(objectID) cell.delegate = delegate cell.config(with: user, isSelected: isSelected) return cell diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift index 017c1ee3d..80cd73cc1 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -110,8 +110,7 @@ extension SuggestionAccountViewController { super.viewWillLayoutSubviews() let avatarImageViewHeight: Double = 56 let avatarImageViewCount = Int(floor((Double(view.frame.width) - 20) / (avatarImageViewHeight + 15))) - viewModel.headerPlaceholderCount = avatarImageViewCount - viewModel.applySelectedCollectionViewDataSource(accounts: []) + viewModel.headerPlaceholderCount.value = avatarImageViewCount } func setupHeader(accounts: [NSManagedObjectID]) { @@ -179,7 +178,7 @@ extension SuggestionAccountViewController: UITableViewDelegate { extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) { - let selected = !viewModel.selectedAccounts.contains(objectID) + let selected = !viewModel.selectedAccounts.value.contains(objectID) cell.startAnimating() viewModel.followAction(objectID: objectID)? .sink(receiveCompletion: { [weak self] completion in @@ -189,13 +188,14 @@ extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegat 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 { - self.viewModel.selectedAccounts.append(objectID) + selectedAccounts.append(objectID) } else { - self.viewModel.selectedAccounts.removeAll { $0 == objectID } + selectedAccounts.removeAll { $0 == objectID } } cell.button.isSelected = selected - self.viewModel.selectedAccountsDidChange.send() + self.viewModel.selectedAccounts.value = selectedAccounts } }, receiveValue: { _ in }) @@ -206,7 +206,7 @@ extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegat extension SuggestionAccountViewController { @objc func doneButtonDidClick(_ sender: UIButton) { dismiss(animated: true, completion: nil) - if viewModel.selectedAccounts.count > 0 { + if viewModel.selectedAccounts.value.count > 0 { viewModel.delegate?.homeTimelineNeedRefresh.send() } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index 3a89d1431..d5ef6f6c7 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -27,11 +27,13 @@ final class SuggestionAccountViewModel: NSObject { weak var delegate: SuggestionAccountViewModelDelegate? // output let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) - var selectedAccounts = [NSManagedObjectID]() - let selectedAccountsDidChange = PassthroughSubject() - var headerPlaceholderCount: Int? + var selectedAccounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + + var headerPlaceholderCount = CurrentValueSubject(nil) var suggestionAccountsFallback = PassthroughSubject() + var viewWillAppear = PassthroughSubject() + var diffableDataSource: UITableViewDiffableDataSource? { didSet(value) { if !accounts.value.isEmpty { @@ -47,11 +49,22 @@ final class SuggestionAccountViewModel: NSObject { super.init() - self.accounts - .receive(on: DispatchQueue.main) - .sink { [weak self] accounts in + Publishers.CombineLatest(self.accounts,self.selectedAccounts) + .sink { [weak self] accounts,selectedAccounts in self?.applyTableViewDataSource(accounts: accounts) - self?.applySelectedCollectionViewDataSource(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) @@ -59,14 +72,6 @@ final class SuggestionAccountViewModel: NSObject { self.accounts.value = accounts } - selectedAccountsDidChange - .sink { [weak self] _ in - guard let self = self else { return } - self.applyTableViewDataSource(accounts: self.accounts.value) - self.applySelectedCollectionViewDataSource(accounts: self.selectedAccounts) - } - .store(in: &disposeBag) - context.authenticationService.activeMastodonAuthentication .sink { [weak self] activeMastodonAuthentication in guard let self = self else { return } @@ -136,7 +141,7 @@ final class SuggestionAccountViewModel: NSObject { } func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) { - guard let count = headerPlaceholderCount else { return } + guard let count = headerPlaceholderCount.value else { return } guard let dataSource = collectionDiffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) @@ -192,12 +197,26 @@ final class SuggestionAccountViewModel: NSObject { guard let currentMastodonUser = currentMastodonUser.value else { return } - let users = accounts.value.compactMap { context.managedObjectContext.object(with: $0) as? MastodonUser } + 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 - return isFollowing + let isPending = user.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false + return isFollowing || isPending }.map(\.objectID) - selectedAccounts = followingUsers - selectedAccountsDidChange.send() + + selectedAccounts.value = followingUsers } } From d6d91180cbf8ed80657c9a91c25501da11d06241 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 23 Apr 2021 19:15:52 +0800 Subject: [PATCH 16/16] fix: the data source update queue , change activityIndicatorView color --- Mastodon/Scene/Search/SearchViewModel.swift | 14 +++++++------- .../SuggestionAccountTableViewCell.swift | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 5f1ff5f46..e10b04c9e 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -162,7 +162,6 @@ final class SearchViewModel: NSObject { .store(in: &disposeBag) requestRecommendAccountsV2() - .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } if !self.recommendAccounts.isEmpty { @@ -176,7 +175,6 @@ final class SearchViewModel: NSObject { .sink { [weak self] _ in guard let self = self else { return } self.requestRecommendAccounts() - .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } if !self.recommendAccounts.isEmpty { @@ -295,11 +293,13 @@ final class SearchViewModel: NSObject { } func applyDataSource() { - guard let dataSource = accountDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(recommendAccounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + 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]) { diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index a550fd889..db56d63ca 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -62,7 +62,6 @@ final class SuggestionAccountTableViewCell: UITableViewCell { let activityIndicatorView: UIActivityIndicatorView = { let activityIndicatorView = UIActivityIndicatorView(style: .medium) - activityIndicatorView.color = .white activityIndicatorView.hidesWhenStopped = true return activityIndicatorView }()