From cb6032bf4fb4fd8127d0da677b906cbe00e833fb Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Sun, 24 Jan 2021 18:10:41 -0800 Subject: [PATCH] Search scope and pagination --- DB/Sources/DB/Content/ContentDatabase.swift | 8 ++- Extensions/SearchViewModel+Extensions.swift | 19 +++++++ Localizations/Localizable.strings | 7 ++- .../Sources/Mastodon/Entities/Results.swift | 15 +++++ Metatext.xcodeproj/project.pbxproj | 4 ++ .../ServiceLayer/Services/SearchService.swift | 11 +++- View Controllers/ExploreViewController.swift | 19 +++++-- View Controllers/TableViewController.swift | 17 +++--- .../ViewModels/CollectionItemsViewModel.swift | 11 +++- .../ViewModels/CollectionViewModel.swift | 2 +- .../Sources/ViewModels/ProfileViewModel.swift | 8 +-- .../Sources/ViewModels/SearchViewModel.swift | 55 ++++++++++++++++++- 12 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 Extensions/SearchViewModel+Extensions.swift diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 9e6381b..fc4f42a 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -547,10 +547,12 @@ public extension ContentDatabase { return accountsPublisher.combineLatest(statusesPublisher) .map { accounts, statuses in - [.init(items: accounts, titleLocalizedStringKey: "search.accounts"), - .init(items: statuses, titleLocalizedStringKey: "search.statuses"), - .init(items: results.hashtags.map(CollectionItem.tag), titleLocalizedStringKey: "search.tags")] + [.init(items: accounts, titleLocalizedStringKey: "search.scope.accounts"), + .init(items: statuses, titleLocalizedStringKey: "search.scope.statuses"), + .init(items: results.hashtags.map(CollectionItem.tag), titleLocalizedStringKey: "search.scope.tags")] + .filter { !$0.items.isEmpty } } + .removeDuplicates() .eraseToAnyPublisher() } diff --git a/Extensions/SearchViewModel+Extensions.swift b/Extensions/SearchViewModel+Extensions.swift new file mode 100644 index 0000000..aebf46a --- /dev/null +++ b/Extensions/SearchViewModel+Extensions.swift @@ -0,0 +1,19 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Foundation +import ViewModels + +extension SearchViewModel.Scope { + var title: String { + switch self { + case .all: + return NSLocalizedString("search.scope.all", comment: "") + case .accounts: + return NSLocalizedString("search.scope.accounts", comment: "") + case .statuses: + return NSLocalizedString("search.scope.statuses", comment: "") + case .tags: + return NSLocalizedString("search.scope.tags", comment: "") + } + } +} diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 4b598cb..6854da9 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -174,9 +174,10 @@ "report.target-%@" = "Reporting %@"; "report.forward.hint" = "The account is from another server. Send an anonymized copy of the report there as well?"; "report.forward-%@" = "Forward report to %@"; -"search.accounts" = "People"; -"search.statuses" = "Posts"; -"search.tags" = "Hashtags"; +"search.scope.all" = "All"; +"search.scope.accounts" = "People"; +"search.scope.statuses" = "Posts"; +"search.scope.tags" = "Hashtags"; "share-extension-error.no-account-found" = "No account found"; "status.bookmark" = "Bookmark"; "status.content-warning-abbreviation" = "CW"; diff --git a/Mastodon/Sources/Mastodon/Entities/Results.swift b/Mastodon/Sources/Mastodon/Entities/Results.swift index 7536ede..4014ed8 100644 --- a/Mastodon/Sources/Mastodon/Entities/Results.swift +++ b/Mastodon/Sources/Mastodon/Entities/Results.swift @@ -7,3 +7,18 @@ public struct Results: Codable { public let statuses: [Status] public let hashtags: [Tag] } + +public extension Results { + static let empty = Self(accounts: [], statuses: [], hashtags: []) + + func appending(_ results: Self) -> Self { + let accountIds = Set(accounts.map(\.id)) + let statusIds = Set(statuses.map(\.id)) + let tagNames = Set(hashtags.map(\.name)) + + return Self( + accounts: accounts + results.accounts.filter { !accountIds.contains($0.id) }, + statuses: statuses + results.statuses.filter { !statusIds.contains($0.id) }, + hashtags: hashtags + results.hashtags.filter { !tagNames.contains($0.name) }) + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 8d76eac..6a3378a 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52ED257D757100FA2C5F /* CompositionView.swift */; }; D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52ED257D757100FA2C5F /* CompositionView.swift */; }; D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; }; + D097F41B25BE3E1A00859F2C /* SearchViewModel+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D097F41A25BE3E1A00859F2C /* SearchViewModel+Extensions.swift */; }; D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; }; D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; @@ -268,6 +269,7 @@ D08E52C6257C7AEE00FA2C5F /* ShareErrorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareErrorViewController.swift; sourceTree = ""; }; D08E52D1257C811200FA2C5F /* ShareExtensionError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ShareExtensionError+Extensions.swift"; sourceTree = ""; }; D08E52ED257D757100FA2C5F /* CompositionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionView.swift; sourceTree = ""; }; + D097F41A25BE3E1A00859F2C /* SearchViewModel+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+Extensions.swift"; sourceTree = ""; }; D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = ""; }; D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = ""; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = ""; }; @@ -604,6 +606,7 @@ D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */, D07EC7CE25B13921006DF726 /* PickerEmoji+Extensions.swift */, D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */, + D097F41A25BE3E1A00859F2C /* SearchViewModel+Extensions.swift */, D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */, D0C7D46A24F76169001EBDBB /* String+Extensions.swift */, D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */, @@ -861,6 +864,7 @@ D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */, D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */, + D097F41B25BE3E1A00859F2C /* SearchViewModel+Extensions.swift in Sources */, D035F8B325B9616000DC75ED /* Timeline+Extensions.swift in Sources */, D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */, D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/SearchService.swift b/ServiceLayer/Sources/ServiceLayer/Services/SearchService.swift index 5c81175..4acde69 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/SearchService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/SearchService.swift @@ -14,14 +14,19 @@ public struct SearchService { private let mastodonAPIClient: MastodonAPIClient private let contentDatabase: ContentDatabase private let nextPageMaxIdSubject = PassthroughSubject() - private let resultsSubject = PassthroughSubject() + private let resultsSubject = PassthroughSubject<(Results, Search), Error>() init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { self.mastodonAPIClient = mastodonAPIClient self.contentDatabase = contentDatabase nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher() navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) - sections = resultsSubject.flatMap(contentDatabase.publisher(results:)).eraseToAnyPublisher() + sections = resultsSubject.scan(.empty) { + let (results, search) = $1 + + return search.offset == nil ? results : $0.appending(results) + } + .flatMap(contentDatabase.publisher(results:)).eraseToAnyPublisher() } } @@ -30,7 +35,7 @@ extension SearchService: CollectionService { guard let search = search else { return Empty().eraseToAnyPublisher() } return mastodonAPIClient.request(ResultsEndpoint.search(search)) - .handleEvents(receiveOutput: resultsSubject.send) + .handleEvents(receiveOutput: { resultsSubject.send(($0, search)) }) .flatMap(contentDatabase.insert(results:)) .eraseToAnyPublisher() } diff --git a/View Controllers/ExploreViewController.swift b/View Controllers/ExploreViewController.swift index ad39c4c..edf0524 100644 --- a/View Controllers/ExploreViewController.swift +++ b/View Controllers/ExploreViewController.swift @@ -31,13 +31,16 @@ final class ExploreViewController: UICollectionViewController { navigationItem.title = NSLocalizedString("main-navigation.explore", comment: "") - let searchController = UISearchController( - searchResultsController: TableViewController( - viewModel: viewModel.searchViewModel, - rootViewModel: rootViewModel, - identification: identification, - parentNavigationController: navigationController)) + let searchResultsController = TableViewController( + viewModel: viewModel.searchViewModel, + rootViewModel: rootViewModel, + identification: identification, + insetBottom: false, + parentNavigationController: navigationController) + let searchController = UISearchController(searchResultsController: searchResultsController) + + searchController.searchBar.scopeButtonTitles = SearchViewModel.Scope.allCases.map(\.title) searchController.searchResultsUpdater = self navigationItem.searchController = searchController } @@ -45,6 +48,10 @@ final class ExploreViewController: UICollectionViewController { extension ExploreViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { + if let scope = SearchViewModel.Scope(rawValue: searchController.searchBar.selectedScopeButtonIndex) { + viewModel.searchViewModel.scope = scope + } + viewModel.searchViewModel.query = searchController.searchBar.text ?? "" } } diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 5b776e8..529a22c 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -21,6 +21,7 @@ class TableViewController: UITableViewController { private var cancellables = Set() private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]() private var shouldKeepPlayingVideoAfterDismissal = false + private let insetBottom: Bool private weak var parentNavigationController: UINavigationController? private lazy var dataSource: TableViewDataSource = { @@ -30,10 +31,12 @@ class TableViewController: UITableViewController { init(viewModel: CollectionViewModel, rootViewModel: RootViewModel, identification: Identification, + insetBottom: Bool = true, parentNavigationController: UINavigationController? = nil) { self.viewModel = viewModel self.rootViewModel = rootViewModel self.identification = identification + self.insetBottom = insetBottom self.parentNavigationController = parentNavigationController super.init(style: .plain) @@ -50,7 +53,7 @@ class TableViewController: UITableViewController { tableView.dataSource = dataSource tableView.cellLayoutMarginsFollowReadableWidth = true tableView.tableFooterView = UIView() - tableView.contentInset.bottom = Self.bottomInset + tableView.contentInset.bottom = bottomInset if viewModel.canRefresh { refreshControl = UIRefreshControl() @@ -105,12 +108,8 @@ class TableViewController: UITableViewController { if !loading, indexPath.section == dataSource.numberOfSections(in: tableView) - 1, - indexPath.row == dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1, - let maxId = viewModel.preferLastPresentIdOverNextPageMaxId - ? dataSource.itemIdentifier(for: indexPath)?.itemId - : viewModel.nextPageMaxId { - // TODO: search offset - viewModel.request(maxId: maxId, minId: nil, search: nil) + indexPath.row == dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1 { + viewModel.requestNextPage(fromIndexPath: indexPath) } if let loadMoreView = cell.contentView as? LoadMoreView { @@ -239,6 +238,8 @@ private extension TableViewController { static let bottomInset: CGFloat = .newStatusButtonDimension + .defaultSpacing * 4 static let loadingFooterDebounceInterval: TimeInterval = 0.5 + var bottomInset: CGFloat { insetBottom ? Self.bottomInset : 0 } + func setupViewModelBindings() { viewModel.title.sink { [weak self] in self?.navigationItem.title = $0 }.store(in: &cancellables) @@ -311,7 +312,7 @@ private extension TableViewController { self.tableView.contentInset.bottom = max( self.tableView.safeAreaLayoutGuide.layoutFrame.height - self.tableView.rectForRow(at: indexPath).height, - Self.bottomInset) + self.bottomInset) } self.tableView.scrollToRow(at: indexPath, at: .top, animated: false) diff --git a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift index 2a0929e..f1fbf46 100644 --- a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift @@ -59,6 +59,15 @@ public class CollectionItemsViewModel: ObservableObject { public var updates: AnyPublisher { $lastUpdate.eraseToAnyPublisher() } + + public func requestNextPage(fromIndexPath indexPath: IndexPath) { + guard let maxId = collectionService.preferLastPresentIdOverNextPageMaxId + ? lastUpdate.sections[indexPath.section].items[indexPath.item].itemId + : nextPageMaxId + else { return } + + request(maxId: maxId, minId: nil, search: nil) + } } extension CollectionItemsViewModel: CollectionViewModel { @@ -78,8 +87,6 @@ extension CollectionItemsViewModel: CollectionViewModel { public var events: AnyPublisher { eventsSubject.eraseToAnyPublisher() } - public var preferLastPresentIdOverNextPageMaxId: Bool { collectionService.preferLastPresentIdOverNextPageMaxId } - public var canRefresh: Bool { collectionService.canRefresh } public func request(maxId: String? = nil, minId: String? = nil, search: Search?) { diff --git a/ViewModels/Sources/ViewModels/CollectionViewModel.swift b/ViewModels/Sources/ViewModels/CollectionViewModel.swift index ef7e9d0..5be0fea 100644 --- a/ViewModels/Sources/ViewModels/CollectionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionViewModel.swift @@ -12,9 +12,9 @@ public protocol CollectionViewModel { var loading: AnyPublisher { get } var events: AnyPublisher { get } var nextPageMaxId: String? { get } - var preferLastPresentIdOverNextPageMaxId: Bool { get } var canRefresh: Bool { get } func request(maxId: String?, minId: String?, search: Search?) + func requestNextPage(fromIndexPath indexPath: IndexPath) func viewedAtTop(indexPath: IndexPath) func select(indexPath: IndexPath) func canSelect(indexPath: IndexPath) -> Bool diff --git a/ViewModels/Sources/ViewModels/ProfileViewModel.swift b/ViewModels/Sources/ViewModels/ProfileViewModel.swift index 2365d95..250399f 100644 --- a/ViewModels/Sources/ViewModels/ProfileViewModel.swift +++ b/ViewModels/Sources/ViewModels/ProfileViewModel.swift @@ -99,10 +99,6 @@ extension ProfileViewModel: CollectionViewModel { collectionViewModel.value.nextPageMaxId } - public var preferLastPresentIdOverNextPageMaxId: Bool { - collectionViewModel.value.preferLastPresentIdOverNextPageMaxId - } - public var canRefresh: Bool { collectionViewModel.value.canRefresh } public func request(maxId: String?, minId: String?, search: Search?) { @@ -116,6 +112,10 @@ extension ProfileViewModel: CollectionViewModel { collectionViewModel.value.request(maxId: maxId, minId: minId, search: nil) } + public func requestNextPage(fromIndexPath indexPath: IndexPath) { + collectionViewModel.value.requestNextPage(fromIndexPath: indexPath) + } + public func viewedAtTop(indexPath: IndexPath) { collectionViewModel.value.viewedAtTop(indexPath: indexPath) } diff --git a/ViewModels/Sources/ViewModels/SearchViewModel.swift b/ViewModels/Sources/ViewModels/SearchViewModel.swift index 5e562d4..84385d4 100644 --- a/ViewModels/Sources/ViewModels/SearchViewModel.swift +++ b/ViewModels/Sources/ViewModels/SearchViewModel.swift @@ -6,6 +6,7 @@ import ServiceLayer public final class SearchViewModel: CollectionItemsViewModel { @Published public var query = "" + @Published public var scope = Scope.all private let searchService: SearchService private var cancellables = Set() @@ -15,8 +16,15 @@ public final class SearchViewModel: CollectionItemsViewModel { super.init(collectionService: searchService, identification: identification) - $query.throttle(for: .seconds(Self.throttleInterval), scheduler: DispatchQueue.global(), latest: true) - .sink { [weak self] in self?.request(maxId: nil, minId: nil, search: .init(query: $0, limit: Self.limit)) } + $query.removeDuplicates() + .throttle(for: .seconds(Self.throttleInterval), scheduler: DispatchQueue.global(), latest: true) + .combineLatest($scope.removeDuplicates()) + .sink { [weak self] in + self?.request( + maxId: nil, + minId: nil, + search: .init(query: $0, type: $1.type, limit: $1.limit)) + } .store(in: &cancellables) } @@ -26,9 +34,50 @@ public final class SearchViewModel: CollectionItemsViewModel { .throttle(for: .seconds(Self.throttleInterval), scheduler: DispatchQueue.global(), latest: true) .eraseToAnyPublisher() } + + public override func requestNextPage(fromIndexPath indexPath: IndexPath) { + guard scope != .all else { return } + + request( + maxId: nil, + minId: nil, + search: .init(query: query, type: scope.type, offset: indexPath.item + 1)) + } +} + +public extension SearchViewModel { + enum Scope: Int, CaseIterable { + case all + case accounts + case statuses + case tags + } } private extension SearchViewModel { static let throttleInterval: TimeInterval = 0.5 - static let limit = 5 +} + +private extension SearchViewModel.Scope { + var type: Search.SearchType? { + switch self { + case .all: + return nil + case .accounts: + return .accounts + case .statuses: + return .statuses + case .tags: + return .hashtags + } + } + + var limit: Int? { + switch self { + case .all: + return 5 + default: + return nil + } + } }