mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-22 00:01:00 +00:00
Instance filter
This commit is contained in:
parent
bb44676a73
commit
d3d737da86
12 changed files with 232 additions and 34 deletions
|
@ -6,7 +6,7 @@ import Foundation
|
||||||
|
|
||||||
public typealias Session = Alamofire.Session
|
public typealias Session = Alamofire.Session
|
||||||
|
|
||||||
open class Client {
|
open class HTTPClient {
|
||||||
private let session: Session
|
private let session: Session
|
||||||
private let decoder: DataDecoder
|
private let decoder: DataDecoder
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ open class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
open func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> {
|
open func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> {
|
||||||
requestPublisher(target).value().mapError { $0 as Error }.eraseToAnyPublisher()
|
requestPublisher(target).value().mapError { $0.underlyingOrTypeErased }.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func request<T: DecodableTarget, E: Error & Decodable>(
|
public func request<T: DecodableTarget, E: Error & Decodable>(
|
||||||
|
@ -35,14 +35,14 @@ open class Client {
|
||||||
throw decodedError
|
throw decodedError
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw error.underlyingOrTypeErased
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Client {
|
private extension HTTPClient {
|
||||||
func requestPublisher<T: DecodableTarget>(_ target: T) -> DataResponsePublisher<T.ResultType> {
|
func requestPublisher<T: DecodableTarget>(_ target: T) -> DataResponsePublisher<T.ResultType> {
|
||||||
if let protocolClasses = session.sessionConfiguration.protocolClasses {
|
if let protocolClasses = session.sessionConfiguration.protocolClasses {
|
||||||
for protocolClass in protocolClasses {
|
for protocolClass in protocolClasses {
|
||||||
|
@ -55,3 +55,9 @@ private extension Client {
|
||||||
.publishDecodable(type: T.ResultType.self, queue: session.rootQueue, decoder: decoder)
|
.publishDecodable(type: T.ResultType.self, queue: session.rootQueue, decoder: decoder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension AFError {
|
||||||
|
var underlyingOrTypeErased: Error {
|
||||||
|
underlyingError ?? self
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import HTTP
|
||||||
|
|
||||||
public class StubbingURLProtocol: URLProtocol {
|
public class StubbingURLProtocol: URLProtocol {
|
||||||
private static var targetsForURLs = [URL: Target]()
|
private static var targetsForURLs = [URL: Target]()
|
||||||
|
private static var stubsForURLs = [URL: HTTPStub]()
|
||||||
|
|
||||||
override public class func canInit(with task: URLSessionTask) -> Bool {
|
override public class func canInit(with task: URLSessionTask) -> Bool {
|
||||||
true
|
true
|
||||||
|
@ -21,24 +22,31 @@ public class StubbingURLProtocol: URLProtocol {
|
||||||
override public func startLoading() {
|
override public func startLoading() {
|
||||||
guard
|
guard
|
||||||
let url = request.url,
|
let url = request.url,
|
||||||
let stub = Self.stub(request: request, target: Self.targetsForURLs[url]) else {
|
let stub = Self.stubsForURLs[url]
|
||||||
// preconditionFailure("Stub for request not found")
|
?? Self.stub(request: request, target: Self.targetsForURLs[url]) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch stub {
|
switch stub {
|
||||||
case let .success((response, data)):
|
case let .success((response, data)):
|
||||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
|
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||||
client?.urlProtocol(self, didLoad: data)
|
client?.urlProtocol(self, didLoad: data)
|
||||||
client?.urlProtocolDidFinishLoading(self)
|
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
client?.urlProtocol(self, didFailWithError: error)
|
client?.urlProtocol(self, didFailWithError: error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client?.urlProtocolDidFinishLoading(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
override public func stopLoading() {}
|
override public func stopLoading() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public extension StubbingURLProtocol {
|
||||||
|
static func setStub(_ stub: HTTPStub, forURL url: URL) {
|
||||||
|
stubsForURLs[url] = stub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private extension StubbingURLProtocol {
|
private extension StubbingURLProtocol {
|
||||||
class func stub(
|
class func stub(
|
||||||
request: URLRequest,
|
request: URLRequest,
|
||||||
|
|
|
@ -5,7 +5,7 @@ import Foundation
|
||||||
import HTTP
|
import HTTP
|
||||||
import Mastodon
|
import Mastodon
|
||||||
|
|
||||||
public final class MastodonAPIClient: Client {
|
public final class MastodonAPIClient: HTTPClient {
|
||||||
public var instanceURL: URL?
|
public var instanceURL: URL?
|
||||||
public var accessToken: String?
|
public var accessToken: String?
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ let package = Package(
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/groue/CombineExpectations.git", .upToNextMajor(from: "0.5.0")),
|
.package(url: "https://github.com/groue/CombineExpectations.git", .upToNextMajor(from: "0.5.0")),
|
||||||
|
.package(path: "CodableBloomFilter"),
|
||||||
.package(path: "DB"),
|
.package(path: "DB"),
|
||||||
.package(path: "Keychain"),
|
.package(path: "Keychain"),
|
||||||
.package(path: "MastodonAPI"),
|
.package(path: "MastodonAPI"),
|
||||||
|
@ -26,7 +27,7 @@ let package = Package(
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "ServiceLayer",
|
name: "ServiceLayer",
|
||||||
dependencies: ["DB", "MastodonAPI", "Secrets"]),
|
dependencies: ["CodableBloomFilter", "DB", "MastodonAPI", "Secrets"]),
|
||||||
.target(
|
.target(
|
||||||
name: "ServiceLayerMocks",
|
name: "ServiceLayerMocks",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
|
|
@ -9,6 +9,7 @@ import Secrets
|
||||||
|
|
||||||
public struct AllIdentitiesService {
|
public struct AllIdentitiesService {
|
||||||
public let mostRecentlyUsedIdentityID: AnyPublisher<UUID?, Never>
|
public let mostRecentlyUsedIdentityID: AnyPublisher<UUID?, Never>
|
||||||
|
public let instanceFilterService: InstanceFilterService
|
||||||
|
|
||||||
private let identityDatabase: IdentityDatabase
|
private let identityDatabase: IdentityDatabase
|
||||||
private let environment: AppEnvironment
|
private let environment: AppEnvironment
|
||||||
|
@ -22,6 +23,7 @@ public struct AllIdentitiesService {
|
||||||
mostRecentlyUsedIdentityID = identityDatabase.mostRecentlyUsedIdentityIDObservation()
|
mostRecentlyUsedIdentityID = identityDatabase.mostRecentlyUsedIdentityIDObservation()
|
||||||
.replaceError(with: nil)
|
.replaceError(with: nil)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
instanceFilterService = InstanceFilterService(environment: environment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -87,7 +87,7 @@ private extension AuthenticationService {
|
||||||
case codeNotFound
|
case codeNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
private func authorizationURL(instanceURL: URL, clientID: String) -> URL? {
|
func authorizationURL(instanceURL: URL, clientID: String) -> URL? {
|
||||||
guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
|
guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import CodableBloomFilter
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import HTTP
|
||||||
|
|
||||||
|
public struct InstanceFilterService {
|
||||||
|
private let httpClient: HTTPClient
|
||||||
|
private var userDefaultsClient: UserDefaultsClient
|
||||||
|
|
||||||
|
init(environment: AppEnvironment) {
|
||||||
|
httpClient = HTTPClient(session: environment.session, decoder: JSONDecoder())
|
||||||
|
userDefaultsClient = UserDefaultsClient(userDefaults: environment.userDefaults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension InstanceFilterService {
|
||||||
|
func isFiltered(url: URL) -> Bool {
|
||||||
|
guard let host = url.host else { return true }
|
||||||
|
|
||||||
|
let allHostComponents = host.components(separatedBy: ".")
|
||||||
|
var hostComponents = [String]()
|
||||||
|
|
||||||
|
for component in allHostComponents.reversed() {
|
||||||
|
hostComponents.insert(component, at: 0)
|
||||||
|
|
||||||
|
if filter.contains(hostComponents.joined(separator: ".")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFilter() -> AnyPublisher<Never, Never> {
|
||||||
|
httpClient.request(UpdatedFilterTarget())
|
||||||
|
.handleEvents(receiveOutput: { userDefaultsClient.updatedInstanceFilter = $0 })
|
||||||
|
.map { _ in () }
|
||||||
|
.replaceError(with: ())
|
||||||
|
.ignoreOutput()
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct UpdatedFilterTarget: DecodableTarget {
|
||||||
|
typealias ResultType = BloomFilter<String>
|
||||||
|
|
||||||
|
let baseURL = URL(string: "https://filter.metabolist.com")!
|
||||||
|
let pathComponents = ["filter.json"]
|
||||||
|
let method = HTTPMethod.get
|
||||||
|
let encoding: ParameterEncoding = JSONEncoding.default
|
||||||
|
let parameters: [String: Any]? = nil
|
||||||
|
let headers: HTTPHeaders? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension InstanceFilterService {
|
||||||
|
var filter: BloomFilter<String> {
|
||||||
|
userDefaultsClient.updatedInstanceFilter ?? Self.defaultFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
static let updatedFilterUserDefaultsKey = "updatedFilter"
|
||||||
|
// 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 = #"{"hashers":["djb2","djb2a","fnv1","fnv1a","sdbm"],"data":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAIAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAgAAAAAQAAAAAABAAACAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAABAAAEAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAIAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAIAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAIAAAQAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAQAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAADAAAAAAAAAAAAA=="}"#
|
||||||
|
.data(using: .utf8)!
|
||||||
|
// swiftlint:enable line_length
|
||||||
|
// swiftlint:disable force_try
|
||||||
|
static let defaultFilter = try! JSONDecoder().decode(BloomFilter<String>.self, from: defaultFilterData)
|
||||||
|
// swiftlint:enable force_try
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import CodableBloomFilter
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class UserDefaultsClient {
|
||||||
|
private let userDefaults: UserDefaults
|
||||||
|
|
||||||
|
init(userDefaults: UserDefaults) {
|
||||||
|
self.userDefaults = userDefaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UserDefaultsClient {
|
||||||
|
var updatedInstanceFilter: BloomFilter<String>? {
|
||||||
|
get {
|
||||||
|
guard let data = self[.updatedFilter] as Data? else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try? JSONDecoder().decode(BloomFilter<String>.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
set {
|
||||||
|
var data: Data?
|
||||||
|
|
||||||
|
if let newValue = newValue {
|
||||||
|
data = try? JSONEncoder().encode(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
self[.updatedFilter] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UserDefaultsClient {
|
||||||
|
enum Item: String {
|
||||||
|
case updatedFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
subscript<T>(index: Item) -> T? {
|
||||||
|
get { userDefaults.value(forKey: index.rawValue) as? T }
|
||||||
|
set { userDefaults.set(newValue, forKey: index.rawValue) }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import CodableBloomFilter
|
||||||
|
import Combine
|
||||||
|
import CombineExpectations
|
||||||
|
@testable import ServiceLayer
|
||||||
|
@testable import ServiceLayerMocks
|
||||||
|
import Stubbing
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
class InstanceFilterServiceTests: XCTestCase {
|
||||||
|
func testFiltering() throws {
|
||||||
|
let sut = InstanceFilterService(environment: .mock())
|
||||||
|
let unfilteredInstanceURL = URL(string: "https://unfiltered.instance")!
|
||||||
|
let filteredInstanceURL = URL(string: "https://filtered.instance")!
|
||||||
|
let subdomainFilteredInstanceURL = URL(string: "https://subdomain.filtered.instance")!
|
||||||
|
|
||||||
|
XCTAssertFalse(sut.isFiltered(url: unfilteredInstanceURL))
|
||||||
|
XCTAssertTrue(sut.isFiltered(url: filteredInstanceURL))
|
||||||
|
XCTAssertTrue(sut.isFiltered(url: subdomainFilteredInstanceURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdating() throws {
|
||||||
|
let environment = AppEnvironment.mock()
|
||||||
|
var sut = InstanceFilterService(environment: environment)
|
||||||
|
let previouslyFilteredInstanceURL = URL(string: "https://filtered.instance")!
|
||||||
|
let newlyFilteredInstanceURL = URL(string: "https://instance.filtered")!
|
||||||
|
|
||||||
|
XCTAssertTrue(sut.isFiltered(url: previouslyFilteredInstanceURL))
|
||||||
|
XCTAssertFalse(sut.isFiltered(url: newlyFilteredInstanceURL))
|
||||||
|
|
||||||
|
var updatedFilter = BloomFilter<String>(hashers: [.djb2, .sdbm], byteCount: 16)
|
||||||
|
|
||||||
|
updatedFilter.insert("instance.filtered")
|
||||||
|
|
||||||
|
let updatedFilterData = try JSONEncoder().encode(updatedFilter)
|
||||||
|
let stub: HTTPStub = .success((URLResponse(), updatedFilterData))
|
||||||
|
|
||||||
|
StubbingURLProtocol.setStub(stub, forURL: URL(string: "https://filter.metabolist.com/filter.json")!)
|
||||||
|
|
||||||
|
let updateRecorder = sut.updateFilter().collect().record()
|
||||||
|
|
||||||
|
_ = try wait(for: updateRecorder.next(), timeout: 1)
|
||||||
|
|
||||||
|
XCTAssertFalse(sut.isFiltered(url: previouslyFilteredInstanceURL))
|
||||||
|
XCTAssertTrue(sut.isFiltered(url: newlyFilteredInstanceURL))
|
||||||
|
|
||||||
|
sut = InstanceFilterService(environment: environment)
|
||||||
|
|
||||||
|
XCTAssertFalse(sut.isFiltered(url: previouslyFilteredInstanceURL))
|
||||||
|
XCTAssertTrue(sut.isFiltered(url: newlyFilteredInstanceURL))
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,7 +26,7 @@ public extension AddIdentityViewModel {
|
||||||
let instanceURL: URL
|
let instanceURL: URL
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try instanceURL = urlFieldText.url()
|
instanceURL = try checkedURL()
|
||||||
} catch {
|
} catch {
|
||||||
alertItem = AlertItem(error: error)
|
alertItem = AlertItem(error: error)
|
||||||
|
|
||||||
|
@ -37,6 +37,9 @@ public extension AddIdentityViewModel {
|
||||||
.collect()
|
.collect()
|
||||||
.map { _ in (identityID, instanceURL) }
|
.map { _ in (identityID, instanceURL) }
|
||||||
.flatMap(allIdentitiesService.createIdentity(id:instanceURL:))
|
.flatMap(allIdentitiesService.createIdentity(id:instanceURL:))
|
||||||
|
.mapError {
|
||||||
|
return $0
|
||||||
|
}
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||||
.handleEvents(
|
.handleEvents(
|
||||||
|
@ -55,7 +58,7 @@ public extension AddIdentityViewModel {
|
||||||
let instanceURL: URL
|
let instanceURL: URL
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try instanceURL = urlFieldText.url()
|
instanceURL = try checkedURL()
|
||||||
} catch {
|
} catch {
|
||||||
alertItem = AlertItem(error: error)
|
alertItem = AlertItem(error: error)
|
||||||
|
|
||||||
|
@ -72,4 +75,33 @@ public extension AddIdentityViewModel {
|
||||||
} receiveValue: { _ in }
|
} receiveValue: { _ in }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshFilter() {
|
||||||
|
allIdentitiesService.instanceFilterService.updateFilter()
|
||||||
|
.sink { _ in }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AddIdentityViewModel {
|
||||||
|
private static let filteredURL = URL(string: "https://filtered")!
|
||||||
|
private static let HTTPSPrefix = "https://"
|
||||||
|
|
||||||
|
func checkedURL() throws -> URL {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
if urlFieldText.hasPrefix(Self.HTTPSPrefix), let prefixedURL = URL(string: urlFieldText) {
|
||||||
|
url = prefixedURL
|
||||||
|
} else if let unprefixedURL = URL(string: Self.HTTPSPrefix + urlFieldText) {
|
||||||
|
url = unprefixedURL
|
||||||
|
} else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if allIdentitiesService.instanceFilterService.isFiltered(url: url) {
|
||||||
|
return Self.filteredURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
// Copyright © 2020 Metabolist. All rights reserved.
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
private static let HTTPSPrefix = "https://"
|
|
||||||
|
|
||||||
func url() throws -> URL {
|
|
||||||
let url: URL?
|
|
||||||
|
|
||||||
if hasPrefix(Self.HTTPSPrefix) {
|
|
||||||
url = URL(string: self)
|
|
||||||
} else {
|
|
||||||
url = URL(string: Self.HTTPSPrefix + self)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let validURL = url else { throw URLError(.badURL) }
|
|
||||||
|
|
||||||
return validURL
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -31,6 +31,7 @@ struct AddIdentityView: View {
|
||||||
rootViewModel.newIdentitySelected(id: id)
|
rootViewModel.newIdentitySelected(id: id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear(perform: viewModel.refreshFilter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue