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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
101 changed files with 722 additions and 305 deletions

View File

@ -106,6 +106,7 @@
126CBCF5B0145FA1377C1316 /* Tracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B574805B9812C111D6215D /* Tracing.swift */; };
126EE01D8BEAEF26105D83C5 /* RoomDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A5FEF17ED7E6176D922D4F /* RoomDetailsScreen.swift */; };
128FFD8A3D85845F9A927F47 /* PollRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF8548D48512127CCC17C520 /* PollRoomTimelineView.swift */; };
12AF926E783E40BFB32A2D84 /* UserIdentityProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8536B40F578BA7FDB23D97B1 /* UserIdentityProxyMock.swift */; };
12C867E85E6D12EEDFD0B127 /* CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C4762F8D6112E43117DB2F /* CustomStringConvertible.swift */; };
12CCA59536EDD99A3272CF77 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC3F82523D6F48B926D6AF68 /* AppSettings.swift */; };
12CD8B5CC30A05061228BF9E /* TimelineItemMenuActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E6065FC6BC4A1B4C629E08 /* TimelineItemMenuActionProvider.swift */; };
@ -188,6 +189,7 @@
238D561CA231339C6D4D06F3 /* ClientBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1C33355FFB0F0953C35036 /* ClientBuilder.swift */; };
241CDEFE23819867D9B39066 /* RoomChangePermissionsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE75941583A033A9EDC9FE0 /* RoomChangePermissionsScreenViewModel.swift */; };
244407B18B2F2D6466BA5961 /* RoomChangeRolesScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82DFA1B7B088D033E0794B82 /* RoomChangeRolesScreenCoordinator.swift */; };
2447FADEF13225BB6227B977 /* VerificationBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D97BAF04AA150C0EF03021 /* VerificationBadge.swift */; };
244CB93DD7390379D905AFA8 /* DeactivateAccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E49D10BFA7E4D70A947888C /* DeactivateAccountScreen.swift */; };
24A1BBADAC43DC3F3A7347DA /* AnalyticsPromptScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E53BFB7E4F329621C844E8C3 /* AnalyticsPromptScreen.swift */; };
24A75F72EEB7561B82D726FD /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2141693488CE5446BB391964 /* Date.swift */; };
@ -401,6 +403,7 @@
4E4EF97B9F9CEFAC726BA72F /* TimelineProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EACAFB3F3E017060F9F1C5 /* TimelineProviderMock.swift */; };
4E8A2A2CFEB212F14E49E1A1 /* AppLockSetupSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5484457C81B325660901B161 /* AppLockSetupSettingsScreen.swift */; };
4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; };
4E9782D683660463804C2DC3 /* UserIdentityProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F793C422BDACE0C60C774F4 /* UserIdentityProxyProtocol.swift */; };
4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B564D748B67A156F413CD97 /* NotificationSettingsEditScreenModels.swift */; };
4EAC427267424192964B16B3 /* AppSettingsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13BE9781699FB510E9263192 /* AppSettingsHook.swift */; };
4ED764A24F2A715C25CF07F1 /* TimelineMediaPreviewFileExportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30856520F3263D0E195710D7 /* TimelineMediaPreviewFileExportPicker.swift */; };
@ -619,6 +622,7 @@
7A0D335D38ECA095A575B4F7 /* TimelineStyler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB0E533508094156D8024C3 /* TimelineStyler.swift */; };
7A170A5A4A352954BB2A1B96 /* AuthenticationStartScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24E8C8817F59BEC7E358EB78 /* AuthenticationStartScreen.swift */; };
7A25D6926A2C01DB8D0D67A5 /* BadgeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A028783CFFF861C5E44FFB1 /* BadgeLabel.swift */; };
7A495A5F3E5522DD7928CF8F /* UserIdentityProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 964093C7CA8823CAB7FFD88E /* UserIdentityProxy.swift */; };
7A642EE5F1ADC5D520F21924 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */; };
7A71AEF419904209BB8C2833 /* UserAgentBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */; };
7A8B264506D3DDABC01B4EEB /* AppMediator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53AC78E49A297AC1D72A7CF /* AppMediator.swift */; };
@ -993,7 +997,6 @@
C76892321558E75101E68ED6 /* ReadableFrameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */; };
C7774720A4B2E34693E3227C /* RoomNotificationSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8896CDD20CA2D87EA3B848A1 /* RoomNotificationSettingsScreen.swift */; };
C7ABEBECDC513F7887DACF66 /* ProgressMaskModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68010886142843705E342645 /* ProgressMaskModifier.swift */; };
C7B07EBA0F12B5912DA9BB97 /* UserIdentitySDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8DE9D0D480D087D0F676B52 /* UserIdentitySDKMock.swift */; };
C7F20DBF873CC72FB482E326 /* test_rotated_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 723B055A57857BFF0F18D9CB /* test_rotated_image.jpg */; };
C80E06ED97CE52704A46C148 /* ClientBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1C33355FFB0F0953C35036 /* ClientBuilder.swift */; };
C85C7A201E4CFDA477ACEBEB /* AppLockSetupSettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8610C1D21565C950BCA6A454 /* AppLockSetupSettingsScreenViewModelProtocol.swift */; };
@ -1414,6 +1417,7 @@
0F5567A7EF6F2AB9473236F6 /* DocumentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPicker.swift; sourceTree = "<group>"; };
0F569CFB77E0D40BD82203D9 /* AuthenticationClientBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationClientBuilder.swift; sourceTree = "<group>"; };
0F64447FF544298A6A3BEF85 /* NotificationSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreenModels.swift; sourceTree = "<group>"; };
0F793C422BDACE0C60C774F4 /* UserIdentityProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIdentityProxyProtocol.swift; sourceTree = "<group>"; };
0FA60F848D1C14F873F9621A /* RoomMemberDetailsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreenCoordinator.swift; sourceTree = "<group>"; };
105429F29096729EDD3152CF /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/SAS.strings; sourceTree = "<group>"; };
1059E2AE7878CF7820592637 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@ -1967,6 +1971,7 @@
8512B82404B1751D0BCC82D2 /* MediaEventsTimelineScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEventsTimelineScreenCoordinator.swift; sourceTree = "<group>"; };
85149F56BA333619900E2410 /* UserDetailsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenViewModelProtocol.swift; sourceTree = "<group>"; };
851B95BB98649B8E773D6790 /* AppLockService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockService.swift; sourceTree = "<group>"; };
8536B40F578BA7FDB23D97B1 /* UserIdentityProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIdentityProxyMock.swift; sourceTree = "<group>"; };
8544F7058D31DBEB8DBFF0F5 /* NotificationSettingsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelTests.swift; sourceTree = "<group>"; };
854BCEAF2A832176FAACD2CB /* SplashScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenCoordinator.swift; sourceTree = "<group>"; };
85666E40F7E817809B4FD787 /* ComposerToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerToolbar.swift; sourceTree = "<group>"; };
@ -2047,6 +2052,7 @@
955336CBD5ED73C792D1F580 /* EncryptionAuthenticity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionAuthenticity.swift; sourceTree = "<group>"; };
95A2E4BD7C0CAD25EF924A4C /* GeneratedPreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedPreviewTests.swift; sourceTree = "<group>"; };
95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSRegularExpresion.swift; sourceTree = "<group>"; };
964093C7CA8823CAB7FFD88E /* UserIdentityProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIdentityProxy.swift; sourceTree = "<group>"; };
969694F67E844FCA51F7E051 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = "<group>"; };
96C4762F8D6112E43117DB2F /* CustomStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomStringConvertible.swift; sourceTree = "<group>"; };
96CE9D6642DD487D8CC90C9C /* landscape_test_image.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = landscape_test_image.jpg; sourceTree = "<group>"; };
@ -2340,6 +2346,7 @@
D196116D2DD3F2757D45FCB7 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/SAS.strings; sourceTree = "<group>"; };
D1BC84BA0AF11C2128D58ABD /* Common.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Common.swift; sourceTree = "<group>"; };
D1D8479BB704B7EF696F8ABE /* RoomPollsHistoryScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreenCoordinator.swift; sourceTree = "<group>"; };
D1D97BAF04AA150C0EF03021 /* VerificationBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationBadge.swift; sourceTree = "<group>"; };
D263254AFE5B7993FFBBF324 /* NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NSE.entitlements; sourceTree = "<group>"; };
D26813CCE39221FE30BF22CD /* PlatformViewVersionPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformViewVersionPredicate.swift; sourceTree = "<group>"; };
D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFlowCoordinator.swift; sourceTree = "<group>"; };
@ -2444,7 +2451,6 @@
E8A1F98AE670377B20679FF5 /* MediaPlayerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProvider.swift; sourceTree = "<group>"; };
E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = "<group>"; };
E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIFont+AttributedStringBuilder.m"; sourceTree = "<group>"; };
E8DE9D0D480D087D0F676B52 /* UserIdentitySDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIdentitySDKMock.swift; sourceTree = "<group>"; };
E96ED747FF90332EA1333C22 /* RoomTimelineItemFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFixtures.swift; sourceTree = "<group>"; };
E992D7B8BE54B2AB454613AF /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = "<group>"; };
E9A3D3CFA199FA7897364547 /* CallInviteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallInviteRoomTimelineItem.swift; sourceTree = "<group>"; };
@ -2707,6 +2713,7 @@
children = (
693E16574C6F7F9FA1015A8C /* Search.swift */,
832397B5C3D00A4BF52C5F0B /* ShouldScrollOnKeyboardDidShow.swift */,
D1D97BAF04AA150C0EF03021 /* VerificationBadge.swift */,
E2DA161C142B7AB8CC40F752 /* Animation */,
CE2FBFD64A89F5DBE4EB30DB /* Layout */,
E6E1D07163F8752D62DA4A93 /* Styles */,
@ -3215,6 +3222,7 @@
B0F5CC38803B8382D2C63222 /* TimelineControllerFactoryMock.swift */,
62EACAFB3F3E017060F9F1C5 /* TimelineProviderMock.swift */,
17A8AA0DFA06012A9DAB951E /* TimelineProxyMock.swift */,
8536B40F578BA7FDB23D97B1 /* UserIdentityProxyMock.swift */,
7893780A1FD6E3F38B3E9049 /* UserIndicatorControllerMock.swift */,
AAD01F7FC2BBAC7351948595 /* UserProfile+Mock.swift */,
F4469F6AE311BDC439B3A5EC /* UserSessionMock.swift */,
@ -5652,6 +5660,8 @@
D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */,
65AAD845E53B0C8B5E0812C2 /* UserDiscoveryService.swift */,
7F615A00DB223FF3280204D2 /* UserDiscoveryServiceProtocol.swift */,
964093C7CA8823CAB7FFD88E /* UserIdentityProxy.swift */,
0F793C422BDACE0C60C774F4 /* UserIdentityProxyProtocol.swift */,
51C454AE59914B551A6D02C0 /* UserProfileProxy.swift */,
);
path = Users;
@ -5717,7 +5727,6 @@
children = (
8EAF4A49F3ACD8BB8B0D2371 /* ClientSDKMock.swift */,
5EFB1D29B0870AFB6A56E9B8 /* IdentityResetHandleSDKMock.swift */,
E8DE9D0D480D087D0F676B52 /* UserIdentitySDKMock.swift */,
);
path = SDK;
sourceTree = "<group>";
@ -7624,7 +7633,9 @@
828EA5009557C2B9DCD4CA0F /* UserDiscoverySection.swift in Sources */,
044DD8F80231BC30570F7965 /* UserDiscoveryService.swift in Sources */,
1C409A26A99F0371C47AFA51 /* UserDiscoveryServiceProtocol.swift in Sources */,
C7B07EBA0F12B5912DA9BB97 /* UserIdentitySDKMock.swift in Sources */,
7A495A5F3E5522DD7928CF8F /* UserIdentityProxy.swift in Sources */,
12AF926E783E40BFB32A2D84 /* UserIdentityProxyMock.swift in Sources */,
4E9782D683660463804C2DC3 /* UserIdentityProxyProtocol.swift in Sources */,
988BA75A182738150894A23F /* UserIndicator.swift in Sources */,
C4E0D03DF88242697545A9B7 /* UserIndicatorController.swift in Sources */,
3467FEE8210D301FF1B77001 /* UserIndicatorControllerMock.swift in Sources */,
@ -7651,6 +7662,7 @@
7E91BAC17963ED41208F489B /* UserSessionStore.swift in Sources */,
79D57E9AE03A2DC689D14EA2 /* UserSessionStoreMock.swift in Sources */,
AC69B6DF15FC451AB2945036 /* UserSessionStoreProtocol.swift in Sources */,
2447FADEF13225BB6227B977 /* VerificationBadge.swift in Sources */,
5C33976A720B64094CBC56B1 /* VideoMediaEventsTimelineView.swift in Sources */,
F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */,
1A83DD22F3E6F76B13B6E2F9 /* VideoRoomTimelineItemContent.swift in Sources */,
@ -8509,7 +8521,7 @@
repositoryURL = "https://github.com/element-hq/matrix-rust-components-swift";
requirement = {
kind = exactVersion;
version = 25.02.07;
version = 25.02.11;
};
};
701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */ = {

View File

@ -149,8 +149,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/element-hq/matrix-rust-components-swift",
"state" : {
"revision" : "bc819f09ac66bbe1adc2fde2afeb7ab023d1b909",
"version" : "25.2.7"
"revision" : "cc010fc6971370d1df2c0eb67cc5cfd577465b62",
"version" : "25.2.11"
}
},
{

View File

@ -889,8 +889,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
private func presentRoomMembersList() {
let parameters = RoomMembersListScreenCoordinatorParameters(mediaProvider: userSession.mediaProvider,
let parameters = RoomMembersListScreenCoordinatorParameters(clientProxy: userSession.clientProxy,
roomProxy: roomProxy,
mediaProvider: userSession.mediaProvider,
userIndicatorController: userIndicatorController,
analytics: analytics)
let coordinator = RoomMembersListScreenCoordinator(parameters: parameters)
@ -1275,8 +1276,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room))
case .startCall(let roomID):
Task { await self.presentCallScreen(roomID: roomID) }
case .verifyUser(let userID):
actionsSubject.send(.verifyUser(userID: userID))
case .dismiss:
break // Not supported when pushed.
}

