Only offer to verify if a cross-signed device is available and improve the UX whilst waiting. (#4710)

* Only offer to verify if a cross-signed device is available

* Wait until we know which verification options are available before showing them.

---------

Co-authored-by: Hubert Chathi <hubertc@matrix.org>
This commit is contained in:
Doug
2025-11-07 12:10:12 +00:00
committed by GitHub
parent 096e2d1713
commit 51a9d8b56b
15 changed files with 142 additions and 25 deletions
@@ -67,6 +67,7 @@ extension ClientProxyMock {
notificationSettings = configuration.notificationSettings notificationSettings = configuration.notificationSettings
isOnlyDeviceLeftReturnValue = .success(false) isOnlyDeviceLeftReturnValue = .success(false)
hasDevicesToVerifyAgainstReturnValue = .success(true)
accountURLActionReturnValue = "https://matrix.org/account" accountURLActionReturnValue = "https://matrix.org/account"
canDeactivateAccount = false canDeactivateAccount = false
directRoomForUserIDReturnValue = .failure(.sdkError(ClientProxyMockError.generic)) directRoomForUserIDReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
@@ -2287,6 +2287,70 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
return isOnlyDeviceLeftReturnValue 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<Bool, ClientProxyError>!
var hasDevicesToVerifyAgainstReturnValue: Result<Bool, ClientProxyError>! {
get {
if Thread.isMainThread {
return hasDevicesToVerifyAgainstUnderlyingReturnValue
} else {
var returnValue: Result<Bool, ClientProxyError>? = nil
DispatchQueue.main.sync {
returnValue = hasDevicesToVerifyAgainstUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
hasDevicesToVerifyAgainstUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
hasDevicesToVerifyAgainstUnderlyingReturnValue = newValue
}
}
}
}
var hasDevicesToVerifyAgainstClosure: (() async -> Result<Bool, ClientProxyError>)?
func hasDevicesToVerifyAgainst() async -> Result<Bool, ClientProxyError> {
hasDevicesToVerifyAgainstCallsCount += 1
if let hasDevicesToVerifyAgainstClosure = hasDevicesToVerifyAgainstClosure {
return await hasDevicesToVerifyAgainstClosure()
} else {
return hasDevicesToVerifyAgainstReturnValue
}
}
//MARK: - startSync //MARK: - startSync
var startSyncUnderlyingCallsCount = 0 var startSyncUnderlyingCallsCount = 0
@@ -23,7 +23,7 @@ struct IdentityConfirmationScreenViewState: BindableState {
case interactiveVerification case interactiveVerification
} }
var availableActions: [AvailableActions] = [] var availableActions: [AvailableActions]?
let learnMoreURL: URL let learnMoreURL: URL
} }
@@ -9,7 +9,7 @@
import Combine import Combine
import SwiftUI import SwiftUI
typealias IdentityConfirmationScreenViewModelType = StateStoreViewModel<IdentityConfirmationScreenViewState, IdentityConfirmationScreenViewAction> typealias IdentityConfirmationScreenViewModelType = StateStoreViewModelV2<IdentityConfirmationScreenViewState, IdentityConfirmationScreenViewAction>
class IdentityConfirmationScreenViewModel: IdentityConfirmationScreenViewModelType, IdentityConfirmationScreenViewModelProtocol { class IdentityConfirmationScreenViewModel: IdentityConfirmationScreenViewModelType, IdentityConfirmationScreenViewModelProtocol {
private let userSession: UserSessionProtocol private let userSession: UserSessionProtocol
@@ -62,14 +62,22 @@ class IdentityConfirmationScreenViewModel: IdentityConfirmationScreenViewModelTy
hideLoadingIndicator() hideLoadingIndicator()
} }
// Note: Until the actions are unset, there's a disabled action button with a loading spinner.
guard sessionSecurityState.verificationState == .unverified else { guard sessionSecurityState.verificationState == .unverified else {
return 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] = [] var availableActions: [IdentityConfirmationScreenViewState.AvailableActions] = []
if case let .success(isOnlyDeviceLeft) = await userSession.clientProxy.isOnlyDeviceLeft(), if case let .success(hasDevicesToVerifyAgainst) = await userSession.clientProxy.hasDevicesToVerifyAgainst(),
!isOnlyDeviceLeft { hasDevicesToVerifyAgainst {
availableActions.append(.interactiveVerification) availableActions.append(.interactiveVerification)
} }
@@ -10,7 +10,7 @@ import Compound
import SwiftUI import SwiftUI
struct IdentityConfirmationScreen: View { struct IdentityConfirmationScreen: View {
@ObservedObject var context: IdentityConfirmationScreenViewModel.Context let context: IdentityConfirmationScreenViewModel.Context
var shouldShowSkipButton: Bool { var shouldShowSkipButton: Bool {
#if DEBUG #if DEBUG
@@ -63,25 +63,38 @@ struct IdentityConfirmationScreen: View {
@ViewBuilder @ViewBuilder
private var actionButtons: some View { private var actionButtons: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
if context.viewState.availableActions.contains(.interactiveVerification) { if let availableActions = context.viewState.availableActions {
Button(L10n.screenIdentityConfirmationUseAnotherDevice) { if availableActions.contains(.interactiveVerification) {
context.send(viewAction: .otherDevice) 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)) .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 { if shouldShowSkipButton {
Button("\(L10n.actionSkip) 🙉") { Button("\(L10n.actionSkip) 🙉") {
context.send(viewAction: .skip) context.send(viewAction: .skip)
@@ -105,20 +118,27 @@ struct IdentityConfirmationScreen: View {
struct IdentityConfirmationScreen_Previews: PreviewProvider, TestablePreview { struct IdentityConfirmationScreen_Previews: PreviewProvider, TestablePreview {
static var viewModel = makeViewModel() static var viewModel = makeViewModel()
static var loadingViewModel = makeViewModel(recoveryState: .unknown)
static var previews: some View { static var previews: some View {
NavigationStack { NavigationStack {
IdentityConfirmationScreen(context: viewModel.context) IdentityConfirmationScreen(context: viewModel.context)
} }
.snapshotPreferences(expect: viewModel.context.$viewState.map { state in .previewDisplayName("Actions")
state.availableActions.contains([.interactiveVerification, .recovery]) .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 clientProxy = ClientProxyMock(.init())
let userSession = UserSessionMock(.init(clientProxy: clientProxy)) let userSession = UserSessionMock(.init(clientProxy: clientProxy))
userSession.sessionSecurityStatePublisher = CurrentValuePublisher<SessionSecurityState, Never>(.init(verificationState: .unverified, recoveryState: .enabled)) userSession.sessionSecurityStatePublisher = CurrentValuePublisher<SessionSecurityState, Never>(.init(verificationState: .unverified, recoveryState: recoveryState))
return IdentityConfirmationScreenViewModel(userSession: userSession, return IdentityConfirmationScreenViewModel(userSession: userSession,
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
@@ -342,6 +342,16 @@ class ClientProxy: ClientProxyProtocol {
} }
} }
func hasDevicesToVerifyAgainst() async -> Result<Bool, ClientProxyError> {
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() { func startSync() {
guard !hasEncounteredAuthError else { guard !hasEncounteredAuthError else {
MXLog.warning("Ignoring request, this client has an unknown token.") MXLog.warning("Ignoring request, this client has an unknown token.")
@@ -136,6 +136,8 @@ protocol ClientProxyProtocol: AnyObject {
func isOnlyDeviceLeft() async -> Result<Bool, ClientProxyError> func isOnlyDeviceLeft() async -> Result<Bool, ClientProxyError>
func hasDevicesToVerifyAgainst() async -> Result<Bool, ClientProxyError>
func startSync() func startSync()
func stopSync() func stopSync()
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:069031b694fefcff95b0946e838a55bb202f9a2f1736db103df5048b30f9b7da
size 95823
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b2f43a07b61fde95bd1702d6a8508b4e61aa551eab94acfaf4c3e3321b74ff82
size 106363
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:06a79601a0bf98a6bcc52dd36e06494f5b6dc28a41c47de09602b9374db1a74f
size 49850
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:89650ec13fbbf63c4299d9faf8f8201e59d2b885359151fd490d8b013d2a1461
size 64111