mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
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:
+9
-4
@@ -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 {
|
||||
|
||||
+15
-6
@@ -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.")
|
||||
}
|
||||
|
||||
+55
-14
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user