mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Handle waiting list error. (#1165)
This commit is contained in:
@@ -168,12 +168,33 @@ class AuthenticationCoordinator: CoordinatorProtocol {
|
||||
case .configuredForOIDC:
|
||||
// Pop back to the confirmation screen for OIDC login to continue.
|
||||
navigationStackCoordinator.pop(animated: false)
|
||||
case .isOnWaitlist(let credentials):
|
||||
showWaitlistScreen(for: credentials)
|
||||
}
|
||||
}
|
||||
|
||||
navigationStackCoordinator.push(coordinator)
|
||||
}
|
||||
|
||||
private func showWaitlistScreen(for credentials: WaitlistScreenCredentials) {
|
||||
let parameters = WaitlistScreenCoordinatorParameters(credentials: credentials,
|
||||
authenticationService: authenticationService)
|
||||
let coordinator = WaitlistScreenCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .signedIn(let userSession):
|
||||
userHasSignedIn(userSession: userSession)
|
||||
case .cancel:
|
||||
navigationStackCoordinator.pop()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationStackCoordinator.push(coordinator)
|
||||
}
|
||||
|
||||
private func userHasSignedIn(userSession: UserSessionProtocol) {
|
||||
showAnalyticsPromptIfNeeded { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -27,6 +27,8 @@ enum LoginScreenCoordinatorAction {
|
||||
case configuredForOIDC
|
||||
/// Login was successful.
|
||||
case signedIn(UserSessionProtocol)
|
||||
/// The user's request to login failed due to being on the proxy waitlist.
|
||||
case isOnWaitlist(WaitlistScreenCredentials)
|
||||
}
|
||||
|
||||
final class LoginScreenCoordinator: CoordinatorProtocol {
|
||||
@@ -117,13 +119,22 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
|
||||
switch await authenticationService.login(username: username,
|
||||
password: password,
|
||||
initialDeviceName: UIDevice.current.initialDeviceName,
|
||||
deviceId: nil) {
|
||||
deviceID: nil) {
|
||||
case .success(let userSession):
|
||||
callback?(.signedIn(userSession))
|
||||
stopLoading()
|
||||
case .failure(let error):
|
||||
stopLoading()
|
||||
handleError(error)
|
||||
switch error {
|
||||
case .isOnWaitlist:
|
||||
callback?(.isOnWaitlist(.init(username: username,
|
||||
password: password,
|
||||
initialDeviceName: UIDevice.current.initialDeviceName,
|
||||
deviceID: nil,
|
||||
homeserver: authenticationService.homeserver.value)))
|
||||
default:
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -31,10 +31,10 @@ enum MockSoftLogoutScreenState: String, CaseIterable {
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
@MainActor var viewModel: SoftLogoutScreenViewModel {
|
||||
let credentials = SoftLogoutScreenCredentials(userId: "@mock:matrix.org",
|
||||
let credentials = SoftLogoutScreenCredentials(userID: "@mock:matrix.org",
|
||||
homeserverName: "matrix.org",
|
||||
userDisplayName: "mock",
|
||||
deviceId: nil)
|
||||
deviceID: nil)
|
||||
switch self {
|
||||
case .emptyPassword:
|
||||
return SoftLogoutScreenViewModel(credentials: credentials,
|
||||
|
||||
+2
-2
@@ -108,7 +108,7 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
/// Login with the supplied username and password.
|
||||
@MainActor private func login(withPassword password: String) {
|
||||
let username = parameters.credentials.userId
|
||||
let username = parameters.credentials.userID
|
||||
|
||||
startLoading()
|
||||
|
||||
@@ -116,7 +116,7 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
|
||||
switch await authenticationService.login(username: username,
|
||||
password: password,
|
||||
initialDeviceName: UIDevice.current.initialDeviceName,
|
||||
deviceId: parameters.credentials.deviceId) {
|
||||
deviceID: parameters.credentials.deviceID) {
|
||||
case .success(let userSession):
|
||||
callback?(.signedIn(userSession))
|
||||
stopLoading()
|
||||
|
||||
+2
-2
@@ -17,10 +17,10 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SoftLogoutScreenCredentials {
|
||||
let userId: String
|
||||
let userID: String
|
||||
let homeserverName: String
|
||||
let userDisplayName: String
|
||||
let deviceId: String?
|
||||
let deviceID: String?
|
||||
}
|
||||
|
||||
enum SoftLogoutScreenViewModelAction: CustomStringConvertible {
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ struct SoftLogoutScreen: View {
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.accessibilityIdentifier(A11yIdentifiers.softLogoutScreen.title)
|
||||
|
||||
Text(UntranslatedL10n.softLogoutSigninNotice(context.viewState.credentials.homeserverName, context.viewState.credentials.userDisplayName, context.viewState.credentials.userId))
|
||||
Text(UntranslatedL10n.softLogoutSigninNotice(context.viewState.credentials.homeserverName, context.viewState.credentials.userDisplayName, context.viewState.credentials.userID))
|
||||
.font(.compound.bodyLG)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WaitlistScreen: View {
|
||||
@ObservedObject var context: WaitlistScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
FullscreenDialog(topPadding: UIConstants.iconTopPaddingToNavigationBar) {
|
||||
header
|
||||
} bottomContent: {
|
||||
buttons
|
||||
}
|
||||
.background()
|
||||
.environment(\.backgroundStyle, AnyShapeStyle(Color.compound.bgCanvasDefault))
|
||||
.navigationBarBackButtonHidden()
|
||||
.toolbar { toolbar }
|
||||
.toolbar(.visible, for: .navigationBar) // Layout consistency in all states.
|
||||
.overlay {
|
||||
EffectsView(effect: context.viewState.isWaiting ? .none : .confetti)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// The main content of the view to be shown in a scroll view.
|
||||
var header: some View {
|
||||
VStack(spacing: 8) {
|
||||
AuthenticationIconImage(image: Image(systemName: context.viewState.iconSymbolName))
|
||||
.fontWeight(.semibold)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text(context.viewState.title)
|
||||
.font(.compound.headingMDBold)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(context.viewState.message)
|
||||
.font(.compound.bodyMD)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
/// The action buttons shown at the bottom of the view.
|
||||
@ViewBuilder
|
||||
var buttons: some View {
|
||||
if let userSession = context.viewState.userSession {
|
||||
Button { context.send(viewAction: .continue(userSession)) } label: {
|
||||
Text(L10n.actionContinue)
|
||||
}
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
var toolbar: some ToolbarContent {
|
||||
if context.viewState.isWaiting {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.actionCancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct WaitlistScreen_Previews: PreviewProvider {
|
||||
static let viewModel = WaitlistScreenViewModel(homeserver: .mockMatrixDotOrg)
|
||||
static let successViewModel = {
|
||||
let viewModel = WaitlistScreenViewModel(homeserver: .mockMatrixDotOrg)
|
||||
viewModel.update(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@alice:matrix.org"),
|
||||
mediaProvider: MockMediaProvider()))
|
||||
return viewModel
|
||||
}()
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
WaitlistScreen(context: viewModel.context)
|
||||
}
|
||||
.previewDisplayName("Waiting")
|
||||
|
||||
NavigationStack {
|
||||
WaitlistScreen(context: successViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Success")
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct WaitlistScreenCoordinatorParameters {
|
||||
/// The credentials for the login.
|
||||
let credentials: WaitlistScreenCredentials
|
||||
/// The service used to authenticate the user.
|
||||
let authenticationService: AuthenticationServiceProxyProtocol
|
||||
/// The service locator for the screen.
|
||||
var userIndicatorController: UserIndicatorControllerProtocol = ServiceLocator.shared.userIndicatorController
|
||||
}
|
||||
|
||||
enum WaitlistScreenCoordinatorAction {
|
||||
/// Login was successful after a retry attempt.
|
||||
case signedIn(UserSessionProtocol)
|
||||
/// The user would like to try sign in another way.
|
||||
case cancel
|
||||
}
|
||||
|
||||
final class WaitlistScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: WaitlistScreenCoordinatorParameters
|
||||
private var viewModel: WaitlistScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<WaitlistScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables: Set<AnyCancellable> = .init()
|
||||
private var refreshCancellable: AnyCancellable?
|
||||
|
||||
var actions: AnyPublisher<WaitlistScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: WaitlistScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = WaitlistScreenViewModel(homeserver: parameters.credentials.homeserver)
|
||||
|
||||
refreshCancellable = NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .cancel:
|
||||
actionsSubject.send(.cancel)
|
||||
case .continue(let userSession):
|
||||
actionsSubject.send(.signedIn(userSession))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(WaitlistScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Refresh the screen by retrying login to see if the waitlist has opened up.
|
||||
private func refresh() {
|
||||
guard parameters.credentials.homeserver == parameters.authenticationService.homeserver.value else {
|
||||
MXLog.warning("Homeserver configuration changed.")
|
||||
actionsSubject.send(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
showRefreshIndicator()
|
||||
|
||||
Task {
|
||||
switch await parameters.authenticationService.login(username: parameters.credentials.username,
|
||||
password: parameters.credentials.password,
|
||||
initialDeviceName: parameters.credentials.initialDeviceName,
|
||||
deviceID: parameters.credentials.deviceID) {
|
||||
case .success(let userSession):
|
||||
hideRefreshIndicator()
|
||||
refreshCancellable = nil
|
||||
viewModel.update(userSession: userSession)
|
||||
case .failure(.isOnWaitlist):
|
||||
hideRefreshIndicator() // Nothing to do, still waiting for availability.
|
||||
case .failure(.invalidCredentials):
|
||||
hideRefreshIndicator()
|
||||
actionsSubject.send(.cancel)
|
||||
case .failure:
|
||||
hideRefreshIndicator()
|
||||
showFailureIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let refreshIndicatorID = "WaitlistCoordinatorRefresh"
|
||||
private static let failureIndicatorID = "WaitlistCoordinatorFailure"
|
||||
|
||||
private func showRefreshIndicator() {
|
||||
parameters.userIndicatorController.submitIndicator(UserIndicator(id: Self.refreshIndicatorID,
|
||||
type: .modal,
|
||||
title: UntranslatedL10n.commonRefreshing,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
private func hideRefreshIndicator() {
|
||||
parameters.userIndicatorController.retractIndicatorWithId(Self.refreshIndicatorID)
|
||||
}
|
||||
|
||||
private func showFailureIndicator() {
|
||||
parameters.userIndicatorController.submitIndicator(UserIndicator(id: Self.failureIndicatorID,
|
||||
type: .toast,
|
||||
title: L10n.errorUnknown,
|
||||
iconName: "xmark"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum WaitlistScreenViewModelAction {
|
||||
case cancel
|
||||
case `continue`(UserSessionProtocol)
|
||||
}
|
||||
|
||||
/// The user's credentials used to retry login and refresh the waiting list.
|
||||
struct WaitlistScreenCredentials: CustomStringConvertible, CustomDebugStringConvertible {
|
||||
let username: String
|
||||
let password: String
|
||||
let initialDeviceName: String?
|
||||
let deviceID: String?
|
||||
|
||||
let homeserver: LoginHomeserver
|
||||
|
||||
var description: String { "Redacted" }
|
||||
var debugDescription: String { "Redacted" }
|
||||
}
|
||||
|
||||
struct WaitlistScreenViewState: BindableState {
|
||||
/// The homeserver the user is waiting for.
|
||||
let homeserver: LoginHomeserver
|
||||
/// When refresh was successful, the user session that was returned by the login.
|
||||
var userSession: UserSessionProtocol?
|
||||
|
||||
/// Whether or not the user is still waiting in the queue.
|
||||
var isWaiting: Bool { userSession == nil }
|
||||
|
||||
var iconSymbolName: String {
|
||||
if isWaiting {
|
||||
return "stopwatch"
|
||||
} else {
|
||||
return "sparkles"
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
if isWaiting {
|
||||
return UntranslatedL10n.screenWaitlistTitle
|
||||
} else {
|
||||
return UntranslatedL10n.screenWaitlistTitleSuccess
|
||||
}
|
||||
}
|
||||
|
||||
var message: String {
|
||||
if isWaiting {
|
||||
return UntranslatedL10n.screenWaitlistMessage(InfoPlistReader.main.bundleDisplayName, homeserver.address)
|
||||
} else {
|
||||
return UntranslatedL10n.screenWaitlistMessageSuccess(InfoPlistReader.main.bundleDisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum WaitlistScreenViewAction {
|
||||
case cancel
|
||||
case `continue`(UserSessionProtocol)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias WaitlistScreenViewModelType = StateStoreViewModel<WaitlistScreenViewState, WaitlistScreenViewAction>
|
||||
|
||||
class WaitlistScreenViewModel: WaitlistScreenViewModelType, WaitlistScreenViewModelProtocol {
|
||||
private var actionsSubject: PassthroughSubject<WaitlistScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actions: AnyPublisher<WaitlistScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(homeserver: LoginHomeserver) {
|
||||
super.init(initialViewState: WaitlistScreenViewState(homeserver: homeserver))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: WaitlistScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
actionsSubject.send(.cancel)
|
||||
case .continue(let userSession):
|
||||
actionsSubject.send(.continue(userSession))
|
||||
}
|
||||
}
|
||||
|
||||
func update(userSession: UserSessionProtocol) {
|
||||
state.userSession = userSession
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol WaitlistScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<WaitlistScreenViewModelAction, Never> { get }
|
||||
var context: WaitlistScreenViewModelType.Context { get }
|
||||
|
||||
/// Set a user session on the screen to transition to the success state.
|
||||
func update(userSession: UserSessionProtocol)
|
||||
}
|
||||
Reference in New Issue
Block a user