Search scope and pagination
This commit is contained in:
parent
a7ec52fcab
commit
cb6032bf4f
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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: "")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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 ?? ""
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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?) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue