diff --git a/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift b/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift index e6b6941..6c09806 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift @@ -4,18 +4,54 @@ import CodableBloomFilter import Combine import Foundation import HTTP +import Mastodon +import MastodonAPI public struct InstanceURLService { private let httpClient: HTTPClient private var userDefaultsClient: UserDefaultsClient public init(environment: AppEnvironment) { - httpClient = HTTPClient(session: environment.session, decoder: JSONDecoder()) + httpClient = HTTPClient(session: environment.session, decoder: MastodonDecoder()) userDefaultsClient = UserDefaultsClient(userDefaults: environment.userDefaults) } } 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 { + httpClient.request( + MastodonAPITarget( + baseURL: url, + endpoint: InstanceEndpoint.instance, + accessToken: nil)) + .map { $0 as Instance? } + .catch { _ in Just(nil) } + .eraseToAnyPublisher() + } + + func isPublicTimelineAvailable(url: URL) -> AnyPublisher { + 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 { guard let host = url.host else { return true } @@ -55,11 +91,8 @@ private struct UpdatedFilterTarget: DecodableTarget { } private extension InstanceURLService { - var filter: BloomFilter { - userDefaultsClient.updatedInstanceFilter ?? Self.defaultFilter - } - - static let updatedFilterUserDefaultsKey = "updatedFilter" + static let httpsPrefix = "https://" + static let shortestPossibleURLLength = 4 // Ugly, but baking this into the compiled app instead of loading the data from the bundle is more secure // swiftlint:disable line_length static let defaultFilterData = #"{"hashes":["djb232","djb2a32","fnv132","fnv1a32","sdbm32"],"data":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAIAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAgAAAAAQAAAAAABAAACAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAABAAAEAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAIAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAIAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAIAAAQAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAQAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAADAAAAAAAAAAAAA=="}"# @@ -68,4 +101,7 @@ private extension InstanceURLService { // swiftlint:disable force_try static let defaultFilter = try! JSONDecoder().decode(BloomFilter.self, from: defaultFilterData) // swiftlint:enable force_try + var filter: BloomFilter { + userDefaultsClient.updatedInstanceFilter ?? Self.defaultFilter + } } diff --git a/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift b/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift index e01b5bd..6f1324f 100644 --- a/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift +++ b/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift @@ -2,6 +2,7 @@ import Combine import Foundation +import Mastodon import ServiceLayer public enum AddIdentityError: Error { @@ -12,6 +13,8 @@ public final class AddIdentityViewModel: ObservableObject { @Published public var urlFieldText = "" @Published public var alertItem: AlertItem? @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 private let allIdentitiesService: AllIdentitiesService @@ -23,6 +26,21 @@ public final class AddIdentityViewModel: ObservableObject { self.allIdentitiesService = allIdentitiesService self.instanceURLService = instanceURLService 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 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) { let identityID = UUID() - guard let url = Self.url(fieldText: urlFieldText) else { + guard let url = InstanceURLService.url(text: urlFieldText) else { alertItem = AlertItem(error: AddIdentityError.unableToConnectToInstance) return @@ -81,27 +86,29 @@ private extension AddIdentityViewModel { url: url, authenticated: authenticated) .receive(on: DispatchQueue.main) - .catch { [weak self] error -> Empty 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 }) .sink { [weak self] in guard let self = self else { return } self.loading = false - if case .finished = $0 { + switch $0 { + case .finished: 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 } .store(in: &cancellables) } + + func checkIfPublicTimelineAvailable(url: URL) -> AnyPublisher { + Just(false).eraseToAnyPublisher() + } } diff --git a/Views/AddIdentityView.swift b/Views/AddIdentityView.swift index 5d9fcaa..012f3e1 100644 --- a/Views/AddIdentityView.swift +++ b/Views/AddIdentityView.swift @@ -1,5 +1,6 @@ // Copyright © 2020 Metabolist. All rights reserved. +import KingfisherSwiftUI import SwiftUI import ViewModels @@ -9,22 +10,46 @@ struct AddIdentityView: View { var body: some View { Form { - urlTextField - .autocapitalization(.none) - .disableAutocorrection(true) - .keyboardType(.URL) - Group { - if viewModel.loading { - ProgressView() - } else { - Button("add-identity.log-in", - action: viewModel.logInTapped) - Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped) - .frame(maxWidth: .infinity, alignment: .center) + Section { + TextField("add-identity.instance-url", text: $viewModel.urlFieldText) + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(.URL) + Group { + if viewModel.loading { + ProgressView() + } else { + Button("add-identity.log-in", + action: viewModel.logInTapped) + 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) .onReceive(viewModel.addedIdentityID) { id in 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 { public var errorDescription: String? { NSLocalizedString("add-identity.unable-to-connect-to-instance", comment: "")