From 2745f2470dfb43757c4e9b570ba4aa757b1f0463 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Fri, 11 Sep 2020 02:55:06 -0700 Subject: [PATCH] Registrations wip --- Localizations/Localizable.strings | 12 ++ .../Endpoints/AccessTokenEndpoint.swift | 58 +++++++--- .../AccessTokenEndpoint+Stubbing.swift | 17 ++- Metatext.xcodeproj/project.pbxproj | 8 ++ .../Services/AllIdentitiesService.swift | 76 +++++++++---- .../Services/AuthenticationService.swift | 58 ++++++++-- .../PreviewViewModels/PreviewViewModels.swift | 25 +++-- .../ViewModels/AddIdentityViewModel.swift | 21 +++- .../ViewModels/RegistrationViewModel.swift | 82 ++++++++++++++ Views/AddIdentityView.swift | 27 ++++- Views/RegistrationView.swift | 103 ++++++++++++++++++ Views/RootView.swift | 10 +- Views/SafariView.swift | 16 +++ 13 files changed, 433 insertions(+), 80 deletions(-) create mode 100644 ViewModels/Sources/ViewModels/RegistrationViewModel.swift create mode 100644 Views/RegistrationView.swift create mode 100644 Views/SafariView.swift diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 1efd8f5..27335bd 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -5,7 +5,19 @@ "add-identity.instance-url" = "Instance URL"; "add-identity.log-in" = "Log in"; "add-identity.browse" = "Browse"; +"add-identity.join" = "Join"; +"add-identity.request-invite" = "Request an invite"; "add-identity.unable-to-connect-to-instance" = "Unable to connect to instance"; +"registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue"; +"registration.username" = "Username"; +"registration.email" = "Email"; +"registration.password" = "Password"; +"registration.password-confirmation" = "Confirm password"; +"registration.reason-%@" = "Why do you want to join %@?"; +"registration.server-rules" = "Server rules"; +"registration.terms-of-service" = "Terms of service"; +"registration.agree-to-server-rules-and-terms-of-service" = "I agree to the server rules and terms of service"; +"registration.password-confirmation-mismatch" = "Password and password confirmation do not match"; "secondary-navigation.manage-accounts" = "Manage Accounts"; "secondary-navigation.lists" = "Lists"; "secondary-navigation.preferences" = "Preferences"; diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/AccessTokenEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/AccessTokenEndpoint.swift index 4202aa6..6cd214c 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/AccessTokenEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/AccessTokenEndpoint.swift @@ -6,41 +6,67 @@ import Mastodon public enum AccessTokenEndpoint { case oauthToken( - clientID: String, - clientSecret: String, - code: String, - grantType: String, - scopes: String, - redirectURI: String - ) + clientID: String, + clientSecret: String, + grantType: String, + scopes: String, + code: String?, + redirectURI: String? + ) + case accounts(username: String, email: String, password: String, reason: String?) } extension AccessTokenEndpoint: Endpoint { public typealias ResultType = AccessToken - public var context: [String] { [] } + public var context: [String] { + switch self { + case .oauthToken: + return [] + case .accounts: + return defaultContext + } + } public var pathComponentsInContext: [String] { - ["oauth", "token"] + switch self { + case .oauthToken: + return ["oauth", "token"] + case .accounts: + return ["accounts"] + } } public var method: HTTPMethod { switch self { - case .oauthToken: return .post + case .oauthToken, .accounts: return .post } } public var parameters: [String: Any]? { switch self { - case let .oauthToken(clientID, clientSecret, code, grantType, scopes, redirectURI): - return [ + case let .oauthToken(clientID, clientSecret, grantType, scopes, code, redirectURI): + var params = [ "client_id": clientID, "client_secret": clientSecret, - "code": code, "grant_type": grantType, - "scope": scopes, - "redirect_uri": redirectURI - ] + "scope": scopes] + + params["code"] = code + params["redirect_uri"] = redirectURI + + return params + case let .accounts(username, email, password, reason): + var params: [String: Any] = [ + "username": username, + "email": email, + "password": password, + "locale": Locale.autoupdatingCurrent.languageCode ?? "en", // TODO: probably need to map + "agreement": true] + + params["reason"] = reason + + return params } } } diff --git a/MastodonAPI/Sources/MastodonAPIStubs/AccessTokenEndpoint+Stubbing.swift b/MastodonAPI/Sources/MastodonAPIStubs/AccessTokenEndpoint+Stubbing.swift index 4cd70b6..3b93a06 100644 --- a/MastodonAPI/Sources/MastodonAPIStubs/AccessTokenEndpoint+Stubbing.swift +++ b/MastodonAPI/Sources/MastodonAPIStubs/AccessTokenEndpoint+Stubbing.swift @@ -6,16 +6,13 @@ import Stubbing extension AccessTokenEndpoint: Stubbing { public func dataString(url: URL) -> String? { - switch self { - case let .oauthToken(_, _, _, _, scopes, _): - return """ - { - "access_token": "ACCESS_TOKEN_STUB_VALUE", - "token_type": "Bearer", - "scope": "\(scopes)", - "created_at": "\(Int(Date().timeIntervalSince1970))" - } - """ + """ + { + "access_token": "ACCESS_TOKEN_STUB_VALUE", + "token_type": "Bearer", + "scope": "ACCESS_TOKEN_STUB_VALUE_SCOPES", + "created_at": "\(Int(Date().timeIntervalSince1970))" } + """ } } diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 6aea70f..59233f5 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -11,7 +11,9 @@ D01F41D824F880C400D55A2D /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D524F880C400D55A2D /* StatusTableViewCell.swift */; }; D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; }; D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* AttachmentsView.swift */; }; + D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; }; D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; }; + D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */; }; D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; }; D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */; }; @@ -82,11 +84,13 @@ D01F41D524F880C400D55A2D /* StatusTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = ""; }; D01F41E224F8889700D55A2D /* AttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentsView.swift; sourceTree = ""; }; + D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; }; D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = ""; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = ""; }; + D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = ""; }; D0BDF66524FD7A6400C7FA1C /* ServiceLayer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ServiceLayer; sourceTree = ""; }; D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = ""; }; D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = ""; }; @@ -253,7 +257,9 @@ D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */, + D0B32F4F250B373600311912 /* RegistrationView.swift */, D0C7D42724F76169001EBDBB /* RootView.swift */, + D02E1F94250B13210071AD56 /* SafariView.swift */, D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */, D01F41D324F8807E00D55A2D /* Status Cell */, D0C7D42524F76169001EBDBB /* StatusListView.swift */, @@ -479,7 +485,9 @@ buildActionMask = 2147483647; files = ( D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */, + D02E1F95250B13210071AD56 /* SafariView.swift in Sources */, D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, + D0B32F50250B373600311912 /* RegistrationView.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */, D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift index 6dff9cf..fd8c457 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift @@ -29,33 +29,26 @@ public extension AllIdentitiesService { } func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher { - let secrets = Secrets(identityID: id, keychain: environment.keychain) - - do { - try secrets.setInstanceURL(url) - } catch { - return Fail(error: error).eraseToAnyPublisher() - } - - let createIdentityPublisher = database.createIdentity( + createIdentity( id: id, url: url, - authenticated: authenticated) - .ignoreOutput() - .eraseToAnyPublisher() + authenticationPublisher: authenticated + ? AuthenticationService(url: url, environment: environment).authenticate() + : nil) + } - if authenticated { - return AuthenticationService(url: url, environment: environment).authenticate() - .tryMap { - try secrets.setClientID($0.clientId) - try secrets.setClientSecret($0.clientSecret) - try secrets.setAccessToken($1.accessToken) - } - .flatMap { createIdentityPublisher } - .eraseToAnyPublisher() - } else { - return createIdentityPublisher - } + func createIdentity( + id: UUID, + url: URL, + username: String, + email: String, + password: String, + reason: String?) -> AnyPublisher { + createIdentity( + id: id, + url: url, + authenticationPublisher: AuthenticationService(url: url, environment: environment) + .register(username: username, email: email, password: password, reason: reason)) } func deleteIdentity(id: UUID) -> AnyPublisher { @@ -101,3 +94,38 @@ public extension AllIdentitiesService { .eraseToAnyPublisher() } } + +private extension AllIdentitiesService { + func createIdentity( + id: UUID, + url: URL, + authenticationPublisher: AnyPublisher<(AppAuthorization, AccessToken), Error>?) -> AnyPublisher { + let secrets = Secrets(identityID: id, keychain: environment.keychain) + + do { + try secrets.setInstanceURL(url) + } catch { + return Fail(error: error).eraseToAnyPublisher() + } + + let createIdentityPublisher = database.createIdentity( + id: id, + url: url, + authenticated: authenticationPublisher != nil) + .ignoreOutput() + .eraseToAnyPublisher() + + if let authenticationPublisher = authenticationPublisher { + return authenticationPublisher + .tryMap { + try secrets.setClientID($0.clientId) + try secrets.setClientSecret($0.clientSecret) + try secrets.setAccessToken($1.accessToken) + } + .flatMap { createIdentityPublisher } + .eraseToAnyPublisher() + } else { + return createIdentityPublisher + } + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AuthenticationService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AuthenticationService.swift index 8837956..c843e22 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AuthenticationService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AuthenticationService.swift @@ -22,16 +22,42 @@ struct AuthenticationService { extension AuthenticationService { func authenticate() -> AnyPublisher<(AppAuthorization, AccessToken), Error> { - let appAuthorization = mastodonAPIClient.request( - AppAuthorizationEndpoint.apps( - clientName: OAuth.clientName, - redirectURI: OAuth.callbackURL.absoluteString, - scopes: OAuth.scopes, - website: OAuth.website)) + let authorization = appAuthorization().share() + + return authorization + .zip(authorization.flatMap(authenticate(appAuthorization:))) + .eraseToAnyPublisher() + } + + func register(username: String, + email: String, + password: String, + reason: String?) -> AnyPublisher<(AppAuthorization, AccessToken), Error> { + let authorization = appAuthorization() .share() - return appAuthorization - .zip(appAuthorization.flatMap(authenticate(appAuthorization:))) + return authorization.zip( + authorization.flatMap { appAuthorization -> AnyPublisher in + mastodonAPIClient.request( + AccessTokenEndpoint.oauthToken( + clientID: appAuthorization.clientId, + clientSecret: appAuthorization.clientSecret, + grantType: OAuth.registrationGrantType, + scopes: OAuth.scopes, + code: nil, + redirectURI: OAuth.callbackURL.absoluteString)) + .flatMap { accessToken -> AnyPublisher in + mastodonAPIClient.accessToken = accessToken.accessToken + + return mastodonAPIClient.request( + AccessTokenEndpoint.accounts( + username: username, + email: email, + password: password, + reason: reason)) + } + .eraseToAnyPublisher() + }) .eraseToAnyPublisher() } } @@ -41,7 +67,8 @@ private extension AuthenticationService { static let clientName = "Metatext" static let scopes = "read write follow push" static let codeCallbackQueryItemName = "code" - static let grantType = "authorization_code" + static let authorizationCodeGrantType = "authorization_code" + static let registrationGrantType = "client_credentials" static let callbackURLScheme = "metatext" static let callbackURL = URL(string: "\(callbackURLScheme)://oauth.callback")! static let website = URL(string: "https://metabolist.com/metatext")! @@ -63,6 +90,15 @@ private extension AuthenticationService { return code } + func appAuthorization() -> AnyPublisher { + mastodonAPIClient.request( + AppAuthorizationEndpoint.apps( + clientName: OAuth.clientName, + redirectURI: OAuth.callbackURL.absoluteString, + scopes: OAuth.scopes, + website: OAuth.website)) + } + func authorizationURL(appAuthorization: AppAuthorization) throws -> URL { guard var authorizationURLComponents = URLComponents( url: mastodonAPIClient.instanceURL, @@ -106,9 +142,9 @@ private extension AuthenticationService { AccessTokenEndpoint.oauthToken( clientID: appAuthorization.clientId, clientSecret: appAuthorization.clientSecret, - code: $0, - grantType: OAuth.grantType, + grantType: OAuth.authorizationCodeGrantType, scopes: OAuth.scopes, + code: $0, redirectURI: OAuth.callbackURL.absoluteString)) } .eraseToAnyPublisher() diff --git a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift index b67f30c..a0b7308 100644 --- a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift +++ b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift @@ -15,25 +15,21 @@ import ViewModels let db: IdentityDatabase = { let id = UUID() - let url = URL(string: "https://mastodon.social")! let db = try! IdentityDatabase(inMemory: true, keychain: MockKeychain.self) - let decoder = MastodonDecoder() - let instance = try! decoder.decode(Instance.self, from: StubData.instance) - let account = try! decoder.decode(Account.self, from: StubData.account) let secrets = Secrets(identityID: id, keychain: MockKeychain.self) - try! secrets.setInstanceURL(url) + try! secrets.setInstanceURL(.previewInstanceURL) try! secrets.setAccessToken(UUID().uuidString) - _ = db.createIdentity(id: id, url: url, authenticated: true) + _ = db.createIdentity(id: id, url: .previewInstanceURL, authenticated: true) .receive(on: ImmediateScheduler.shared) .sink { _ in } receiveValue: { _ in } - _ = db.updateInstance(instance, forIdentityID: id) + _ = db.updateInstance(.preview, forIdentityID: id) .receive(on: ImmediateScheduler.shared) .sink { _ in } receiveValue: { _ in } - _ = db.updateAccount(account, forIdentityID: id) + _ = db.updateAccount(.preview, forIdentityID: id) .receive(on: ImmediateScheduler.shared) .sink { _ in } receiveValue: { _ in } @@ -41,6 +37,19 @@ let db: IdentityDatabase = { }() let environment = AppEnvironment.mock(fixtureDatabase: db) +let decoder = MastodonDecoder() + +public extension URL { + static let previewInstanceURL = URL(string: "https://mastodon.social")! +} + +public extension Account { + static let preview = try! decoder.decode(Account.self, from: StubData.account) +} + +public extension Instance { + static let preview = try! decoder.decode(Instance.self, from: StubData.instance) +} public extension RootViewModel { static let preview = try! RootViewModel(environment: environment) { Empty().eraseToAnyPublisher() } diff --git a/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift b/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift index bf4da8a..d80723d 100644 --- a/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift +++ b/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift @@ -13,7 +13,7 @@ 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 instanceAndURL: (Instance, URL)? @Published public private(set) var isPublicTimelineAvailable = false public let addedIdentityID: AnyPublisher @@ -32,18 +32,23 @@ public final class AddIdentityViewModel: ObservableObject { .removeDuplicates() .map(instanceURLService.url(text:)) .share() - let urlPresent = url.map { $0 != nil }.share() url.compactMap { $0 } .flatMap(instanceURLService.instance(url:)) - .combineLatest(urlPresent) - .map { $1 ? $0 : nil } + .combineLatest(url) + .map { + if let instance = $0, let url = $1 { + return (instance, url) + } + + return nil + } .receive(on: DispatchQueue.main) - .assign(to: &$instance) + .assign(to: &$instanceAndURL) url.compactMap { $0 } .flatMap(instanceURLService.isPublicTimelineAvailable(url:)) - .combineLatest(urlPresent) + .combineLatest(url.map { $0 != nil }) .map { $0 && $1 } .receive(on: DispatchQueue.main) .assign(to: &$isPublicTimelineAvailable) @@ -64,6 +69,10 @@ public extension AddIdentityViewModel { .sink { _ in } .store(in: &cancellables) } + + func registrationViewModel(instance: Instance, url: URL) -> RegistrationViewModel { + RegistrationViewModel(instance: instance, url: url, allIdentitiesService: allIdentitiesService) + } } private extension AddIdentityViewModel { diff --git a/ViewModels/Sources/ViewModels/RegistrationViewModel.swift b/ViewModels/Sources/ViewModels/RegistrationViewModel.swift new file mode 100644 index 0000000..9c194c0 --- /dev/null +++ b/ViewModels/Sources/ViewModels/RegistrationViewModel.swift @@ -0,0 +1,82 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon +import MastodonAPI +import ServiceLayer + +public enum RegistrationError: Error { + case passwordConfirmationMismatch +} + +public final class RegistrationViewModel: ObservableObject { + public let instance: Instance + public let serverRulesURL: URL + public let termsOfServiceURL: URL + @Published public var alertItem: AlertItem? + @Published public var username = "" + @Published public var email = "" + @Published public var password = "" + @Published public var passwordConfirmation = "" + @Published public var reason = "" + @Published public var passwordsMatch = false + @Published public var agreement = false + @Published public private(set) var registerButtonEnabled = false + @Published public private(set) var registering = false + + private let url: URL + private let allIdentitiesService: AllIdentitiesService + private var cancellables = Set() + + init(instance: Instance, url: URL, allIdentitiesService: AllIdentitiesService) { + self.instance = instance + self.url = url + self.serverRulesURL = url.appendingPathComponent("about").appendingPathComponent("more") + self.termsOfServiceURL = url.appendingPathComponent("terms") + self.allIdentitiesService = allIdentitiesService + + Publishers.CombineLatest4($username, $email, $password, $reason) + .map { username, email, password, reason in + !username.isEmpty + && !email.isEmpty + && !password.isEmpty + && (!instance.approvalRequired || !reason.isEmpty) + } + .combineLatest($agreement) + .map { $0 && $1 } + .assign(to: &$registerButtonEnabled) + } +} + +public extension RegistrationViewModel { + func registerTapped() { + guard password == passwordConfirmation else { + alertItem = AlertItem(error: RegistrationError.passwordConfirmationMismatch) + + return + } + + allIdentitiesService.createIdentity( + id: UUID(), + url: url, + username: username, + email: email, + password: password, + reason: reason) + .handleEvents(receiveSubscription: { [weak self] _ in self?.registering = true }) + .mapError { error -> Error in + if error is URLError { + return AddIdentityError.unableToConnectToInstance + } else { + return error + } + } + .receive(on: DispatchQueue.main) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { [weak self] _ in + self?.registering = false + } receiveValue: { _ in } + .store(in: &cancellables) + } +} diff --git a/Views/AddIdentityView.swift b/Views/AddIdentityView.swift index b861a77..a1492a9 100644 --- a/Views/AddIdentityView.swift +++ b/Views/AddIdentityView.swift @@ -7,6 +7,7 @@ import ViewModels struct AddIdentityView: View { @StateObject var viewModel: AddIdentityViewModel @EnvironmentObject var rootViewModel: RootViewModel + @State private var navigateToRegister = false var body: some View { Form { @@ -15,7 +16,7 @@ struct AddIdentityView: View { .autocapitalization(.none) .disableAutocorrection(true) .keyboardType(.URL) - if let instance = viewModel.instance { + if let (instance, _) = viewModel.instanceAndURL { VStack(alignment: .center) { KFImage(instance.thumbnail) .placeholder { @@ -41,6 +42,25 @@ struct AddIdentityView: View { } else { Button("add-identity.log-in", action: viewModel.logInTapped) + if let (instance, url) = viewModel.instanceAndURL, + instance.registrations { + ZStack { + NavigationLink( + destination: RegistrationView( + viewModel: viewModel.registrationViewModel( + instance: instance, + url: url)), + isActive: $navigateToRegister) { + EmptyView() + } + .hidden() + Button(instance.approvalRequired + ? "add-identity.request-invite" + : "add-identity.join") { + navigateToRegister.toggle() + } + } + } if viewModel.isPublicTimelineAvailable { Button("add-identity.browse", action: viewModel.browseTapped) } @@ -71,7 +91,10 @@ import PreviewViewModels struct AddAccountView_Previews: PreviewProvider { static var previews: some View { - AddIdentityView(viewModel: RootViewModel.preview.addIdentityViewModel()) + NavigationView { + AddIdentityView(viewModel: RootViewModel.preview.addIdentityViewModel()) + .navigationBarTitleDisplayMode(.inline) + } } } #endif diff --git a/Views/RegistrationView.swift b/Views/RegistrationView.swift new file mode 100644 index 0000000..2bbce09 --- /dev/null +++ b/Views/RegistrationView.swift @@ -0,0 +1,103 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI +import ViewModels + +struct RegistrationView: View { + @StateObject var viewModel: RegistrationViewModel + + @State private var presentWebView = false + @State private var toReview = ToReview.serverRules + + var body: some View { + Form { + Section { + HStack { + TextField("registration.username", text: $viewModel.username) + .autocapitalization(.none) + .disableAutocorrection(true) + Text("@" + viewModel.instance.uri) + .foregroundColor(.secondary) + } + TextField("registration.email", text: $viewModel.email) + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(.emailAddress) + SecureField("registration.password", text: $viewModel.password) + SecureField("registration.password-confirmation", text: $viewModel.passwordConfirmation) + if viewModel.instance.approvalRequired { + VStack(alignment: .leading) { + Text("registration.reason-\(viewModel.instance.uri)") + TextEditor(text: $viewModel.reason) + } + } + Button("registration.server-rules") { + toReview = .serverRules + presentWebView = true + } + Button("registration.terms-of-service") { + toReview = .termsOfService + presentWebView = true + } + Toggle("registration.agree-to-server-rules-and-terms-of-service", + isOn: $viewModel.agreement) + } + Section { + Group { + if viewModel.registering { + ProgressView() + } else { + Button(viewModel.instance.approvalRequired + ? "add-identity.request-invite" + : "add-identity.join", + action: viewModel.registerTapped) + .disabled(!viewModel.registerButtonEnabled) + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + } + .alertItem($viewModel.alertItem) + .sheet(isPresented: $presentWebView) { () -> SafariView in + let url: URL + + switch toReview { + case .serverRules: url = viewModel.serverRulesURL + case .termsOfService: url = viewModel.termsOfServiceURL + } + + return SafariView(url: url) + } + } +} + +private extension RegistrationView { + enum ToReview { + case serverRules + case termsOfService + } +} + +extension RegistrationError: LocalizedError { + public var errorDescription: String? { + switch self { + case .passwordConfirmationMismatch: + return NSLocalizedString( + "registration.password-confirmation-mismatch", + comment: "") + } + } +} + +#if DEBUG +import PreviewViewModels + +struct RegistrationView_Previews: PreviewProvider { + static var previews: some View { + RegistrationView(viewModel: RootViewModel.preview + .addIdentityViewModel() + .registrationViewModel(instance: .preview, + url: .previewInstanceURL)) + } +} +#endif diff --git a/Views/RootView.swift b/Views/RootView.swift index 78e27c6..f293c98 100644 --- a/Views/RootView.swift +++ b/Views/RootView.swift @@ -13,9 +13,13 @@ struct RootView: View { .environmentObject(viewModel) .transition(.opacity) } else { - AddIdentityView(viewModel: viewModel.addIdentityViewModel()) - .environmentObject(viewModel) - .transition(.opacity) + NavigationView { + AddIdentityView(viewModel: viewModel.addIdentityViewModel()) + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(true) + } + .environmentObject(viewModel) + .transition(.opacity) } } } diff --git a/Views/SafariView.swift b/Views/SafariView.swift new file mode 100644 index 0000000..0ef3a67 --- /dev/null +++ b/Views/SafariView.swift @@ -0,0 +1,16 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SafariServices +import SwiftUI + +struct SafariView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> SFSafariViewController { + SFSafariViewController(url: url) + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { + + } +}