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:
Michael
2026-08-10 12:38:37 -05:00
committed by GitHub
parent 42a7b6e8ee
commit 6efa8743ff
71 changed files with 977 additions and 1357 deletions
@@ -14,12 +14,13 @@ struct ServerSelectionScreenCoordinatorParameters {
let authenticationService: AuthenticationServiceProtocol
let authenticationFlow: AuthenticationFlow
let appSettings: AppSettings
let homeserverHistoryManager: HomeserverHistoryManager
let userIndicatorController: UserIndicatorControllerProtocol
}
enum ServerSelectionScreenCoordinatorAction {
case updated
case dismiss
case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow)
case continueWithPassword
}
/// Note: This code was brought over from Riot, we should move the authentication service logic into the view model.
@@ -36,9 +37,18 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
init(parameters: ServerSelectionScreenCoordinatorParameters) {
self.parameters = parameters
let mode: ServerSelectionScreenMode = if parameters.appSettings.allowOtherAccountProviders {
.userInput
} else {
.picker(parameters.appSettings.accountProviders)
}
viewModel = ServerSelectionScreenViewModel(authenticationService: parameters.authenticationService,
mode: mode,
authenticationFlow: parameters.authenticationFlow,
appSettings: parameters.appSettings,
homeserverHistoryManager: parameters.homeserverHistoryManager,
userIndicatorController: parameters.userIndicatorController)
}
@@ -50,10 +60,10 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .updated:
actionsSubject.send(.updated)
case .dismiss:
actionsSubject.send(.dismiss)
case .continueWithOAuth(let oAuthData, let window):
actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window))
case .continueWithPassword:
actionsSubject.send(.continueWithPassword)
}
}
.store(in: &cancellables)
@@ -6,21 +6,71 @@
// Please see LICENSE files in the repository root for full details.
//
import Foundation
import SwiftUI
enum ServerSelectionScreenViewModelAction {
/// The homeserver selection has been updated.
case updated
/// Dismiss the view without using the entered address.
case dismiss
/// Continue the flow using the provided OAuth parameters.
case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow)
/// Continue the flow using password authentication.
case continueWithPassword
}
enum ServerSelectionScreenMode: Equatable {
/// The user is confirming the displayed account provider (or can enter their own).
case userInput
/// The user is only allowed to pick from a list of account providers.
case picker([String])
}
struct ServerSelectionScreenViewState: BindableState {
/// The message to be shown in the text field footer when no error has occurred.
private let regularFooterMessage = L10n.screenChangeServerFormNotice
/// Whether the screen is configured to confirm a single account provider or pick one from a list of many.
var mode: ServerSelectionScreenMode
/// Whether the screen is configured for registration or existing account login
var authenticationFlow: AuthenticationFlow
/// The presentation anchor used for OAuth authentication.
var window: UIWindow?
/// View state that can be bound to from SwiftUI.
var bindings: ServerSelectionScreenBindings
/// Upon introspection, this is the UITextField backing the TextField for server input
var textField: UITextField? {
get { textFieldAdapter.textField }
set { textFieldAdapter.textField = newValue }
}
/// Adapts the text field for custom functionality beyond what's available in SwiftUI
let textFieldAdapter = TextFieldAdapter()
var screenTitle: String {
switch authenticationFlow {
case .login:
L10n.screenChangeServerNavigationTitleLogin
case .register:
L10n.screenChangeServerNavigationTitleRegister
}
}
/// The header text for the screen
var screenHeader: String {
switch authenticationFlow {
case .login:
L10n.screenChangeServerTitleLogin
case .register:
L10n.screenChangeServerTitleRegister
}
}
/// The message to be shown in the text field footer when no error has occurred.
var regularFooterMessage: String {
switch authenticationFlow {
case .login:
L10n.screenChangeServerTextfieldFooterLogin
case .register:
L10n.screenChangeServerTextfieldFooterRegister
}
}
/// An error message to be shown in the text field footer.
var footerErrorMessage: String?
@@ -36,22 +86,32 @@ struct ServerSelectionScreenViewState: BindableState {
/// Whether it is possible to continue when tapping the confirmation button.
var hasValidationError: Bool {
bindings.homeserverAddress.isEmpty || isShowingFooterError
switch mode {
case .userInput:
bindings.homeserverAddress.isEmpty || isShowingFooterError
case .picker(let options):
!options.contains(bindings.homeserverAddress) || isShowingFooterError
}
}
}
struct ServerSelectionScreenBindings {
/// The homeserver address input by the user.
/// The homeserver address input or chosen by the user.
var homeserverAddress: String
/// The selection range in `homeserverAddress`
var homeserverSelection: TextSelection?
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<ServerSelectionScreenErrorType>?
}
enum ServerSelectionScreenViewAction {
/// Updates the window used as the OAuth presentation anchor.
case updateWindow(UIWindow)
/// Updates the textfield from introspection
case updateTextField(UITextField)
/// The user would like to use the homeserver at the input address.
case confirm
/// Dismiss the view without using the entered address.
case dismiss
/// Clear any errors shown in the text field footer.
case clearFooterError
}
@@ -67,4 +127,6 @@ enum ServerSelectionScreenErrorType: Hashable {
case registrationAlert
/// An alert that informs the user that Element Pro should be used for a particular server.
case elementProAlert
/// An unknown error has occurred.
case unknownError
}
@@ -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)
}
}
}
@@ -12,74 +12,91 @@ import SwiftUI
struct ServerSelectionScreen: View {
@Bindable var context: ServerSelectionScreenViewModel.Context
private var backgroundColor: Color {
switch context.viewState.mode {
case .userInput: .compound.bgCanvasDefault
case .picker: .compound.bgSubtleSecondaryLevel0
}
}
private var headerIconStyle: BigIcon.Style {
switch context.viewState.mode {
case .userInput: .defaultSolid
case .picker: .default
}
}
var body: some View {
ScrollView {
FullscreenDialog(topPadding: 24) {
VStack(spacing: 0) {
header
.padding(.top, UIConstants.iconTopPaddingToNavigationBar)
.padding(.bottom, 36)
serverForm
}
.readableFrame()
.padding(.horizontal, 16)
} bottomContent: {
continueButton
}
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.toolbar { toolbar }
.background()
.backgroundStyle(backgroundColor)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(context.viewState.screenTitle)
.alert(item: $context.alertInfo)
.interactiveDismissDisabled()
.introspect(.window, on: .supportedVersions) { window in
context.send(viewAction: .updateWindow(window))
}
}
/// The title, message and icon at the top of the screen.
var header: some View {
VStack(spacing: 8) {
BigIcon(icon: \.host)
BigIcon(icon: \.userProfileSolid, style: headerIconStyle)
.padding(.bottom, 8)
Text(L10n.screenChangeServerTitle)
Text(context.viewState.screenHeader)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textPrimary)
Text(L10n.screenChangeServerSubtitle)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textSecondary)
}
.padding(.horizontal, 16)
}
/// The text field and confirm button where the user enters a server URL.
/// The main input and confirm button.
var serverForm: some View {
VStack(alignment: .leading, spacing: 24) {
TextField(L10n.commonServerUrl, text: $context.homeserverAddress)
.textFieldStyle(.compound(labelText: Text(L10n.screenChangeServerFormHeader),
footerText: Text(context.viewState.footerMessage),
state: context.viewState.isShowingFooterError ? .error : .default,
accessibilityIdentifier: A11yIdentifiers.changeServerScreen.server))
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: context.homeserverAddress) { context.send(viewAction: .clearFooterError) }
.submitLabel(.done)
.onSubmit(submit)
Button(action: submit) {
Text(L10n.actionContinue)
switch context.viewState.mode {
case .userInput:
TextField(L10n.screenChangeServerTextfieldPlaceholder, text: $context.homeserverAddress, selection: $context.homeserverSelection)
.textFieldStyle(.compound(labelText: Text(L10n.screenChangeServerTextfieldHeader),
footerText: Text(context.viewState.footerMessage),
state: context.viewState.isShowingFooterError ? .error : .default,
accessibilityIdentifier: A11yIdentifiers.changeServerScreen.server))
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.introspect(.textField, on: .supportedVersions) {
context.send(viewAction: .updateTextField($0))
}
.onChange(of: context.homeserverAddress) { context.send(viewAction: .clearFooterError) }
.submitLabel(.done)
.onSubmit(submit)
case .picker(let providers):
FakeInlinePicker(items: providers,
icon: \.host,
selection: $context.homeserverAddress)
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.serverPicker)
}
.buttonStyle(.compound(.primary))
.disabled(context.viewState.hasValidationError)
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.continue)
}
}
var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button { context.send(viewAction: .dismiss) } label: {
Text(L10n.actionCancel)
}
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.dismiss)
private var continueButton: some View {
Button(action: submit) {
Text(L10n.actionContinue)
}
.buttonStyle(.compound(.primary))
.disabled(context.viewState.hasValidationError)
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.continue)
}
/// Sends the `confirm` view action so long as the text field input is valid.
@@ -89,38 +106,81 @@ struct ServerSelectionScreen: View {
}
}
// MARK: - Private
private struct FakeInlinePicker: View {
let items: [String]
let icon: KeyPath<CompoundIcons, Image>
@Binding var selection: String
var body: some View {
VStack(spacing: 0) {
ForEach(items, id: \.self) { item in
ListRow(label: .default(title: item, icon: icon),
kind: .selection(isSelected: selection == item) {
selection = item
})
.accessibilityLabel(item)
.accessibilityIdentifier(item)
.overlay(alignment: .bottom) {
if item != items.last {
Divider()
.hidden()
.overlay(Color.compound._borderInteractiveSecondaryAlpha)
.padding(.leading, 54)
}
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
// MARK: - Previews
@available(iOS 26.0, *)
struct ServerSelection_Previews: PreviewProvider, TestablePreview {
static let matrixViewModel = makeViewModel(for: "https://matrix.org")
static let emptyViewModel = makeViewModel(for: "")
static let invalidViewModel = makeViewModel(for: "thisisbad")
static let matrixViewModel = makeViewModel(mode: .userInput, homeserverAddress: "matrix.org")
static let emptyViewModel = makeViewModel(mode: .userInput, homeserverAddress: "")
static let invalidViewModel = makeViewModel(mode: .userInput, homeserverAddress: "thisisbad")
static let pickerViewModel = makeViewModel(mode: .picker(["matrix.org", "foo.bar", "baz.me"]), homeserverAddress: "foo.bar")
static var previews: some View {
ElementNavigationStack {
ServerSelectionScreen(context: matrixViewModel.context)
}
.previewDisplayName("Matrix.org")
ElementNavigationStack {
ServerSelectionScreen(context: emptyViewModel.context)
}
.previewDisplayName("Empty")
ElementNavigationStack {
ServerSelectionScreen(context: invalidViewModel.context)
}
.snapshotPreferences(expect: invalidViewModel.context.observe(\.viewState.hasValidationError))
.previewDisplayName("Error")
ElementNavigationStack {
ServerSelectionScreen(context: pickerViewModel.context)
}
.previewDisplayName("Picker")
}
static func makeViewModel(for homeserverAddress: String) -> ServerSelectionScreenViewModel {
static func makeViewModel(mode: ServerSelectionScreenMode = .userInput, homeserverAddress: String) -> ServerSelectionScreenViewModel {
let authenticationService = AuthenticationService.mock
let appSettings = AppSettings.volatile()
let viewModel = ServerSelectionScreenViewModel(authenticationService: authenticationService,
mode: mode,
authenticationFlow: .login,
appSettings: .volatile(),
appSettings: appSettings,
homeserverHistoryManager: HomeserverHistoryManager(appSettings: appSettings),
userIndicatorController: UserIndicatorControllerMock())
viewModel.context.homeserverAddress = homeserverAddress
if homeserverAddress == "thisisbad" {
if case .userInput = mode, homeserverAddress == "thisisbad" {
viewModel.context.send(viewAction: .confirm)
}
return viewModel