Warn and block sending on verification violation (#3679)

* feat(crypto): Warn and block sending on verification violation

* fixup: Fix ComposerToolbar previews

* fixup! add ComposerToolBarViewModelTests for canSend

* add new preview tests for verification violations

* Use `deferFulfillment`s in the unit tests.

---------

Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
Valere 2025-01-20 17:29:34 +01:00 committed by GitHub
parent 0fd8df52ab
commit f20847f62b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 419 additions and 22 deletions

View File

@ -4682,6 +4682,76 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
return pinUserIdentityReturnValue
}
}
//MARK: - withdrawUserIdentityVerification
var withdrawUserIdentityVerificationUnderlyingCallsCount = 0
var withdrawUserIdentityVerificationCallsCount: Int {
get {
if Thread.isMainThread {
return withdrawUserIdentityVerificationUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = withdrawUserIdentityVerificationUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
withdrawUserIdentityVerificationUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
withdrawUserIdentityVerificationUnderlyingCallsCount = newValue
}
}
}
}
var withdrawUserIdentityVerificationCalled: Bool {
return withdrawUserIdentityVerificationCallsCount > 0
}
var withdrawUserIdentityVerificationReceivedUserID: String?
var withdrawUserIdentityVerificationReceivedInvocations: [String] = []
var withdrawUserIdentityVerificationUnderlyingReturnValue: Result<Void, ClientProxyError>!
var withdrawUserIdentityVerificationReturnValue: Result<Void, ClientProxyError>! {
get {
if Thread.isMainThread {
return withdrawUserIdentityVerificationUnderlyingReturnValue
} else {
var returnValue: Result<Void, ClientProxyError>? = nil
DispatchQueue.main.sync {
returnValue = withdrawUserIdentityVerificationUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
withdrawUserIdentityVerificationUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
withdrawUserIdentityVerificationUnderlyingReturnValue = newValue
}
}
}
}
var withdrawUserIdentityVerificationClosure: ((String) async -> Result<Void, ClientProxyError>)?
func withdrawUserIdentityVerification(_ userID: String) async -> Result<Void, ClientProxyError> {
withdrawUserIdentityVerificationCallsCount += 1
withdrawUserIdentityVerificationReceivedUserID = userID
DispatchQueue.main.async {
self.withdrawUserIdentityVerificationReceivedInvocations.append(userID)
}
if let withdrawUserIdentityVerificationClosure = withdrawUserIdentityVerificationClosure {
return await withdrawUserIdentityVerificationClosure(userID)
} else {
return withdrawUserIdentityVerificationReturnValue
}
}
//MARK: - resetIdentity
var resetIdentityUnderlyingCallsCount = 0

View File

