Search wip

This commit is contained in:
Justin Mazzocchi 2021-01-22 19:48:33 -08:00
parent 7c17618065
commit c4da421846
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
27 changed files with 357 additions and 49 deletions

View file

@ -425,6 +425,38 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func process(results: Results) -> AnyPublisher<[[CollectionItem]], Error> {
databaseWriter.writePublisher { db -> ([StatusInfo], [Status.Id]) in
for account in results.accounts {
try account.save(db)
}
for status in results.statuses {
try status.save(db)
}
let ids = results.statuses.map(\.id)
let statusInfos = try StatusInfo.request(
StatusRecord.filter(ids.contains(StatusRecord.Columns.id)))
.fetchAll(db)
return (statusInfos, ids)
}
.map { statusInfos, ids -> [[CollectionItem]] in
[
results.accounts.map(CollectionItem.account),
statusInfos
.sorted { ids.firstIndex(of: $0.record.id) ?? 0 < ids.firstIndex(of: $1.record.id) ?? 0 }
.map {
.status(.init(info: $0),
.init(showContentToggled: $0.showContentToggled,
showAttachmentsToggled: $0.showAttachmentsToggled))
}
]
}
.eraseToAnyPublisher()
}
func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> { func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> {
ValueObservation.tracking( ValueObservation.tracking(
TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne) TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne)

View file

@ -5,7 +5,43 @@ import HTTP
import Mastodon import Mastodon
public enum ResultsEndpoint { public enum ResultsEndpoint {
case search(query: String, resolve: Bool) case search(Search)
}
public extension ResultsEndpoint {
struct Search {
public let query: String
public let type: SearchType?
public let excludeUnreviewed: Bool
public let resolve: Bool
public let limit: Int?
public let offset: Int?
public let following: Bool
public init(query: String,
type: SearchType? = nil,
excludeUnreviewed: Bool = false,
resolve: Bool = false,
limit: Int? = nil,
offset: Int? = nil,
following: Bool = false) {
self.query = query
self.type = type
self.excludeUnreviewed = excludeUnreviewed
self.resolve = resolve
self.limit = limit
self.offset = offset
self.following = following
}
}
}
public extension ResultsEndpoint.Search {
enum SearchType: String {
case accounts
case hashtags
case statuses
}
} }
extension ResultsEndpoint: Endpoint { extension ResultsEndpoint: Endpoint {
@ -34,13 +70,33 @@ extension ResultsEndpoint: Endpoint {
public var queryParameters: [URLQueryItem] { public var queryParameters: [URLQueryItem] {
switch self { switch self {
case let .search(query, resolve): case let .search(search):
var params = [URLQueryItem(name: "q", value: query)] var params = [URLQueryItem(name: "q", value: search.query)]
if resolve { if let type = search.type {
params.append(.init(name: "type", value: type.rawValue))
}
if search.excludeUnreviewed {
params.append(.init(name: "exclude_unreviewed", value: "true"))
}
if search.resolve {
params.append(.init(name: "resolve", value: "true")) params.append(.init(name: "resolve", value: "true"))
} }
if let limit = search.limit {
params.append(.init(name: "limit", value: String(limit)))
}
if let offset = search.offset {
params.append(.init(name: "offset", value: String(offset)))
}
if search.following {
params.append(.init(name: "following", value: "true"))
}
return params return params
} }
} }

View file

@ -74,6 +74,7 @@
D07EC81125B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; }; D07EC81125B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; };
D07EC81225B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; }; D07EC81225B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; };
D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */; }; D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */; };
D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D087671525BAA8C0001FDD43 /* ExploreViewController.swift */; };
D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; }; D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; };
D088406E25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; }; D088406E25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; };
D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */; }; D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */; };
@ -244,6 +245,7 @@
D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemEmoji+Extensions.swift"; sourceTree = "<group>"; }; D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemEmoji+Extensions.swift"; sourceTree = "<group>"; };
D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+Extensions.swift"; sourceTree = "<group>"; }; D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+Extensions.swift"; sourceTree = "<group>"; };
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; }; D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
D087671525BAA8C0001FDD43 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = "<group>"; };
D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerViewController.swift; sourceTree = "<group>"; }; D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerViewController.swift; sourceTree = "<group>"; };
D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = "<group>"; }; D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = "<group>"; };
D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePageViewController.swift; sourceTree = "<group>"; }; D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePageViewController.swift; sourceTree = "<group>"; };
@ -549,6 +551,7 @@
children = ( children = (
D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */, D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */,
D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */, D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */,
D087671525BAA8C0001FDD43 /* ExploreViewController.swift */,
D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */, D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */,
D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */, D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */,
D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */, D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */,
@ -916,6 +919,7 @@
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */, D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */,
D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */, D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */,
D0F2D54B2581CF7D00986197 /* VisualEffectBlur.swift in Sources */, D0F2D54B2581CF7D00986197 /* VisualEffectBlur.swift in Sources */,
D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */,
D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */, D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */,
D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */, D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */,
D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */, D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */,

View file

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

View file

@ -34,7 +34,7 @@ public struct AccountListService {
} }
extension AccountListService: CollectionService { extension AccountListService: CollectionService {
public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> { public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
mastodonAPIClient.pagedRequest(endpoint, maxId: maxId, minId: minId) mastodonAPIClient.pagedRequest(endpoint, maxId: maxId, minId: minId)
.handleEvents(receiveOutput: { .handleEvents(receiveOutput: {
guard let maxId = $0.info.maxId else { return } guard let maxId = $0.info.maxId else { return }

View file

@ -12,7 +12,7 @@ public protocol CollectionService {
var titleLocalizationComponents: AnyPublisher<[String], Never> { get } var titleLocalizationComponents: AnyPublisher<[String], Never> { get }
var navigationService: NavigationService { get } var navigationService: NavigationService { get }
var markerTimeline: Marker.Timeline? { get } var markerTimeline: Marker.Timeline? { get }
func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error>
} }
extension CollectionService { extension CollectionService {

View file

@ -24,7 +24,7 @@ public struct ContextService {
} }
extension ContextService: CollectionService { extension ContextService: CollectionService {
public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> { public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(StatusEndpoint.status(id: id)) mastodonAPIClient.request(StatusEndpoint.status(id: id))
.flatMap(contentDatabase.insert(status:)) .flatMap(contentDatabase.insert(status:))
.merge(with: mastodonAPIClient.request(ContextEndpoint.context(id: id)) .merge(with: mastodonAPIClient.request(ContextEndpoint.context(id: id))

View file

@ -27,7 +27,7 @@ public struct ConversationsService {
} }
extension ConversationsService: CollectionService { extension ConversationsService: CollectionService {
public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> { public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
mastodonAPIClient.pagedRequest(ConversationsEndpoint.conversations, maxId: maxId, minId: minId) mastodonAPIClient.pagedRequest(ConversationsEndpoint.conversations, maxId: maxId, minId: minId)
.handleEvents(receiveOutput: { .handleEvents(receiveOutput: {
guard let maxId = $0.info.maxId else { return } guard let maxId = $0.info.maxId else { return }

View file

@ -0,0 +1,23 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
import Mastodon
import MastodonAPI
public struct ExploreService {
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
self.mastodonAPIClient = mastodonAPIClient
self.contentDatabase = contentDatabase
}
}
public extension ExploreService {
func searchService() -> SearchService {
SearchService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
}

View file

@ -249,6 +249,10 @@ public extension IdentityService {
contentDatabase: contentDatabase) contentDatabase: contentDatabase)
} }
func exploreService() -> ExploreService {
ExploreService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func notificationsService() -> NotificationsService { func notificationsService() -> NotificationsService {
NotificationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) NotificationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
} }

View file

@ -114,7 +114,7 @@ private extension NavigationService {
func webfinger(url: URL) -> AnyPublisher<Navigation, Never> { func webfinger(url: URL) -> AnyPublisher<Navigation, Never> {
let navigationSubject = PassthroughSubject<Navigation, Never>() let navigationSubject = PassthroughSubject<Navigation, Never>()
let request = mastodonAPIClient.request(ResultsEndpoint.search(query: url.absoluteString, resolve: true)) let request = mastodonAPIClient.request(ResultsEndpoint.search(.init(query: url.absoluteString, resolve: true)))
.handleEvents( .handleEvents(
receiveSubscription: { _ in navigationSubject.send(.webfingerStart) }, receiveSubscription: { _ in navigationSubject.send(.webfingerStart) },
receiveCompletion: { _ in navigationSubject.send(.webfingerEnd) }) receiveCompletion: { _ in navigationSubject.send(.webfingerEnd) })

View file

@ -39,7 +39,7 @@ public struct NotificationsService {
extension NotificationsService: CollectionService { extension NotificationsService: CollectionService {
public var markerTimeline: Marker.Timeline? { .notifications } public var markerTimeline: Marker.Timeline? { .notifications }
public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> { public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
mastodonAPIClient.pagedRequest(NotificationsEndpoint.notifications, maxId: maxId, minId: minId) mastodonAPIClient.pagedRequest(NotificationsEndpoint.notifications, maxId: maxId, minId: minId)
.handleEvents(receiveOutput: { .handleEvents(receiveOutput: {
guard let maxId = $0.info.maxId, maxId < nextPageMaxIdSubject.value else { return } guard let maxId = $0.info.maxId, maxId < nextPageMaxIdSubject.value else { return }

View file

@ -0,0 +1,38 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
import Mastodon
import MastodonAPI
public struct SearchService {
public let sections: AnyPublisher<[[CollectionItem]], Error>
public let navigationService: NavigationService
public let nextPageMaxId: AnyPublisher<String, Never>
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
private let nextPageMaxIdSubject = PassthroughSubject<String, Never>()
private let sectionsSubject = PassthroughSubject<[[CollectionItem]], Error>()
init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
self.mastodonAPIClient = mastodonAPIClient
self.contentDatabase = contentDatabase
nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher()
navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
sections = sectionsSubject.eraseToAnyPublisher()
}
}
extension SearchService: CollectionService {
public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
guard let search = search else { return Empty().eraseToAnyPublisher() }
return mastodonAPIClient.request(ResultsEndpoint.search(search))
.flatMap(contentDatabase.process(results:))
.handleEvents(receiveOutput: sectionsSubject.send)
.ignoreOutput()
.eraseToAnyPublisher()
}
}

View file

@ -44,7 +44,7 @@ extension TimelineService: CollectionService {
} }
} }
public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> { public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
mastodonAPIClient.pagedRequest(timeline.endpoint, maxId: maxId, minId: minId) mastodonAPIClient.pagedRequest(timeline.endpoint, maxId: maxId, minId: minId)
.handleEvents(receiveOutput: { .handleEvents(receiveOutput: {
if let maxId = $0.info.maxId { if let maxId = $0.info.maxId {

View file

@ -0,0 +1,50 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
import ViewModels
final class ExploreViewController: UICollectionViewController {
private let viewModel: ExploreViewModel
private let rootViewModel: RootViewModel
private let identification: Identification
init(viewModel: ExploreViewModel, rootViewModel: RootViewModel, identification: Identification) {
self.viewModel = viewModel
self.rootViewModel = rootViewModel
self.identification = identification
super.init(collectionViewLayout: UICollectionViewFlowLayout())
tabBarItem = UITabBarItem(
title: NSLocalizedString("main-navigation.explore", comment: ""),
image: UIImage(systemName: "magnifyingglass"),
selectedImage: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = NSLocalizedString("main-navigation.explore", comment: "")
let searchController = UISearchController(
searchResultsController: TableViewController(
viewModel: viewModel.searchViewModel,
rootViewModel: rootViewModel,
identification: identification,
parentNavigationController: navigationController))
searchController.searchResultsUpdater = self
navigationItem.searchController = searchController
}
}
extension ExploreViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
viewModel.searchViewModel.query = searchController.searchBar.text ?? ""
}
}

View file

@ -54,9 +54,15 @@ final class MainNavigationViewController: UITabBarController {
private extension MainNavigationViewController { private extension MainNavigationViewController {
func setupViewControllers() { func setupViewControllers() {
var controllers: [UIViewController] = [TimelinesViewController( var controllers: [UIViewController] = [
viewModel: viewModel, TimelinesViewController(
rootViewModel: rootViewModel)] viewModel: viewModel,
rootViewModel: rootViewModel),
ExploreViewController(
viewModel: viewModel.exploreViewModel,
rootViewModel: rootViewModel,
identification: viewModel.identification)
]
if let notificationsViewModel = viewModel.notificationsViewModel { if let notificationsViewModel = viewModel.notificationsViewModel {
let notificationsViewController = TableViewController( let notificationsViewController = TableViewController(

View file

@ -9,10 +9,18 @@ final class ProfileViewController: TableViewController {
private let viewModel: ProfileViewModel private let viewModel: ProfileViewModel
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
required init(viewModel: ProfileViewModel, rootViewModel: RootViewModel, identification: Identification) { required init(
viewModel: ProfileViewModel,
rootViewModel: RootViewModel,
identification: Identification,
parentNavigationController: UINavigationController?) {
self.viewModel = viewModel self.viewModel = viewModel
super.init(viewModel: viewModel, rootViewModel: rootViewModel, identification: identification) super.init(
viewModel: viewModel,
rootViewModel: rootViewModel,
identification: identification,
parentNavigationController: parentNavigationController)
} }
override func viewDidLoad() { override func viewDidLoad() {

View file

@ -21,15 +21,20 @@ class TableViewController: UITableViewController {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]() private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]()
private var shouldKeepPlayingVideoAfterDismissal = false private var shouldKeepPlayingVideoAfterDismissal = false
private weak var parentNavigationController: UINavigationController?
private lazy var dataSource: TableViewDataSource = { private lazy var dataSource: TableViewDataSource = {
.init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:)) .init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:))
}() }()
init(viewModel: CollectionViewModel, rootViewModel: RootViewModel, identification: Identification) { init(viewModel: CollectionViewModel,
rootViewModel: RootViewModel,
identification: Identification,
parentNavigationController: UINavigationController? = nil) {
self.viewModel = viewModel self.viewModel = viewModel
self.rootViewModel = rootViewModel self.rootViewModel = rootViewModel
self.identification = identification self.identification = identification
self.parentNavigationController = parentNavigationController
super.init(style: .plain) super.init(style: .plain)
} }
@ -51,7 +56,7 @@ class TableViewController: UITableViewController {
refreshControl = UIRefreshControl() refreshControl = UIRefreshControl()
refreshControl?.addAction( refreshControl?.addAction(
UIAction { [weak self] _ in UIAction { [weak self] _ in
self?.viewModel.request(maxId: nil, minId: nil) }, self?.viewModel.request(maxId: nil, minId: nil, search: nil) },
for: .valueChanged) for: .valueChanged)
} }
@ -69,7 +74,7 @@ class TableViewController: UITableViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
viewModel.request(maxId: nil, minId: nil) viewModel.request(maxId: nil, minId: nil, search: nil)
} }
override func scrollViewDidScroll(_ scrollView: UIScrollView) { override func scrollViewDidScroll(_ scrollView: UIScrollView) {
@ -104,7 +109,8 @@ class TableViewController: UITableViewController {
let maxId = viewModel.preferLastPresentIdOverNextPageMaxId let maxId = viewModel.preferLastPresentIdOverNextPageMaxId
? dataSource.itemIdentifier(for: indexPath)?.itemId ? dataSource.itemIdentifier(for: indexPath)?.itemId
: viewModel.nextPageMaxId { : viewModel.nextPageMaxId {
viewModel.request(maxId: maxId, minId: nil) // TODO: search offset
viewModel.request(maxId: maxId, minId: nil, search: nil)
} }
if let loadMoreView = cell.contentView as? LoadMoreView { if let loadMoreView = cell.contentView as? LoadMoreView {
@ -336,21 +342,33 @@ private extension TableViewController {
func handle(navigation: Navigation) { func handle(navigation: Navigation) {
switch navigation { switch navigation {
case let .collection(collectionService): case let .collection(collectionService):
show(TableViewController( let vc = TableViewController(
viewModel: CollectionItemsViewModel( viewModel: CollectionItemsViewModel(
collectionService: collectionService, collectionService: collectionService,
identification: identification),
rootViewModel: rootViewModel,
identification: identification), identification: identification),
sender: self) rootViewModel: rootViewModel,
identification: identification,
parentNavigationController: parentNavigationController)
if let parentNavigationController = parentNavigationController {
parentNavigationController.pushViewController(vc, animated: true)
} else {
show(vc, sender: self)
}
case let .profile(profileService): case let .profile(profileService):
show(ProfileViewController( let vc = ProfileViewController(
viewModel: ProfileViewModel( viewModel: ProfileViewModel(
profileService: profileService, profileService: profileService,
identification: identification),
rootViewModel: rootViewModel,
identification: identification), identification: identification),
sender: self) rootViewModel: rootViewModel,
identification: identification,
parentNavigationController: parentNavigationController)
if let parentNavigationController = parentNavigationController {
parentNavigationController.pushViewController(vc, animated: true)
} else {
show(vc, sender: self)
}
case let .url(url): case let .url(url):
present(SFSafariViewController(url: url), animated: true) present(SFSafariViewController(url: url), animated: true)
case .webfingerStart: case .webfingerStart:

View file

@ -35,6 +35,11 @@ final class TimelinesViewController: UIPageViewController {
if let firstViewController = timelineViewControllers.first { if let firstViewController = timelineViewControllers.first {
setViewControllers([firstViewController], direction: .forward, animated: false) setViewControllers([firstViewController], direction: .forward, animated: false)
} }
tabBarItem = UITabBarItem(
title: NSLocalizedString("main-navigation.timelines", comment: ""),
image: UIImage(systemName: "newspaper"),
selectedImage: nil)
} }
@available(*, unavailable) @available(*, unavailable)
@ -48,11 +53,6 @@ final class TimelinesViewController: UIPageViewController {
dataSource = self dataSource = self
delegate = self delegate = self
tabBarItem = UITabBarItem(
title: NSLocalizedString("main-navigation.timelines", comment: ""),
image: UIImage(systemName: "newspaper"),
selectedImage: nil)
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "megaphone"), primaryAction: nil) navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "megaphone"), primaryAction: nil)
navigationItem.titleView = segmentedControl navigationItem.titleView = segmentedControl
segmentedControl.selectedSegmentIndex = 0 segmentedControl.selectedSegmentIndex = 0

View file

@ -5,7 +5,7 @@ import Foundation
import Mastodon import Mastodon
import ServiceLayer import ServiceLayer
public final 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?
@ -82,7 +82,7 @@ extension CollectionItemsViewModel: CollectionViewModel {
public var canRefresh: Bool { collectionService.canRefresh } public var canRefresh: Bool { collectionService.canRefresh }
public func request(maxId: String? = nil, minId: String? = nil) { public func request(maxId: String? = nil, minId: String? = nil, search: Search?) {
let publisher: AnyPublisher<Never, Error> let publisher: AnyPublisher<Never, Error>
if let markerTimeline = collectionService.markerTimeline, if let markerTimeline = collectionService.markerTimeline,
@ -90,19 +90,22 @@ extension CollectionItemsViewModel: CollectionViewModel {
!hasRequestedUsingMarker { !hasRequestedUsingMarker {
publisher = identification.service.getMarker(markerTimeline) publisher = identification.service.getMarker(markerTimeline)
.flatMap { [weak self] in .flatMap { [weak self] in
self?.collectionService.request(maxId: $0.lastReadId, minId: nil) ?? Empty().eraseToAnyPublisher() self?.collectionService.request(maxId: $0.lastReadId, minId: nil, search: nil)
?? Empty().eraseToAnyPublisher()
} }
.catch { [weak self] _ in .catch { [weak self] _ in
self?.collectionService.request(maxId: nil, minId: nil) ?? Empty().eraseToAnyPublisher() self?.collectionService.request(maxId: nil, minId: nil, search: nil)
?? Empty().eraseToAnyPublisher()
} }
.collect() .collect()
.flatMap { [weak self] _ in .flatMap { [weak self] _ in
self?.collectionService.request(maxId: nil, minId: nil) ?? Empty().eraseToAnyPublisher() self?.collectionService.request(maxId: nil, minId: nil, search: nil)
?? Empty().eraseToAnyPublisher()
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
self.hasRequestedUsingMarker = true self.hasRequestedUsingMarker = true
} else { } else {
publisher = collectionService.request(maxId: realMaxId(maxId: maxId), minId: minId) publisher = collectionService.request(maxId: realMaxId(maxId: maxId), minId: minId, search: search)
} }
publisher publisher

View file

@ -14,7 +14,7 @@ public protocol CollectionViewModel {
var nextPageMaxId: String? { get } var nextPageMaxId: String? { get }
var preferLastPresentIdOverNextPageMaxId: Bool { get } var preferLastPresentIdOverNextPageMaxId: Bool { get }
var canRefresh: Bool { get } var canRefresh: Bool { get }
func request(maxId: String?, minId: String?) func request(maxId: String?, minId: String?, search: Search?)
func viewedAtTop(indexPath: IndexPath) func viewedAtTop(indexPath: IndexPath)
func select(indexPath: IndexPath) func select(indexPath: IndexPath)
func canSelect(indexPath: IndexPath) -> Bool func canSelect(indexPath: IndexPath) -> Bool

View file

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

View file

@ -0,0 +1,19 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import ServiceLayer
public final class ExploreViewModel: ObservableObject {
public let searchViewModel: SearchViewModel
private let exploreService: ExploreService
private let identification: Identification
init(service: ExploreService, identification: Identification) {
exploreService = service
self.identification = identification
searchViewModel = SearchViewModel(
searchService: exploreService.searchService(),
identification: identification)
}
}

View file

@ -13,13 +13,23 @@ public final class NavigationViewModel: ObservableObject {
@Published public var presentingSecondaryNavigation = false @Published public var presentingSecondaryNavigation = false
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
public lazy var exploreViewModel: ExploreViewModel = {
let exploreViewModel = ExploreViewModel(
service: identification.service.exploreService(),
identification: identification)
// TODO: initial request
return exploreViewModel
}()
public lazy var notificationsViewModel: CollectionViewModel? = { public lazy var notificationsViewModel: CollectionViewModel? = {
if identification.identity.authenticated { if identification.identity.authenticated {
let notificationsViewModel = CollectionItemsViewModel( let notificationsViewModel = CollectionItemsViewModel(
collectionService: identification.service.notificationsService(), collectionService: identification.service.notificationsService(),
identification: identification) identification: identification)
notificationsViewModel.request(maxId: nil, minId: nil) notificationsViewModel.request(maxId: nil, minId: nil, search: nil)
return notificationsViewModel return notificationsViewModel
} else { } else {
@ -33,7 +43,7 @@ public final class NavigationViewModel: ObservableObject {
collectionService: identification.service.conversationsService(), collectionService: identification.service.conversationsService(),
identification: identification) identification: identification)
conversationsViewModel.request(maxId: nil, minId: nil) conversationsViewModel.request(maxId: nil, minId: nil, search: nil)
return conversationsViewModel return conversationsViewModel
} else { } else {

View file

@ -105,7 +105,7 @@ extension ProfileViewModel: CollectionViewModel {
public var canRefresh: Bool { collectionViewModel.value.canRefresh } public var canRefresh: Bool { collectionViewModel.value.canRefresh }
public func request(maxId: String?, minId: String?) { public func request(maxId: String?, minId: String?, search: Search?) {
if case .statuses = collection, maxId == nil { if case .statuses = collection, maxId == nil {
profileService.fetchPinnedStatuses() profileService.fetchPinnedStatuses()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
@ -113,7 +113,7 @@ extension ProfileViewModel: CollectionViewModel {
.store(in: &cancellables) .store(in: &cancellables)
} }
collectionViewModel.value.request(maxId: maxId, minId: minId) collectionViewModel.value.request(maxId: maxId, minId: minId, search: nil)
} }
public func viewedAtTop(indexPath: IndexPath) { public func viewedAtTop(indexPath: IndexPath) {

View file

@ -0,0 +1,27 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
import ServiceLayer
public final class SearchViewModel: CollectionItemsViewModel {
@Published public var query = ""
private let searchService: SearchService
private var cancellables = Set<AnyCancellable>()
public init(searchService: SearchService, identification: Identification) {
self.searchService = searchService
super.init(collectionService: searchService, identification: identification)
$query.throttle(for: .seconds(Self.queryThrottleInterval), scheduler: DispatchQueue.global(), latest: true)
.sink { [weak self] in self?.request(maxId: nil, minId: nil, search: .init(query: $0, limit: Self.limit)) }
.store(in: &cancellables)
}
}
private extension SearchViewModel {
static let queryThrottleInterval: TimeInterval = 0.5
static let limit = 5
}

View file

@ -310,7 +310,7 @@ private extension AccountHeaderView {
segmentedControl.insertSegment( segmentedControl.insertSegment(
action: UIAction(title: collection.title) { [weak self] _ in action: UIAction(title: collection.title) { [weak self] _ in
self?.viewModel?.collection = collection self?.viewModel?.collection = collection
self?.viewModel?.request(maxId: nil, minId: nil) self?.viewModel?.request(maxId: nil, minId: nil, search: nil)
}, },
at: index, at: index,
animated: false) animated: false)