mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +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
|
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
|
//MARK: - resetIdentity
|
||||||
|
|
||||||
var resetIdentityUnderlyingCallsCount = 0
|
var resetIdentityUnderlyingCallsCount = 0
|
||||||
|
@ -66,6 +66,8 @@ enum ComposerAttachmentType {
|
|||||||
struct ComposerToolbarViewState: BindableState {
|
struct ComposerToolbarViewState: BindableState {
|
||||||
var composerMode: ComposerMode = .default
|
var composerMode: ComposerMode = .default
|
||||||
var composerEmpty = true
|
var composerEmpty = true
|
||||||
|
/// Could be false if sending is disabled in the room
|
||||||
|
var canSend = true
|
||||||
var suggestions: [SuggestionItem] = []
|
var suggestions: [SuggestionItem] = []
|
||||||
var audioPlayerState: AudioPlayerState
|
var audioPlayerState: AudioPlayerState
|
||||||
var audioRecorderState: AudioRecorderState
|
var audioRecorderState: AudioRecorderState
|
||||||
@ -97,6 +99,10 @@ struct ComposerToolbarViewState: BindableState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sendButtonDisabled: Bool {
|
var sendButtonDisabled: Bool {
|
||||||
|
if !canSend {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if case .previewVoiceMessage = composerMode {
|
if case .previewVoiceMessage = composerMode {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -18,8 +18,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
private var initialText: String?
|
private var initialText: String?
|
||||||
private let wysiwygViewModel: WysiwygComposerViewModel
|
private let wysiwygViewModel: WysiwygComposerViewModel
|
||||||
private let completionSuggestionService: CompletionSuggestionServiceProtocol
|
private let completionSuggestionService: CompletionSuggestionServiceProtocol
|
||||||
|
private let roomProxy: JoinedRoomProxyProtocol
|
||||||
private let analyticsService: AnalyticsService
|
private let analyticsService: AnalyticsService
|
||||||
private let draftService: ComposerDraftServiceProtocol
|
private let draftService: ComposerDraftServiceProtocol
|
||||||
|
private var identityPinningViolations = [String: RoomMemberProxyProtocol]()
|
||||||
|
|
||||||
private let mentionBuilder: MentionBuilderProtocol
|
private let mentionBuilder: MentionBuilderProtocol
|
||||||
private let attributedStringBuilder: AttributedStringBuilderProtocol
|
private let attributedStringBuilder: AttributedStringBuilderProtocol
|
||||||
@ -43,6 +45,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
private var replyLoadingTask: Task<Void, Never>?
|
private var replyLoadingTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(initialText: String? = nil,
|
init(initialText: String? = nil,
|
||||||
|
roomProxy: JoinedRoomProxyProtocol,
|
||||||
wysiwygViewModel: WysiwygComposerViewModel,
|
wysiwygViewModel: WysiwygComposerViewModel,
|
||||||
completionSuggestionService: CompletionSuggestionServiceProtocol,
|
completionSuggestionService: CompletionSuggestionServiceProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
mediaProvider: MediaProviderProtocol,
|
||||||
@ -53,6 +56,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
self.wysiwygViewModel = wysiwygViewModel
|
self.wysiwygViewModel = wysiwygViewModel
|
||||||
self.completionSuggestionService = completionSuggestionService
|
self.completionSuggestionService = completionSuggestionService
|
||||||
self.analyticsService = analyticsService
|
self.analyticsService = analyticsService
|
||||||
|
self.roomProxy = roomProxy
|
||||||
draftService = composerDraftService
|
draftService = composerDraftService
|
||||||
|
|
||||||
mentionBuilder = MentionBuilder()
|
mentionBuilder = MentionBuilder()
|
||||||
@ -120,6 +124,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
|
|
||||||
setupMentionsHandling(mentionDisplayHelper: mentionDisplayHelper)
|
setupMentionsHandling(mentionDisplayHelper: mentionDisplayHelper)
|
||||||
focusComposerIfHardwareKeyboardConnected()
|
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
|
// 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) {
|
private func set(mode: ComposerMode) {
|
||||||
if state.composerMode.isLoadingReply, state.composerMode.replyEventID != mode.replyEventID {
|
if state.composerMode.isLoadingReply, state.composerMode.replyEventID != mode.replyEventID {
|
||||||
|
@ -5,7 +5,9 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import Compound
|
import Compound
|
||||||
|
import MatrixRustSDK
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WysiwygComposer
|
import WysiwygComposer
|
||||||
|
|
||||||
@ -44,6 +46,7 @@ struct ComposerToolbar: View {
|
|||||||
.offset(y: -frame.height)
|
.offset(y: -frame.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(!context.viewState.canSend)
|
||||||
.alert(item: $context.alertInfo)
|
.alert(item: $context.alertInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,7 +300,8 @@ struct ComposerToolbar: View {
|
|||||||
|
|
||||||
struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
|
struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
|
||||||
static let wysiwygViewModel = WysiwygComposerViewModel()
|
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)),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)),
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||||
@ -331,6 +335,11 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
|
|||||||
ComposerToolbar.replyLoadingPreviewMock(isLoading: false)
|
ComposerToolbar.replyLoadingPreviewMock(isLoading: false)
|
||||||
}
|
}
|
||||||
.previewDisplayName("Reply")
|
.previewDisplayName("Reply")
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ComposerToolbar.disabledPreviewMock()
|
||||||
|
}
|
||||||
|
.previewDisplayName("Disabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -338,7 +347,8 @@ extension ComposerToolbar {
|
|||||||
static func mock(focused: Bool = true) -> ComposerToolbar {
|
static func mock(focused: Bool = true) -> ComposerToolbar {
|
||||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||||
var composerViewModel: ComposerToolbarViewModel {
|
var composerViewModel: ComposerToolbarViewModel {
|
||||||
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
|
||||||
|
wysiwygViewModel: wysiwygViewModel,
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||||
@ -355,7 +365,8 @@ extension ComposerToolbar {
|
|||||||
static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar {
|
static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar {
|
||||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||||
var composerViewModel: ComposerToolbarViewModel {
|
var composerViewModel: ComposerToolbarViewModel {
|
||||||
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
|
||||||
|
wysiwygViewModel: wysiwygViewModel,
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||||
@ -372,7 +383,8 @@ extension ComposerToolbar {
|
|||||||
static func voiceMessageRecordingMock() -> ComposerToolbar {
|
static func voiceMessageRecordingMock() -> ComposerToolbar {
|
||||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||||
var composerViewModel: ComposerToolbarViewModel {
|
var composerViewModel: ComposerToolbarViewModel {
|
||||||
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
|
||||||
|
wysiwygViewModel: wysiwygViewModel,
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||||
@ -390,7 +402,8 @@ extension ComposerToolbar {
|
|||||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||||
let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
|
let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
|
||||||
var composerViewModel: ComposerToolbarViewModel {
|
var composerViewModel: ComposerToolbarViewModel {
|
||||||
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
|
||||||
|
wysiwygViewModel: wysiwygViewModel,
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||||
@ -411,7 +424,8 @@ extension ComposerToolbar {
|
|||||||
static func replyLoadingPreviewMock(isLoading: Bool) -> ComposerToolbar {
|
static func replyLoadingPreviewMock(isLoading: Bool) -> ComposerToolbar {
|
||||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||||
var composerViewModel: ComposerToolbarViewModel {
|
var composerViewModel: ComposerToolbarViewModel {
|
||||||
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
|
||||||
|
wysiwygViewModel: wysiwygViewModel,
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||||
@ -430,4 +444,22 @@ extension ComposerToolbar {
|
|||||||
wysiwygViewModel: wysiwygViewModel,
|
wysiwygViewModel: wysiwygViewModel,
|
||||||
keyCommands: [])
|
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 {
|
struct RoomAttachmentPicker: View {
|
||||||
@ObservedObject var context: ComposerToolbarViewModel.Context
|
@ObservedObject var context: ComposerToolbarViewModel.Context
|
||||||
|
|
||||||
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// Use a menu instead of the popover/sheet shown in Figma because overriding the colour scheme
|
// 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
|
// 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: {
|
} label: {
|
||||||
CompoundIcon(asset: Asset.Images.composerAttachment, size: .custom(30), relativeTo: .compound.headingLG)
|
CompoundIcon(asset: Asset.Images.composerAttachment, size: .custom(30), relativeTo: .compound.headingLG)
|
||||||
.scaledPadding(7, relativeTo: .compound.headingLG)
|
.scaledPadding(7, relativeTo: .compound.headingLG)
|
||||||
|
.foregroundColor(
|
||||||
|
isEnabled ? .compound.iconPrimary : .compound.iconDisabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(RoomAttachmentPickerButtonStyle())
|
.buttonStyle(RoomAttachmentPickerButtonStyle())
|
||||||
.accessibilityLabel(L10n.actionAddToTimeline)
|
.accessibilityLabel(L10n.actionAddToTimeline)
|
||||||
@ -81,7 +86,8 @@ private struct RoomAttachmentPickerButtonStyle: ButtonStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct RoomAttachmentPicker_Previews: PreviewProvider, TestablePreview {
|
struct RoomAttachmentPicker_Previews: PreviewProvider, TestablePreview {
|
||||||
static let viewModel = ComposerToolbarViewModel(wysiwygViewModel: WysiwygComposerViewModel(),
|
static let viewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
|
||||||
|
wysiwygViewModel: WysiwygComposerViewModel(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||||
|
@ -14,6 +14,8 @@ enum VoiceMessageRecordingButtonMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct VoiceMessageRecordingButton: View {
|
struct VoiceMessageRecordingButton: View {
|
||||||
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
|
||||||
let mode: VoiceMessageRecordingButtonMode
|
let mode: VoiceMessageRecordingButtonMode
|
||||||
var startRecording: (() -> Void)?
|
var startRecording: (() -> Void)?
|
||||||
var stopRecording: (() -> Void)?
|
var stopRecording: (() -> Void)?
|
||||||
@ -33,7 +35,9 @@ struct VoiceMessageRecordingButton: View {
|
|||||||
switch mode {
|
switch mode {
|
||||||
case .idle:
|
case .idle:
|
||||||
CompoundIcon(\.micOn, size: .medium, relativeTo: .compound.headingLG)
|
CompoundIcon(\.micOn, size: .medium, relativeTo: .compound.headingLG)
|
||||||
.foregroundColor(.compound.iconSecondary)
|
.foregroundColor(
|
||||||
|
isEnabled ? .compound.iconSecondary : .compound.iconDisabled
|
||||||
|
)
|
||||||
.scaledPadding(10, relativeTo: .compound.headingLG)
|
.scaledPadding(10, relativeTo: .compound.headingLG)
|
||||||
case .recording:
|
case .recording:
|
||||||
CompoundIcon(asset: Asset.Images.stopRecording, size: .medium, relativeTo: .compound.headingLG)
|
CompoundIcon(asset: Asset.Images.stopRecording, size: .medium, relativeTo: .compound.headingLG)
|
||||||
|
@ -91,6 +91,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
|||||||
maxExpandedHeight: ComposerConstant.maxHeight,
|
maxExpandedHeight: ComposerConstant.maxHeight,
|
||||||
parserStyle: .elementX)
|
parserStyle: .elementX)
|
||||||
let composerViewModel = ComposerToolbarViewModel(initialText: parameters.sharedText,
|
let composerViewModel = ComposerToolbarViewModel(initialText: parameters.sharedText,
|
||||||
|
roomProxy: parameters.roomProxy,
|
||||||
wysiwygViewModel: wysiwygViewModel,
|
wysiwygViewModel: wysiwygViewModel,
|
||||||
completionSuggestionService: parameters.completionSuggestionService,
|
completionSuggestionService: parameters.completionSuggestionService,
|
||||||
mediaProvider: parameters.mediaProvider,
|
mediaProvider: parameters.mediaProvider,
|
||||||
|
@ -71,10 +71,12 @@ struct RoomScreenViewStateBindings { }
|
|||||||
|
|
||||||
enum RoomScreenFooterViewAction {
|
enum RoomScreenFooterViewAction {
|
||||||
case resolvePinViolation(userID: String)
|
case resolvePinViolation(userID: String)
|
||||||
|
case resolveVerificationViolation(userID: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RoomScreenFooterViewDetails {
|
enum RoomScreenFooterViewDetails {
|
||||||
case pinViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL)
|
case pinViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL)
|
||||||
|
case verificationViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PinnedEventsBannerState: Equatable {
|
enum PinnedEventsBannerState: Equatable {
|
||||||
|
@ -25,6 +25,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
private let pinnedEventStringBuilder: RoomEventStringBuilder
|
private let pinnedEventStringBuilder: RoomEventStringBuilder
|
||||||
|
|
||||||
private var identityPinningViolations = [String: RoomMemberProxyProtocol]()
|
private var identityPinningViolations = [String: RoomMemberProxyProtocol]()
|
||||||
|
private var identityVerificationViolations = [String: RoomMemberProxyProtocol]()
|
||||||
|
|
||||||
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
|
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
|
||||||
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
|
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
|
||||||
@ -102,6 +103,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
switch action {
|
switch action {
|
||||||
case .resolvePinViolation(let userID):
|
case .resolvePinViolation(let userID):
|
||||||
Task { await resolveIdentityPinningViolation(userID) }
|
Task { await resolveIdentityPinningViolation(userID) }
|
||||||
|
case .resolveVerificationViolation(let userID):
|
||||||
|
Task { await resolveIdentityVerificationViolation(userID) }
|
||||||
}
|
}
|
||||||
case .acceptKnock(let eventID):
|
case .acceptKnock(let eventID):
|
||||||
Task { await acceptKnock(eventID: eventID) }
|
Task { await acceptKnock(eventID: eventID) }
|
||||||
@ -209,21 +212,30 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
private func processIdentityStatusChanges(_ changes: [IdentityStatusChange]) async {
|
private func processIdentityStatusChanges(_ changes: [IdentityStatusChange]) async {
|
||||||
for change in changes {
|
for change in changes {
|
||||||
switch change.changedTo {
|
switch change.changedTo {
|
||||||
case .pinned:
|
|
||||||
identityPinningViolations[change.userId] = nil
|
|
||||||
case .pinViolation:
|
case .pinViolation:
|
||||||
guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else {
|
guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else {
|
||||||
MXLog.error("Failed retrieving room member for identity status change: \(change)")
|
MXLog.error("Failed retrieving room member for identity status change: \(change)")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
identityPinningViolations[change.userId] = member
|
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:
|
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,
|
state.footerDetails = .pinViolation(member: member,
|
||||||
learnMoreURL: appSettings.identityPinningViolationDetailsURL)
|
learnMoreURL: appSettings.identityPinningViolationDetailsURL)
|
||||||
} else {
|
} 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]) {
|
private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) {
|
||||||
var pinnedEventContents = OrderedDictionary<String, AttributedString>()
|
var pinnedEventContents = OrderedDictionary<String, AttributedString>()
|
||||||
|
|
||||||
|
@ -15,17 +15,25 @@ struct RoomScreenFooterView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
if let details {
|
if let details {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
VStack(spacing: 0) {
|
|
||||||
Color.compound.borderInfoSubtle
|
|
||||||
.frame(height: 1)
|
|
||||||
LinearGradient(colors: [.compound.bgInfoSubtle, .compound.bgCanvasDefault],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch details {
|
switch details {
|
||||||
case .pinViolation(let member, let learnMoreURL):
|
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)
|
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)
|
.padding(.top, 8)
|
||||||
@ -60,6 +68,33 @@ struct RoomScreenFooterView: View {
|
|||||||
.padding(.bottom, 8)
|
.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 {
|
private func pinViolationDescriptionWithLearnMoreLink(displayName: String?, userID: String, url: URL) -> AttributedString {
|
||||||
let userIDPlaceholder = "{mxid}"
|
let userIDPlaceholder = "{mxid}"
|
||||||
let linkPlaceholder = "{link}"
|
let linkPlaceholder = "{link}"
|
||||||
@ -77,6 +112,23 @@ struct RoomScreenFooterView: View {
|
|||||||
return description
|
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 {
|
private func fallbackDisplayName(_ userID: String) -> String {
|
||||||
guard let localpart = userID.components(separatedBy: ":").first else { return userID }
|
guard let localpart = userID.components(separatedBy: ":").first else { return userID }
|
||||||
return String(localpart.trimmingPrefix("@"))
|
return String(localpart.trimmingPrefix("@"))
|
||||||
@ -89,10 +141,15 @@ struct RoomScreenFooterView_Previews: PreviewProvider, TestablePreview {
|
|||||||
static let noNameDetails: RoomScreenFooterViewDetails = .pinViolation(member: RoomMemberProxyMock.mockNoName,
|
static let noNameDetails: RoomScreenFooterViewDetails = .pinViolation(member: RoomMemberProxyMock.mockNoName,
|
||||||
learnMoreURL: "https://element.io/")
|
learnMoreURL: "https://element.io/")
|
||||||
|
|
||||||
|
static let verificationViolationDetails: RoomScreenFooterViewDetails = .verificationViolation(member: RoomMemberProxyMock.mockBob,
|
||||||
|
learnMoreURL: "https://element.io/")
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
RoomScreenFooterView(details: bobDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in }
|
RoomScreenFooterView(details: bobDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in }
|
||||||
.previewDisplayName("With displayname")
|
.previewDisplayName("With displayname")
|
||||||
RoomScreenFooterView(details: noNameDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in }
|
RoomScreenFooterView(details: noNameDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in }
|
||||||
.previewDisplayName("Without displayname")
|
.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> {
|
func resetIdentity() async -> Result<IdentityResetHandle?, ClientProxyError> {
|
||||||
do {
|
do {
|
||||||
return try await .success(client.encryption().resetIdentity())
|
return try await .success(client.encryption().resetIdentity())
|
||||||
|
@ -204,6 +204,7 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
|
|||||||
func curve25519Base64() async -> String?
|
func curve25519Base64() async -> String?
|
||||||
|
|
||||||
func pinUserIdentity(_ userID: String) async -> Result<Void, ClientProxyError>
|
func pinUserIdentity(_ userID: String) async -> Result<Void, ClientProxyError>
|
||||||
|
func withdrawUserIdentityVerification(_ userID: String) async -> Result<Void, ClientProxyError>
|
||||||
func resetIdentity() async -> Result<IdentityResetHandle?, ClientProxyError>
|
func resetIdentity() async -> Result<IdentityResetHandle?, ClientProxyError>
|
||||||
|
|
||||||
func userIdentity(for userID: String) async -> Result<UserIdentity?, 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
|
import Combine
|
||||||
@testable import ElementX
|
@testable import ElementX
|
||||||
|
import MatrixRustSDK
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
import WysiwygComposer
|
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())),
|
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()))]
|
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCAvatar, range: .init()))]
|
||||||
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions))
|
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions))
|
||||||
viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
|
||||||
|
viewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
|
||||||
|
wysiwygViewModel: wysiwygViewModel,
|
||||||
completionSuggestionService: mockCompletionSuggestionService,
|
completionSuggestionService: mockCompletionSuggestionService,
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||||
@ -632,6 +635,120 @@ class ComposerToolbarViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: sharedText))
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func setUpViewModel(initialText: String? = nil, loadDraftClosure: (() async -> Result<ComposerDraftProxy?, ComposerDraftServiceError>)? = nil) {
|
private func setUpViewModel(initialText: String? = nil, loadDraftClosure: (() async -> Result<ComposerDraftProxy?, ComposerDraftServiceError>)? = nil) {
|
||||||
@ -643,6 +760,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModel = ComposerToolbarViewModel(initialText: initialText,
|
viewModel = ComposerToolbarViewModel(initialText: initialText,
|
||||||
|
roomProxy: JoinedRoomProxyMock(.init()),
|
||||||
wysiwygViewModel: wysiwygViewModel,
|
wysiwygViewModel: wysiwygViewModel,
|
||||||
completionSuggestionService: completionSuggestionServiceMock,
|
completionSuggestionService: completionSuggestionServiceMock,
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user