@ -66,6 +66,8 @@ enum ComposerAttachmentType {
struct ComposerToolbarViewState: BindableState {
var composerMode: ComposerMode = .default
var composerEmpty = true
/// Could be false if sending is disabled in the room
var canSend = true
var suggestions: [SuggestionItem] = []
var audioPlayerState: AudioPlayerState
var audioRecorderState: AudioRecorderState
@ -97,6 +99,10 @@ struct ComposerToolbarViewState: BindableState {
}
var sendButtonDisabled: Bool {
if !canSend {
return true
}
if case .previewVoiceMessage = composerMode {
return false
}

View File

@ -18,8 +18,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
private var initialText: String?
private let wysiwygViewModel: WysiwygComposerViewModel
private let completionSuggestionService: CompletionSuggestionServiceProtocol
private let roomProxy: JoinedRoomProxyProtocol
private let analyticsService: AnalyticsService
private let draftService: ComposerDraftServiceProtocol
private var identityPinningViolations = [String: RoomMemberProxyProtocol]()
private let mentionBuilder: MentionBuilderProtocol
private let attributedStringBuilder: AttributedStringBuilderProtocol
@ -43,6 +45,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
private var replyLoadingTask: Task<Void, Never>?
init(initialText: String? = nil,
roomProxy: JoinedRoomProxyProtocol,
wysiwygViewModel: WysiwygComposerViewModel,
completionSuggestionService: CompletionSuggestionServiceProtocol,
mediaProvider: MediaProviderProtocol,
@ -53,6 +56,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
self.wysiwygViewModel = wysiwygViewModel
self.completionSuggestionService = completionSuggestionService
self.analyticsService = analyticsService
self.roomProxy = roomProxy
draftService = composerDraftService
mentionBuilder = MentionBuilder()
@ -120,6 +124,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
setupMentionsHandling(mentionDisplayHelper: mentionDisplayHelper)
focusComposerIfHardwareKeyboardConnected()
let identityStatusChangesPublisher = roomProxy.identityStatusChangesPublisher.receive(on: DispatchQueue.main)
Task { [weak self] in
for await changes in identityStatusChangesPublisher.values {
guard !Task.isCancelled else {
return
}
await self?.processIdentityStatusChanges(changes)
}
}
.store(in: &cancellables)
}
// MARK: - Public
@ -477,6 +494,25 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
}
}
}
private func processIdentityStatusChanges(_ changes: [IdentityStatusChange]) async {
for change in changes {
switch change.changedTo {
case .verificationViolation:
guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else {
MXLog.error("Failed retrieving room member for identity status change: \(change)")
continue
}
identityPinningViolations[change.userId] = member
default:
// clear
identityPinningViolations[change.userId] = nil
}
}
state.canSend = identityPinningViolations.isEmpty
}
private func set(mode: ComposerMode) {
if state.composerMode.isLoadingReply, state.composerMode.replyEventID != mode.replyEventID {

View File

@ -5,7 +5,9 @@
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Compound
import MatrixRustSDK
import SwiftUI
import WysiwygComposer
@ -44,6 +46,7 @@ struct ComposerToolbar: View {
.offset(y: -frame.height)
}
}
.disabled(!context.viewState.canSend)
.alert(item: $context.alertInfo)
}
@ -297,7 +300,8 @@ struct ComposerToolbar: View {
struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
static let wysiwygViewModel = WysiwygComposerViewModel()
static let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
static let composerViewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
@ -331,6 +335,11 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
ComposerToolbar.replyLoadingPreviewMock(isLoading: false)
}
.previewDisplayName("Reply")
VStack(spacing: 8) {
ComposerToolbar.disabledPreviewMock()
}
.previewDisplayName("Disabled")
}
}
@ -338,7 +347,8 @@ extension ComposerToolbar {
static func mock(focused: Bool = true) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
@ -355,7 +365,8 @@ extension ComposerToolbar {
static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
@ -372,7 +383,8 @@ extension ComposerToolbar {
static func voiceMessageRecordingMock() -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
@ -390,7 +402,8 @@ extension ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
@ -411,7 +424,8 @@ extension ComposerToolbar {
static func replyLoadingPreviewMock(isLoading: Bool) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
@ -430,4 +444,22 @@ extension ComposerToolbar {
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
}
static func disabledPreviewMock() -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock())
model.state.canSend = false
return model
}
return ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
}
}

View File

