diff --git a/Mastodon/Sources/Mastodon/Entities/Results.swift b/Mastodon/Sources/Mastodon/Entities/Results.swift new file mode 100644 index 0000000..7536ede --- /dev/null +++ b/Mastodon/Sources/Mastodon/Entities/Results.swift @@ -0,0 +1,9 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +public struct Results: Codable { + public let accounts: [Account] + public let statuses: [Status] + public let hashtags: [Tag] +} diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/ResultsEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/ResultsEndpoint.swift new file mode 100644 index 0000000..1ba3a17 --- /dev/null +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/ResultsEndpoint.swift @@ -0,0 +1,47 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import HTTP +import Mastodon + +public enum ResultsEndpoint { + case search(query: String, resolve: Bool) +} + +extension ResultsEndpoint: Endpoint { + public typealias ResultType = Results + + public var APIVersion: String { + switch self { + case .search: + return "v2" + } + } + + public var pathComponentsInContext: [String] { + switch self { + case .search: + return ["search"] + } + } + + public var method: HTTPMethod { + switch self { + case .search: + return .get + } + } + + public var queryParameters: [String: String]? { + switch self { + case let .search(query, resolve): + var params = ["q": query] + + if resolve { + params["resolve"] = String(true) + } + + return params + } + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 10ba16f..dbe3756 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ D0C7D4D724F7616A001EBDBB /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */; }; D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */; }; D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46F24F76169001EBDBB /* View+Extensions.swift */; }; + D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */; }; D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; }; D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; }; D0E5362024E3EB4D00FB1CE1 /* Notification Service Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -139,6 +140,7 @@ D0C7D46F24F76169001EBDBB /* View+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; D0D7C013250440610039AD6F /* CodableBloomFilter */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CodableBloomFilter; sourceTree = ""; }; D0E0F1E424FC49FC002C04BF /* Mastodon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Mastodon; sourceTree = ""; }; + D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebfingerIndicatorView.swift; sourceTree = ""; }; D0E2C1CF24FD8BA400854680 /* ViewModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ViewModels; sourceTree = ""; }; D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Notification Service Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; @@ -288,6 +290,7 @@ D0625E55250F086B00502611 /* Status */, D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */, D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */, + D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */, ); path = Views; sourceTree = ""; @@ -545,6 +548,7 @@ D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */, D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */, D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */, + D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */, D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */, D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */, D0C7D4D724F7616A001EBDBB /* UIColor+Extensions.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift index 2c7da81..5cf06a4 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift @@ -10,6 +10,8 @@ public enum Navigation { case url(URL) case statusList(StatusListService) case accountStatuses(AccountStatusesService) + case webfingerStart + case webfingerEnd } public struct NavigationService { @@ -46,7 +48,11 @@ public extension NavigationService { .eraseToAnyPublisher() } - return Just(.url(url)).eraseToAnyPublisher() + if url.shouldWebfinger { + return webfinger(url: url) + } else { + return Just(.url(url)).eraseToAnyPublisher() + } } func contextStatusListService(id: String) -> StatusListService { @@ -88,6 +94,37 @@ private extension NavigationService { return nil } + + func webfinger(url: URL) -> AnyPublisher { + let navigationSubject = PassthroughSubject() + + let request = mastodonAPIClient.request(ResultsEndpoint.search(query: url.absoluteString, resolve: true)) + .handleEvents( + receiveSubscription: { _ in navigationSubject.send(.webfingerStart) }, + receiveCompletion: { _ in navigationSubject.send(.webfingerEnd) }) + .map { results -> Navigation in + if let tag = results.hashtags.first { + return .statusList( + StatusListService( + timeline: .tag(tag.name), + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase)) + } else if let account = results.accounts.first { + return .accountStatuses(accountStatusesService(id: account.id)) + } else if let status = results.statuses.first { + return .statusList( + StatusListService( + statusID: status.id, + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase)) + } else { + return .url(url) + } + } + .replaceError(with: .url(url)) + + return navigationSubject.merge(with: request).eraseToAnyPublisher() + } } private extension URL { diff --git a/View Controllers/CollectionViewController.swift b/View Controllers/CollectionViewController.swift index 4564c03..71ac4c5 100644 --- a/View Controllers/CollectionViewController.swift +++ b/View Controllers/CollectionViewController.swift @@ -8,6 +8,7 @@ import ViewModels class CollectionViewController: UITableViewController { private let viewModel: CollectionViewModel private let loadingTableFooterView = LoadingTableFooterView() + private let webfingerIndicatorView = WebfingerIndicatorView() private var cancellables = Set() private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]() private let dataSourceQueue = @@ -57,49 +58,15 @@ class CollectionViewController: UITableViewController { tableView.cellLayoutMarginsFollowReadableWidth = true tableView.tableFooterView = UIView() - viewModel.title.sink { [weak self] in self?.navigationItem.title = $0 }.store(in: &cancellables) + view.addSubview(webfingerIndicatorView) + webfingerIndicatorView.translatesAutoresizingMaskIntoConstraints = false - viewModel.collectionItems - .sink { [weak self] in self?.update(items: $0) } - .store(in: &cancellables) + NSLayoutConstraint.activate([ + webfingerIndicatorView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + webfingerIndicatorView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor) + ]) - viewModel.navigationEvents.sink { [weak self] in - guard let self = self else { return } - switch $0 { - case let .share(url): - self.share(url: url) - case let .collectionNavigation(collectionViewModel): - self.show(CollectionViewController(viewModel: collectionViewModel), sender: self) - case let .urlNavigation(url): - self.present(SFSafariViewController(url: url), animated: true) - } - } - .store(in: &cancellables) - - viewModel.loading - .receive(on: RunLoop.main) - .sink { [weak self] in - guard let self = self else { return } - - self.tableView.tableFooterView = $0 ? self.loadingTableFooterView : UIView() - self.sizeTableHeaderFooterViews() - } - .store(in: &cancellables) - - if let accountsStatusesViewModel = viewModel as? AccountStatusesViewModel { - // Initial size is to avoid unsatisfiable constraint warning - let accountHeaderView = AccountHeaderView( - frame: .init( - origin: .zero, - size: .init(width: 100, height: 100))) - accountHeaderView.viewModel = accountsStatusesViewModel - accountsStatusesViewModel.$account.dropFirst().receive(on: DispatchQueue.main).sink { [weak self] _ in - accountHeaderView.viewModel = accountsStatusesViewModel - self?.sizeTableHeaderFooterViews() - } - .store(in: &cancellables) - tableView.tableHeaderView = accountHeaderView - } + setupViewModelBindings() } override func viewWillAppear(_ animated: Bool) { @@ -158,6 +125,57 @@ extension CollectionViewController: UITableViewDataSourcePrefetching { } private extension CollectionViewController { + func setupViewModelBindings() { + viewModel.title.sink { [weak self] in self?.navigationItem.title = $0 }.store(in: &cancellables) + + viewModel.collectionItems + .sink { [weak self] in self?.update(items: $0) } + .store(in: &cancellables) + + viewModel.navigationEvents.receive(on: DispatchQueue.main).sink { [weak self] in + guard let self = self else { return } + + switch $0 { + case let .share(url): + self.share(url: url) + case let .collectionNavigation(collectionViewModel): + self.show(CollectionViewController(viewModel: collectionViewModel), sender: self) + case let .urlNavigation(url): + self.present(SFSafariViewController(url: url), animated: true) + case .webfingerStart: + self.webfingerIndicatorView.startAnimating() + case .webfingerEnd: + self.webfingerIndicatorView.stopAnimating() + } + } + .store(in: &cancellables) + + viewModel.loading + .receive(on: RunLoop.main) + .sink { [weak self] in + guard let self = self else { return } + + self.tableView.tableFooterView = $0 ? self.loadingTableFooterView : UIView() + self.sizeTableHeaderFooterViews() + } + .store(in: &cancellables) + + if let accountsStatusesViewModel = viewModel as? AccountStatusesViewModel { + // Initial size is to avoid unsatisfiable constraint warning + let accountHeaderView = AccountHeaderView( + frame: .init( + origin: .zero, + size: .init(width: 100, height: 100))) + accountHeaderView.viewModel = accountsStatusesViewModel + accountsStatusesViewModel.$account.dropFirst().receive(on: DispatchQueue.main).sink { [weak self] _ in + accountHeaderView.viewModel = accountsStatusesViewModel + self?.sizeTableHeaderFooterViews() + } + .store(in: &cancellables) + tableView.tableHeaderView = accountHeaderView + } + } + func update(items: [[CollectionItem]]) { var offsetFromNavigationBar: CGFloat? diff --git a/ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift b/ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift index f9ae679..74a867f 100644 --- a/ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift +++ b/ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift @@ -6,6 +6,8 @@ public enum NavigationEvent { case collectionNavigation(CollectionViewModel) case urlNavigation(URL) case share(URL) + case webfingerStart + case webfingerEnd } extension NavigationEvent { @@ -21,6 +23,10 @@ extension NavigationEvent { self = .collectionNavigation(StatusListViewModel(statusListService: statusListService)) case let .accountStatuses(accountStatusesService): self = .collectionNavigation(AccountStatusesViewModel(accountStatusesService: accountStatusesService)) + case .webfingerStart: + self = .webfingerStart + case .webfingerEnd: + self = .webfingerEnd } case let .accountListNavigation(accountListViewModel): self = .collectionNavigation(accountListViewModel) diff --git a/Views/WebfingerIndicatorView.swift b/Views/WebfingerIndicatorView.swift new file mode 100644 index 0000000..f73d2e2 --- /dev/null +++ b/Views/WebfingerIndicatorView.swift @@ -0,0 +1,61 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +class WebfingerIndicatorView: UIVisualEffectView { + private let activityIndicatorView = UIActivityIndicatorView() + + init() { + super.init(effect: nil) + + clipsToBounds = true + layer.cornerRadius = 8 + + contentView.addSubview(activityIndicatorView) + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + activityIndicatorView.style = .large + + NSLayoutConstraint.activate([ + trailingAnchor.constraint( + equalTo: activityIndicatorView.trailingAnchor, constant: 8), + bottomAnchor.constraint( + equalTo: activityIndicatorView.bottomAnchor, constant: 8), + activityIndicatorView.topAnchor.constraint( + equalTo: topAnchor, constant: 8), + activityIndicatorView.leadingAnchor.constraint( + equalTo: leadingAnchor, constant: 8), + activityIndicatorView.centerXAnchor.constraint( + equalTo: contentView.safeAreaLayoutGuide.centerXAnchor), + activityIndicatorView.centerYAnchor.constraint( + equalTo: contentView.safeAreaLayoutGuide.centerYAnchor) + ]) + + isHidden = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension WebfingerIndicatorView { + func startAnimating() { + isHidden = false + activityIndicatorView.startAnimating() + + UIView.animate(withDuration: 0.5) { + self.effect = UIBlurEffect(style: .systemUltraThinMaterial) + } + } + + func stopAnimating() { + activityIndicatorView.stopAnimating() + + UIView.animate(withDuration: 0.5) { + self.effect = nil + } completion: { _ in + self.isHidden = true + } + } +}