View File

@ -923,8 +923,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .room))
case .startCall(let roomID):
Task { await self.presentCallScreen(roomID: roomID, notifyOtherParticipants: false) }
case .verifyUser(let userID):
presentSessionVerificationScreen(flow: .userIntiator(userID: userID))
case .dismiss:
navigationSplitCoordinator.setSheetCoordinator(nil)
}

View File

@ -95,6 +95,6 @@ extension ClientProxyMock {
return await .joined(JoinedRoomProxyMock(.init(id: room.id, name: room.name)))
}
userIdentityForReturnValue = .success(UserIdentitySDKMock(configuration: .init()))
userIdentityForReturnValue = .success(UserIdentityProxyMock(configuration: .init()))
}
}

View File

@ -4860,13 +4860,13 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
var userIdentityForReceivedUserID: String?
var userIdentityForReceivedInvocations: [String] = []
var userIdentityForUnderlyingReturnValue: Result<UserIdentity?, ClientProxyError>!
var userIdentityForReturnValue: Result<UserIdentity?, ClientProxyError>! {
var userIdentityForUnderlyingReturnValue: Result<UserIdentityProxyProtocol?, ClientProxyError>!
var userIdentityForReturnValue: Result<UserIdentityProxyProtocol?, ClientProxyError>! {
get {
if Thread.isMainThread {
return userIdentityForUnderlyingReturnValue
} else {
var returnValue: Result<UserIdentity?, ClientProxyError>? = nil
var returnValue: Result<UserIdentityProxyProtocol?, ClientProxyError>? = nil
DispatchQueue.main.sync {
returnValue = userIdentityForUnderlyingReturnValue
}
@ -4884,9 +4884,9 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
}
}
}
var userIdentityForClosure: ((String) async -> Result<UserIdentity?, ClientProxyError>)?
var userIdentityForClosure: ((String) async -> Result<UserIdentityProxyProtocol?, ClientProxyError>)?
func userIdentity(for userID: String) async -> Result<UserIdentity?, ClientProxyError> {
func userIdentity(for userID: String) async -> Result<UserIdentityProxyProtocol?, ClientProxyError> {
userIdentityForCallsCount += 1
userIdentityForReceivedUserID = userID
DispatchQueue.main.async {
@ -16511,6 +16511,14 @@ class UserDiscoveryServiceMock: UserDiscoveryServiceProtocol, @unchecked Sendabl
}
}
}
class UserIdentityProxyMock: UserIdentityProxyProtocol, @unchecked Sendable {
var verificationState: UserIdentityVerificationState {
get { return underlyingVerificationState }
set(value) { underlyingVerificationState = value }
}
var underlyingVerificationState: UserIdentityVerificationState!
}
class UserIndicatorControllerMock: UserIndicatorControllerProtocol, @unchecked Sendable {
var window: UIWindow?
var alertInfo: AlertInfo<UUID>?

View File

@ -22876,6 +22876,71 @@ open class UserIdentitySDKMock: MatrixRustSDK.UserIdentity, @unchecked Sendable
fileprivate var pointer: UnsafeMutableRawPointer!
//MARK: - hasVerificationViolation
var hasVerificationViolationUnderlyingCallsCount = 0
open var hasVerificationViolationCallsCount: Int {
get {
if Thread.isMainThread {
return hasVerificationViolationUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = hasVerificationViolationUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
hasVerificationViolationUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
hasVerificationViolationUnderlyingCallsCount = newValue
}
}
}
}
open var hasVerificationViolationCalled: Bool {
return hasVerificationViolationCallsCount > 0
}
var hasVerificationViolationUnderlyingReturnValue: Bool!
open var hasVerificationViolationReturnValue: Bool! {
get {
if Thread.isMainThread {
return hasVerificationViolationUnderlyingReturnValue
} else {
var returnValue: Bool? = nil
DispatchQueue.main.sync {
returnValue = hasVerificationViolationUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
hasVerificationViolationUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
hasVerificationViolationUnderlyingReturnValue = newValue
}
}
}
}
open var hasVerificationViolationClosure: (() -> Bool)?
open override func hasVerificationViolation() -> Bool {
hasVerificationViolationCallsCount += 1
if let hasVerificationViolationClosure = hasVerificationViolationClosure {
return hasVerificationViolationClosure()
} else {
return hasVerificationViolationReturnValue
}
}
//MARK: - isVerified
var isVerifiedUnderlyingCallsCount = 0
@ -23046,6 +23111,71 @@ open class UserIdentitySDKMock: MatrixRustSDK.UserIdentity, @unchecked Sendable
try await pinClosure?()
}
//MARK: - wasPreviouslyVerified
var wasPreviouslyVerifiedUnderlyingCallsCount = 0
open var wasPreviouslyVerifiedCallsCount: Int {
get {
if Thread.isMainThread {
return wasPreviouslyVerifiedUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = wasPreviouslyVerifiedUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
wasPreviouslyVerifiedUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
wasPreviouslyVerifiedUnderlyingCallsCount = newValue
}
}
}
}
open var wasPreviouslyVerifiedCalled: Bool {
return wasPreviouslyVerifiedCallsCount > 0
}
var wasPreviouslyVerifiedUnderlyingReturnValue: Bool!
open var wasPreviouslyVerifiedReturnValue: Bool! {
get {
if Thread.isMainThread {
return wasPreviouslyVerifiedUnderlyingReturnValue
} else {
var returnValue: Bool? = nil
DispatchQueue.main.sync {
returnValue = wasPreviouslyVerifiedUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
wasPreviouslyVerifiedUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
wasPreviouslyVerifiedUnderlyingReturnValue = newValue
}
}
}
}
open var wasPreviouslyVerifiedClosure: (() -> Bool)?
open override func wasPreviouslyVerified() -> Bool {
wasPreviouslyVerifiedCallsCount += 1
if let wasPreviouslyVerifiedClosure = wasPreviouslyVerifiedClosure {
return wasPreviouslyVerifiedClosure()
} else {
return wasPreviouslyVerifiedReturnValue
}
}
//MARK: - withdrawVerification
open var withdrawVerificationThrowableError: Error?

View File

@ -1,21 +1,18 @@
//
// Copyright 2024 New Vector Ltd.
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Foundation
import MatrixRustSDK
extension UserIdentitySDKMock {
extension UserIdentityProxyMock {
struct Configuration {
var isVerified = false
var verificationState: UserIdentityVerificationState = .notVerified
}
convenience init(configuration: Configuration) {
self.init()
isVerifiedReturnValue = configuration.isVerified
underlyingVerificationState = configuration.verificationState
}
}

View File

@ -0,0 +1,37 @@
//
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct VerificationBadge: View {
let verificationState: UserIdentityVerificationState
var body: some View {
switch verificationState {
case .verified:
CompoundIcon(\.verified, size: .small, relativeTo: .compound.bodyMD)
.foregroundStyle(.compound.iconSuccessPrimary)
case .verificationViolation:
CompoundIcon(\.infoSolid, size: .small, relativeTo: .compound.bodyMD)
.foregroundStyle(.compound.iconCriticalPrimary)
case .notVerified:
EmptyView()
}
}
}
struct VerificationBadge_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 16.0) {
VerificationBadge(verificationState: .notVerified)
VerificationBadge(verificationState: .verificationViolation)
VerificationBadge(verificationState: .verified)
}
.previewLayout(.sizeThatFits)
}
}

