User verification state indicators (#3793)

* Introduce a `UserIdentityProxy` and have it combine upstream methods into an easy to digest  `UserIdentityVerificationState`. Use it in a dedicated `VerificationBadge` UI component

* Show a DMs counterpart verification state in the room header

* Show a warning on the room details `People` entry when there are identity verification state violations on any of the members.

* Show verification badges in the room member list

* Show a withdraw verification section on the room member details for users that have pinning violations.

* Remove the verification section from the profile screen as there's no reliable way to keep it up to date
- the underlying Rust SDK Olm Machine can be rebuilt without notice which would break any existing user identity change streams.

* Update preview test snapshots
This commit is contained in:
Stefan Ceriu
2025-02-18 08:37:34 +02:00
committed by GitHub
parent b71c93dfaa
commit f77faee981
101 changed files with 722 additions and 305 deletions
@@ -17,7 +17,7 @@ enum RoomMemberDetailsScreenViewModelAction {
struct RoomMemberDetailsScreenViewState: BindableState {
let userID: String
var memberDetails: RoomMemberDetails?
var isVerified: Bool?
var verificationState: UserIdentityVerificationState?
var isOwnMemberDetails = false
var isProcessingIgnoreRequest = false
var dmRoomID: String?
@@ -25,11 +25,15 @@ struct RoomMemberDetailsScreenViewState: BindableState {
var bindings: RoomMemberDetailsScreenViewStateBindings
var showVerifiedBadge: Bool {
isVerified == true // We purposely show the badge on your own account for consistency with Web.
verificationState == .verified // We purposely show the badge on your own account for consistency with Web.
}
var showVerificationSection: Bool {
isVerified == false && !isOwnMemberDetails
var showVerifyIdentitySection: Bool {
verificationState == .notVerified && !isOwnMemberDetails
}
var showWithdrawVerificationSection: Bool {
verificationState == .verificationViolation && !isOwnMemberDetails
}
}
@@ -90,6 +94,7 @@ enum RoomMemberDetailsScreenViewAction {
case createDirectChat
case startCall(roomID: String)
case verifyUser
case withdrawVerification
}
enum RoomMemberDetailsScreenAlertType: Hashable {
@@ -42,10 +42,20 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
super.init(initialViewState: initialViewState, mediaProvider: mediaProvider)
showMemberLoadingIndicator()
Task {
await loadMember()
hideMemberLoadingIndicator()
}
roomProxy.identityStatusChangesPublisher
.receive(on: DispatchQueue.main)
.sink { changes in
if changes.map(\.userId).contains(userID) {
Task { await self.loadMember() }
}
}
.store(in: &cancellables)
}
// MARK: - Public
@@ -77,16 +87,15 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
actionsSubject.send(.startCall(roomID: roomID))
case .verifyUser:
actionsSubject.send(.verifyUser(userID: state.userID))
case .withdrawVerification:
Task { await clientProxy.withdrawUserIdentityVerification(state.userID) }
}
}
// MARK: - Private
private func loadMember() async {
async let memberResult = roomProxy.getMember(userID: state.userID)
async let identityResult = clientProxy.userIdentity(for: state.userID)
switch await memberResult {
switch await roomProxy.getMember(userID: state.userID) {
case .success(let member):
roomMemberProxy = member
state.memberDetails = RoomMemberDetails(withProxy: member)
@@ -105,8 +114,8 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
actionsSubject.send(.openUserProfile)
}
if case let .success(.some(identity)) = await identityResult {
state.isVerified = identity.isVerified()
if case let .success(.some(identity)) = await clientProxy.userIdentity(for: state.userID) {
state.verificationState = identity.verificationState
} else {
MXLog.error("Failed to find the member's identity.")
}
@@ -15,7 +15,9 @@ struct RoomMemberDetailsScreen: View {
Form {
headerSection
verificationSection
if context.viewState.showVerifyIdentitySection {
verificationSection
}
if context.viewState.memberDetails != nil, !context.viewState.isOwnMemberDetails {
blockUserSection
@@ -46,7 +48,14 @@ struct RoomMemberDetailsScreen: View {
mediaProvider: context.mediaProvider) { url in
context.send(viewAction: .displayAvatar(url))
} footer: {
otherUserFooter
VStack(spacing: 24) {
if context.viewState.showWithdrawVerificationSection {
withdrawVerificationSection
}
otherUserFooter
}
.padding(.top, 24)
}
} else {
AvatarHeaderView(user: UserProfileProxy(userID: context.viewState.userID),
@@ -56,6 +65,26 @@ struct RoomMemberDetailsScreen: View {
}
}
private var withdrawVerificationSection: some View {
VStack(spacing: 16) {
if let memberDetails = context.viewState.memberDetails {
Text(L10n.cryptoIdentityChangeProfilePinViolation(memberDetails.name ?? memberDetails.id))
.foregroundStyle(.compound.textCriticalPrimary)
.font(.compound.bodyMDSemibold)
} else {
Text(L10n.cryptoIdentityChangeProfilePinViolation(context.viewState.userID))
.foregroundStyle(.compound.textCriticalPrimary)
.font(.compound.bodyMDSemibold)
}
Button(L10n.cryptoIdentityChangeWithdrawVerificationAction) {
context.send(viewAction: .withdrawVerification)
}
.buttonStyle(.compound(.secondary, size: .medium))
}
.padding(.horizontal, 16)
}
private var otherUserFooter: some View {
HStack(spacing: 8) {
if context.viewState.memberDetails != nil, !context.viewState.isOwnMemberDetails {
@@ -84,18 +113,13 @@ struct RoomMemberDetailsScreen: View {
.buttonStyle(FormActionButtonStyle(title: L10n.actionShare))
}
}
.padding(.top, 32)
}
@ViewBuilder
var verificationSection: some View {
if context.viewState.showVerificationSection {
Section {
ListRow(label: .default(title: L10n.commonVerifyUser, icon: \.lock),
kind: .button {
context.send(viewAction: .verifyUser)
})
}
Section {
ListRow(label: .default(title: L10n.commonVerifyUser, icon: \.lock), kind: .button {
context.send(viewAction: .verifyUser)
})
}
}
@@ -138,6 +162,7 @@ struct RoomMemberDetailsScreen: View {
struct RoomMemberDetailsScreen_Previews: PreviewProvider, TestablePreview {
static let verifiedUserViewModel = makeViewModel(member: .mockDan)
static let verificationViolationUserViewModel = makeViewModel(member: .mockBob)
static let otherUserViewModel = makeViewModel(member: .mockAlice)
static let accountOwnerViewModel = makeViewModel(member: .mockMe)
static let ignoredUserViewModel = makeViewModel(member: .mockIgnored)
@@ -145,9 +170,15 @@ struct RoomMemberDetailsScreen_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
RoomMemberDetailsScreen(context: verifiedUserViewModel.context)
.snapshotPreferences(expect: verifiedUserViewModel.context.$viewState.map { state in
state.isVerified == true
state.verificationState == .verified
})
.previewDisplayName("Verified User")
RoomMemberDetailsScreen(context: verificationViolationUserViewModel.context)
.snapshotPreferences(expect: verificationViolationUserViewModel.context.$viewState.map { state in
state.verificationState == .verificationViolation
})
.previewDisplayName("Verification Violation User")
RoomMemberDetailsScreen(context: otherUserViewModel.context)
.snapshotPreferences(expect: otherUserViewModel.context.$viewState.map { state in
@@ -171,12 +202,22 @@ struct RoomMemberDetailsScreen_Previews: PreviewProvider, TestablePreview {
static func makeViewModel(member: RoomMemberProxyMock) -> RoomMemberDetailsScreenViewModel {
let roomProxyMock = JoinedRoomProxyMock(.init(name: ""))
roomProxyMock.getMemberUserIDReturnValue = .success(member)
let clientProxyMock = ClientProxyMock(.init())
clientProxyMock.userIdentityForClosure = { userID in
let isVerified = userID == RoomMemberProxyMock.mockDan.userID
return .success(UserIdentitySDKMock(configuration: .init(isVerified: isVerified)))
let identity = switch userID {
case RoomMemberProxyMock.mockDan.userID:
UserIdentityProxyMock(configuration: .init(verificationState: .verified))
case RoomMemberProxyMock.mockBob.userID:
UserIdentityProxyMock(configuration: .init(verificationState: .verificationViolation))
default:
UserIdentityProxyMock(configuration: .init())
}
return .success(identity)
}
// to avoid mock the call state for the account owner test case
if member.userID != RoomMemberProxyMock.mockMe.userID {
clientProxyMock.directRoomForUserIDReturnValue = .success("roomID")