More results footers

This commit is contained in:
Justin Mazzocchi 2021-01-24 23:42:39 -08:00
parent cb6032bf4f
commit 93e4fa7496
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
19 changed files with 154 additions and 28 deletions

View file

@ -508,7 +508,8 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func publisher(results: Results) -> AnyPublisher<[CollectionSection], Error> { // swiftlint:disable:next function_body_length
func publisher(results: Results, limit: Int?) -> AnyPublisher<[CollectionSection], Error> {
let accountIds = results.accounts.map(\.id) let accountIds = results.accounts.map(\.id)
let statusIds = results.statuses.map(\.id) let statusIds = results.statuses.map(\.id)
@ -518,12 +519,18 @@ public extension ContentDatabase {
.fetchAll) .fetchAll)
.removeDuplicates() .removeDuplicates()
.publisher(in: databaseWriter) .publisher(in: databaseWriter)
.map { .map { infos -> [CollectionItem] in
$0.sorted { var accounts = infos.sorted {
accountIds.firstIndex(of: $0.record.id) ?? 0 accountIds.firstIndex(of: $0.record.id) ?? 0
< accountIds.firstIndex(of: $1.record.id) ?? 0 < accountIds.firstIndex(of: $1.record.id) ?? 0
} }
.map { CollectionItem.account(.init(info: $0)) } .map { CollectionItem.account(.init(info: $0)) }
if let limit = limit, accounts.count >= limit {
accounts.append(.moreResults(.init(scope: .accounts)))
}
return accounts
} }
let statusesPublisher = ValueObservation.tracking( let statusesPublisher = ValueObservation.tracking(
@ -532,8 +539,8 @@ public extension ContentDatabase {
.fetchAll) .fetchAll)
.removeDuplicates() .removeDuplicates()
.publisher(in: databaseWriter) .publisher(in: databaseWriter)
.map { .map { infos -> [CollectionItem] in
$0.sorted { var statuses = infos.sorted {
statusIds.firstIndex(of: $0.record.id) ?? 0 statusIds.firstIndex(of: $0.record.id) ?? 0
< statusIds.firstIndex(of: $1.record.id) ?? 0 < statusIds.firstIndex(of: $1.record.id) ?? 0
} }
@ -543,13 +550,25 @@ public extension ContentDatabase {
.init(showContentToggled: $0.showContentToggled, .init(showContentToggled: $0.showContentToggled,
showAttachmentsToggled: $0.showAttachmentsToggled)) showAttachmentsToggled: $0.showAttachmentsToggled))
} }
if let limit = limit, statuses.count >= limit {
statuses.append(.moreResults(.init(scope: .statuses)))
}
return statuses
} }
var hashtags = results.hashtags.map(CollectionItem.tag)
if let limit = limit, hashtags.count >= limit {
hashtags.append(.moreResults(.init(scope: .tags)))
}
return accountsPublisher.combineLatest(statusesPublisher) return accountsPublisher.combineLatest(statusesPublisher)
.map { accounts, statuses in .map { accounts, statuses in
[.init(items: accounts, titleLocalizedStringKey: "search.scope.accounts"), [.init(items: accounts, titleLocalizedStringKey: "search.scope.accounts"),
.init(items: statuses, titleLocalizedStringKey: "search.scope.statuses"), .init(items: statuses, titleLocalizedStringKey: "search.scope.statuses"),
.init(items: results.hashtags.map(CollectionItem.tag), titleLocalizedStringKey: "search.scope.tags")] .init(items: hashtags, titleLocalizedStringKey: "search.scope.tags")]
.filter { !$0.items.isEmpty } .filter { !$0.items.isEmpty }
} }
.removeDuplicates() .removeDuplicates()

View file

@ -9,6 +9,7 @@ public enum CollectionItem: Hashable {
case notification(MastodonNotification, StatusConfiguration?) case notification(MastodonNotification, StatusConfiguration?)
case conversation(Conversation) case conversation(Conversation)
case tag(Tag) case tag(Tag)
case moreResults(MoreResults)
} }
public extension CollectionItem { public extension CollectionItem {
@ -51,6 +52,8 @@ public extension CollectionItem {
return conversation.id return conversation.id
case let .tag(tag): case let .tag(tag):
return tag.name return tag.name
case .moreResults:
return nil
} }
} }
} }

