Search scope and pagination

This commit is contained in:
Justin Mazzocchi 2021-01-24 18:10:41 -08:00
parent a7ec52fcab
commit cb6032bf4f
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
12 changed files with 143 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = "<group>"; };
D08E52D1257C811200FA2C5F /* ShareExtensionError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ShareExtensionError+Extensions.swift"; sourceTree = "<group>"; };
D08E52ED257D757100FA2C5F /* CompositionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionView.swift; sourceTree = "<group>"; };
D097F41A25BE3E1A00859F2C /* SearchViewModel+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+Extensions.swift"; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; };
D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
@ -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 */,

View File

@ -14,14 +14,19 @@ public struct SearchService {
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
private let nextPageMaxIdSubject = PassthroughSubject<String, Never>()
private let resultsSubject = PassthroughSubject<Results, Error>()
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()
}

View File

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

View File

@ -21,6 +21,7 @@ class TableViewController: UITableViewController {
private var cancellables = Set<AnyCancellable>()
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)

View File

@ -59,6 +59,15 @@ public class CollectionItemsViewModel: ObservableObject {
public var updates: AnyPublisher<CollectionUpdate, Never> {
$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<CollectionItemEvent, Never> { 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?) {

View File

@ -12,9 +12,9 @@ public protocol CollectionViewModel {
var loading: AnyPublisher<Bool, Never> { get }
var events: AnyPublisher<CollectionItemEvent, Never> { 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

View File

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

View File

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