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
@@ -182,6 +182,14 @@ final nonisolated class AppSettings: @unchecked Sendable {
@UserPreference(defaultValue: AppAppearance.system)
var appAppearance: AppAppearance
/// Tracks previous servers the user connected to for autocompletion purposes. Entries are made lowercase on write.
@UserPreference(key: "previousServers", defaultValue: [])
var previousServers: [String]
var defaultServer: String {
previousServers.first ?? accountProviders[0]
}
// MARK: - Security
/// The app must be locked with a PIN code as part of the authentication flow.
@@ -21,6 +21,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
private let navigationStackCoordinator: NavigationStackCoordinator
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let homeserverHistoryManager: HomeserverHistoryManager
private let appHooks: AppHooks
private let userIndicatorController: UserIndicatorControllerProtocol
@@ -34,10 +35,9 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The screen used for the whole QR Code flow.
case qrCodeLoginScreen
/// The screen to continue authentication with the current server.
case serverConfirmationScreen
/// The screen to choose a different server.
/// The screen used to choose a server
case serverSelectionScreen
/// The screen to login with a password.
case loginScreen
@@ -60,17 +60,12 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The user would like to login with a QR code.
case loginWithQR
/// Show the server confirmation screen.
case confirmServer(AuthenticationFlow)
case selectServer(AuthenticationFlow)
/// The QR login flow was aborted.
case cancelledLoginWithQR
/// The user aborted manual login.
case cancelledServerConfirmation
/// The user would like to enter a different server.
case changeServer(AuthenticationFlow)
/// The user is no longer selecting a server.
case dismissedServerSelection
case cancelledServerSelection
/// Show the screen to login with password (with the optional login hint in the `userInfo`).
case continueWithPassword
@@ -113,6 +108,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
self.navigationRootCoordinator = navigationRootCoordinator
self.appMediator = appMediator
self.appSettings = appSettings
homeserverHistoryManager = HomeserverHistoryManager(appSettings: appSettings)
self.appHooks = appHooks
self.userIndicatorController = userIndicatorController
@@ -155,10 +151,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case .qrCodeLoginScreen:
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR) // Needs to be handled manually.
case .serverConfirmationScreen:
navigationStackCoordinator.popToRoot(animated: animated)
case .serverSelectionScreen:
navigationStackCoordinator.setSheetCoordinator(nil)
navigationStackCoordinator.popToRoot(animated: animated)
case .loginScreen:
navigationStackCoordinator.popToRoot(animated: animated)
@@ -202,28 +195,20 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
// Manual Authentication
stateMachine.addRoutes(event: .confirmServer(.login), transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in
self?.showServerConfirmationScreen(authenticationFlow: .login)
}
stateMachine.addRoutes(event: .confirmServer(.register), transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in
self?.showServerConfirmationScreen(authenticationFlow: .register)
}
stateMachine.addRoutes(event: .cancelledServerConfirmation, transitions: [.serverConfirmationScreen => .startScreen])
stateMachine.addRoutes(event: .changeServer(.login), transitions: [.serverConfirmationScreen => .serverSelectionScreen]) { [weak self] _ in
stateMachine.addRoutes(event: .selectServer(.login), transitions: [.startScreen => .serverSelectionScreen]) { [weak self] _ in
self?.showServerSelectionScreen(authenticationFlow: .login)
}
stateMachine.addRoutes(event: .changeServer(.register), transitions: [.serverConfirmationScreen => .serverSelectionScreen]) { [weak self] _ in
stateMachine.addRoutes(event: .selectServer(.register), transitions: [.startScreen => .serverSelectionScreen]) { [weak self] _ in
self?.showServerSelectionScreen(authenticationFlow: .register)
}
stateMachine.addRoutes(event: .dismissedServerSelection, transitions: [.serverSelectionScreen => .serverConfirmationScreen])
stateMachine.addRoutes(event: .cancelledServerSelection, transitions: [.serverSelectionScreen => .startScreen])
stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverConfirmationScreen => .loginScreen,
stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverSelectionScreen => .loginScreen,
.startScreen => .loginScreen]) { [weak self] context in
let loginHint = context.userInfo as? String
self?.showLoginScreen(loginHint: loginHint, fromState: context.fromState)
}
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .serverConfirmationScreen), transitions: [.loginScreen => .serverConfirmationScreen])
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .serverSelectionScreen), transitions: [.loginScreen => .serverSelectionScreen])
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .startScreen), transitions: [.loginScreen => .startScreen])
// Bug Report
@@ -243,7 +228,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
// Completion
stateMachine.addRoutes(event: .signedIn, transitions: [.qrCodeLoginScreen => .complete,
.serverConfirmationScreen => .complete, // OAuth authentication
.serverSelectionScreen => .complete, // OAuth authentication
.startScreen => .complete, // Direct OAuth authentication
.loginScreen => .complete]) { [weak self] context in
guard let userSession = context.userInfo as? UserSessionProtocol else { fatalError("The user session wasn't included in the context") }
@@ -292,9 +277,9 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case .loginWithQR:
stateMachine.tryEvent(.loginWithQR)
case .login:
stateMachine.tryEvent(.confirmServer(.login))
stateMachine.tryEvent(.selectServer(.login))
case .register:
stateMachine.tryEvent(.confirmServer(.register))
stateMachine.tryEvent(.selectServer(.register))
case .loginDirectlyWithOAuth(let oAuthData, let window):
showOAuthAuthentication(oAuthData: oAuthData, presentationAnchor: window)
@@ -336,7 +321,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case .signInManually:
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR)
stateMachine.tryEvent(.confirmServer(.login))
stateMachine.tryEvent(.selectServer(.login))
case .signedIn(let userSession):
navigationStackCoordinator.setSheetCoordinator(nil)
DispatchQueue.main.async {
@@ -355,42 +340,15 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - Manual Authentication
private func showServerConfirmationScreen(authenticationFlow: AuthenticationFlow) {
private func showServerSelectionScreen(authenticationFlow: AuthenticationFlow) {
// Reset the service back to the default homeserver before continuing. This ensures
// we check that registration is supported if it was previously configured for login.
authenticationService.reset()
let parameters = ServerConfirmationScreenCoordinatorParameters(authenticationService: authenticationService,
authenticationFlow: authenticationFlow,
appSettings: appSettings,
userIndicatorController: userIndicatorController)
let coordinator = ServerConfirmationScreenCoordinator(parameters: parameters)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .continueWithOAuth(let oAuthData, let window):
showOAuthAuthentication(oAuthData: oAuthData, presentationAnchor: window)
case .continueWithPassword:
stateMachine.tryEvent(.continueWithPassword)
case .changeServer:
stateMachine.tryEvent(.changeServer(authenticationFlow))
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.cancelledServerConfirmation)
}
}
private func showServerSelectionScreen(authenticationFlow: AuthenticationFlow) {
let navigationCoordinator = NavigationStackCoordinator()
let parameters = ServerSelectionScreenCoordinatorParameters(authenticationService: authenticationService,
authenticationFlow: authenticationFlow,
appSettings: appSettings,
homeserverHistoryManager: homeserverHistoryManager,
userIndicatorController: userIndicatorController)
let coordinator = ServerSelectionScreenCoordinator(parameters: parameters)
@@ -399,17 +357,16 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return }
switch action {
case .updated:
navigationStackCoordinator.setSheetCoordinator(nil)
case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil)
case .continueWithOAuth(let oAuthData, let window):
showOAuthAuthentication(oAuthData: oAuthData, presentationAnchor: window)
case .continueWithPassword:
stateMachine.tryEvent(.continueWithPassword)
}
}
.store(in: &cancellables)
navigationCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(navigationCoordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissedServerSelection)
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.cancelledServerSelection)
}
}
@@ -507,5 +464,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
private func userHasSignedIn(userSession: UserSessionProtocol) {
delegate?.authenticationFlowCoordinator(didLoginWithSession: userSession)
let newServer = authenticationService.homeserver.value.address
homeserverHistoryManager.addServerToList(newServer)
}
}
@@ -164,7 +164,6 @@ enum TestablePreviewsDictionary {
"SendInviteConfirmationView_Previews" : SendInviteConfirmationView_Previews.self,
"SeparatorMediaEventsTimelineView_Previews" : SeparatorMediaEventsTimelineView_Previews.self,
"SeparatorRoomTimelineView_Previews" : SeparatorRoomTimelineView_Previews.self,
"ServerConfirmationScreen_Previews" : ServerConfirmationScreen_Previews.self,
"SessionVerificationRequestDetailsView_Previews" : SessionVerificationRequestDetailsView_Previews.self,
"SessionVerification_Previews" : SessionVerification_Previews.self,
"SettingsScreenUserStatusPickerView_Previews" : SettingsScreenUserStatusPickerView_Previews.self,
@@ -0,0 +1,100 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import UIKit
/// When leveraging introspection, this allows intercepting UITextFieldDelegate calls
class TextFieldAdapter: NSObject, UITextFieldDelegate {
var textField: UITextField? {
didSet {
oldValue?.delegate = nil
passthroughDelegate = textField?.delegate
textField?.delegate = self
}
}
/// the original swiftui delegate
private var passthroughDelegate: UITextFieldDelegate?
private let keystrokeSubject = PassthroughSubject<String, Never>()
/// fires when keystrokes are made. does not fire for deletions or programmatic text changes
var keystrokePublisher: AnyPublisher<String, Never> {
keystrokeSubject.eraseToAnyPublisher()
}
func textField(_ textField: UITextField, shouldChangeCharactersInRanges ranges: [NSValue], replacementString string: String) -> Bool {
guard let range = ranges.first?.rangeValue else { return true }
if string.isEmpty == false { // don't fire for backspaces
let previousText = textField.text ?? ""
let completeString = (previousText as NSString).replacingCharacters(in: range, with: string)
keystrokeSubject.send(completeString)
}
return true
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
self.textField(textField, shouldChangeCharactersInRanges: [NSValue(range: range)], replacementString: string)
}
// MARK: - Passthrough
func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
passthroughDelegate?.textFieldShouldBeginEditing?(textField) ?? true
}
func textFieldDidBeginEditing(_ textField: UITextField) {
passthroughDelegate?.textFieldDidBeginEditing?(textField)
}
func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
passthroughDelegate?.textFieldShouldEndEditing?(textField) ?? true
}
func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) {
passthroughDelegate?.textFieldDidEndEditing?(textField, reason: reason)
}
func textFieldDidEndEditing(_ textField: UITextField) {
passthroughDelegate?.textFieldDidEndEditing?(textField)
}
func textFieldShouldClear(_ textField: UITextField) -> Bool {
passthroughDelegate?.textFieldShouldClear?(textField) ?? true
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
passthroughDelegate?.textFieldShouldReturn?(textField) ?? true
}
func textFieldDidChangeSelection(_ textField: UITextField) {
passthroughDelegate?.textFieldDidChangeSelection?(textField)
}
@available(iOS 26.0, *)
func textField(_ textField: UITextField, editMenuForCharactersInRanges ranges: [NSValue], suggestedActions: [UIMenuElement]) -> UIMenu? {
passthroughDelegate?.textField?(textField, editMenuForCharactersInRanges: ranges, suggestedActions: suggestedActions)
}
func textField(_ textField: UITextField, editMenuForCharactersIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
passthroughDelegate?.textField?(textField, editMenuForCharactersIn: range, suggestedActions: suggestedActions)
}
func textField(_ textField: UITextField, willPresentEditMenuWith animator: any UIEditMenuInteractionAnimating) {
passthroughDelegate?.textField?(textField, willPresentEditMenuWith: animator)
}
func textField(_ textField: UITextField, willDismissEditMenuWith animator: any UIEditMenuInteractionAnimating) {
passthroughDelegate?.textField?(textField, willDismissEditMenuWith: animator)
}
func textField(_ textField: UITextField, insertInputSuggestion inputSuggestion: UIInputSuggestion) {
passthroughDelegate?.textField?(textField, insertInputSuggestion: inputSuggestion)
}
}
@@ -1,67 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
struct ServerConfirmationScreenCoordinatorParameters {
let authenticationService: AuthenticationServiceProtocol
let authenticationFlow: AuthenticationFlow
let appSettings: AppSettings
let userIndicatorController: UserIndicatorControllerProtocol
}
enum ServerConfirmationScreenCoordinatorAction {
case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow)
case continueWithPassword
case changeServer
}
final class ServerConfirmationScreenCoordinator: CoordinatorProtocol {
private var viewModel: ServerConfirmationScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<ServerConfirmationScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<ServerConfirmationScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: ServerConfirmationScreenCoordinatorParameters) {
let mode = if parameters.appSettings.allowOtherAccountProviders {
ServerConfirmationScreenMode.confirmation(parameters.authenticationService.homeserver.value.address)
} else {
ServerConfirmationScreenMode.picker(parameters.appSettings.accountProviders)
}
viewModel = ServerConfirmationScreenViewModel(authenticationService: parameters.authenticationService,
mode: mode,
authenticationFlow: parameters.authenticationFlow,
appSettings: parameters.appSettings,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .continueWithOAuth(let oAuthData, let window):
actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window))
case .continueWithPassword:
actionsSubject.send(.continueWithPassword)
case .changeServer:
actionsSubject.send(.changeServer)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(ServerConfirmationScreen(context: viewModel.context))
}
}
@@ -1,102 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
enum ServerConfirmationScreenViewModelAction {
/// Continue the flow using the provided OAuth parameters.
case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow)
/// Continue the flow using password authentication.
case continueWithPassword
/// The user would like to change to a different homeserver.
case changeServer
}
enum ServerConfirmationScreenMode: Equatable {
/// The user is confirming the displayed account provider (or can enter their own).
case confirmation(String)
/// The user is only allowed to pick from a list of account providers.
case picker([String])
}
struct ServerConfirmationScreenViewState: BindableState {
/// Whether the screen is configured to confirm a single account provider or pick one from a list of many.
var mode: ServerConfirmationScreenMode
/// The flow being attempted on the selected homeserver.
let authenticationFlow: AuthenticationFlow
/// The presentation anchor used for OAuth authentication.
var window: UIWindow?
var bindings = ServerConfirmationScreenBindings()
/// The screen's title.
var title: String {
switch mode {
case .confirmation(let accountProvider):
switch authenticationFlow {
case .login:
L10n.screenServerConfirmationTitleLogin(accountProvider)
case .register:
L10n.screenServerConfirmationTitleRegister(accountProvider)
}
case .picker:
L10n.screenServerConfirmationTitlePickerMode
}
}
/// The message shown beneath the title.
var message: String? {
guard case let .confirmation(homeserverAddress) = mode else { return nil }
return switch authenticationFlow {
case .login:
if homeserverAddress == "matrix.org" {
L10n.screenServerConfirmationMessageLoginMatrixDotOrg
} else if homeserverAddress == "element.io" {
L10n.screenServerConfirmationMessageLoginElementDotIo
} else {
""
}
case .register:
L10n.screenServerConfirmationMessageRegister
}
}
}
struct ServerConfirmationScreenBindings {
/// The chosen server when in `.picker` mode, otherwise `nil`.
var pickerSelection: String?
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<ServerConfirmationScreenAlert>?
}
enum ServerConfirmationScreenViewAction {
/// Updates the window used as the OAuth presentation anchor.
case updateWindow(UIWindow)
/// The user would like to continue with the current homeserver.
case confirm
/// The user would like to change to a different homeserver.
case changeServer
}
enum ServerConfirmationScreenAlert: Hashable {
/// An alert that informs the user that a server could not be found.
case homeserverNotFound
/// An alert that informs the user about a bad well-known file.
case invalidWellKnown(String)
/// An alert that allows the user to learn about sliding sync.
case slidingSync
/// An alert that informs the user that login isn't supported.
case login
/// An alert that informs the user that registration isn't supported.
case registration
/// An alert that informs the user that Element Pro should be used for a particular server.
case elementProRequired(serverName: String)
/// An unknown error has occurred.
case unknownError
}
@@ -1,205 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
typealias ServerConfirmationScreenViewModelType = StateStoreViewModelV2<ServerConfirmationScreenViewState, ServerConfirmationScreenViewAction>
class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType, ServerConfirmationScreenViewModelProtocol {
let authenticationService: AuthenticationServiceProtocol
let authenticationFlow: AuthenticationFlow
let appSettings: AppSettings
let userIndicatorController: UserIndicatorControllerProtocol
private var actionsSubject: PassthroughSubject<ServerConfirmationScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<ServerConfirmationScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(authenticationService: AuthenticationServiceProtocol,
mode: ServerConfirmationScreenMode,
authenticationFlow: AuthenticationFlow,
appSettings: AppSettings,
userIndicatorController: UserIndicatorControllerProtocol) {
self.authenticationService = authenticationService
self.authenticationFlow = authenticationFlow
self.appSettings = appSettings
self.userIndicatorController = userIndicatorController
let pickerSelection: String? = switch mode {
case .picker(let providers): providers[0]
case .confirmation: nil
}
super.init(initialViewState: ServerConfirmationScreenViewState(mode: mode,
authenticationFlow: authenticationFlow,
bindings: .init(pickerSelection: pickerSelection)))
if case .confirmation = mode {
authenticationService.homeserver
.receive(on: DispatchQueue.main)
.map { .confirmation($0.address) }
.weakAssign(to: \.state.mode, on: self)
.store(in: &cancellables)
}
}
override func process(viewAction: ServerConfirmationScreenViewAction) {
switch viewAction {
case .updateWindow(let window):
guard state.window != window else { return }
Task { state.window = window }
case .confirm:
switch state.mode {
case .confirmation:
startLoading()
Task { await confirmServer() }
case .picker:
startLoading()
Task { await pickServer() }
}
case .changeServer:
actionsSubject.send(.changeServer)
}
}
// MARK: - Private
private func confirmServer() async {
defer { stopLoading() }
startLoading()
let homeserver = authenticationService.homeserver.value
// If the login mode is unknown, the service hasn't been configured and we need to do it now.
// Otherwise we can continue the flow as server selection has been performed and succeeded.
guard homeserver.loginMode == .unknown || authenticationService.flow != authenticationFlow else {
await fetchLoginURLIfNeededAndContinue()
return
}
switch await authenticationService.configure(for: homeserver.address, flow: authenticationFlow) {
case .success:
await fetchLoginURLIfNeededAndContinue()
case .failure(let error):
switch error {
case .invalidServer, .invalidHomeserverAddress:
displayError(.homeserverNotFound)
case .invalidWellKnown(let error):
displayError(.invalidWellKnown(error))
case .slidingSyncNotAvailable:
displayError(.slidingSync)
case .loginNotSupported:
displayError(.login)
case .registrationNotSupported:
displayError(.registration)
case .elementProRequired(let serverName):
displayError(.elementProRequired(serverName: serverName))
default:
displayError(.unknownError)
}
}
}
private func pickServer() async {
defer { stopLoading() }
startLoading()
guard let accountProvider = state.bindings.pickerSelection else {
fatalError("It shouldn't be possible to confirm without a selection.")
}
// Don't bother reconfiguring the service if it has already been done for the selected server.
let homeserver = authenticationService.homeserver.value
guard homeserver.loginMode == .unknown || homeserver.address != accountProvider else {
await fetchLoginURLIfNeededAndContinue()
return
}
switch await authenticationService.configure(for: accountProvider, flow: authenticationFlow) {
case .success:
await fetchLoginURLIfNeededAndContinue()
case .failure:
// When the servers are hard-coded they should have a valid configuration, so show a generic error.
displayError(.unknownError)
}
}
private func fetchLoginURLIfNeededAndContinue() async {
guard authenticationService.homeserver.value.loginMode.supportsOAuthFlow else {
actionsSubject.send(.continueWithPassword)
return
}
guard let window = state.window else {
displayError(.unknownError)
return
}
startLoading() // Uses the same ID, so no need to worry if the indicator already exists
defer { stopLoading() }
switch await authenticationService.urlForOAuthLogin(loginHint: nil) {
case .success(let oAuthData):
actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window))
case .failure:
displayError(.unknownError)
}
}
private let loadingIndicatorID = "\(ServerConfirmationScreenViewModel.self)-Loading"
private func startLoading() {
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorID,
type: .modal,
title: L10n.commonLoading,
persistent: true))
}
private func stopLoading() {
userIndicatorController.retractIndicatorWithId(loadingIndicatorID)
}
private func displayError(_ type: ServerConfirmationScreenAlert) {
switch type {
case .homeserverNotFound:
state.bindings.alertInfo = AlertInfo(id: .homeserverNotFound,
title: L10n.errorUnknown,
message: L10n.screenChangeServerErrorInvalidHomeserver)
case .invalidWellKnown(let error):
state.bindings.alertInfo = AlertInfo(id: .invalidWellKnown(error),
title: L10n.commonServerNotSupported,
message: L10n.screenChangeServerErrorInvalidWellKnown(error))
case .slidingSync:
let nonBreakingAppName = InfoPlistReader.main.bundleDisplayName.replacingOccurrences(of: " ", with: "\u{00A0}")
state.bindings.alertInfo = AlertInfo(id: .slidingSync,
title: L10n.commonServerNotSupported,
message: L10n.screenChangeServerErrorNoSlidingSyncMessage(nonBreakingAppName))
case .login:
state.bindings.alertInfo = AlertInfo(id: .login,
title: L10n.commonServerNotSupported,
message: L10n.screenLoginErrorUnsupportedAuthentication)
case .registration:
state.bindings.alertInfo = AlertInfo(id: .registration,
title: L10n.commonServerNotSupported,
message: L10n.errorAccountCreationNotPossible)
case .elementProRequired(let serverName):
state.bindings.alertInfo = AlertInfo(id: .elementProRequired(serverName: serverName),
title: L10n.screenChangeServerErrorElementProRequiredTitle,
message: L10n.screenChangeServerErrorElementProRequiredMessage(serverName),
primaryButton: .init(title: L10n.screenChangeServerErrorElementProRequiredActionIos) {
UIApplication.shared.open(self.appSettings.elementProAppStoreURL)
},
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
case .unknownError:
state.bindings.alertInfo = AlertInfo(id: .unknownError)
}
}
}
@@ -1,14 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
protocol ServerConfirmationScreenViewModelProtocol {
var actions: AnyPublisher<ServerConfirmationScreenViewModelAction, Never> { get }
var context: ServerConfirmationScreenViewModelType.Context { get }
}
@@ -1,169 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct ServerConfirmationScreen: View {
@Bindable var context: ServerConfirmationScreenViewModel.Context
private var backgroundColor: Color {
switch context.viewState.mode {
case .confirmation: .compound.bgCanvasDefault
case .picker: .compound.bgSubtleSecondaryLevel0
}
}
private var headerIcon: KeyPath<CompoundIcons, Image> {
switch context.viewState.mode {
case .confirmation: \.userProfileSolid
case .picker: \.homeSolid
}
}
private var headerIconStyle: BigIcon.Style {
switch context.viewState.mode {
case .confirmation: .defaultSolid
case .picker: .default
}
}
var body: some View {
FullscreenDialog(topPadding: UIConstants.iconTopPaddingToNavigationBar) {
VStack(spacing: 36) {
header
mainContent
}
} bottomContent: {
buttons
}
.background()
.backgroundStyle(backgroundColor)
.alert(item: $context.alertInfo)
.introspect(.window, on: .supportedVersions) { window in
context.send(viewAction: .updateWindow(window))
}
}
/// The main content of the view to be shown in a scroll view.
var header: some View {
VStack(spacing: 8) {
BigIcon(icon: headerIcon, style: headerIconStyle)
.padding(.bottom, 8)
Text(context.viewState.title)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textPrimary)
.fixedSize(horizontal: false, vertical: true)
if let message = context.viewState.message {
Text(message)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textSecondary)
}
}
.padding(.horizontal, 16)
}
@ViewBuilder
var mainContent: some View {
if case .picker(let accountProviders) = context.viewState.mode {
FakeInlinePicker(items: accountProviders,
icon: \.host,
selection: $context.pickerSelection)
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.serverPicker)
}
}
/// The action buttons shown at the bottom of the view.
var buttons: some View {
VStack(spacing: 16) {
Button { context.send(viewAction: .confirm) } label: {
Text(L10n.actionContinue)
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.continue)
if case .confirmation = context.viewState.mode {
Button { context.send(viewAction: .changeServer) } label: {
Text(L10n.screenServerConfirmationChangeServer)
.font(.compound.bodyLGSemibold)
.padding(14)
}
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.changeServer)
}
}
}
}
/// This is such a hack. I hate it!
/// But… We're not in a List/Form, the compound picker doesn't
/// support icons and this screen's design might change so 🤷‍♂️.
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
})
.overlay(alignment: .bottom) {
if item != items.last {
Divider()
.hidden()
.overlay(Color.compound._borderInteractiveSecondaryAlpha)
.padding(.leading, 54)
}
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
// MARK: - Previews
struct ServerConfirmationScreen_Previews: PreviewProvider, TestablePreview {
static let loginViewModel = makeViewModel(mode: .confirmation("matrix.org"), flow: .login)
static let registerViewModel = makeViewModel(mode: .confirmation("matrix.org"), flow: .register)
static let pickerViewModel = makeViewModel(mode: .picker(["dept1.company.com", "dept2.company.com", "dept3.company.com"]), flow: .login)
static var previews: some View {
ElementNavigationStack {
ServerConfirmationScreen(context: loginViewModel.context)
.toolbar(.visible, for: .navigationBar)
}
.previewDisplayName("Login")
ElementNavigationStack {
ServerConfirmationScreen(context: registerViewModel.context)
.toolbar(.visible, for: .navigationBar)
}
.previewDisplayName("Register")
ElementNavigationStack {
ServerConfirmationScreen(context: pickerViewModel.context)
.toolbar(.visible, for: .navigationBar)
}
.previewDisplayName("Picker")
}
static func makeViewModel(mode: ServerConfirmationScreenMode, flow: AuthenticationFlow) -> ServerConfirmationScreenViewModel {
ServerConfirmationScreenViewModel(authenticationService: AuthenticationService.mock,
mode: mode,
authenticationFlow: flow,
appSettings: .volatile(),
userIndicatorController: UserIndicatorControllerMock())
}
}
@@ -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
@@ -64,7 +64,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
}
// When updating these, don't forget to update the reset method too.
homeserverSubject = .init(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown))
homeserverSubject = .init(LoginHomeserver(address: appSettings.defaultServer, loginMode: .unknown))
flow = .login
}
@@ -240,7 +240,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
}
func reset() {
homeserverSubject.send(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown))
homeserverSubject.send(LoginHomeserver(address: appSettings.defaultServer, loginMode: .unknown))
flow = .login
client = nil
}
@@ -0,0 +1,74 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
/// Manages the tracking, removal, and autocomplete matchin of previous servers in AppSettings.
/// Servers are sorted by most recently used.
class HomeserverHistoryManager {
let appSettings: AppSettings
/// cache with confirmed lowercase servers to avoid having to lowercase the entire list on every keystroke
private var cachedServers: [String] = []
var servers: [String] {
cachedServers
}
private var cancellables: Set<AnyCancellable> = []
init(appSettings: AppSettings) {
self.appSettings = appSettings
appSettings
.previousServersPublisher
.sink { [weak self] _ in
self?.updateCachedServers()
}
.store(in: &cancellables)
}
/// Tracks the given server in AppSettings's `previousServers`. The server is asserted
/// unique and placed at the beginning of the list, such that the most recent connected server is at the front.
func addServerToList(_ server: String) {
MXLog.info("Tracking server in previous servers: \(server)")
let lowercaseServer = server.lowercased()
var newList = _removeServerFromList(lowercaseServer) // don't trigger a publisher update
newList.insert(lowercaseServer, at: 0)
appSettings.previousServers = newList
}
/// Removes `server` from the tracking history of `AppSettings.previousServers`
func removeServerFromList(_ server: String) {
let lowercaseServer = server.lowercased()
appSettings.previousServers = _removeServerFromList(lowercaseServer)
}
/// Removes `lowercaseServer` from `appSettings.previousServers`
/// - Parameter lowercaseServer: the server string. It MUST be passed in with all lowercased letters.
/// - Returns: a new copy of the list without `lowercaseServer`
private func _removeServerFromList(_ lowercaseServer: String) -> [String] {
var newList = appSettings.previousServers
newList.removeAll { $0.lowercased() == lowercaseServer }
return newList
}
/// Retrieves the most recent server matching the given prefix, if any
func server(matchingPrefix prefix: String) -> String? {
let lowercasedPrefix = prefix.lowercased()
return servers.first { $0.hasPrefix(lowercasedPrefix) }
}
/// Retrieves all servers from AppSettings, previous and pre-provided and stores them in memory
/// in all lowercase. This is the list that matches are made against.
private func updateCachedServers() {
let previous = appSettings.previousServers.map { $0.lowercased() }
let defaultProviders = appSettings.accountProviders.map { $0.lowercased() }
cachedServers = previous + defaultProviders
}
}
@@ -142,6 +142,7 @@ class MockScreen: Identifiable {
let coordinator = ServerSelectionScreenCoordinator(parameters: .init(authenticationService: AuthenticationService.mock,
authenticationFlow: .login,
appSettings: appSettings,
homeserverHistoryManager: HomeserverHistoryManager(appSettings: appSettings),
userIndicatorController: userIndicatorController))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator