diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index bb5b66f..5314d12 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -425,6 +425,38 @@ public extension ContentDatabase { .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> { ValueObservation.tracking( TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne) diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/ResultsEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/ResultsEndpoint.swift index 3d78fe2..15adaec 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/ResultsEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/ResultsEndpoint.swift @@ -5,7 +5,43 @@ import HTTP import Mastodon 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 { @@ -34,13 +70,33 @@ extension ResultsEndpoint: Endpoint { public var queryParameters: [URLQueryItem] { switch self { - case let .search(query, resolve): - var params = [URLQueryItem(name: "q", value: query)] + case let .search(search): + 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")) } + 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 } } diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index dba74d8..6ce4508 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ 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 */; }; 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 */; }; D088406E25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.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 = ""; }; D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+Extensions.swift"; sourceTree = ""; }; D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = ""; }; + D087671525BAA8C0001FDD43 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = ""; }; D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerViewController.swift; sourceTree = ""; }; D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePageViewController.swift; sourceTree = ""; }; @@ -549,6 +551,7 @@ children = ( D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */, D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */, + D087671525BAA8C0001FDD43 /* ExploreViewController.swift */, D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */, D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */, D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */, @@ -916,6 +919,7 @@ D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */, D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */, D0F2D54B2581CF7D00986197 /* VisualEffectBlur.swift in Sources */, + D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */, D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */, D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */, D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/Search.swift b/ServiceLayer/Sources/ServiceLayer/Entities/Search.swift new file mode 100644 index 0000000..70b0475 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Entities/Search.swift @@ -0,0 +1,5 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import MastodonAPI + +public typealias Search = ResultsEndpoint.Search diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift index ff9028c..3908427 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift @@ -34,7 +34,7 @@ public struct AccountListService { } extension AccountListService: CollectionService { - public func request(maxId: String?, minId: String?) -> AnyPublisher { + public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher { mastodonAPIClient.pagedRequest(endpoint, maxId: maxId, minId: minId) .handleEvents(receiveOutput: { guard let maxId = $0.info.maxId else { return } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift b/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift index 13bb17e..7174839 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift @@ -12,7 +12,7 @@ public protocol CollectionService { var titleLocalizationComponents: AnyPublisher<[String], Never> { get } var navigationService: NavigationService { get } var markerTimeline: Marker.Timeline? { get } - func request(maxId: String?, minId: String?) -> AnyPublisher + func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher } extension CollectionService { diff --git a/ServiceLayer/Sources/ServiceLayer/Services/ContextService.swift b/ServiceLayer/Sources/ServiceLayer/Services/ContextService.swift index 5b5eb30..abddb04 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/ContextService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/ContextService.swift @@ -24,7 +24,7 @@ public struct ContextService { } extension ContextService: CollectionService { - public func request(maxId: String?, minId: String?) -> AnyPublisher { + public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher { mastodonAPIClient.request(StatusEndpoint.status(id: id)) .flatMap(contentDatabase.insert(status:)) .merge(with: mastodonAPIClient.request(ContextEndpoint.context(id: id)) diff --git a/ServiceLayer/Sources/ServiceLayer/Services/ConversationsService.swift b/ServiceLayer/Sources/ServiceLayer/Services/ConversationsService.swift index ce97674..9f290e7 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/ConversationsService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/ConversationsService.swift @@ -27,7 +27,7 @@ public struct ConversationsService { } extension ConversationsService: CollectionService { - public func request(maxId: String?, minId: String?) -> AnyPublisher { + public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher { mastodonAPIClient.pagedRequest(ConversationsEndpoint.conversations, maxId: maxId, minId: minId) .handleEvents(receiveOutput: { guard let maxId = $0.info.maxId else { return } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/ExploreService.swift b/ServiceLayer/Sources/ServiceLayer/Services/ExploreService.swift new file mode 100644 index 0000000..e5b3d88 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/ExploreService.swift @@ -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) + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index 92fb293..7549223 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -249,6 +249,10 @@ public extension IdentityService { contentDatabase: contentDatabase) } + func exploreService() -> ExploreService { + ExploreService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) + } + func notificationsService() -> NotificationsService { NotificationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift index 43267eb..a80bd35 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift @@ -114,7 +114,7 @@ private extension NavigationService { func webfinger(url: URL) -> AnyPublisher { let navigationSubject = PassthroughSubject() - let request = mastodonAPIClient.request(ResultsEndpoint.search(query: url.absoluteString, resolve: true)) + let request = mastodonAPIClient.request(ResultsEndpoint.search(.init(query: url.absoluteString, resolve: true))) .handleEvents( receiveSubscription: { _ in navigationSubject.send(.webfingerStart) }, receiveCompletion: { _ in navigationSubject.send(.webfingerEnd) }) diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NotificationsService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NotificationsService.swift index 33422b1..95f8b9b 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/NotificationsService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/NotificationsService.swift @@ -39,7 +39,7 @@ public struct NotificationsService { extension NotificationsService: CollectionService { public var markerTimeline: Marker.Timeline? { .notifications } - public func request(maxId: String?, minId: String?) -> AnyPublisher { + public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher { mastodonAPIClient.pagedRequest(NotificationsEndpoint.notifications, maxId: maxId, minId: minId) .handleEvents(receiveOutput: { guard let maxId = $0.info.maxId, maxId < nextPageMaxIdSubject.value else { return } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/SearchService.swift b/ServiceLayer/Sources/ServiceLayer/Services/SearchService.swift new file mode 100644 index 0000000..633cc54 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/SearchService.swift @@ -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 + + private let mastodonAPIClient: MastodonAPIClient + private let contentDatabase: ContentDatabase + private let nextPageMaxIdSubject = PassthroughSubject() + 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 { + guard let search = search else { return Empty().eraseToAnyPublisher() } + + return mastodonAPIClient.request(ResultsEndpoint.search(search)) + .flatMap(contentDatabase.process(results:)) + .handleEvents(receiveOutput: sectionsSubject.send) + .ignoreOutput() + .eraseToAnyPublisher() + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift b/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift index a5ac402..a451736 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift @@ -44,7 +44,7 @@ extension TimelineService: CollectionService { } } - public func request(maxId: String?, minId: String?) -> AnyPublisher { + public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher { mastodonAPIClient.pagedRequest(timeline.endpoint, maxId: maxId, minId: minId) .handleEvents(receiveOutput: { if let maxId = $0.info.maxId { diff --git a/View Controllers/ExploreViewController.swift b/View Controllers/ExploreViewController.swift new file mode 100644 index 0000000..ad39c4c --- /dev/null +++ b/View Controllers/ExploreViewController.swift @@ -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 ?? "" + } +} diff --git a/View Controllers/MainNavigationViewController.swift b/View Controllers/MainNavigationViewController.swift index 34410e3..9cdeb2b 100644 --- a/View Controllers/MainNavigationViewController.swift +++ b/View Controllers/MainNavigationViewController.swift @@ -54,9 +54,15 @@ final class MainNavigationViewController: UITabBarController { private extension MainNavigationViewController { func setupViewControllers() { - var controllers: [UIViewController] = [TimelinesViewController( - viewModel: viewModel, - rootViewModel: rootViewModel)] + var controllers: [UIViewController] = [ + TimelinesViewController( + viewModel: viewModel, + rootViewModel: rootViewModel), + ExploreViewController( + viewModel: viewModel.exploreViewModel, + rootViewModel: rootViewModel, + identification: viewModel.identification) + ] if let notificationsViewModel = viewModel.notificationsViewModel { let notificationsViewController = TableViewController( diff --git a/View Controllers/ProfileViewController.swift b/View Controllers/ProfileViewController.swift index 0df55f0..4a5dc8d 100644 --- a/View Controllers/ProfileViewController.swift +++ b/View Controllers/ProfileViewController.swift @@ -9,10 +9,18 @@ final class ProfileViewController: TableViewController { private let viewModel: ProfileViewModel private var cancellables = Set() - required init(viewModel: ProfileViewModel, rootViewModel: RootViewModel, identification: Identification) { + required init( + viewModel: ProfileViewModel, + rootViewModel: RootViewModel, + identification: Identification, + parentNavigationController: UINavigationController?) { self.viewModel = viewModel - super.init(viewModel: viewModel, rootViewModel: rootViewModel, identification: identification) + super.init( + viewModel: viewModel, + rootViewModel: rootViewModel, + identification: identification, + parentNavigationController: parentNavigationController) } override func viewDidLoad() { diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 17deb8f..f4aca05 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -21,15 +21,20 @@ class TableViewController: UITableViewController { private var cancellables = Set() private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]() private var shouldKeepPlayingVideoAfterDismissal = false + private weak var parentNavigationController: UINavigationController? private lazy var dataSource: TableViewDataSource = { .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.rootViewModel = rootViewModel self.identification = identification + self.parentNavigationController = parentNavigationController super.init(style: .plain) } @@ -51,7 +56,7 @@ class TableViewController: UITableViewController { refreshControl = UIRefreshControl() refreshControl?.addAction( UIAction { [weak self] _ in - self?.viewModel.request(maxId: nil, minId: nil) }, + self?.viewModel.request(maxId: nil, minId: nil, search: nil) }, for: .valueChanged) } @@ -69,7 +74,7 @@ class TableViewController: UITableViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - viewModel.request(maxId: nil, minId: nil) + viewModel.request(maxId: nil, minId: nil, search: nil) } override func scrollViewDidScroll(_ scrollView: UIScrollView) { @@ -104,7 +109,8 @@ class TableViewController: UITableViewController { let maxId = viewModel.preferLastPresentIdOverNextPageMaxId ? dataSource.itemIdentifier(for: indexPath)?.itemId : 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 { @@ -336,21 +342,33 @@ private extension TableViewController { func handle(navigation: Navigation) { switch navigation { case let .collection(collectionService): - show(TableViewController( - viewModel: CollectionItemsViewModel( - collectionService: collectionService, - identification: identification), - rootViewModel: rootViewModel, + let vc = TableViewController( + viewModel: CollectionItemsViewModel( + collectionService: collectionService, 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): - show(ProfileViewController( - viewModel: ProfileViewModel( - profileService: profileService, - identification: identification), - rootViewModel: rootViewModel, + let vc = ProfileViewController( + viewModel: ProfileViewModel( + profileService: profileService, 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): present(SFSafariViewController(url: url), animated: true) case .webfingerStart: diff --git a/View Controllers/TimelinesViewController.swift b/View Controllers/TimelinesViewController.swift index 452ecc7..594a40e 100644 --- a/View Controllers/TimelinesViewController.swift +++ b/View Controllers/TimelinesViewController.swift @@ -35,6 +35,11 @@ final class TimelinesViewController: UIPageViewController { if let firstViewController = timelineViewControllers.first { setViewControllers([firstViewController], direction: .forward, animated: false) } + + tabBarItem = UITabBarItem( + title: NSLocalizedString("main-navigation.timelines", comment: ""), + image: UIImage(systemName: "newspaper"), + selectedImage: nil) } @available(*, unavailable) @@ -48,11 +53,6 @@ final class TimelinesViewController: UIPageViewController { dataSource = 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.titleView = segmentedControl segmentedControl.selectedSegmentIndex = 0 diff --git a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift index f90e814..93fad65 100644 --- a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift @@ -5,7 +5,7 @@ import Foundation import Mastodon import ServiceLayer -public final class CollectionItemsViewModel: ObservableObject { +public class CollectionItemsViewModel: ObservableObject { @Published public var alertItem: AlertItem? public private(set) var nextPageMaxId: String? @@ -82,7 +82,7 @@ extension CollectionItemsViewModel: CollectionViewModel { 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 if let markerTimeline = collectionService.markerTimeline, @@ -90,19 +90,22 @@ extension CollectionItemsViewModel: CollectionViewModel { !hasRequestedUsingMarker { publisher = identification.service.getMarker(markerTimeline) .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 - self?.collectionService.request(maxId: nil, minId: nil) ?? Empty().eraseToAnyPublisher() + self?.collectionService.request(maxId: nil, minId: nil, search: nil) + ?? Empty().eraseToAnyPublisher() } .collect() .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() self.hasRequestedUsingMarker = true } else { - publisher = collectionService.request(maxId: realMaxId(maxId: maxId), minId: minId) + publisher = collectionService.request(maxId: realMaxId(maxId: maxId), minId: minId, search: search) } publisher diff --git a/ViewModels/Sources/ViewModels/CollectionViewModel.swift b/ViewModels/Sources/ViewModels/CollectionViewModel.swift index 6f321d6..ef7e9d0 100644 --- a/ViewModels/Sources/ViewModels/CollectionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionViewModel.swift @@ -14,7 +14,7 @@ public protocol CollectionViewModel { var nextPageMaxId: String? { get } var preferLastPresentIdOverNextPageMaxId: 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 select(indexPath: IndexPath) func canSelect(indexPath: IndexPath) -> Bool diff --git a/ViewModels/Sources/ViewModels/Entities/Search.swift b/ViewModels/Sources/ViewModels/Entities/Search.swift new file mode 100644 index 0000000..23ed7c5 --- /dev/null +++ b/ViewModels/Sources/ViewModels/Entities/Search.swift @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import ServiceLayer + +public typealias Search = ServiceLayer.Search diff --git a/ViewModels/Sources/ViewModels/ExploreViewModel.swift b/ViewModels/Sources/ViewModels/ExploreViewModel.swift new file mode 100644 index 0000000..a1b03bd --- /dev/null +++ b/ViewModels/Sources/ViewModels/ExploreViewModel.swift @@ -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) + } +} diff --git a/ViewModels/Sources/ViewModels/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/NavigationViewModel.swift index 17fa39b..6fb5943 100644 --- a/ViewModels/Sources/ViewModels/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/NavigationViewModel.swift @@ -13,13 +13,23 @@ public final class NavigationViewModel: ObservableObject { @Published public var presentingSecondaryNavigation = false @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? = { if identification.identity.authenticated { let notificationsViewModel = CollectionItemsViewModel( collectionService: identification.service.notificationsService(), identification: identification) - notificationsViewModel.request(maxId: nil, minId: nil) + notificationsViewModel.request(maxId: nil, minId: nil, search: nil) return notificationsViewModel } else { @@ -33,7 +43,7 @@ public final class NavigationViewModel: ObservableObject { collectionService: identification.service.conversationsService(), identification: identification) - conversationsViewModel.request(maxId: nil, minId: nil) + conversationsViewModel.request(maxId: nil, minId: nil, search: nil) return conversationsViewModel } else { diff --git a/ViewModels/Sources/ViewModels/ProfileViewModel.swift b/ViewModels/Sources/ViewModels/ProfileViewModel.swift index 4a963ac..2365d95 100644 --- a/ViewModels/Sources/ViewModels/ProfileViewModel.swift +++ b/ViewModels/Sources/ViewModels/ProfileViewModel.swift @@ -105,7 +105,7 @@ extension ProfileViewModel: CollectionViewModel { 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 { profileService.fetchPinnedStatuses() .assignErrorsToAlertItem(to: \.alertItem, on: self) @@ -113,7 +113,7 @@ extension ProfileViewModel: CollectionViewModel { .store(in: &cancellables) } - collectionViewModel.value.request(maxId: maxId, minId: minId) + collectionViewModel.value.request(maxId: maxId, minId: minId, search: nil) } public func viewedAtTop(indexPath: IndexPath) { diff --git a/ViewModels/Sources/ViewModels/SearchViewModel.swift b/ViewModels/Sources/ViewModels/SearchViewModel.swift new file mode 100644 index 0000000..69d27de --- /dev/null +++ b/ViewModels/Sources/ViewModels/SearchViewModel.swift @@ -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() + + 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 +} diff --git a/Views/AccountHeaderView.swift b/Views/AccountHeaderView.swift index e2f57ad..9df558c 100644 --- a/Views/AccountHeaderView.swift +++ b/Views/AccountHeaderView.swift @@ -310,7 +310,7 @@ private extension AccountHeaderView { segmentedControl.insertSegment( action: UIAction(title: collection.title) { [weak self] _ in self?.viewModel?.collection = collection - self?.viewModel?.request(maxId: nil, minId: nil) + self?.viewModel?.request(maxId: nil, minId: nil, search: nil) }, at: index, animated: false)