View File

@ -12,17 +12,25 @@ import SwiftUI
struct RoomHeaderView: View {
let roomName: String
let roomAvatar: RoomAvatar
var dmRecipientVerificationState: UserIdentityVerificationState?
let mediaProvider: MediaProviderProtocol?
var body: some View {
HStack(spacing: 12) {
HStack(spacing: 8) {
avatarImage
.accessibilityHidden(true)
Text(roomName)
.lineLimit(1)
.font(.compound.bodyLGSemibold)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name)
HStack(spacing: 4) {
Text(roomName)
.lineLimit(1)
.font(.compound.bodyLGSemibold)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name)
if let dmRecipientVerificationState {
VerificationBadge(verificationState: dmRecipientVerificationState)
}
}
}
// Take up as much space as possible, with a leading alignment for use in the principal toolbar position.
.frame(idealWidth: .greatestFiniteMagnitude, maxWidth: .infinity, alignment: .leading)
@ -38,20 +46,23 @@ struct RoomHeaderView: View {
struct RoomHeaderView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
RoomHeaderView(roomName: "Some Room name",
roomAvatar: .room(id: "1",
name: "Some Room Name",
avatarURL: .mockMXCAvatar),
mediaProvider: MediaProviderMock(configuration: .init()))
.previewLayout(.sizeThatFits)
.padding()
VStack(spacing: 8) {
makeHeader(avatarURL: nil, verificationState: .notVerified)
makeHeader(avatarURL: .mockMXCAvatar, verificationState: .notVerified)
makeHeader(avatarURL: .mockMXCAvatar, verificationState: .verified)
makeHeader(avatarURL: .mockMXCAvatar, verificationState: .verificationViolation)
}
.previewLayout(.sizeThatFits)
}
static func makeHeader(avatarURL: URL?,
verificationState: UserIdentityVerificationState) -> some View {
RoomHeaderView(roomName: "Some Room name",
roomAvatar: .room(id: "1",
name: "Some Room Name",
avatarURL: nil),
avatarURL: avatarURL),
dmRecipientVerificationState: verificationState,
mediaProvider: MediaProviderMock(configuration: .init()))
.previewLayout(.sizeThatFits)
.padding()
}
}

View File

