Show instance info if available

This commit is contained in:
Justin Mazzocchi 2020-09-10 02:38:21 -07:00
parent 83bce93225
commit 5586011eea
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
3 changed files with 113 additions and 51 deletions

View file

@ -4,18 +4,54 @@ import CodableBloomFilter
import Combine import Combine
import Foundation import Foundation
import HTTP import HTTP
import Mastodon
import MastodonAPI
public struct InstanceURLService { public struct InstanceURLService {
private let httpClient: HTTPClient private let httpClient: HTTPClient
private var userDefaultsClient: UserDefaultsClient private var userDefaultsClient: UserDefaultsClient
public init(environment: AppEnvironment) { public init(environment: AppEnvironment) {
httpClient = HTTPClient(session: environment.session, decoder: JSONDecoder()) httpClient = HTTPClient(session: environment.session, decoder: MastodonDecoder())
userDefaultsClient = UserDefaultsClient(userDefaults: environment.userDefaults) userDefaultsClient = UserDefaultsClient(userDefaults: environment.userDefaults)
} }
} }
public extension InstanceURLService { public extension InstanceURLService {
static func url(text: String) -> URL? {
guard text.count >= shortestPossibleURLLength else { return nil }
if text.hasPrefix(httpsPrefix), let prefixedURL = URL(string: text) {
return prefixedURL
} else if let unprefixedURL = URL(string: httpsPrefix + text) {
return unprefixedURL
}
return nil
}
func instance(url: URL) -> AnyPublisher<Instance?, Never> {
httpClient.request(
MastodonAPITarget(
baseURL: url,
endpoint: InstanceEndpoint.instance,
accessToken: nil))
.map { $0 as Instance? }
.catch { _ in Just(nil) }
.eraseToAnyPublisher()
}
func isPublicTimelineAvailable(url: URL) -> AnyPublisher<Bool, Never> {
httpClient.request(
MastodonAPITarget(
baseURL: url,
endpoint: TimelinesEndpoint.public(local: true),
accessToken: nil))
.map { _ in true }
.catch { _ in Just(false) }
.eraseToAnyPublisher()
}
func isFiltered(url: URL) -> Bool { func isFiltered(url: URL) -> Bool {
guard let host = url.host else { return true } guard let host = url.host else { return true }
@ -55,11 +91,8 @@ private struct UpdatedFilterTarget: DecodableTarget {
} }
private extension InstanceURLService { private extension InstanceURLService {
var filter: BloomFilter<String> { static let httpsPrefix = "https://"
userDefaultsClient.updatedInstanceFilter ?? Self.defaultFilter static let shortestPossibleURLLength = 4
}
static let updatedFilterUserDefaultsKey = "updatedFilter"
// Ugly, but baking this into the compiled app instead of loading the data from the bundle is more secure // Ugly, but baking this into the compiled app instead of loading the data from the bundle is more secure
// swiftlint:disable line_length // swiftlint:disable line_length
static let defaultFilterData = #"{"hashes":["djb232","djb2a32","fnv132","fnv1a32","sdbm32"],"data":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAIAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAgAAAAAQAAAAAABAAACAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAABAAAEAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAIAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAIAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAIAAAQAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAQAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAADAAAAAAAAAAAAA=="}"# static let defaultFilterData = #"{"hashes":["djb232","djb2a32","fnv132","fnv1a32","sdbm32"],"data":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAIAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAgAAAAAQAAAAAABAAACAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAABAAAEAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAIAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAIAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAIAAAQAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAQAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAADAAAAAAAAAAAAA=="}"#
@ -68,4 +101,7 @@ private extension InstanceURLService {
// swiftlint:disable force_try // swiftlint:disable force_try
static let defaultFilter = try! JSONDecoder().decode(BloomFilter<String>.self, from: defaultFilterData) static let defaultFilter = try! JSONDecoder().decode(BloomFilter<String>.self, from: defaultFilterData)
// swiftlint:enable force_try // swiftlint:enable force_try
var filter: BloomFilter<String> {
userDefaultsClient.updatedInstanceFilter ?? Self.defaultFilter
}
} }

View file

@ -2,6 +2,7 @@
import Combine import Combine
import Foundation import Foundation
import Mastodon
import ServiceLayer import ServiceLayer
public enum AddIdentityError: Error { public enum AddIdentityError: Error {
@ -12,6 +13,8 @@ public final class AddIdentityViewModel: ObservableObject {
@Published public var urlFieldText = "" @Published public var urlFieldText = ""
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
@Published public private(set) var loading = false @Published public private(set) var loading = false
@Published public private(set) var instance: Instance?
@Published public private(set) var isPublicTimelineAvailable = false
public let addedIdentityID: AnyPublisher<UUID, Never> public let addedIdentityID: AnyPublisher<UUID, Never>
private let allIdentitiesService: AllIdentitiesService private let allIdentitiesService: AllIdentitiesService
@ -23,6 +26,21 @@ public final class AddIdentityViewModel: ObservableObject {
self.allIdentitiesService = allIdentitiesService self.allIdentitiesService = allIdentitiesService
self.instanceURLService = instanceURLService self.instanceURLService = instanceURLService
addedIdentityID = addedIdentityIDSubject.eraseToAnyPublisher() addedIdentityID = addedIdentityIDSubject.eraseToAnyPublisher()
let url = $urlFieldText
.debounce(for: 0.5, scheduler: DispatchQueue.main)
.removeDuplicates()
.compactMap(InstanceURLService.url(text:))
.filter { !instanceURLService.isFiltered(url: $0) }
.share()
url.flatMap(instanceURLService.instance(url:))
.receive(on: DispatchQueue.main)
.assign(to: &$instance)
url.flatMap(instanceURLService.isPublicTimelineAvailable(url:))
.receive(on: DispatchQueue.main)
.assign(to: &$isPublicTimelineAvailable)
} }
} }
@ -43,23 +61,10 @@ public extension AddIdentityViewModel {
} }
private extension AddIdentityViewModel { private extension AddIdentityViewModel {
private static let filteredURL = URL(string: "https://filtered")!
private static let HTTPSPrefix = "https://"
static func url(fieldText: String) -> URL? {
if fieldText.hasPrefix(HTTPSPrefix), let prefixedURL = URL(string: fieldText) {
return prefixedURL
} else if let unprefixedURL = URL(string: HTTPSPrefix + fieldText) {
return unprefixedURL
}
return nil
}
func addIdentity(authenticated: Bool) { func addIdentity(authenticated: Bool) {
let identityID = UUID() let identityID = UUID()
guard let url = Self.url(fieldText: urlFieldText) else { guard let url = InstanceURLService.url(text: urlFieldText) else {
alertItem = AlertItem(error: AddIdentityError.unableToConnectToInstance) alertItem = AlertItem(error: AddIdentityError.unableToConnectToInstance)
return return
@ -81,27 +86,29 @@ private extension AddIdentityViewModel {
url: url, url: url,
authenticated: authenticated) authenticated: authenticated)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.catch { [weak self] error -> Empty<Never, Never> in
if case AuthenticationError.canceled = error {
// no-op
} else {
let displayedError = error is URLError ? AddIdentityError.unableToConnectToInstance : error
self?.alertItem = AlertItem(error: displayedError)
}
return Empty()
}
.handleEvents(receiveSubscription: { [weak self] _ in self?.loading = true }) .handleEvents(receiveSubscription: { [weak self] _ in self?.loading = true })
.sink { [weak self] in .sink { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.loading = false self.loading = false
if case .finished = $0 { switch $0 {
case .finished:
self.addedIdentityIDSubject.send(identityID) self.addedIdentityIDSubject.send(identityID)
case let .failure(error):
if case AuthenticationError.canceled = error {
return
}
let displayedError = error is URLError ? AddIdentityError.unableToConnectToInstance : error
self.alertItem = AlertItem(error: displayedError)
} }
} receiveValue: { _ in } } receiveValue: { _ in }
.store(in: &cancellables) .store(in: &cancellables)
} }
func checkIfPublicTimelineAvailable(url: URL) -> AnyPublisher<Bool, Never> {
Just(false).eraseToAnyPublisher()
}
} }

View file

@ -1,5 +1,6 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
import KingfisherSwiftUI
import SwiftUI import SwiftUI
import ViewModels import ViewModels
@ -9,22 +10,46 @@ struct AddIdentityView: View {
var body: some View { var body: some View {
Form { Form {
urlTextField Section {
.autocapitalization(.none) TextField("add-identity.instance-url", text: $viewModel.urlFieldText)
.disableAutocorrection(true) .autocapitalization(.none)
.keyboardType(.URL) .disableAutocorrection(true)
Group { .keyboardType(.URL)
if viewModel.loading { Group {
ProgressView() if viewModel.loading {
} else { ProgressView()
Button("add-identity.log-in", } else {
action: viewModel.logInTapped) Button("add-identity.log-in",
Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped) action: viewModel.logInTapped)
.frame(maxWidth: .infinity, alignment: .center) if viewModel.isPublicTimelineAvailable {
Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped)
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
Section {
if let instance = viewModel.instance {
KFImage(instance.thumbnail)
.placeholder {
ProgressView()
}
.resizable()
.scaledToFit()
.listRowInsets(EdgeInsets())
VStack(alignment: .center) {
Text(instance.title)
.font(.headline)
Text(instance.uri)
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.listRowInsets(EdgeInsets())
} }
} }
.frame(maxWidth: .infinity, alignment: .center)
} }
.animation(.default)
.alertItem($viewModel.alertItem) .alertItem($viewModel.alertItem)
.onReceive(viewModel.addedIdentityID) { id in .onReceive(viewModel.addedIdentityID) { id in
withAnimation { withAnimation {
@ -35,12 +60,6 @@ struct AddIdentityView: View {
} }
} }
extension AddIdentityView {
private var urlTextField: some View {
TextField("add-identity.instance-url", text: $viewModel.urlFieldText)
}
}
extension AddIdentityError: LocalizedError { extension AddIdentityError: LocalizedError {
public var errorDescription: String? { public var errorDescription: String? {
NSLocalizedString("add-identity.unable-to-connect-to-instance", comment: "") NSLocalizedString("add-identity.unable-to-connect-to-instance", comment: "")