mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +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:
@@ -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)
|
||||
}
|
||||
}
|
||||
-67
@@ -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))
|
||||
}
|
||||
}
|
||||
-102
@@ -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
|
||||
}
|
||||
-205
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-14
@@ -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 }
|
||||
}
|
||||
-169
@@ -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())
|
||||
}
|
||||
}
|
||||
+16
-6
@@ -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)
|
||||
|
||||
+73
-11
@@ -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
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
-43
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user