View file

@ -0,0 +1,7 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Foundation
public struct MoreResults: Hashable {
public let scope: SearchScope
}

View file

@ -0,0 +1,10 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Foundation
public enum SearchScope: Int, CaseIterable {
case all
case accounts
case statuses
case tags
}

View file

@ -30,6 +30,13 @@ final class TableViewDataSource: UITableViewDiffableDataSource<CollectionSection
conversationListCell.viewModel = conversationViewModel conversationListCell.viewModel = conversationViewModel
case let (tagTableViewCell as TagTableViewCell, tagViewModel as TagViewModel): case let (tagTableViewCell as TagTableViewCell, tagViewModel as TagViewModel):
tagTableViewCell.viewModel = tagViewModel tagTableViewCell.viewModel = tagViewModel
case let (_, moreResultsViewModel as MoreResultsViewModel):
var configuration = cell.defaultContentConfiguration()
configuration.text = moreResultsViewModel.scope.moreDescription
cell.contentConfiguration = configuration
cell.accessoryType = .disclosureIndicator
default: default:
break break
} }

View file

@ -10,7 +10,8 @@ extension CollectionItem {
LoadMoreCell.self, LoadMoreCell.self,
NotificationListCell.self, NotificationListCell.self,
ConversationListCell.self, ConversationListCell.self,
TagTableViewCell.self] TagTableViewCell.self,
UITableViewCell.self]
var cellClass: AnyClass { var cellClass: AnyClass {
switch self { switch self {
@ -26,6 +27,8 @@ extension CollectionItem {
return ConversationListCell.self return ConversationListCell.self
case .tag: case .tag:
return TagTableViewCell.self return TagTableViewCell.self
case .moreResults:
return UITableViewCell.self
} }
} }
@ -54,6 +57,8 @@ extension CollectionItem {
conversation: conversation) conversation: conversation)
case let .tag(tag): case let .tag(tag):
return TagView.estimatedHeight(width: width, tag: tag) return TagView.estimatedHeight(width: width, tag: tag)
case .moreResults:
return UITableView.automaticDimension
} }
} }
} }

View file

@ -3,7 +3,7 @@
import Foundation import Foundation
import ViewModels import ViewModels
extension SearchViewModel.Scope { extension SearchScope {
var title: String { var title: String {
switch self { switch self {
case .all: case .all:
@ -16,4 +16,17 @@ extension SearchViewModel.Scope {
return NSLocalizedString("search.scope.tags", comment: "") return NSLocalizedString("search.scope.tags", comment: "")
} }
} }
var moreDescription: String? {
switch self {
case .all:
return nil
case .accounts:
return NSLocalizedString("more-results.accounts", comment: "")
case .statuses:
return NSLocalizedString("more-results.statuses", comment: "")
case .tags:
return NSLocalizedString("more-results.tags", comment: "")
}
}
} }

View file

@ -160,6 +160,9 @@
"filter.context.thread" = "Conversations"; "filter.context.thread" = "Conversations";
"filter.context.account" = "Profiles"; "filter.context.account" = "Profiles";
"filter.context.unknown" = "Unknown context"; "filter.context.unknown" = "Unknown context";
"more-results.accounts" = "More people";
"more-results.statuses" = "More posts";
"more-results.tags" = "More hashtags";
"notifications" = "Notifications"; "notifications" = "Notifications";
"notifications.reblogged-your-status" = "%@ boosted your status"; "notifications.reblogged-your-status" = "%@ boosted your status";
"notifications.favourited-your-status" = "%@ favorited your status"; "notifications.favourited-your-status" = "%@ favorited your status";

View file

@ -95,7 +95,7 @@
D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52ED257D757100FA2C5F /* CompositionView.swift */; }; D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52ED257D757100FA2C5F /* CompositionView.swift */; };
D08E52EF257D757100FA2C5F /* 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 */; }; D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; };
D097F41B25BE3E1A00859F2C /* SearchViewModel+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D097F41A25BE3E1A00859F2C /* SearchViewModel+Extensions.swift */; }; D097F41B25BE3E1A00859F2C /* SearchScope+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D097F41A25BE3E1A00859F2C /* SearchScope+Extensions.swift */; };
D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; }; D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; };
D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */; }; D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */; };
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
@ -269,7 +269,7 @@
D08E52C6257C7AEE00FA2C5F /* ShareErrorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareErrorViewController.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; D097F41A25BE3E1A00859F2C /* SearchScope+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchScope+Extensions.swift"; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.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>"; }; D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
@ -606,7 +606,7 @@
D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */, D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */,
D07EC7CE25B13921006DF726 /* PickerEmoji+Extensions.swift */, D07EC7CE25B13921006DF726 /* PickerEmoji+Extensions.swift */,
D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */, D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */,
D097F41A25BE3E1A00859F2C /* SearchViewModel+Extensions.swift */, D097F41A25BE3E1A00859F2C /* SearchScope+Extensions.swift */,
D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */, D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */,
D0C7D46A24F76169001EBDBB /* String+Extensions.swift */, D0C7D46A24F76169001EBDBB /* String+Extensions.swift */,
D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */, D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */,
@ -864,7 +864,7 @@
D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */, D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */,
D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */,
D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */, D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */,
D097F41B25BE3E1A00859F2C /* SearchViewModel+Extensions.swift in Sources */, D097F41B25BE3E1A00859F2C /* SearchScope+Extensions.swift in Sources */,
D035F8B325B9616000DC75ED /* Timeline+Extensions.swift in Sources */, D035F8B325B9616000DC75ED /* Timeline+Extensions.swift in Sources */,
D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */, D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */,
D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */, D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */,

