mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 13:37:11 +00:00
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:
parent
0fd8df52ab
commit
f20847f62b
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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: [])
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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>()
|
||||
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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>
|
||||
|
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_composerToolbar-iPad-en-GB.Disabled.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_composerToolbar-iPad-en-GB.Disabled.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_composerToolbar-iPad-pseudo.Disabled.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_composerToolbar-iPad-pseudo.Disabled.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-16-en-GB.Disabled.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-16-en-GB.Disabled.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-16-pseudo.Disabled.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-16-pseudo.Disabled.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPad-en-GB.Verification-Violation.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPad-en-GB.Verification-Violation.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPad-pseudo.Verification-Violation.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPad-pseudo.Verification-Violation.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPhone-16-en-GB.Verification-Violation.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPhone-16-en-GB.Verification-Violation.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPhone-16-pseudo.Verification-Violation.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPhone-16-pseudo.Verification-Violation.png
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -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()),
|
||||
|
Loading…
x
Reference in New Issue
Block a user