@ -12,6 +12,8 @@ import WysiwygComposer
struct RoomAttachmentPicker: View {
@ObservedObject var context: ComposerToolbarViewModel.Context
@Environment(\.isEnabled) private var isEnabled
var body: some View {
// Use a menu instead of the popover/sheet shown in Figma because overriding the colour scheme
// results in a rendering bug on 17.1: https://github.com/element-hq/element-x-ios/issues/2157
@ -20,6 +22,9 @@ struct RoomAttachmentPicker: View {
} label: {
CompoundIcon(asset: Asset.Images.composerAttachment, size: .custom(30), relativeTo: .compound.headingLG)
.scaledPadding(7, relativeTo: .compound.headingLG)
.foregroundColor(
isEnabled ? .compound.iconPrimary : .compound.iconDisabled
)
}
.buttonStyle(RoomAttachmentPickerButtonStyle())
.accessibilityLabel(L10n.actionAddToTimeline)
@ -81,7 +86,8 @@ private struct RoomAttachmentPickerButtonStyle: ButtonStyle {
}
struct RoomAttachmentPicker_Previews: PreviewProvider, TestablePreview {
static let viewModel = ComposerToolbarViewModel(wysiwygViewModel: WysiwygComposerViewModel(),
static let viewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: WysiwygComposerViewModel(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,

View File

@ -14,6 +14,8 @@ enum VoiceMessageRecordingButtonMode {
}
struct VoiceMessageRecordingButton: View {
@Environment(\.isEnabled) private var isEnabled
let mode: VoiceMessageRecordingButtonMode
var startRecording: (() -> Void)?
var stopRecording: (() -> Void)?
@ -33,7 +35,9 @@ struct VoiceMessageRecordingButton: View {
switch mode {
case .idle:
CompoundIcon(\.micOn, size: .medium, relativeTo: .compound.headingLG)
.foregroundColor(.compound.iconSecondary)
.foregroundColor(
isEnabled ? .compound.iconSecondary : .compound.iconDisabled
)
.scaledPadding(10, relativeTo: .compound.headingLG)
case .recording:
CompoundIcon(asset: Asset.Images.stopRecording, size: .medium, relativeTo: .compound.headingLG)

View File

@ -91,6 +91,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
maxExpandedHeight: ComposerConstant.maxHeight,
parserStyle: .elementX)
let composerViewModel = ComposerToolbarViewModel(initialText: parameters.sharedText,
roomProxy: parameters.roomProxy,
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: parameters.completionSuggestionService,
mediaProvider: parameters.mediaProvider,

View File

@ -71,10 +71,12 @@ struct RoomScreenViewStateBindings { }
enum RoomScreenFooterViewAction {
case resolvePinViolation(userID: String)
case resolveVerificationViolation(userID: String)
}
enum RoomScreenFooterViewDetails {
case pinViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL)
case verificationViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL)
}
enum PinnedEventsBannerState: Equatable {

View File

@ -25,6 +25,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private let pinnedEventStringBuilder: RoomEventStringBuilder
private var identityPinningViolations = [String: RoomMemberProxyProtocol]()
private var identityVerificationViolations = [String: RoomMemberProxyProtocol]()
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
@ -102,6 +103,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
switch action {
case .resolvePinViolation(let userID):
Task { await resolveIdentityPinningViolation(userID) }
case .resolveVerificationViolation(let userID):
Task { await resolveIdentityVerificationViolation(userID) }
}
case .acceptKnock(let eventID):
Task { await acceptKnock(eventID: eventID) }
@ -209,21 +212,30 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private func processIdentityStatusChanges(_ changes: [IdentityStatusChange]) async {
for change in changes {
switch change.changedTo {
case .pinned:
identityPinningViolations[change.userId] = nil
case .pinViolation:
guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else {
MXLog.error("Failed retrieving room member for identity status change: \(change)")
continue
}
identityPinningViolations[change.userId] = member
case .verificationViolation:
guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else {
MXLog.error("Failed retrieving room member for identity status change: \(change)")
continue
}
identityVerificationViolations[change.userId] = member
default:
break
identityVerificationViolations[change.userId] = nil
identityPinningViolations[change.userId] = nil
}
}
if let member = identityPinningViolations.values.first {
if let member = identityVerificationViolations.values.first {
state.footerDetails = .verificationViolation(member: member,
learnMoreURL: appSettings.identityPinningViolationDetailsURL)
} else if let member = identityPinningViolations.values.first {
state.footerDetails = .pinViolation(member: member,
learnMoreURL: appSettings.identityPinningViolationDetailsURL)
} else {
@ -243,6 +255,18 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
private func resolveIdentityVerificationViolation(_ userID: String) async {
defer {
hideLoadingIndicator()
}
showLoadingIndicator()
if case .failure = await clientProxy.withdrawUserIdentityVerification(userID) {
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError)
}
}
private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) {
var pinnedEventContents = OrderedDictionary<String, AttributedString>()

View File

@ -15,17 +15,25 @@ struct RoomScreenFooterView: View {
var body: some View {
if let details {
ZStack(alignment: .top) {
VStack(spacing: 0) {
Color.compound.borderInfoSubtle
.frame(height: 1)
LinearGradient(colors: [.compound.bgInfoSubtle, .compound.bgCanvasDefault],
startPoint: .top,
endPoint: .bottom)
}
switch details {
case .pinViolation(let member, let learnMoreURL):
VStack(spacing: 0) {
Color.compound.borderInfoSubtle
.frame(height: 1)
LinearGradient(colors: [.compound.bgInfoSubtle, .compound.bgCanvasDefault],
startPoint: .top,
endPoint: .bottom)
}
pinViolation(member: member, learnMoreURL: learnMoreURL)
case .verificationViolation(member: let member, learnMoreURL: let learnMoreURL):
VStack(spacing: 0) {
Color.compound.borderCriticalSubtle
.frame(height: 1)
LinearGradient(colors: [.compound.bgCriticalSubtle, .compound.bgCanvasDefault],
startPoint: .top,
endPoint: .bottom)
}
verificationViolation(member: member, learnMoreURL: learnMoreURL)
}
}
.padding(.top, 8)
@ -60,6 +68,33 @@ struct RoomScreenFooterView: View {
.padding(.bottom, 8)
}
private func verificationViolation(member: RoomMemberProxyProtocol,
learnMoreURL: URL) -> some View {
VStack(spacing: 16) {
HStack(spacing: 16) {
LoadableAvatarImage(url: member.avatarURL,
name: member.disambiguatedDisplayName,
contentID: member.userID,
avatarSize: .user(on: .timeline),
mediaProvider: mediaProvider)
Text(verificationViolationDescriptionWithLearnMoreLink(displayName: member.displayName,
userID: member.userID,
url: learnMoreURL))
.font(.compound.bodyMD)
.foregroundColor(.compound.textCriticalPrimary)
}
Button(L10n.cryptoIdentityChangeWithdrawVerificationAction) {
callback(.resolveVerificationViolation(userID: member.userID))
}
.buttonStyle(.compound(.primary, size: .medium))
}
.padding(.top, 16)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
private func pinViolationDescriptionWithLearnMoreLink(displayName: String?, userID: String, url: URL) -> AttributedString {
let userIDPlaceholder = "{mxid}"
let linkPlaceholder = "{link}"
@ -77,6 +112,23 @@ struct RoomScreenFooterView: View {
return description
}
private func verificationViolationDescriptionWithLearnMoreLink(displayName: String?, userID: String, url: URL) -> AttributedString {
let userIDPlaceholder = "{mxid}"
let linkPlaceholder = "{link}"
let displayName = displayName ?? fallbackDisplayName(userID)
var description = AttributedString(L10n.cryptoIdentityChangeVerificationViolationNew(displayName, userIDPlaceholder, linkPlaceholder))
var userIDString = AttributedString(L10n.cryptoIdentityChangePinViolationNewUserId(userID))
userIDString.bold()
description.replace(userIDPlaceholder, with: userIDString)
var linkString = AttributedString(L10n.actionLearnMore)
linkString.link = url
linkString.bold()
description.replace(linkPlaceholder, with: linkString)
return description
}
private func fallbackDisplayName(_ userID: String) -> String {
guard let localpart = userID.components(separatedBy: ":").first else { return userID }
return String(localpart.trimmingPrefix("@"))
@ -89,10 +141,15 @@ struct RoomScreenFooterView_Previews: PreviewProvider, TestablePreview {
static let noNameDetails: RoomScreenFooterViewDetails = .pinViolation(member: RoomMemberProxyMock.mockNoName,
learnMoreURL: "https://element.io/")
static let verificationViolationDetails: RoomScreenFooterViewDetails = .verificationViolation(member: RoomMemberProxyMock.mockBob,
learnMoreURL: "https://element.io/")
static var previews: some View {
RoomScreenFooterView(details: bobDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in }
.previewDisplayName("With displayname")
RoomScreenFooterView(details: noNameDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in }
.previewDisplayName("Without displayname")
RoomScreenFooterView(details: verificationViolationDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in }
.previewDisplayName("Verification Violation")
}
}

View File

@ -1005,6 +1005,22 @@ class ClientProxy: ClientProxyProtocol {
}
}
func withdrawUserIdentityVerification(_ userID: String) async -> Result<Void, ClientProxyError> {
MXLog.info("Withdrawing current identity verification for user: \(userID)")
do {
guard let userIdentity = try await client.encryption().userIdentity(userId: userID) else {
MXLog.error("Failed retrieving identity for user: \(userID)")
return .failure(.failedRetrievingUserIdentity)
}
return try await .success(userIdentity.withdrawVerification())
} catch {
MXLog.error("Failed withdrawing current identity verification for user: \(error)")
return .failure(.sdkError(error))
}
}
func resetIdentity() async -> Result<IdentityResetHandle?, ClientProxyError> {
do {
return try await .success(client.encryption().resetIdentity())

View File

@ -204,6 +204,7 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func curve25519Base64() async -> String?
func pinUserIdentity(_ userID: String) async -> Result<Void, ClientProxyError>
func withdrawUserIdentityVerification(_ userID: String) async -> Result<Void, ClientProxyError>
func resetIdentity() async -> Result<IdentityResetHandle?, ClientProxyError>
func userIdentity(for userID: String) async -> Result<UserIdentity?, ClientProxyError>

View File

@ -7,6 +7,7 @@
import Combine
@testable import ElementX
import MatrixRustSDK
import XCTest
import WysiwygComposer
@ -93,7 +94,9 @@ class ComposerToolbarViewModelTests: XCTestCase {
let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init())),
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCAvatar, range: .init()))]
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions))
viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
viewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: mockCompletionSuggestionService,
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
@ -632,6 +635,120 @@ class ComposerToolbarViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: sharedText))
}
// MARK: - Identity Violation
func testVerificationViolationDisablesComposer() async throws {
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init())
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test"))
let roomMemberProxyMock = RoomMemberProxyMock(with: .init(userID: "@alice:localhost", membership: .join))
roomProxyMock.getMemberUserIDClosure = { _ in
.success(roomMemberProxyMock)
}
let mockSubject = CurrentValueSubject<[IdentityStatusChange], Never>([])
roomProxyMock.underlyingIdentityStatusChangesPublisher = mockSubject.asCurrentValuePublisher()
viewModel = ComposerToolbarViewModel(roomProxy: roomProxyMock,
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: mockCompletionSuggestionService,
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: draftServiceMock)
var fulfillment = deferFulfillment(viewModel.context.$viewState, message: "Composer is disabled") { $0.canSend == false }
mockSubject.send([IdentityStatusChange(userId: "@alice:localhost", changedTo: .verificationViolation)])
try await fulfillment.fulfill()
fulfillment = deferFulfillment(viewModel.context.$viewState, message: "Composer is enabled") { $0.canSend == true }
mockSubject.send([IdentityStatusChange(userId: "@alice:localhost", changedTo: .pinned)])
try await fulfillment.fulfill()
}
func testMultipleViolation() async throws {
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init())
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test"))
let aliceRoomMemberProxyMock = RoomMemberProxyMock(with: .init(userID: "@alice:localhost", membership: .join))
let bobRoomMemberProxyMock = RoomMemberProxyMock(with: .init(userID: "@bob:localhost", membership: .join))
roomProxyMock.getMemberUserIDClosure = { userId in
if userId == "@alice:localhost" {
return .success(aliceRoomMemberProxyMock)
} else if userId == "@bob:localhost" {
return .success(bobRoomMemberProxyMock)
} else {
return .failure(.sdkError(ClientProxyMockError.generic))
}
}
// There are 2 violations, ensure that resolving the first one is not enough
let mockSubject = CurrentValueSubject<[IdentityStatusChange], Never>([
IdentityStatusChange(userId: "@alice:localhost", changedTo: .verificationViolation),
IdentityStatusChange(userId: "@bob:localhost", changedTo: .verificationViolation)
])
roomProxyMock.underlyingIdentityStatusChangesPublisher = mockSubject.asCurrentValuePublisher()
viewModel = ComposerToolbarViewModel(roomProxy: roomProxyMock,
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: mockCompletionSuggestionService,
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: draftServiceMock)
var fulfillment = deferFulfillment(viewModel.context.$viewState, message: "Composer is disabled") { $0.canSend == false }
mockSubject.send([IdentityStatusChange(userId: "@alice:localhost", changedTo: .verificationViolation)])
try await fulfillment.fulfill()
fulfillment = deferFulfillment(viewModel.context.$viewState, message: "Composer is still disabled") { $0.canSend == false }
mockSubject.send([IdentityStatusChange(userId: "@alice:localhost", changedTo: .pinned)])
try await fulfillment.fulfill()
fulfillment = deferFulfillment(viewModel.context.$viewState, message: "Composer is now enabled") { $0.canSend == true }
mockSubject.send([IdentityStatusChange(userId: "@bob:localhost", changedTo: .pinned)])
try await fulfillment.fulfill()
}
func testPinViolationDoesNotDisableComposer() {
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init())
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test"))
let roomMemberProxyMock = RoomMemberProxyMock(with: .init(userID: "@alice:localhost", membership: .join))
roomProxyMock.getMemberUserIDClosure = { _ in
.success(roomMemberProxyMock)
}
roomProxyMock.underlyingIdentityStatusChangesPublisher = CurrentValueSubject([IdentityStatusChange(userId: "@alice:localhost", changedTo: .pinViolation)]).asCurrentValuePublisher()
viewModel = ComposerToolbarViewModel(roomProxy: roomProxyMock,
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: mockCompletionSuggestionService,
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: draftServiceMock)
let expectation = expectation(description: "Composer should be enabled")
let cancellable = viewModel
.context
.$viewState
.map(\.canSend)
.sink { canSend in
if canSend {
expectation.fulfill()
}
}
wait(for: [expectation], timeout: 2.0)
cancellable.cancel()
}
// MARK: - Helpers
private func setUpViewModel(initialText: String? = nil, loadDraftClosure: (() async -> Result<ComposerDraftProxy?, ComposerDraftServiceError>)? = nil) {
@ -643,6 +760,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
}
viewModel = ComposerToolbarViewModel(initialText: initialText,
roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: completionSuggestionServiceMock,
mediaProvider: MediaProviderMock(configuration: .init()),