From 121d9f8bf5d5dba60fbf964a32e48ac1f2e33eff Mon Sep 17 00:00:00 2001 From: Kilian Koeltzsch Date: Fri, 1 May 2020 02:10:40 +0200 Subject: [PATCH] Implement new account registration see #20 --- Nio.xcodeproj/project.pbxproj | 12 ++ Nio/AccountStore.swift | 27 +++ Nio/Authentication/LoginFormTextField.swift | 51 ++++++ Nio/Authentication/LoginView.swift | 70 +++----- Nio/Authentication/RegistrationView.swift | 159 ++++++++++++++++++ Nio/Extensions/URL+Homeserver.swift | 18 ++ Nio/Generated/Strings.swift | 13 ++ Nio/RootView.swift | 2 +- .../en.lproj/Localizable.strings | 6 + 9 files changed, 309 insertions(+), 49 deletions(-) create mode 100644 Nio/Authentication/LoginFormTextField.swift create mode 100644 Nio/Authentication/RegistrationView.swift create mode 100644 Nio/Extensions/URL+Homeserver.swift diff --git a/Nio.xcodeproj/project.pbxproj b/Nio.xcodeproj/project.pbxproj index be6c35bd..079e5483 100644 --- a/Nio.xcodeproj/project.pbxproj +++ b/Nio.xcodeproj/project.pbxproj @@ -41,6 +41,9 @@ 393411C723903C94003B49B8 /* EventCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 393411C623903C94003B49B8 /* EventCollection.swift */; }; 393411C923904428003B49B8 /* MXEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 393411C823904428003B49B8 /* MXEvent.swift */; }; 393411D1239087D2003B49B8 /* EventCollectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 393411D0239087D2003B49B8 /* EventCollectionTests.swift */; }; + 3955DD31245B81A200827F07 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3955DD30245B81A200827F07 /* RegistrationView.swift */; }; + 3955DD33245B824E00827F07 /* LoginFormTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3955DD32245B824E00827F07 /* LoginFormTextField.swift */; }; + 3955DD35245B9D2100827F07 /* URL+Homeserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3955DD34245B9D2100827F07 /* URL+Homeserver.swift */; }; 3970DC942385A8BE00EFE31B /* KeyboardObserving in Frameworks */ = {isa = PBXBuildFile; productRef = 3970DC932385A8BE00EFE31B /* KeyboardObserving */; }; 3984654523B7ECBA006C173B /* MXURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3984654423B7ECBA006C173B /* MXURL.swift */; }; 3984654823B8D809006C173B /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 3984654723B8D809006C173B /* SDWebImageSwiftUI */; }; @@ -133,6 +136,9 @@ 393411D0239087D2003B49B8 /* EventCollectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCollectionTests.swift; sourceTree = ""; }; 393411D2239087D2003B49B8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3955DD36245C371C00827F07 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + 3955DD30245B81A200827F07 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = ""; }; + 3955DD32245B824E00827F07 /* LoginFormTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginFormTextField.swift; sourceTree = ""; }; + 3955DD34245B9D2100827F07 /* URL+Homeserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Homeserver.swift"; sourceTree = ""; }; 3984654423B7ECBA006C173B /* MXURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MXURL.swift; sourceTree = ""; }; 3997DCCF245732F000763C07 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 39B834BF243FC42000AE1EA0 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; @@ -210,6 +216,8 @@ isa = PBXGroup; children = ( 39C931F423846966004449E1 /* LoginView.swift */, + 3955DD30245B81A200827F07 /* RegistrationView.swift */, + 3955DD32245B824E00827F07 /* LoginFormTextField.swift */, 3902B8A42395A77800698B87 /* LoadingView.swift */, ); path = Authentication; @@ -404,6 +412,7 @@ CAC46D6223A278F40079C24F /* PreviewProvider+Enumeration.swift */, 39BA0722240B3C9A00FD28C6 /* MXCredentials+Keychain.swift */, 39BA0726240B534600FD28C6 /* Color+allAccent.swift */, + 3955DD34245B9D2100827F07 /* URL+Homeserver.swift */, ); path = Extensions; sourceTree = ""; @@ -709,11 +718,13 @@ 392389892386FD3900B2E1DF /* MXClient+Publisher.swift in Sources */, 3984654523B7ECBA006C173B /* MXURL.swift in Sources */, 392389D2238F2E6F00B2E1DF /* NIORoom.swift in Sources */, + 3955DD33245B824E00827F07 /* LoginFormTextField.swift in Sources */, 4B058B5624573A570059BC75 /* EditEvent.swift in Sources */, 392221AE243A0508004D8794 /* GroupedReactionsView.swift in Sources */, 3923898F2388707E00B2E1DF /* RoomListItemView.swift in Sources */, 39C931DD2384328A004449E1 /* AppDelegate.swift in Sources */, 393411C923904428003B49B8 /* MXEvent.swift in Sources */, + 3955DD35245B9D2100827F07 /* URL+Homeserver.swift in Sources */, 3923898D238859D100B2E1DF /* MX+Identifiable.swift in Sources */, CAC46D5B23A2734C0079C24F /* EnvironmentValues.swift in Sources */, 39C932072384BB13004449E1 /* RecentRoomsView.swift in Sources */, @@ -733,6 +744,7 @@ 392389CC238EBB1500B2E1DF /* ReverseList.swift in Sources */, 392389942388899200B2E1DF /* Formatter.swift in Sources */, 39BA0723240B3C9A00FD28C6 /* MXCredentials+Keychain.swift in Sources */, + 3955DD31245B81A200827F07 /* RegistrationView.swift in Sources */, 392221AC2438149D004D8794 /* RoomMemberEventView.swift in Sources */, 392221B4243D1627004D8794 /* RoomPowerLevelsEventView.swift in Sources */, CAC46D6323A278F40079C24F /* PreviewProvider+Enumeration.swift in Sources */, diff --git a/Nio/AccountStore.swift b/Nio/AccountStore.swift index 840c294f..9fd23318 100644 --- a/Nio/AccountStore.swift +++ b/Nio/AccountStore.swift @@ -45,6 +45,33 @@ class AccountStore: ObservableObject { self.session?.removeListener(self.listenReference) } + // MARK: - Registration + + func register(username: String, password: String, homeserver: URL) { + self.loginState = .authenticating + + self.client = MXRestClient(homeServer: homeserver, unrecognizedCertificateHandler: nil) + self.client?.register(username: username, password: password) { response in + switch response { + case .failure(let error): + self.loginState = .failure(error) + case .success(let credentials): + self.credentials = credentials + credentials.save(to: self.keychain) + + self.sync { result in + switch result { + case .failure(let error): + // Does this make sense? The login itself didn't fail, but syncing did. + self.loginState = .failure(error) + case .success(let state): + self.loginState = state + } + } + } + } + } + // MARK: - Login & Sync @Published var loginState: LoginState = .loggedOut diff --git a/Nio/Authentication/LoginFormTextField.swift b/Nio/Authentication/LoginFormTextField.swift new file mode 100644 index 00000000..77a68aa6 --- /dev/null +++ b/Nio/Authentication/LoginFormTextField.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct LoginFormTextField: View { + @Environment(\.colorScheme) var colorScheme + + var placeholder: String + @Binding var text: String + var onEditingChanged: ((Bool) -> Void)? + + var isSecure = false + + var buttonIcon: String? + var buttonAction: (() -> Void)? + + var body: some View { + ZStack { + Capsule(style: .continuous) + .foregroundColor(colorScheme == .light ? Color(#colorLiteral(red: 0.9395676295, green: 0.9395676295, blue: 0.9395676295, alpha: 1)) : Color(#colorLiteral(red: 0.2293992357, green: 0.2293992357, blue: 0.2293992357, alpha: 1))) + .frame(height: 50) + if isSecure { + SecureField(placeholder, text: $text) + .padding() + .textContentType(.password) + } else { + HStack { + TextField(placeholder, text: $text, onEditingChanged: onEditingChanged ?? { _ in }) + .padding() + .autocapitalization(.none) + .disableAutocorrection(true) + if buttonIcon != nil && buttonAction != nil { + Button(action: { + self.buttonAction!() + }, label: { + Image(systemName: buttonIcon!) + }) + .padding() + } + } + } + } + .frame(maxWidth: 400) + } +} + +struct LoginFormTextField_Previews: PreviewProvider { + static var previews: some View { + LoginFormTextField(placeholder: "Username", text: .constant("")) + .padding() + .previewLayout(.sizeThatFits) + } +} diff --git a/Nio/Authentication/LoginView.swift b/Nio/Authentication/LoginView.swift index 218f3ae0..f4f15792 100644 --- a/Nio/Authentication/LoginView.swift +++ b/Nio/Authentication/LoginView.swift @@ -3,6 +3,7 @@ import SwiftMatrixSDK struct LoginContainerView: View { @EnvironmentObject var store: AccountStore + @EnvironmentObject var settings: AppSettings @State private var username = "" @State private var password = "" @@ -18,18 +19,16 @@ struct LoginContainerView: View { isLoginEnabled: isLoginEnabled, onLogin: login, guessHomeserverURL: guessHomeserverURL) + .sheet(isPresented: $showingRegisterView) { + RegistrationContainerView() + .accentColor(self.settings.accentColor) + .environmentObject(self.store) + } } private func login() { - var homeserver = self.homeserver.isEmpty ? "https://matrix.org" : self.homeserver - - // If there's no scheme at all, the URLComponents initializer below will think it's a path with no hostname. - if !homeserver.contains("//") { - homeserver = "https://\(homeserver)" - } - var homeserverURLComponents = URLComponents(string: homeserver) - homeserverURLComponents?.scheme = "https" - guard let homeserverURL = homeserverURLComponents?.url else { + let homeserver = self.homeserver.isEmpty ? "https://matrix.org" : self.homeserver + guard let homeserverURL = URL(homeserverString: homeserver) else { // TODO: Handle error print("Invalid homeserver URL '\(homeserver)'") return @@ -86,9 +85,6 @@ struct LoginView: View { Spacer() } .keyboardObserving() - .sheet(isPresented: $showingRegisterView) { - Text(L10n.Login.registerNotYetImplemented) - } } var buttons: some View { @@ -117,6 +113,7 @@ struct LoginTitleView: View { let nio = Text("Nio").foregroundColor(.accentColor) return VStack { + // FIXME: This probably breaks localisation. (Text(L10n.Login.welcomeHeader) + nio + Text("!")) .font(.title) .bold() @@ -129,18 +126,25 @@ struct LoginForm: View { @Binding var username: String @Binding var password: String @Binding var homeserver: String - + let guessHomeserverURL: () -> Void var body: some View { VStack { - FormTextField(title: L10n.Login.Form.username, text: $username, onEditingChanged: { _ in - self.guessHomeserverURL() - }) + LoginFormTextField(placeholder: L10n.Login.Form.username, + text: $username, + onEditingChanged: { _ in self.guessHomeserverURL() }) + .padding(.horizontal) - FormTextField(title: L10n.Login.Form.password, text: $password, isSecure: true) + LoginFormTextField(placeholder: L10n.Login.Form.password, + text: $password, + isSecure: true) + .padding(.horizontal) + + LoginFormTextField(placeholder: L10n.Login.Form.homeserver, + text: $homeserver) + .padding(.horizontal) - FormTextField(title: L10n.Login.Form.homeserver, text: $homeserver) Text(L10n.Login.Form.homeserverOptionalExplanation) .font(.caption) .foregroundColor(.gray) @@ -148,36 +152,6 @@ struct LoginForm: View { } } -private struct FormTextField: View { - @Environment(\.colorScheme) var colorScheme - - var title: String - @Binding var text: String - var onEditingChanged: ((Bool) -> Void)? - - var isSecure = false - - var body: some View { - ZStack { - Capsule(style: .continuous) - .foregroundColor(colorScheme == .light ? Color(#colorLiteral(red: 0.9395676295, green: 0.9395676295, blue: 0.9395676295, alpha: 1)) : Color(#colorLiteral(red: 0.2293992357, green: 0.2293992357, blue: 0.2293992357, alpha: 1))) - .frame(height: 50) - if isSecure { - SecureField(title, text: $text) - .padding() - .textContentType(.password) - } else { - TextField(title, text: $text, onEditingChanged: onEditingChanged ?? { _ in }) - .padding() - .autocapitalization(.none) - .disableAutocorrection(true) - } - } - .padding(.horizontal) - .frame(maxWidth: 400) - } -} - struct LoginView_Previews: PreviewProvider { static var previews: some View { LoginView(username: .constant(""), diff --git a/Nio/Authentication/RegistrationView.swift b/Nio/Authentication/RegistrationView.swift new file mode 100644 index 00000000..16316e66 --- /dev/null +++ b/Nio/Authentication/RegistrationView.swift @@ -0,0 +1,159 @@ +import SwiftUI + +struct RegistrationContainerView: View { + @EnvironmentObject var store: AccountStore + + @State private var username = "" + @State private var password = "" + @State private var passwordConfirmation = "" + @State private var homeserver = "" + + private func register() { + let homeserver = self.homeserver.isEmpty ? "https://matrix.org" : self.homeserver + guard let homeserverURL = URL(homeserverString: homeserver) else { + // TODO: Handle error + print("Invalid homeserver URL '\(homeserver)'") + return + } + store.register(username: username, password: password, homeserver: homeserverURL) + } + + var body: some View { + RegistrationView(username: $username, + password: $password, + passwordConfirmation: $passwordConfirmation, + homeserver: $homeserver, + onRegister: register, + isRegistrationEnabled: isRegistrationEnabled) + } + + private func isRegistrationEnabled() -> Bool { + guard !username.isEmpty && !password.isEmpty else { return false } + guard password == passwordConfirmation else { return false } + let homeserver = self.homeserver.isEmpty ? "https://matrix.org" : self.homeserver + guard URL(string: homeserver) != nil else { return false } + return true + } +} + +struct RegistrationView: View { + @Binding var username: String + @Binding var password: String + @Binding var passwordConfirmation: String + @Binding var homeserver: String + + var onRegister: () -> Void + var isRegistrationEnabled: () -> Bool + + static var randomServerSuggestions = [ + "https://feneas.org", + "https://allmende.io", + "https://tchncs.de", + "https://fairydust.space", + ] + + var header: some View { + VStack { + Image(systemName: "person.3.fill") + .font(.title) + .foregroundColor(.accentColor) + Text(L10n.Registration.header) + .font(.headline) + } + .padding(.bottom) + } + + var mxidPreview: String? { + // TODO: This should ideally also try a well-known discovery like the login does. + switch (username, homeserver) { + case ("", _): + return nil + case (var user, ""): + user = user.replacingOccurrences(of: "@", with: "") + return "@\(user):matrix.org" + case (var user, var server): + user = user.replacingOccurrences(of: "@", with: "") + server = server.replacingOccurrences(of: "https://", with: "") + return "@\(user):\(server)" + } + } + + var form: some View { + VStack { + LoginFormTextField(placeholder: L10n.Login.Form.username, text: $username) + .padding(.horizontal) + .padding(.bottom) + + LoginFormTextField(placeholder: L10n.Login.Form.password, + text: $password, + isSecure: true) + .padding(.horizontal) + + LoginFormTextField(placeholder: L10n.Registration.confirmPassword, + text: $passwordConfirmation, + isSecure: true) + .padding(.horizontal) + .padding(.bottom) + + LoginFormTextField(placeholder: L10n.Login.Form.homeserver, + text: $homeserver, + buttonIcon: "shuffle", + buttonAction: { self.homeserver = Self.randomServerSuggestions.randomElement()! }) + .padding(.horizontal) + } + } + + var body: some View { + VStack { + Spacer() + header + + Spacer() + Text(L10n.Registration.explanation) + .font(.callout) + .padding(.horizontal) + Spacer() + + form + + Text(L10n.Registration.homeserverExplanation) + .font(.caption) + .foregroundColor(.gray) + .padding(.horizontal) + .fixedSize(horizontal: false, vertical: true) + + Button(action: { + self.onRegister() + }, label: { + VStack { + Text(L10n.Registration.register) + .font(.system(size: 18)) + .bold() + if mxidPreview != nil { + Text(mxidPreview!) + .font(.caption) + .bold() + } + } + + }) + .padding([.top, .bottom], 30) + .disabled(!isRegistrationEnabled()) + + Spacer() + } + .keyboardObserving() + } +} + +struct RegistrationView_Previews: PreviewProvider { + static var previews: some View { + RegistrationView(username: .constant(""), + password: .constant(""), + passwordConfirmation: .constant(""), + homeserver: .constant(""), + onRegister: { }, + isRegistrationEnabled: { true }) + .accentColor(.purple) + } +} diff --git a/Nio/Extensions/URL+Homeserver.swift b/Nio/Extensions/URL+Homeserver.swift new file mode 100644 index 00000000..34b17f76 --- /dev/null +++ b/Nio/Extensions/URL+Homeserver.swift @@ -0,0 +1,18 @@ +import Foundation + +extension URL { + init?(homeserverString: String) { + var homeserver = homeserverString + + // If there's no scheme at all, the URLComponents initializer below will think it's a path with no hostname. + if !homeserver.contains("//") { + homeserver = "https://\(homeserver)" + } + + var homeserverURLComponents = URLComponents(string: homeserver) + homeserverURLComponents?.scheme = "https" + + guard let homeserverURL = homeserverURLComponents?.url else { return nil } + self = homeserverURL + } +} diff --git a/Nio/Generated/Strings.swift b/Nio/Generated/Strings.swift index 1d528aa4..6f4bc610 100644 --- a/Nio/Generated/Strings.swift +++ b/Nio/Generated/Strings.swift @@ -194,6 +194,19 @@ internal enum L10n { } } + internal enum Registration { + /// Confirm password + internal static let confirmPassword = L10n.tr("Localizable", "registration.confirm-password") + /// Matrix is a decentralized network, like E-Mail, meaning there's no single server but many that talk to each other. You'll need an account on one of them to talk to other users. + internal static let explanation = L10n.tr("Localizable", "registration.explanation") + /// Register a new Matrix account + internal static let header = L10n.tr("Localizable", "registration.header") + /// You can use this to create an account on a specific Matrix server. Tap the shuffle button to get a random suggestion or leave it empty to create your account on matrix.org. + internal static let homeserverExplanation = L10n.tr("Localizable", "registration.homeserver-explanation") + /// Register + internal static let register = L10n.tr("Localizable", "registration.register") + } + internal enum Room { /// Not yet implemented internal static let attachmentPlaceholder = L10n.tr("Localizable", "room.attachment-placeholder") diff --git a/Nio/RootView.swift b/Nio/RootView.swift index d2247ed3..ba17b68f 100644 --- a/Nio/RootView.swift +++ b/Nio/RootView.swift @@ -10,7 +10,7 @@ struct RootView: View { RecentRoomsContainerView() .environment(\.userId, userId) // Can this ever be nil? And if so, what happens with the default fallback? - .environment(\.homeserver, (store.client?.homeserver.flatMap(URL.init)) ?? HomeserverKey.defaultValue) + .environment(\.homeserver, (store.client?.homeserver.flatMap(URL.init(string:))) ?? HomeserverKey.defaultValue) ) case .loggedOut: return AnyView( diff --git a/Nio/Supporting Files/en.lproj/Localizable.strings b/Nio/Supporting Files/en.lproj/Localizable.strings index 37bdd237..0ad5d301 100644 --- a/Nio/Supporting Files/en.lproj/Localizable.strings +++ b/Nio/Supporting Files/en.lproj/Localizable.strings @@ -9,6 +9,12 @@ "login.register-not-yet-implemented" = "Registering for new accounts is not yet implemented."; "login.failure-back-to-login" = "Back to Login"; +"registration.header" = "Register a new Matrix account"; +"registration.explanation" = "Matrix is a decentralized network, like E-Mail, meaning there's no single server but many that talk to each other. You'll need an account on one of them to talk to other users."; +"registration.confirm-password" = "Confirm password"; +"registration.homeserver-explanation" = "You can use this to create an account on a specific Matrix server. Tap the shuffle button to get a random suggestion or leave it empty to create your account on matrix.org."; +"registration.register" = "Register"; + "loading.1" = "🧑‍🎤 Reticulating splines"; "loading.2" = "🧑‍🏭 Discomfrobulating messages"; "loading.3" = "🧑‍🔧 Logging in";