diff --git a/ElementX/Sources/Mocks/ClientProxyMock.swift b/ElementX/Sources/Mocks/ClientProxyMock.swift index bd068e679..2625243b3 100644 --- a/ElementX/Sources/Mocks/ClientProxyMock.swift +++ b/ElementX/Sources/Mocks/ClientProxyMock.swift @@ -67,6 +67,7 @@ extension ClientProxyMock { notificationSettings = configuration.notificationSettings isOnlyDeviceLeftReturnValue = .success(false) + hasDevicesToVerifyAgainstReturnValue = .success(true) accountURLActionReturnValue = "https://matrix.org/account" canDeactivateAccount = false directRoomForUserIDReturnValue = .failure(.sdkError(ClientProxyMockError.generic)) diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 388945d2d..d9ff7d91c 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2287,6 +2287,70 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { return isOnlyDeviceLeftReturnValue } } + //MARK: - hasDevicesToVerifyAgainst + + var hasDevicesToVerifyAgainstUnderlyingCallsCount = 0 + var hasDevicesToVerifyAgainstCallsCount: Int { + get { + if Thread.isMainThread { + return hasDevicesToVerifyAgainstUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = hasDevicesToVerifyAgainstUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + hasDevicesToVerifyAgainstUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + hasDevicesToVerifyAgainstUnderlyingCallsCount = newValue + } + } + } + } + var hasDevicesToVerifyAgainstCalled: Bool { + return hasDevicesToVerifyAgainstCallsCount > 0 + } + + var hasDevicesToVerifyAgainstUnderlyingReturnValue: Result! + var hasDevicesToVerifyAgainstReturnValue: Result! { + get { + if Thread.isMainThread { + return hasDevicesToVerifyAgainstUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = hasDevicesToVerifyAgainstUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + hasDevicesToVerifyAgainstUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + hasDevicesToVerifyAgainstUnderlyingReturnValue = newValue + } + } + } + } + var hasDevicesToVerifyAgainstClosure: (() async -> Result)? + + func hasDevicesToVerifyAgainst() async -> Result { + hasDevicesToVerifyAgainstCallsCount += 1 + if let hasDevicesToVerifyAgainstClosure = hasDevicesToVerifyAgainstClosure { + return await hasDevicesToVerifyAgainstClosure() + } else { + return hasDevicesToVerifyAgainstReturnValue + } + } //MARK: - startSync var startSyncUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenModels.swift b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenModels.swift index af82b1b8f..a46b87264 100644 --- a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenModels.swift +++ b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenModels.swift @@ -23,7 +23,7 @@ struct IdentityConfirmationScreenViewState: BindableState { case interactiveVerification } - var availableActions: [AvailableActions] = [] + var availableActions: [AvailableActions]? let learnMoreURL: URL } diff --git a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenViewModel.swift b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenViewModel.swift index 79eb404bf..d8e3e51e4 100644 --- a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenViewModel.swift +++ b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenViewModel.swift @@ -9,7 +9,7 @@ import Combine import SwiftUI -typealias IdentityConfirmationScreenViewModelType = StateStoreViewModel +typealias IdentityConfirmationScreenViewModelType = StateStoreViewModelV2 class IdentityConfirmationScreenViewModel: IdentityConfirmationScreenViewModelType, IdentityConfirmationScreenViewModelProtocol { private let userSession: UserSessionProtocol @@ -62,14 +62,22 @@ class IdentityConfirmationScreenViewModel: IdentityConfirmationScreenViewModelTy hideLoadingIndicator() } + // Note: Until the actions are unset, there's a disabled action button with a loading spinner. + guard sessionSecurityState.verificationState == .unverified else { return } + // Continue to show the loading action button until we know that there's a recovery set up. + // https://github.com/element-hq/element-x-ios/issues/4699 + guard sessionSecurityState.recoveryState != .unknown else { + return + } + var availableActions: [IdentityConfirmationScreenViewState.AvailableActions] = [] - if case let .success(isOnlyDeviceLeft) = await userSession.clientProxy.isOnlyDeviceLeft(), - !isOnlyDeviceLeft { + if case let .success(hasDevicesToVerifyAgainst) = await userSession.clientProxy.hasDevicesToVerifyAgainst(), + hasDevicesToVerifyAgainst { availableActions.append(.interactiveVerification) } diff --git a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/View/IdentityConfirmationScreen.swift b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/View/IdentityConfirmationScreen.swift index 55e98018c..929ce6951 100644 --- a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/View/IdentityConfirmationScreen.swift +++ b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/View/IdentityConfirmationScreen.swift @@ -10,7 +10,7 @@ import Compound import SwiftUI struct IdentityConfirmationScreen: View { - @ObservedObject var context: IdentityConfirmationScreenViewModel.Context + let context: IdentityConfirmationScreenViewModel.Context var shouldShowSkipButton: Bool { #if DEBUG @@ -63,25 +63,38 @@ struct IdentityConfirmationScreen: View { @ViewBuilder private var actionButtons: some View { VStack(spacing: 16) { - if context.viewState.availableActions.contains(.interactiveVerification) { - Button(L10n.screenIdentityConfirmationUseAnotherDevice) { - context.send(viewAction: .otherDevice) + if let availableActions = context.viewState.availableActions { + if availableActions.contains(.interactiveVerification) { + Button(L10n.screenIdentityConfirmationUseAnotherDevice) { + context.send(viewAction: .otherDevice) + } + .buttonStyle(.compound(.primary)) + } + + if availableActions.contains(.recovery) { + Button(L10n.screenIdentityConfirmationUseRecoveryKey) { + context.send(viewAction: .recoveryKey) + } + .buttonStyle(.compound(.primary)) + } + + Button(L10n.screenIdentityConfirmationCannotConfirm) { + context.send(viewAction: .reset) + } + .buttonStyle(.compound(.secondary)) + } else { + Button { /* Placeholder button, there is no action */ } label: { + Label { + Text(L10n.commonLoading) + } icon: { + ProgressView() + .tint(.compound.iconOnSolidPrimary) + } } .buttonStyle(.compound(.primary)) + .disabled(true) } - if context.viewState.availableActions.contains(.recovery) { - Button(L10n.screenIdentityConfirmationUseRecoveryKey) { - context.send(viewAction: .recoveryKey) - } - .buttonStyle(.compound(.primary)) - } - - Button(L10n.screenIdentityConfirmationCannotConfirm) { - context.send(viewAction: .reset) - } - .buttonStyle(.compound(.secondary)) - if shouldShowSkipButton { Button("\(L10n.actionSkip) 🙉") { context.send(viewAction: .skip) @@ -105,20 +118,27 @@ struct IdentityConfirmationScreen: View { struct IdentityConfirmationScreen_Previews: PreviewProvider, TestablePreview { static var viewModel = makeViewModel() + static var loadingViewModel = makeViewModel(recoveryState: .unknown) static var previews: some View { NavigationStack { IdentityConfirmationScreen(context: viewModel.context) } - .snapshotPreferences(expect: viewModel.context.$viewState.map { state in - state.availableActions.contains([.interactiveVerification, .recovery]) - }) + .previewDisplayName("Actions") + .snapshotPreferences(expect: viewModel.context.observe(\.viewState.availableActions).map { actions in + actions?.contains([.interactiveVerification, .recovery]) == true + }.eraseToStream()) + + NavigationStack { + IdentityConfirmationScreen(context: loadingViewModel.context) + } + .previewDisplayName("Loading") } - static func makeViewModel() -> IdentityConfirmationScreenViewModel { + static func makeViewModel(recoveryState: SecureBackupRecoveryState = .enabled) -> IdentityConfirmationScreenViewModel { let clientProxy = ClientProxyMock(.init()) let userSession = UserSessionMock(.init(clientProxy: clientProxy)) - userSession.sessionSecurityStatePublisher = CurrentValuePublisher(.init(verificationState: .unverified, recoveryState: .enabled)) + userSession.sessionSecurityStatePublisher = CurrentValuePublisher(.init(verificationState: .unverified, recoveryState: recoveryState)) return IdentityConfirmationScreenViewModel(userSession: userSession, appSettings: ServiceLocator.shared.settings, diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 62e53c0f5..82929f775 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -342,6 +342,16 @@ class ClientProxy: ClientProxyProtocol { } } + func hasDevicesToVerifyAgainst() async -> Result { + do { + let result = try await client.encryption().hasDevicesToVerifyAgainst() + return .success(result) + } catch { + MXLog.error("Failed checking hasDevicesToVerifyAgainst with error: \(error)") + return .failure(.sdkError(error)) + } + } + func startSync() { guard !hasEncounteredAuthError else { MXLog.warning("Ignoring request, this client has an unknown token.") diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 60e762cf3..697753bf4 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -136,6 +136,8 @@ protocol ClientProxyProtocol: AnyObject { func isOnlyDeviceLeft() async -> Result + func hasDevicesToVerifyAgainst() async -> Result + func startSync() func stopSync() diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Actions-iPad-en-GB.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.iPad-en-GB-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Actions-iPad-en-GB.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Actions-iPad-pseudo.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.iPad-pseudo-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Actions-iPad-pseudo.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.iPhone-16-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Actions-iPhone-16-en-GB.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.iPhone-16-en-GB-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Actions-iPhone-16-en-GB.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.iPhone-16-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Actions-iPhone-16-pseudo.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.iPhone-16-pseudo-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Actions-iPhone-16-pseudo.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPad-en-GB.png new file mode 100644 index 000000000..3cb083151 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:069031b694fefcff95b0946e838a55bb202f9a2f1736db103df5048b30f9b7da +size 95823 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPad-pseudo.png new file mode 100644 index 000000000..d11ce81f4 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2f43a07b61fde95bd1702d6a8508b4e61aa551eab94acfaf4c3e3321b74ff82 +size 106363 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPhone-16-en-GB.png new file mode 100644 index 000000000..66abe71bd --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPhone-16-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06a79601a0bf98a6bcc52dd36e06494f5b6dc28a41c47de09602b9374db1a74f +size 49850 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPhone-16-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPhone-16-pseudo.png new file mode 100644 index 000000000..ac94d57dd --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/identityConfirmationScreen.Loading-iPhone-16-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89650ec13fbbf63c4299d9faf8f8201e59d2b885359151fd490d8b013d2a1461 +size 64111