Webfingering

This commit is contained in:
Justin Mazzocchi 2020-09-25 23:37:30 -07:00
parent cf342bf3ae
commit eab12976cd
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
7 changed files with 224 additions and 42 deletions

View file

@ -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]
}

View file

@ -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
}
}
}

View file

@ -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 = "<group>"; };
D0D7C013250440610039AD6F /* CodableBloomFilter */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CodableBloomFilter; sourceTree = "<group>"; };
D0E0F1E424FC49FC002C04BF /* Mastodon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Mastodon; sourceTree = "<group>"; };
D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebfingerIndicatorView.swift; sourceTree = "<group>"; };
D0E2C1CF24FD8BA400854680 /* ViewModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ViewModels; sourceTree = "<group>"; };
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 = "<group>"; };
@ -288,6 +290,7 @@
D0625E55250F086B00502611 /* Status */,
D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */,
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */,
D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -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 */,

View file

@ -10,6 +10,8 @@ public enum Navigation {
case url(URL)
case statusList(StatusListService)
case accountStatuses(AccountStatusesService)
case webfingerStart
case webfingerEnd
}
public struct NavigationService {
@ -46,8 +48,12 @@ public extension NavigationService {
.eraseToAnyPublisher()
}
if url.shouldWebfinger {
return webfinger(url: url)
} else {
return Just(.url(url)).eraseToAnyPublisher()
}
}
func contextStatusListService(id: String) -> StatusListService {
StatusListService(statusID: id, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
@ -88,6 +94,37 @@ private extension NavigationService {
return nil
}
func webfinger(url: URL) -> AnyPublisher<Navigation, Never> {
let navigationSubject = PassthroughSubject<Navigation, Never>()
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 {

View file

@ -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<AnyCancellable>()
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?

View file

@ -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)

View file

@ -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
}
}
}