@ -39,7 +39,10 @@ struct RoomDetailsScreenViewState: BindableState {
var topic: AttributedString?
var topicSummary: AttributedString?
var joinedMembersCount: Int
var hasMemberIdentityVerificationStateViolations = false
var isProcessingIgnoreRequest = false
var canInviteUsers = false
var canEditRoomName = false

View File

@ -200,6 +200,12 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
.weakAssign(to: \.state.knockRequestsCount, on: self)
.store(in: &cancellables)
roomProxy.membersPublisher.combineLatest(roomProxy.identityStatusChangesPublisher)
.sink { _ in
Task { await self.updateMemberIdentityVerificationStates() }
}
.store(in: &cancellables)
}
private func updateRoomInfo(_ roomInfo: RoomInfoProxy) {
@ -239,6 +245,24 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
await roomProxy.updateMembers()
}
private func updateMemberIdentityVerificationStates() async {
guard roomProxy.isEncrypted else {
// We don't care about identity statuses on non-encrypted rooms
return
}
for member in roomProxy.membersPublisher.value {
if case let .success(identity) = await clientProxy.userIdentity(for: member.userID) {
if identity?.verificationState == .verificationViolation {
state.hasMemberIdentityVerificationStateViolations = true
return
}
}
}
state.hasMemberIdentityVerificationStateViolations = false
}
private func updatePowerLevelPermissions() async {
state.canEditRoomName = await (try? roomProxy.canUser(userID: roomProxy.ownUserID, sendStateEvent: .roomName).get()) == true
state.canEditRoomTopic = await (try? roomProxy.canUser(userID: roomProxy.ownUserID, sendStateEvent: .roomTopic).get()) == true

View File

@ -198,13 +198,22 @@ struct RoomDetailsScreen: View {
private var peopleSection: some View {
Section {
ListRow(label: .default(title: L10n.commonPeople,
icon: \.user),
details: .title(String(context.viewState.joinedMembersCount)),
kind: .navigationLink {
context.send(viewAction: .processTapPeople)
})
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.people)
if context.viewState.hasMemberIdentityVerificationStateViolations {
ListRow(label: .default(title: L10n.commonPeople, icon: \.user),
details: .icon(CompoundIcon(\.infoSolid).foregroundStyle(.compound.iconCriticalPrimary)),
kind: .navigationLink {
context.send(viewAction: .processTapPeople)
})
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.people)
} else {
ListRow(label: .default(title: L10n.commonPeople, icon: \.user),
details: .title(String(context.viewState.joinedMembersCount)),
kind: .navigationLink {
context.send(viewAction: .processTapPeople)
})
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.people)
}
if context.viewState.canSeeKnockingRequests {
ListRow(label: .default(title: L10n.screenRoomDetailsRequestsToJoinTitle,

View File

@ -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 {

View File

@ -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.")
}

View File

@ -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,10 +170,16 @@ 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
state.memberDetails?.role == .user && state.dmRoomID != nil
@ -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")

View File

@ -9,8 +9,9 @@ import Combine
import SwiftUI
struct RoomMembersListScreenCoordinatorParameters {
let mediaProvider: MediaProviderProtocol
let clientProxy: ClientProxyProtocol
let roomProxy: JoinedRoomProxyProtocol
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
let analytics: AnalyticsService
}
@ -31,7 +32,8 @@ final class RoomMembersListScreenCoordinator: CoordinatorProtocol {
}
init(parameters: RoomMembersListScreenCoordinatorParameters) {
viewModel = RoomMembersListScreenViewModel(roomProxy: parameters.roomProxy,
viewModel = RoomMembersListScreenViewModel(clientProxy: parameters.clientProxy,
roomProxy: parameters.roomProxy,
mediaProvider: parameters.mediaProvider,
userIndicatorController: parameters.userIndicatorController,
analytics: parameters.analytics)

View File

@ -29,10 +29,15 @@ enum RoomMembersListScreenMode {
case banned
}
struct RoomMemberListScreenEntry: Equatable {
let member: RoomMemberDetails
let verificationState: UserIdentityVerificationState
}
struct RoomMembersListScreenViewState: BindableState {
private var joinedMembers: [RoomMemberDetails]
private var invitedMembers: [RoomMemberDetails]
private var bannedMembers: [RoomMemberDetails]
private var joinedMembers: [RoomMemberListScreenEntry]
private var invitedMembers: [RoomMemberListScreenEntry]
private var bannedMembers: [RoomMemberListScreenEntry]
let joinedMembersCount: Int
var bannedMembersCount: Int { bannedMembers.count }
@ -44,9 +49,9 @@ struct RoomMembersListScreenViewState: BindableState {
var bindings: RoomMembersListScreenViewStateBindings
init(joinedMembersCount: Int,
joinedMembers: [RoomMemberDetails] = [],
invitedMembers: [RoomMemberDetails] = [],
bannedMembers: [RoomMemberDetails] = [],
joinedMembers: [RoomMemberListScreenEntry] = [],
invitedMembers: [RoomMemberListScreenEntry] = [],
bannedMembers: [RoomMemberListScreenEntry] = [],
bindings: RoomMembersListScreenViewStateBindings) {
self.joinedMembersCount = joinedMembersCount
self.joinedMembers = joinedMembers
@ -55,19 +60,19 @@ struct RoomMembersListScreenViewState: BindableState {
self.bindings = bindings
}
var visibleJoinedMembers: [RoomMemberDetails] {
var visibleJoinedMembers: [RoomMemberListScreenEntry] {
joinedMembers
.filter { $0.matches(searchQuery: bindings.searchQuery) }
.filter { $0.member.matches(searchQuery: bindings.searchQuery) }
}
var visibleInvitedMembers: [RoomMemberDetails] {
var visibleInvitedMembers: [RoomMemberListScreenEntry] {
invitedMembers
.filter { $0.matches(searchQuery: bindings.searchQuery) }
.filter { $0.member.matches(searchQuery: bindings.searchQuery) }
}
var visibleBannedMembers: [RoomMemberDetails] {
var visibleBannedMembers: [RoomMemberListScreenEntry] {
bannedMembers
.filter { $0.matches(searchQuery: bindings.searchQuery) }
.filter { $0.member.matches(searchQuery: bindings.searchQuery) }
}
}

View File

@ -11,6 +11,7 @@ import SwiftUI
typealias RoomMembersListScreenViewModelType = StateStoreViewModel<RoomMembersListScreenViewState, RoomMembersListScreenViewAction>
class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMembersListScreenViewModelProtocol {
private let clientProxy: ClientProxyProtocol
private let roomProxy: JoinedRoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let analytics: AnalyticsService
@ -24,10 +25,12 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
}
init(initialMode: RoomMembersListScreenMode = .members,
clientProxy: ClientProxyProtocol,
roomProxy: JoinedRoomProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
analytics: AnalyticsService) {
self.clientProxy = clientProxy
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
self.analytics = analytics
@ -59,22 +62,23 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
}
func stop() {
hideLoader()
hideLoadingIndicator(Self.setupMembersLoadingIndicatorIdentifier)
hideLoadingIndicator(Self.updateStateLoadingIndicatorIdentifier)
}
// MARK: - Members
private func setupMembers() {
Task {
showLoader()
showLoadingIndicator(Self.setupMembersLoadingIndicatorIdentifier)
await roomProxy.updateMembers()
hideLoader()
hideLoadingIndicator(Self.setupMembersLoadingIndicatorIdentifier)
}
roomProxy.membersPublisher
.filter { !$0.isEmpty }
roomProxy.membersPublisher.combineLatest(roomProxy.identityStatusChangesPublisher)
.filter { !$0.0.isEmpty }
.receive(on: DispatchQueue.main)
.sink { [weak self] members in
.sink { [weak self] members, _ in
self?.updateState(members: members)
}
.store(in: &cancellables)
@ -87,11 +91,12 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
private func updateState(members: [RoomMemberProxyProtocol]) {
Task {
showLoader()
showLoadingIndicator(Self.updateStateLoadingIndicatorIdentifier)
let members = members.sorted()
let roomMembersDetails = await buildMembersDetails(members: members)
self.members = members
self.state = .init(joinedMembersCount: roomProxy.infoPublisher.value.joinedMembersCount,
joinedMembers: roomMembersDetails.joinedMembers,
invitedMembers: roomMembersDetails.invitedMembers,
@ -102,25 +107,32 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
self.state.canKickUsers = await (try? roomProxy.canUserKick(userID: roomProxy.ownUserID).get()) == true
self.state.canBanUsers = await (try? roomProxy.canUserBan(userID: roomProxy.ownUserID).get()) == true
hideLoader()
hideLoadingIndicator(Self.updateStateLoadingIndicatorIdentifier)
}
}
private func buildMembersDetails(members: [RoomMemberProxyProtocol]) async -> RoomMembersDetails {
await Task.detached {
await Task.detached { [clientProxy, roomProxy] in
// accessing RoomMember's properties is very slow. We need to do it in a background thread.
var invitedMembers: [RoomMemberDetails] = .init()
var joinedMembers: [RoomMemberDetails] = .init()
var bannedMembers: [RoomMemberDetails] = .init()
var invitedMembers: [RoomMemberListScreenEntry] = .init()
var joinedMembers: [RoomMemberListScreenEntry] = .init()
var bannedMembers: [RoomMemberListScreenEntry] = .init()
for member in members {
var verificationState: UserIdentityVerificationState = .notVerified
if roomProxy.isEncrypted, // We don't care about identity statuses on non-encrypted rooms
case let .success(userIdentity) = await clientProxy.userIdentity(for: member.userID),
let userIdentity {
verificationState = userIdentity.verificationState
}
switch member.membership {
case .invite:
invitedMembers.append(.init(withProxy: member))
invitedMembers.append(.init(member: .init(withProxy: member), verificationState: verificationState))
case .join:
joinedMembers.append(.init(withProxy: member))
joinedMembers.append(.init(member: .init(withProxy: member), verificationState: verificationState))
case .ban:
bannedMembers.append(.init(withProxy: member))
bannedMembers.append(.init(member: .init(withProxy: member), verificationState: verificationState))
default:
continue
}
@ -128,7 +140,7 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
return .init(invitedMembers: invitedMembers,
joinedMembers: joinedMembers,
bannedMembers: bannedMembers.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }) // Re-sort ignoring display name.
bannedMembers: bannedMembers.sorted { $0.member.id.localizedStandardCompare($1.member.id) == .orderedAscending }) // Re-sort ignoring display name.
}
.value
}
@ -215,18 +227,19 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
// MARK: - Indicators
private let userIndicatorID = UUID().uuidString
private static let setupMembersLoadingIndicatorIdentifier = "\(RoomMembersListScreenViewModel.self)-SetupMembers"
private static let updateStateLoadingIndicatorIdentifier = "\(RoomMembersListScreenViewModel.self)-UpdateState"
private func showLoader() {
userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID,
private func showLoadingIndicator(_ identifier: String) {
userIndicatorController.submitIndicator(UserIndicator(id: identifier,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: true),
title: L10n.commonLoading,
persistent: true),
delay: .milliseconds(200))
}
private func hideLoader() {
userIndicatorController.retractIndicatorWithId(userIndicatorID)
private func hideLoadingIndicator(_ identifier: String) {
userIndicatorController.retractIndicatorWithId(identifier)
}
private func showManageMemberIndicator(title: String) {
@ -247,7 +260,7 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
}
private struct RoomMembersDetails {
var invitedMembers: [RoomMemberDetails]
var joinedMembers: [RoomMemberDetails]
var bannedMembers: [RoomMemberDetails]
var invitedMembers: [RoomMemberListScreenEntry]
var joinedMembers: [RoomMemberListScreenEntry]
var bannedMembers: [RoomMemberListScreenEntry]
}

View File

@ -97,6 +97,7 @@ struct RoomMembersListManageMemberSheetLive_Previews: PreviewProvider {
private extension RoomMembersListScreenViewModel {
static var mock: RoomMembersListScreenViewModel {
RoomMembersListScreenViewModel(initialMode: .members,
clientProxy: ClientProxyMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(members: .allMembersAsAdmin)),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: ServiceLocator.shared.userIndicatorController,

View File

@ -59,23 +59,23 @@ struct RoomMembersListScreen: View {
var roomMembers: some View {
LazyVStack(alignment: .leading, spacing: 12) {
membersSection(data: context.viewState.visibleInvitedMembers, sectionTitle: L10n.screenRoomMemberListPendingHeaderTitle)
membersSection(data: context.viewState.visibleJoinedMembers, sectionTitle: L10n.screenRoomMemberListHeaderTitle(Int(context.viewState.joinedMembersCount)))
membersSection(entries: context.viewState.visibleInvitedMembers, sectionTitle: L10n.screenRoomMemberListPendingHeaderTitle)
membersSection(entries: context.viewState.visibleJoinedMembers, sectionTitle: L10n.screenRoomMemberListHeaderTitle(Int(context.viewState.joinedMembersCount)))
}
}
var bannedUsers: some View {
LazyVStack(alignment: .leading, spacing: 12) {
membersSection(data: context.viewState.visibleBannedMembers)
membersSection(entries: context.viewState.visibleBannedMembers)
}
}
@ViewBuilder
private func membersSection(data: [RoomMemberDetails], sectionTitle: String? = nil) -> some View {
if !data.isEmpty {
private func membersSection(entries: [RoomMemberListScreenEntry], sectionTitle: String? = nil) -> some View {
if !entries.isEmpty {
Section {
ForEach(data, id: \.id) { member in
RoomMembersListScreenMemberCell(member: member, context: context)
ForEach(entries, id: \.member.id) { entry in
RoomMembersListScreenMemberCell(listEntry: entry, context: context)
}
} header: {
if let sectionTitle {
@ -179,7 +179,22 @@ struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview {
members.append(.mockInvited)
}
let clientProxyMock = ClientProxyMock(.init())
clientProxyMock.userIdentityForClosure = { userID in
let identity = switch userID {
case RoomMemberProxyMock.mockAlice.userID:
UserIdentityProxyMock(configuration: .init(verificationState: .verified))
case RoomMemberProxyMock.mockBob.userID:
UserIdentityProxyMock(configuration: .init(verificationState: .verificationViolation))
default:
UserIdentityProxyMock(configuration: .init())
}
return .success(identity)
}
return RoomMembersListScreenViewModel(initialMode: initialMode,
clientProxy: clientProxyMock,
roomProxy: JoinedRoomProxyMock(.init(name: "Some room",
members: members,
ownUserID: ownUserID,

View File

@ -8,22 +8,22 @@
import SwiftUI
struct RoomMembersListScreenMemberCell: View {
let member: RoomMemberDetails
let listEntry: RoomMemberListScreenEntry
let context: RoomMembersListScreenViewModel.Context
var body: some View {
Button {
context.send(viewAction: .selectMember(member))
context.send(viewAction: .selectMember(listEntry.member))
} label: {
HStack(spacing: 8) {
LoadableAvatarImage(url: avatarURL,
name: avatarName,
contentID: member.id,
contentID: listEntry.member.id,
avatarSize: .user(on: .roomDetails),
mediaProvider: context.mediaProvider)
.accessibilityHidden(true)
HStack(alignment: .firstTextBaseline, spacing: 4) {
HStack(alignment: .center, spacing: 4) {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(.compound.bodyMDSemibold)
@ -39,6 +39,8 @@ struct RoomMembersListScreenMemberCell: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
VerificationBadge(verificationState: listEntry.verificationState)
if let role {
Text(role)
.font(.compound.bodyXS)
@ -52,7 +54,7 @@ struct RoomMembersListScreenMemberCell: View {
}
var role: String? {
switch member.role {
switch listEntry.member.role {
case .administrator:
L10n.screenRoomMemberListRoleAdministrator
case .moderator:
@ -65,55 +67,72 @@ struct RoomMembersListScreenMemberCell: View {
// Computed properties to hide the user's profile when banned.
var title: String {
guard !member.isBanned else { return member.id }
return member.name ?? member.id
guard !listEntry.member.isBanned else { return listEntry.member.id }
return listEntry.member.name ?? listEntry.member.id
}
var subtitle: String? {
member.isBanned ? nil : member.id
listEntry.member.isBanned ? nil : listEntry.member.id
}
var avatarName: String? {
member.isBanned ? nil : member.name
listEntry.member.isBanned ? nil : listEntry.member.name
}
var avatarURL: URL? {
member.isBanned ? nil : member.avatarURL
listEntry.member.isBanned ? nil : listEntry.member.avatarURL
}
}
struct RoomMembersListMemberCell_Previews: PreviewProvider, TestablePreview {
static let members: [RoomMemberProxyMock] = [
.mockAlice,
.mockAdmin,
.mockModerator,
.init(with: .init(userID: "@nodisplayname:matrix.org", membership: .join)),
.init(with: .init(userID: "@avatar:matrix.org", displayName: "Avatar", avatarURL: .mockMXCUserAvatar, membership: .join))
static let members: [RoomMemberListScreenEntry] = [
.init(member: .init(withProxy: RoomMemberProxyMock.mockAlice),
verificationState: .notVerified),
.init(member: .init(withProxy: RoomMemberProxyMock.mockAdmin),
verificationState: .verified),
.init(member: .init(withProxy: RoomMemberProxyMock.mockModerator),
verificationState: .verificationViolation),
.init(member: .init(withProxy: RoomMemberProxyMock(with: .init(userID: "@nodisplayname:matrix.org",
membership: .join))),
verificationState: .notVerified),
.init(member: .init(withProxy: RoomMemberProxyMock(with: .init(userID: "@avatar:matrix.org",
displayName: "Avatar",
avatarURL: .mockMXCUserAvatar,
membership: .join))),
verificationState: .notVerified)
]
static let bannedMembers: [RoomMemberProxyMock] = [
.init(with: .init(userID: "@nodisplayname:matrix.org", membership: .ban)),
.init(with: .init(userID: "@fake:matrix.org", displayName: "President", membership: .ban)),
.init(with: .init(userID: "@badavatar:matrix.org", avatarURL: .mockMXCUserAvatar, membership: .ban))
static let bannedMembers: [RoomMemberListScreenEntry] = [
.init(member: .init(withProxy: RoomMemberProxyMock(with: .init(userID: "@nodisplayname:matrix.org",
membership: .ban))),
verificationState: .notVerified),
.init(member: .init(withProxy: RoomMemberProxyMock(with: .init(userID: "@fake:matrix.org",
displayName: "President",
membership: .ban))),
verificationState: .verified),
.init(member: .init(withProxy: RoomMemberProxyMock(with: .init(userID: "@badavatar:matrix.org",
avatarURL: .mockMXCUserAvatar,
membership: .ban))),
verificationState: .verificationViolation)
]
static let viewModel = RoomMembersListScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Some room",
members: members)),
static let viewModel = RoomMembersListScreenViewModel(clientProxy: ClientProxyMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(name: "Some room", members: [])),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
static var previews: some View {
VStack(spacing: 12) {
Section("Invited/Joined") {
ForEach(members, id: \.userID) { member in
RoomMembersListScreenMemberCell(member: .init(withProxy: member), context: viewModel.context)
ForEach(members, id: \.member.id) { entry in
RoomMembersListScreenMemberCell(listEntry: entry, context: viewModel.context)
}
}
// Banned members should have their profiles hidden and the avatar should use the first letter from their user ID.
Section("Banned") {
ForEach(bannedMembers, id: \.userID) { member in
RoomMembersListScreenMemberCell(member: .init(withProxy: member), context: viewModel.context)
ForEach(bannedMembers, id: \.member.id) { entry in
RoomMembersListScreenMemberCell(listEntry: entry, context: viewModel.context)
}
}
}

View File

@ -31,6 +31,7 @@ enum RoomScreenViewAction {
struct RoomScreenViewState: BindableState {
var roomTitle = ""
var roomAvatar: RoomAvatar
var dmRecipientVerificationState: UserIdentityVerificationState?
var lastScrollDirection: ScrollDirection?
// This is used to control the banner

View File

@ -77,6 +77,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task {
await handleRoomInfoUpdate(roomProxy.infoPublisher.value)
await updateVerificationBadge()
}
setupSubscriptions(ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher)
@ -182,6 +184,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
await self?.processIdentityStatusChanges(changes)
await self?.updateVerificationBadge()
}
}
.store(in: &cancellables)
@ -262,6 +265,23 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
private func updateVerificationBadge() async {
guard roomProxy.isDirectOneToOneRoom,
let dmRecipient = roomProxy.membersPublisher.value.first(where: { $0.userID != roomProxy.ownUserID }),
case let .success(userIdentity) = await clientProxy.userIdentity(for: dmRecipient.userID) else {
state.dmRecipientVerificationState = .notVerified
return
}
guard let userIdentity else {
MXLog.failure("User identity should be known at this point")
state.dmRecipientVerificationState = .notVerified
return
}
state.dmRecipientVerificationState = userIdentity.verificationState
}
private func resolveIdentityPinningViolation(_ userID: String) async {
defer {
hideLoadingIndicator()

View File

@ -204,6 +204,7 @@ struct RoomScreen: View {
ToolbarItem(placement: .principal) {
RoomHeaderView(roomName: roomContext.viewState.roomTitle,
roomAvatar: roomContext.viewState.roomAvatar,
dmRecipientVerificationState: roomContext.viewState.dmRecipientVerificationState,
mediaProvider: roomContext.mediaProvider)
// Using a button stops it from getting truncated in the navigation bar
.contentShape(.rect)

View File

@ -20,7 +20,6 @@ struct UserProfileScreenCoordinatorParameters {
enum UserProfileScreenCoordinatorAction {
case openDirectChat(roomID: String)
case startCall(roomID: String)
case verifyUser(userID: String)
case dismiss
}
@ -52,8 +51,6 @@ final class UserProfileScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.openDirectChat(roomID: roomID))
case .startCall(let roomID):
actionsSubject.send(.startCall(roomID: roomID))
case .verifyUser(let userID):
actionsSubject.send(.verifyUser(userID: userID))
case .dismiss:
actionsSubject.send(.dismiss)
}

View File

@ -10,7 +10,6 @@ import Foundation
enum UserProfileScreenViewModelAction {
case openDirectChat(roomID: String)
case startCall(roomID: String)
case verifyUser(userID: String)
case dismiss
}
@ -29,10 +28,6 @@ struct UserProfileScreenViewState: BindableState {
var showVerifiedBadge: Bool {
isVerified == true // We purposely show the badge on your own account for consistency with Web.
}
var showVerificationSection: Bool {
isVerified == false && !isOwnUser
}
}
struct UserProfileScreenViewStateBindings {
@ -48,7 +43,6 @@ enum UserProfileScreenViewAction {
case openDirectChat
case createDirectChat
case startCall(roomID: String)
case verifyUser
case dismiss
}

View File

@ -66,8 +66,6 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
Task { await createDirectChat() }
case .startCall(let roomID):
actionsSubject.send(.startCall(roomID: roomID))
case .verifyUser:
actionsSubject.send(.verifyUser(userID: state.userID))
case .dismiss:
actionsSubject.send(.dismiss)
}
@ -95,7 +93,7 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
}
if case let .success(.some(identity)) = await identityResult {
state.isVerified = identity.isVerified()
state.isVerified = identity.verificationState == .verified
} else {
MXLog.error("Failed to find the user's identity.")
}

View File

@ -14,8 +14,6 @@ struct UserProfileScreen: View {
var body: some View {
Form {
headerSection
verificationSection
}
.compoundList()
.navigationTitle(L10n.screenRoomMemberDetailsTitle)
@ -84,18 +82,6 @@ struct UserProfileScreen: View {
.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)
})
}
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
if context.viewState.isPresentedModally {
@ -137,13 +123,22 @@ struct UserProfileScreen_Previews: PreviewProvider, TestablePreview {
static func makeViewModel(userID: String) -> UserProfileScreenViewModel {
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))
default:
UserIdentityProxyMock(configuration: .init())
}
return .success(identity)
}
if userID != RoomMemberProxyMock.mockMe.userID {
clientProxyMock.directRoomForUserIDReturnValue = .success("roomID")
}
return UserProfileScreenViewModel(userID: userID,
isPresentedModally: false,
clientProxy: clientProxyMock,

View File

@ -1018,9 +1018,9 @@ class ClientProxy: ClientProxyProtocol {
}
}
func userIdentity(for userID: String) async -> Result<UserIdentity?, ClientProxyError> {
func userIdentity(for userID: String) async -> Result<UserIdentityProxyProtocol?, ClientProxyError> {
do {
return try await .success(client.encryption().userIdentity(userId: userID))
return try await .success(client.encryption().userIdentity(userId: userID).map(UserIdentityProxy.init))
} catch {
MXLog.error("Failed retrieving user identity: \(error)")
return .failure(.sdkError(error))

View File

@ -191,5 +191,5 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func withdrawUserIdentityVerification(_ userID: String) async -> Result<Void, ClientProxyError>
func resetIdentity() async -> Result<IdentityResetHandle?, ClientProxyError>
func userIdentity(for userID: String) async -> Result<UserIdentity?, ClientProxyError>
func userIdentity(for userID: String) async -> Result<UserIdentityProxyProtocol?, ClientProxyError>
}

View File

@ -0,0 +1,26 @@
//
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import MatrixRustSDK
class UserIdentityProxy: UserIdentityProxyProtocol {
private let userIdentity: UserIdentity
init(userIdentity: UserIdentity) {
self.userIdentity = userIdentity
}
var verificationState: UserIdentityVerificationState {
if userIdentity.hasVerificationViolation() {
return .verificationViolation
} else if userIdentity.isVerified() {
return .verified
}
return .notVerified
}
}

View File

@ -0,0 +1,17 @@
//
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
enum UserIdentityVerificationState {
case notVerified
case verified
case verificationViolation
}
// sourcery: AutoMockable
protocol UserIdentityProxyProtocol {
var verificationState: UserIdentityVerificationState { get }
}

View File

@ -571,8 +571,9 @@ class MockScreen: Identifiable {
case .roomMembersListScreenPendingInvites:
let navigationStackCoordinator = NavigationStackCoordinator()
let members: [RoomMemberProxyMock] = [.mockInvitedAlice, .mockBob, .mockCharlie]
let coordinator = RoomMembersListScreenCoordinator(parameters: .init(mediaProvider: MediaProviderMock(configuration: .init()),
let coordinator = RoomMembersListScreenCoordinator(parameters: .init(clientProxy: ClientProxyMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(name: "test", members: members)),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics))
navigationStackCoordinator.setRootCoordinator(coordinator)

View File

@ -1037,6 +1037,12 @@ extension PreviewTests {
}
}
func test_verificationBadge() async throws {
for preview in VerificationBadge_Previews._allPreviews {
try await assertSnapshots(matching: preview)
}
}
func test_videoMediaEventsTimelineView() async throws {
for preview in VideoMediaEventsTimelineView_Previews._allPreviews {
try await assertSnapshots(matching: preview)

Binary file not shown.

View File

@ -40,8 +40,18 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
let sortedMembers: [RoomMemberProxyMock] = [.mockAdmin, .mockModerator, .mockAlice, .mockDan]
XCTAssertEqual(viewModel.state.visibleJoinedMembers, sortedMembers.map(RoomMemberDetails.init))
let sortedMembers: [RoomMemberListScreenEntry] = [
.init(member: .init(withProxy: RoomMemberProxyMock.mockAdmin),
verificationState: .notVerified),
.init(member: .init(withProxy: RoomMemberProxyMock.mockModerator),
verificationState: .notVerified),
.init(member: .init(withProxy: RoomMemberProxyMock.mockAlice),
verificationState: .notVerified),
.init(member: .init(withProxy: RoomMemberProxyMock.mockDan),
verificationState: .notVerified)
]
XCTAssertEqual(viewModel.state.visibleJoinedMembers, sortedMembers)
}
func testSearch() async throws {
@ -125,7 +135,7 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
// When tapping on another user in the list.
let memberDetailsAction = deferFulfillment(viewModel.actions) { $0.isSelectMember }
guard let user = viewModel.state.visibleJoinedMembers.first(where: { $0.role == .user && $0.id != RoomMemberProxyMock.mockMe.userID }) else {
guard let user = viewModel.state.visibleJoinedMembers.first(where: { $0.member.role == .user && $0.member.id != RoomMemberProxyMock.mockMe.userID })?.member else {
XCTFail("Expected to find a regular user.")
return
}
@ -145,7 +155,7 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
// When tapping on a user in the list.
deferred = deferFulfillment(context.$viewState) { $0.bindings.memberToManage != nil }
guard let user = viewModel.state.visibleJoinedMembers.first(where: { $0.role == .user && $0.id != RoomMemberProxyMock.mockMe.userID }) else {
guard let user = viewModel.state.visibleJoinedMembers.first(where: { $0.member.role == .user && $0.member.id != RoomMemberProxyMock.mockMe.userID })?.member else {
XCTFail("Expected to find a regular user.")
return
}
@ -166,7 +176,7 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
// When tapping on a moderator in the list.
deferred = deferFulfillment(context.$viewState) { $0.bindings.memberToManage != nil }
guard let moderator = viewModel.state.visibleJoinedMembers.first(where: { $0.role == .moderator }) else {
guard let moderator = viewModel.state.visibleJoinedMembers.first(where: { $0.member.role == .moderator })?.member else {
XCTFail("Expected to find a moderator.")
return
}
@ -186,7 +196,7 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
// When tapping on another administrator in the list.
let memberDetailsAction = deferFulfillment(viewModel.actions) { $0.isSelectMember }
guard let admin = viewModel.state.visibleJoinedMembers.first(where: { $0.role == .administrator && $0.id != RoomMemberProxyMock.mockMe.userID }) else {
guard let admin = viewModel.state.visibleJoinedMembers.first(where: { $0.member.role == .administrator && $0.member.id != RoomMemberProxyMock.mockMe.userID })?.member else {
XCTFail("Expected to find another admin.")
return
}
@ -205,7 +215,7 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
// When tapping on yourself in the list.
let memberDetailsAction = deferFulfillment(viewModel.actions) { $0.isSelectMember }
guard let ownMember = viewModel.state.visibleJoinedMembers.first(where: { $0.id == RoomMemberProxyMock.mockMe.userID }) else {
guard let ownMember = viewModel.state.visibleJoinedMembers.first(where: { $0.member.id == RoomMemberProxyMock.mockMe.userID })?.member else {
XCTFail("Expected to find own user admin.")
return
}
@ -225,7 +235,7 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
// When tapping on a banned member in the list.
deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil }
guard let bannedMember = viewModel.state.visibleBannedMembers.first else {
guard let bannedMember = viewModel.state.visibleBannedMembers.first?.member else {
XCTFail("Expected to find a banned user.")
return
}
@ -242,7 +252,7 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
let deferred = deferFulfillment(context.$viewState) { !$0.visibleJoinedMembers.isEmpty }
try await deferred.fulfill()
context.send(viewAction: .kickMember(viewModel.state.visibleJoinedMembers[0]))
context.send(viewAction: .kickMember(viewModel.state.visibleJoinedMembers[0].member))
// Calling the mock won't actually change any view state, so sleep instead.
try await Task.sleep(for: .milliseconds(100))
@ -255,7 +265,7 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
let deferred = deferFulfillment(context.$viewState) { !$0.visibleJoinedMembers.isEmpty }
try await deferred.fulfill()
context.send(viewAction: .banMember(viewModel.state.visibleJoinedMembers[0]))
context.send(viewAction: .banMember(viewModel.state.visibleJoinedMembers[0].member))
// Calling the mock won't actually change any view state, so sleep instead.
try await Task.sleep(for: .milliseconds(100))
@ -268,7 +278,7 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
let deferred = deferFulfillment(context.$viewState) { !$0.visibleJoinedMembers.isEmpty }
try await deferred.fulfill()
context.send(viewAction: .unbanMember(viewModel.state.visibleJoinedMembers[0]))
context.send(viewAction: .unbanMember(viewModel.state.visibleJoinedMembers[0].member))
// Calling the mock won't actually change any view state, so sleep instead.
try await Task.sleep(for: .milliseconds(100))
@ -278,7 +288,8 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
private func setup(with members: [RoomMemberProxyMock]) {
roomProxy = JoinedRoomProxyMock(.init(name: "test", members: members))
viewModel = .init(roomProxy: roomProxy,
viewModel = .init(clientProxy: ClientProxyMock(.init()),
roomProxy: roomProxy,
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)

Some files were not shown because too many files have changed in this diff Show More