View file

@ -0,0 +1,5 @@
// Copyright © 2021 Metabolist. All rights reserved.
import DB
public typealias MoreResults = DB.MoreResults

View file

@ -0,0 +1,5 @@
// Copyright © 2021 Metabolist. All rights reserved.
import DB
public typealias SearchScope = DB.SearchScope

View file

@ -10,6 +10,7 @@ public enum Navigation {
case url(URL) case url(URL)
case collection(CollectionService) case collection(CollectionService)
case profile(ProfileService) case profile(ProfileService)
case searchScope(SearchScope)
case webfingerStart case webfingerStart
case webfingerEnd case webfingerEnd
} }

View file

@ -21,12 +21,12 @@ public struct SearchService {
self.contentDatabase = contentDatabase self.contentDatabase = contentDatabase
nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher() nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher()
navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
sections = resultsSubject.scan(.empty) { sections = resultsSubject.scan((.empty, nil)) {
let (results, search) = $1 let (results, search) = $1
return search.offset == nil ? results : $0.appending(results) return (search.offset == nil ? results : $0.0.appending(results), search.limit)
} }
.flatMap(contentDatabase.publisher(results:)).eraseToAnyPublisher() .flatMap(contentDatabase.publisher(results:limit:)).eraseToAnyPublisher()
} }
} }

View file

@ -1,5 +1,6 @@
// Copyright © 2021 Metabolist. All rights reserved. // Copyright © 2021 Metabolist. All rights reserved.
import Combine
import UIKit import UIKit
import ViewModels import ViewModels
@ -7,6 +8,7 @@ final class ExploreViewController: UICollectionViewController {
private let viewModel: ExploreViewModel private let viewModel: ExploreViewModel
private let rootViewModel: RootViewModel private let rootViewModel: RootViewModel
private let identification: Identification private let identification: Identification
private var cancellables = Set<AnyCancellable>()
init(viewModel: ExploreViewModel, rootViewModel: RootViewModel, identification: Identification) { init(viewModel: ExploreViewModel, rootViewModel: RootViewModel, identification: Identification) {
self.viewModel = viewModel self.viewModel = viewModel
@ -40,15 +42,29 @@ final class ExploreViewController: UICollectionViewController {
let searchController = UISearchController(searchResultsController: searchResultsController) let searchController = UISearchController(searchResultsController: searchResultsController)
searchController.searchBar.scopeButtonTitles = SearchViewModel.Scope.allCases.map(\.title) searchController.searchBar.scopeButtonTitles = SearchScope.allCases.map(\.title)
searchController.searchResultsUpdater = self searchController.searchResultsUpdater = self
navigationItem.searchController = searchController navigationItem.searchController = searchController
viewModel.searchViewModel.events.sink { [weak self] in
if case let .navigation(navigation) = $0,
case let .searchScope(scope) = navigation {
searchController.searchBar.selectedScopeButtonIndex = scope.rawValue
self?.updateSearchResults(for: searchController)
}
}
.store(in: &cancellables)
} }
} }
extension ExploreViewController: UISearchResultsUpdating { extension ExploreViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) { func updateSearchResults(for searchController: UISearchController) {
if let scope = SearchViewModel.Scope(rawValue: searchController.searchBar.selectedScopeButtonIndex) { if let scope = SearchScope(rawValue: searchController.searchBar.selectedScopeButtonIndex) {
if scope != viewModel.searchViewModel.scope,
let scrollView = searchController.searchResultsController?.view as? UIScrollView {
scrollView.setContentOffset(.init(x: 0, y: -scrollView.safeAreaInsets.top), animated: false)
}
viewModel.searchViewModel.scope = scope viewModel.searchViewModel.scope = scope
} }

View file

@ -372,6 +372,8 @@ private extension TableViewController {
} }
case let .url(url): case let .url(url):
present(SFSafariViewController(url: url), animated: true) present(SFSafariViewController(url: url), animated: true)
case .searchScope:
break
case .webfingerStart: case .webfingerStart:
webfingerIndicatorView.startAnimating() webfingerIndicatorView.startAnimating()
case .webfingerEnd: case .webfingerEnd:

View file

@ -5,6 +5,7 @@ import Foundation
import Mastodon import Mastodon
import ServiceLayer import ServiceLayer
// swiftlint:disable file_length
public class CollectionItemsViewModel: ObservableObject { public class CollectionItemsViewModel: ObservableObject {
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
public private(set) var nextPageMaxId: String? public private(set) var nextPageMaxId: String?
@ -166,6 +167,8 @@ extension CollectionItemsViewModel: CollectionViewModel {
.navigation(.collection(collectionService .navigation(.collection(collectionService
.navigationService .navigationService
.timelineService(timeline: .tag(tag.name))))) .timelineService(timeline: .tag(tag.name)))))
case let .moreResults(moreResults):
eventsSubject.send(.navigation(.searchScope(moreResults.scope)))
} }
} }
@ -277,6 +280,16 @@ extension CollectionItemsViewModel: CollectionViewModel {
cache(viewModel: viewModel, forItem: item) cache(viewModel: viewModel, forItem: item)
return viewModel
case let .moreResults(moreResults):
if let cachedViewModel = cachedViewModel {
return cachedViewModel
}
let viewModel = MoreResultsViewModel(moreResults: moreResults)
cache(viewModel: viewModel, forItem: item)
return viewModel return viewModel
} }
} }
@ -405,3 +418,4 @@ private extension CollectionItemsViewModel {
return nil return nil
} }
} }
// swiftlint:enable file_length

View file

@ -0,0 +1,5 @@
// Copyright © 2021 Metabolist. All rights reserved.
import ServiceLayer
public typealias SearchScope = ServiceLayer.SearchScope

View file

@ -0,0 +1,20 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import ServiceLayer
public final class MoreResultsViewModel: ObservableObject, CollectionItemViewModel {
public var events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
private let moreResults: MoreResults
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>()
init(moreResults: MoreResults) {
self.moreResults = moreResults
events = eventsSubject.eraseToAnyPublisher()
}
}
public extension MoreResultsViewModel {
var scope: SearchScope { moreResults.scope }
}

View file

@ -6,7 +6,7 @@ import ServiceLayer
public final class SearchViewModel: CollectionItemsViewModel { public final class SearchViewModel: CollectionItemsViewModel {
@Published public var query = "" @Published public var query = ""
@Published public var scope = Scope.all @Published public var scope = SearchScope.all
private let searchService: SearchService private let searchService: SearchService
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@ -45,20 +45,11 @@ public final class SearchViewModel: CollectionItemsViewModel {
} }
} }
public extension SearchViewModel {
enum Scope: Int, CaseIterable {
case all
case accounts
case statuses
case tags
}
}
private extension SearchViewModel { private extension SearchViewModel {
static let throttleInterval: TimeInterval = 0.5 static let throttleInterval: TimeInterval = 0.5
} }
private extension SearchViewModel.Scope { private extension SearchScope {
var type: Search.SearchType? { var type: Search.SearchType? {
switch self { switch self {
case .all: case .all: