mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20: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:
@@ -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,
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user