FTUE tweaks

- Allow login using a phone number.
- Update the server when entering a full MXID during registration.
- Reset the authentication service back to matrix.org after onboarding completes.
- Disable zoom on ReCaptcha to fix responsiveness issue.
- Tidy up unused methods.
This commit is contained in:
Doug 2022-07-18 15:22:06 +01:00 committed by Doug
parent 2b6d9479a2
commit e3bcb71b09
18 changed files with 162 additions and 54 deletions

View file

@ -121,7 +121,7 @@
"onboarding_congratulations_title" = "Congratulations!";
/* The placeholder string contains the user's matrix ID */
"onboarding_congratulations_message" = "Your account %@ has been created.";
"onboarding_congratulations_message" = "Your account %@ has been created";
"onboarding_congratulations_personalize_button" = "Personalise profile";
"onboarding_congratulations_home_button" = "Take me home";
@ -139,7 +139,7 @@
"onboarding_avatar_accessibility_label" = "Profile picture";
"onboarding_celebration_title" = "Looking good!";
"onboarding_celebration_message" = "Head to settings anytime to update your profile.";
"onboarding_celebration_message" = "Head to settings anytime to update your profile";
"onboarding_celebration_button" = "Let's go";
// MARK: Authentication

View file

@ -4127,7 +4127,7 @@ public class VectorL10n: NSObject {
public static var onboardingCelebrationButton: String {
return VectorL10n.tr("Vector", "onboarding_celebration_button")
}
/// Head to settings anytime to update your profile.
/// Head to settings anytime to update your profile
public static var onboardingCelebrationMessage: String {
return VectorL10n.tr("Vector", "onboarding_celebration_message")
}
@ -4139,7 +4139,7 @@ public class VectorL10n: NSObject {
public static var onboardingCongratulationsHomeButton: String {
return VectorL10n.tr("Vector", "onboarding_congratulations_home_button")
}
/// Your account %@ has been created.
/// Your account %@ has been created
public static func onboardingCongratulationsMessage(_ p1: String) -> String {
return VectorL10n.tr("Vector", "onboarding_congratulations_message", p1)
}

View file

@ -566,6 +566,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
trackSignup()
completion?()
// Reset the authentication service back to using matrix.org
authenticationService.reset(useDefaultServer: true)
}
/// Sends a signup event to the Analytics class if onboarding has completed via the register flow.

View file

@ -174,13 +174,14 @@ class AuthenticationService: NSObject {
}
/// Reset the service to a fresh state.
func reset() {
/// - Parameter useDefaultServer: Pass `true` to revert back to the one in `BuildSettings`, otherwise the current homeserver will be kept.
func reset(useDefaultServer: Bool = false) {
loginWizard = nil
registrationWizard = nil
softLogoutCredentials = nil
// The previously used homeserver is re-used as `startFlow` will be called again a replace it anyway.
let address = state.homeserver.addressFromUser ?? state.homeserver.address
let address = useDefaultServer ? BuildSettings.serverConfigDefaultHomeserverUrlString : state.homeserver.addressFromUser ?? state.homeserver.address
let identityServer = state.identityServer
self.state = AuthenticationState(flow: .login,
homeserverAddress: address,
@ -196,27 +197,6 @@ class AuthenticationService: NSObject {
delegate?.authenticationService(self, didReceive: token, with: transactionID) ?? false
}
// /// Perform a well-known request, using the domain from the matrixId
// func getWellKnownData(matrixId: String,
// homeServerConnectionConfig: HomeServerConnectionConfig?) async -> WellknownResult {
//
// }
//
// /// Authenticate with a matrixId and a password
// /// Usually call this after a successful call to getWellKnownData()
// /// - Parameter homeServerConnectionConfig the information about the homeserver and other configuration
// /// - Parameter matrixId the matrixId of the user
// /// - Parameter password the password of the account
// /// - Parameter initialDeviceName the initial device name
// /// - Parameter deviceId the device id, optional. If not provided or null, the server will generate one.
// func directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig,
// matrixId: String,
// password: String,
// initialDeviceName: String,
// deviceId: String? = nil) async -> MXSession {
//
// }
// MARK: - Private
/// Query the supported login flows for the supplied homeserver.

View file

@ -15,6 +15,7 @@
//
import Foundation
import libPhoneNumber_iOS
/// Set of methods to be able to login to an existing account on a homeserver.
///
@ -42,11 +43,6 @@ class LoginWizard {
self.state = State()
}
// /// Get some information about a matrixId: displayName and avatar url
// func profileInfo(for matrixID: String) async -> LoginProfileInfo {
//
// }
/// Login to the homeserver.
/// - Parameters:
/// - login: The login field. Can be a user name, or a msisdn (email or phone number) associated to the account.
@ -67,6 +63,13 @@ class LoginWizard {
password: password,
deviceDisplayName: initialDeviceName,
deviceID: deviceID)
} else if let number = try? NBPhoneNumberUtil.sharedInstance().parse(login, defaultRegion: nil),
NBPhoneNumberUtil.sharedInstance().isValidNumber(number) {
let msisdn = login.replacingOccurrences(of: "+", with: "")
parameters = LoginPasswordParameters(id: .thirdParty(medium: .msisdn, address: msisdn),
password: password,
deviceDisplayName: initialDeviceName,
deviceID: deviceID)
} else {
parameters = LoginPasswordParameters(id: .user(login),
password: password,
@ -92,12 +95,6 @@ class LoginWizard {
client: client,
removeOtherAccounts: removeOtherAccounts)
}
// /// Login to the homeserver by sending a custom JsonDict.
// /// The data should contain at least one entry `type` with a String value.
// func loginCustom(data: Codable) async -> MXSession {
//
// }
/// Ask the homeserver to reset the user password. The password will not be
/// reset until `resetPasswordMailConfirmed` is successfully called.

View file

@ -195,8 +195,8 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable {
@MainActor private func parseUsername(_ username: String) {
guard MXTools.isMatrixUserIdentifier(username) else { return }
let domain = username.split(separator: ":")[1]
let homeserverAddress = HomeserverAddress.sanitized(String(domain))
let domain = username.components(separatedBy: ":")[1]
let homeserverAddress = HomeserverAddress.sanitized(domain)
startLoading(isInteractionBlocking: false)

View file

@ -96,6 +96,7 @@ class AuthenticationLoginViewModelTests: XCTestCase {
context.username = "bob"
context.password = "12345678"
XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid.")
XCTAssertTrue(context.viewState.canSubmit, "The form should be valid to submit.")
XCTAssertFalse(context.viewState.isLoading, "The view shouldn't start in a loading state.")
// When updating the view model whilst loading a homeserver.
@ -103,12 +104,14 @@ class AuthenticationLoginViewModelTests: XCTestCase {
// Then the view state should reflect that the homeserver is loading.
XCTAssertTrue(context.viewState.isLoading, "The view should now be in a loading state.")
XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked from submission.")
// When updating the view model after loading a homeserver.
viewModel.update(isLoading: false)
// Then the view state should reflect that the homeserver is now loaded.
XCTAssertFalse(context.viewState.isLoading, "The view should be back in a loaded state.")
XCTAssertTrue(context.viewState.canSubmit, "The form should once again be valid to submit.")
}
@MainActor func testFallbackServer() {

View file

@ -92,7 +92,7 @@ struct AuthenticationRecaptchaWebView: UIViewRepresentable {
"""
<html>
<head>
<meta name='viewport' content='initial-scale=1.0' />
<meta name='viewport' content='initial-scale=1.0, user-scalable=no' />
<style>@media (prefers-color-scheme: dark) { body { background-color: #15191E; } }</style>
<script type="text/javascript">
var verifyCallback = function(response) {

View file

@ -61,6 +61,8 @@ struct AuthenticationRegistrationViewState: BindableState {
/// Data about the selected homeserver.
var homeserver: AuthenticationHomeserverViewData
/// Whether a new homeserver is currently being loaded.
var isLoading: Bool = false
/// View state that can be bound to from SwiftUI.
var bindings: AuthenticationRegistrationBindings
/// Whether or not the username field has been edited yet.
@ -113,6 +115,11 @@ struct AuthenticationRegistrationViewState: BindableState {
var hasValidCredentials: Bool {
!isUsernameInvalid && !isPasswordInvalid
}
/// `true` if valid credentials have been entered and the homeserver is loaded.
var canSubmit: Bool {
hasValidCredentials && !isLoading
}
}
struct AuthenticationRegistrationBindings {

View file

@ -59,10 +59,20 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy
}
}
@MainActor func update(isLoading: Bool) {
guard state.isLoading != isLoading else { return }
state.isLoading = isLoading
}
@MainActor func update(homeserver: AuthenticationHomeserverViewData) {
state.homeserver = homeserver
}
@MainActor func update(username: String) {
guard username != state.bindings.username else { return }
state.bindings.username = username
}
@MainActor func confirmUsernameAvailability(_ username: String) {
guard username == state.bindings.username else { return }
state.usernameAvailability = .available

View file

@ -21,10 +21,18 @@ protocol AuthenticationRegistrationViewModelProtocol {
var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)? { get set }
var context: AuthenticationRegistrationViewModelType.Context { get }
/// Update the view to reflect that a new homeserver is being loaded.
/// - Parameter isLoading: Whether or not the homeserver is being loaded.
@MainActor func update(isLoading: Bool)
/// Update the view with new homeserver information.
/// - Parameter homeserver: The view data for the homeserver. This can be generated using `AuthenticationService.Homeserver.viewData`.
@MainActor func update(homeserver: AuthenticationHomeserverViewData)
/// Update the username, for example to convert a full MXID into just the local part.
/// - Parameter username: The username to be shown instead.
@MainActor func update(username: String)
/// Update the view to confirm that the chosen username is available.
/// - Parameter username: The username that was checked.
@MainActor func confirmUsernameAvailability(_ username: String)

View file

@ -129,18 +129,58 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
}
}
/// Show a blocking activity indicator whilst saving.
@MainActor private func startLoading() {
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
/// Show an activity indicator whilst loading.
/// - Parameter isInteractionBlocking: Whether or not the indicator blocks user interaction.
@MainActor private func startLoading(isInteractionBlocking: Bool = true) {
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: isInteractionBlocking))
if !isInteractionBlocking {
authenticationRegistrationViewModel.update(isLoading: true)
}
}
/// Hide the currently displayed activity indicator.
@MainActor private func stopLoading() {
authenticationRegistrationViewModel.update(isLoading: false)
waitingIndicator = nil
}
/// Asks the homeserver to check the supplied username's format and availability.
/// Updates the homeserver if a full MXID is entered, then requests whether the username is valid and available.
@MainActor private func validateUsername(_ username: String) {
guard MXTools.isMatrixUserIdentifier(username) else {
// Continue with availability check for a normal username.
confirmAvailability(of: username)
return
}
// Otherwise split out the domain and username and update the homeserver first.
let components = username.dropFirst().components(separatedBy: ":")
let domain = components[1]
let username = components[0]
let homeserverAddress = HomeserverAddress.sanitized(domain)
startLoading(isInteractionBlocking: false)
currentTask = Task { [weak self] in
do {
try await authenticationService.startFlow(.register, for: homeserverAddress)
guard !Task.isCancelled else { return }
self?.updateViewModelHomeserver()
self?.authenticationRegistrationViewModel.update(username: username)
self?.stopLoading()
self?.confirmAvailability(of: username)
} catch {
self?.stopLoading()
self?.handleError(error)
}
}
}
/// Asks the homeserver to check the supplied username's format and availability.
@MainActor private func confirmAvailability(of username: String) {
guard let registrationWizard = registrationWizard else {
MXLog.failure("[AuthenticationRegistrationCoordinator] The registration wizard was requested before getting the login flow.")
return
@ -245,12 +285,16 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
@MainActor private func serverSelectionCoordinator(_ coordinator: AuthenticationServerSelectionCoordinator,
didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) {
if result == .updated {
let homeserver = authenticationService.state.homeserver
authenticationRegistrationViewModel.update(homeserver: homeserver.viewData)
updateViewModelHomeserver()
}
navigationRouter.dismissModule(animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
@MainActor private func updateViewModelHomeserver() {
let homeserver = authenticationService.state.homeserver
authenticationRegistrationViewModel.update(homeserver: homeserver.viewData)
}
}

View file

@ -232,6 +232,45 @@ import Combine
XCTAssertFalse(context.viewState.isUsernameInvalid, "The username should be valid when unverified.")
XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid when the username and password are valid.")
}
@MainActor func testLoadingServer() {
// Given a form with valid credentials.
context.username = "bob"
context.password = "12345678"
XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid.")
XCTAssertTrue(context.viewState.canSubmit, "The form should be valid to submit.")
XCTAssertFalse(context.viewState.isLoading, "The view shouldn't start in a loading state.")
// When updating the view model whilst loading a homeserver.
viewModel.update(isLoading: true)
// Then the view state should reflect that the homeserver is loading.
XCTAssertTrue(context.viewState.isLoading, "The view should now be in a loading state.")
XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked from submission.")
// When updating the view model after loading a homeserver.
viewModel.update(isLoading: false)
// Then the view state should reflect that the homeserver is now loaded.
XCTAssertFalse(context.viewState.isLoading, "The view should be back in a loaded state.")
XCTAssertTrue(context.viewState.canSubmit, "The form should once again be valid to submit.")
}
@MainActor func testUpdatingUsername() {
// Given a form with valid credentials.
let fullMXID = "@bob:example.com"
context.username = fullMXID
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid without a password.")
XCTAssertFalse(context.viewState.canSubmit, "The form not be ready to submit without a password.")
XCTAssertFalse(context.viewState.isLoading, "The view shouldn't start in a loading state.")
// When updating the view model with a new username.
let localPart = "bob"
viewModel.update(username: localPart)
// Then the view state should reflect that the homeserver is loading.
XCTAssertEqual(context.username, localPart, "The username should match the value passed to the update method.")
}
}
extension AuthenticationRegistrationViewState.UsernameAvailability: Equatable {

View file

@ -128,7 +128,7 @@ struct AuthenticationRegistrationScreen: View {
Text(VectorL10n.next)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(!viewModel.viewState.hasValidCredentials)
.disabled(!viewModel.viewState.canSubmit)
.accessibilityIdentifier("nextButton")
}
}
@ -172,7 +172,7 @@ struct AuthenticationRegistrationScreen: View {
/// Sends the `next` view action so long as valid credentials have been input.
func submit() {
guard viewModel.viewState.hasValidCredentials else { return }
guard viewModel.viewState.canSubmit else { return }
viewModel.send(viewAction: .next)
}

View file

@ -59,26 +59,40 @@ import XCTest
func testReset() async throws {
// Given a service that has begun registration.
try await service.startFlow(.register, for: "https://matrix.org")
try await service.startFlow(.register, for: "https://example.com")
_ = try await service.registrationWizard?.createAccount(username: UUID().uuidString, password: UUID().uuidString, initialDeviceDisplayName: "Test")
XCTAssertNotNil(service.loginWizard, "The login wizard should exist after starting a registration flow.")
XCTAssertNotNil(service.registrationWizard, "The registration wizard should exist after starting a registration flow.")
XCTAssertNotNil(service.state.homeserver.registrationFlow, "The supported registration flow should be stored after starting a registration flow.")
XCTAssertTrue(service.isRegistrationStarted, "The service should show as having started registration.")
XCTAssertEqual(service.state.flow, .register, "The service should show as using a registration flow.")
XCTAssertEqual(service.state.homeserver.address, "https://matrix-client.matrix.org", "The actual homeserver address should be discovered.")
XCTAssertEqual(service.state.homeserver.addressFromUser, "https://matrix.org", "The address from the startFlow call should be stored.")
XCTAssertEqual(service.state.homeserver.address, "https://matrix.example.com", "The actual homeserver address should be discovered.")
XCTAssertEqual(service.state.homeserver.addressFromUser, "https://example.com", "The address from the startFlow call should be stored.")
// When resetting the service.
service.reset()
// Then the wizards should no longer exist.
// Then the wizards should no longer exist, but the chosen server should be remembered.
XCTAssertNil(service.loginWizard, "The login wizard should be cleared after calling reset.")
XCTAssertNil(service.registrationWizard, "The registration wizard should be cleared after calling reset.")
XCTAssertNil(service.state.homeserver.registrationFlow, "The supported registration flow should be cleared when calling reset.")
XCTAssertFalse(service.isRegistrationStarted, "The service should not indicate it has started registration after calling reset.")
XCTAssertEqual(service.state.flow, .login, "The flow should have been set back to login when calling reset.")
XCTAssertEqual(service.state.homeserver.address, "https://matrix.org", "The address should reset to the value entered by the user.")
XCTAssertEqual(service.state.homeserver.address, "https://example.com", "The address should reset to the value entered by the user.")
}
func testResetDefaultServer() async throws {
// Given a service that has begun login on one server.
try await service.startFlow(.login, for: "https://example.com")
XCTAssertEqual(service.state.homeserver.address, "https://matrix.example.com", "The actual homeserver address should be discovered.")
XCTAssertEqual(service.state.homeserver.addressFromUser, "https://example.com", "The address from the startFlow call should be stored.")
// When resetting the service to use the default server.
service.reset(useDefaultServer: true)
// Then the service should reset back to the default server.
XCTAssertEqual(service.state.homeserver.address, BuildSettings.serverConfigDefaultHomeserverUrlString,
"The address should reset to the value configured in the build settings.")
}
func testHomeserverState() async throws {

1
changelog.d/6428.wip Normal file
View file

@ -0,0 +1 @@
Check for a phone number during login and send an MSISDN when using the new flow.

1
changelog.d/6429.wip Normal file
View file

@ -0,0 +1 @@
Fix ReCaptcha form sometimes being slow to react to taps in the new flow.

1
changelog.d/6430.wip Normal file
View file

@ -0,0 +1 @@
When entering a full MXID during registration on the new flow, update the homeserver to match.