mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Combine the Server Selection screen into the Server Confirmation screen and add autocompletion when entering an account provider. (#5949)
* Adjust login flow to exclusively use the ServerSelectionScreen * Move async code to server selection vm * ServerSelection supports pro picker options * Use homeserver address for both confirmation and picker modes * rename ServerSelectionScreenMode * remove ServerConfirmationScreen It's been superseded by the ServerSelectionScreen * rename userInput * Update strings with latest design strings * Add server input autocompletion * Store previous servers in AppSettings and autofill on server signin * Audit changes for logging * Enforce lowercase server history storage * Update tests * Update Xcode project * PR review fixups * Add HomeserverHistoryManager * Review fixups 2 * Review fixups 3
This commit is contained in:
+115
-17
@@ -17,31 +17,60 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
|
||||
private let appSettings: AppSettings
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
/// Debounces autocompletion from user input
|
||||
private var debouncedAutocompleteTask: Task<Void, Never>?
|
||||
|
||||
private var actionsSubject: PassthroughSubject<ServerSelectionScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actions: AnyPublisher<ServerSelectionScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private let homeserverHistoryManager: HomeserverHistoryManager
|
||||
|
||||
init(authenticationService: AuthenticationServiceProtocol,
|
||||
mode: ServerSelectionScreenMode,
|
||||
authenticationFlow: AuthenticationFlow,
|
||||
appSettings: AppSettings,
|
||||
homeserverHistoryManager: HomeserverHistoryManager,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
self.authenticationService = authenticationService
|
||||
self.authenticationFlow = authenticationFlow
|
||||
self.appSettings = appSettings
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.homeserverHistoryManager = homeserverHistoryManager
|
||||
|
||||
let bindings = ServerSelectionScreenBindings(homeserverAddress: authenticationService.homeserver.value.address)
|
||||
super.init(initialViewState: ServerSelectionScreenViewState(bindings: bindings))
|
||||
let homeserverAddress: String
|
||||
if case .picker(let providers) = mode {
|
||||
homeserverAddress = providers[0]
|
||||
} else {
|
||||
homeserverAddress = authenticationService.homeserver.value.address
|
||||
}
|
||||
let bindings = ServerSelectionScreenBindings(homeserverAddress: homeserverAddress)
|
||||
super.init(initialViewState: ServerSelectionScreenViewState(mode: mode, authenticationFlow: authenticationFlow, bindings: bindings))
|
||||
|
||||
context.viewState.textFieldAdapter.keystrokePublisher
|
||||
.sink { [weak self] in
|
||||
self?.didUpdateTextFromKeystroke(userProvidedString: $0)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
override func process(viewAction: ServerSelectionScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .updateWindow(let window):
|
||||
guard state.window != window else { return } // stop the infinite loop
|
||||
state.window = window
|
||||
case .updateTextField(let textField):
|
||||
guard state.textField != textField else { return } // stop the infinite loop
|
||||
state.textField = textField
|
||||
case .confirm:
|
||||
configureHomeserver()
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
switch state.mode {
|
||||
case .userInput:
|
||||
Task { await configureHomeserver() }
|
||||
case .picker:
|
||||
Task { await pickServer() }
|
||||
}
|
||||
case .clearFooterError:
|
||||
clearFooterError()
|
||||
}
|
||||
@@ -49,22 +78,62 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func pickServer() async {
|
||||
let accountProvider = state.bindings.homeserverAddress
|
||||
guard accountProvider.isEmpty == false else {
|
||||
fatalError("It shouldn't be possible to confirm without a selection.")
|
||||
}
|
||||
|
||||
startLoading()
|
||||
|
||||
switch await authenticationService.configure(for: accountProvider, flow: authenticationFlow) {
|
||||
case .success:
|
||||
MXLog.info("Selected server: \(accountProvider)")
|
||||
await fetchLoginURLIfNeededAndContinue()
|
||||
stopLoading()
|
||||
case .failure:
|
||||
MXLog.info("Invalid server: \(accountProvider)")
|
||||
stopLoading()
|
||||
// When the servers are hard-coded they should have a valid configuration, so show a generic error.
|
||||
state.bindings.alertInfo = AlertInfo(id: .unknownError)
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the login flow using the supplied homeserver address, or shows an error when this isn't possible.
|
||||
private func configureHomeserver() {
|
||||
private func configureHomeserver() async {
|
||||
let homeserverAddress = state.bindings.homeserverAddress
|
||||
startLoading()
|
||||
|
||||
Task {
|
||||
switch await authenticationService.configure(for: homeserverAddress, flow: authenticationFlow) {
|
||||
case .success:
|
||||
MXLog.info("Selected homeserver: \(homeserverAddress)")
|
||||
actionsSubject.send(.updated)
|
||||
stopLoading()
|
||||
case .failure(let error):
|
||||
MXLog.info("Invalid homeserver: \(homeserverAddress)")
|
||||
stopLoading()
|
||||
handleError(error)
|
||||
}
|
||||
switch await authenticationService.configure(for: homeserverAddress, flow: authenticationFlow) {
|
||||
case .success:
|
||||
MXLog.info("Selected homeserver: \(homeserverAddress)")
|
||||
await fetchLoginURLIfNeededAndContinue()
|
||||
stopLoading()
|
||||
case .failure(let error):
|
||||
MXLog.info("Invalid homeserver: \(homeserverAddress)")
|
||||
stopLoading()
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchLoginURLIfNeededAndContinue() async {
|
||||
guard authenticationService.homeserver.value.loginMode.supportsOAuthFlow else {
|
||||
actionsSubject.send(.continueWithPassword)
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = state.window else {
|
||||
MXLog.error("No window available for OAuth presentation")
|
||||
showFooterMessage(L10n.errorUnknown)
|
||||
return
|
||||
}
|
||||
|
||||
switch await authenticationService.urlForOAuthLogin(loginHint: nil) {
|
||||
case .success(let oAuthData):
|
||||
actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window))
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed to get OAuth login URL: \(error)")
|
||||
showFooterMessage(L10n.errorUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,4 +194,33 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
|
||||
guard state.footerErrorMessage != nil else { return }
|
||||
withElementAnimation { state.footerErrorMessage = nil }
|
||||
}
|
||||
|
||||
/// Given the textfield's complete text from the user's latest keystroke, evaluate if it matches any historical
|
||||
/// server prefixes. If there's a match, update the text value with the complete server, highlighting the portion
|
||||
/// that was appended.
|
||||
private func didUpdateTextFromKeystroke(userProvidedString: String) {
|
||||
let new = userProvidedString
|
||||
let lowerNew = new.lowercased()
|
||||
|
||||
guard
|
||||
lowerNew.isEmpty == false,
|
||||
let expectedMatch = homeserverHistoryManager.server(matchingPrefix: lowerNew),
|
||||
lowerNew != expectedMatch
|
||||
else { return }
|
||||
|
||||
let appendage = expectedMatch.dropFirst(new.count)
|
||||
let newInput = "\(new)\(appendage)"
|
||||
|
||||
let selectionRange = newInput.index(newInput.startIndex, offsetBy: new.count)..<newInput.endIndex
|
||||
|
||||
// debounce a delay to assure that we are not in the same swiftui update cycle
|
||||
debouncedAutocompleteTask?.cancel()
|
||||
debouncedAutocompleteTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
guard Task.isCancelled == false else { return }
|
||||
context.homeserverAddress = newInput
|
||||
context.viewState.textField?.text = newInput
|
||||
context.homeserverSelection = TextSelection(range: selectionRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user