From 574129047159c391ab24b83d675c267f054a6af4 Mon Sep 17 00:00:00 2001 From: Ghazi Tozri Date: Tue, 15 Sep 2026 22:52:04 +0200 Subject: [PATCH] Cancel message edit if content remains unchanged --- .../ComposerToolbarViewModel.swift | 44 +++++-- .../ComposerToolbarViewModelTests.swift | 113 ++++++++++++++++++ 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift index 1e43c88f5..d61be3944 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift @@ -42,6 +42,21 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool private var currentLinkData: WysiwygLinkData? + private struct ComposerContent: Equatable { + let plain: String + let html: String? + } + + private var originalEditContent: ComposerContent? + + private var currentContent: ComposerContent { + if context.composerFormattingEnabled { + .init(plain: wysiwygViewModel.content.markdown, html: wysiwygViewModel.content.html) + } else { + .init(plain: plainComposerContent.text, html: nil) + } + } + private var replyLoadingTask: Task? init(initialText: String? = nil, @@ -192,6 +207,12 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool case .recordVoiceMessage: MXLog.warning("Ignoring send action while recording a voice message.") default: + if state.composerMode.isEdit, currentContent == originalEditContent { + MXLog.info("Edit content is unchanged, cancelling the edit instead of sending it.") + cancelEdit() + return + } + if context.composerFormattingEnabled { actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown, html: wysiwygViewModel.content.html, @@ -206,13 +227,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool case .cancelReply: set(mode: .default) case .cancelEdit: - if let draft = draftService.loadVolatileDraft() { - handleLoadDraft(draft) - draftService.clearVolatileDraft() - } else { - set(text: "") - set(mode: .default) - } + cancelEdit() case .attach(let attachment): state.bindings.composerFocused = false actionsSubject.send(.attach(attachment)) @@ -265,6 +280,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } else { set(text: plainText) } + + if state.composerMode.isEdit { + originalEditContent = currentContent + } case .setFocus: state.bindings.composerFocused = true case .removeFocus: @@ -453,6 +472,16 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool intentionalMentions: .init(userIDs: plainComposerContent.mentionedUserIDs, atRoom: plainComposerContent.containsAtRoomMention))) } + private func cancelEdit() { + if let draft = draftService.loadVolatileDraft() { + handleLoadDraft(draft) + draftService.clearVolatileDraft() + } else { + set(text: "") + set(mode: .default) + } + } + private func processVoiceMessageAction(_ action: ComposerToolbarVoiceMessageAction) { switch action { case .startRecording: @@ -576,6 +605,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool guard mode != state.composerMode else { return } state.composerMode = mode + originalEditContent = nil switch mode { case .default: break diff --git a/UnitTests/Sources/ComposerToolbarViewModelTests.swift b/UnitTests/Sources/ComposerToolbarViewModelTests.swift index 57c0d3a9c..cc043a3d6 100644 --- a/UnitTests/Sources/ComposerToolbarViewModelTests.swift +++ b/UnitTests/Sources/ComposerToolbarViewModelTests.swift @@ -19,6 +19,7 @@ final class ComposerToolbarViewModelTests { private var viewModel: ComposerToolbarViewModel! private var completionSuggestionServiceMock: CompletionSuggestionServiceMock! private var draftServiceMock: ComposerDraftServiceMock! + private var cancellables = Set() init() { setUpViewModel() @@ -546,6 +547,106 @@ final class ComposerToolbarViewModelTests { #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world")) } + @Test + func sendingUnchangedPlainTextEditCancelsTheEdit() { + viewModel.context.composerFormattingEnabled = false + let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default) + viewModel.process(timelineAction: .setMode(mode: mode)) + viewModel.process(timelineAction: .setText(plainText: "Hello world!", htmlText: nil)) + + let sentMessages = collectSentMessages() + viewModel.process(viewAction: .sendMessage) + + #expect(sentMessages.value.isEmpty) + #expect(viewModel.state.composerMode == .default) + #expect(viewModel.context.plainComposerText.string.isEmpty) + } + + @Test + func sendingUnchangedRichTextEditCancelsTheEdit() { + viewModel.context.composerFormattingEnabled = true + let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default) + viewModel.process(timelineAction: .setMode(mode: mode)) + viewModel.process(timelineAction: .setText(plainText: "Hello **world**!", htmlText: "Hello world!")) + viewModel.state.composerEmpty = false + + let sentMessages = collectSentMessages() + viewModel.process(viewAction: .sendMessage) + + #expect(sentMessages.value.isEmpty) + #expect(viewModel.state.composerMode == .default) + #expect(wysiwygViewModel.content.markdown.isEmpty) + } + + @Test + func sendingUnchangedEditRestoresTheVolatileDraft() { + viewModel.context.composerFormattingEnabled = false + viewModel.context.plainComposerText = .init(string: "Work in progress") + let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default) + viewModel.process(timelineAction: .setMode(mode: mode)) + viewModel.process(timelineAction: .setText(plainText: "Hello world!", htmlText: nil)) + draftServiceMock.loadVolatileDraftReturnValue = .init(plainText: "Work in progress", htmlText: nil, draftType: .newMessage) + + let sentMessages = collectSentMessages() + viewModel.process(viewAction: .sendMessage) + + #expect(sentMessages.value.isEmpty) + #expect(viewModel.state.composerMode == .default) + #expect(viewModel.context.plainComposerText.string == "Work in progress") + #expect(draftServiceMock.clearVolatileDraftCallsCount == 1) + } + + @Test + func sendingUnchangedCaptionEditCancelsTheEdit() { + viewModel.context.composerFormattingEnabled = false + let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .editCaption) + viewModel.process(timelineAction: .setMode(mode: mode)) + viewModel.process(timelineAction: .setText(plainText: "A caption", htmlText: nil)) + + let sentMessages = collectSentMessages() + viewModel.process(viewAction: .sendMessage) + + #expect(sentMessages.value.isEmpty) + #expect(viewModel.state.composerMode == .default) + } + + @Test + func sendingChangedEditSendsTheEdit() async throws { + viewModel.context.composerFormattingEnabled = false + let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default) + viewModel.process(timelineAction: .setMode(mode: mode)) + viewModel.process(timelineAction: .setText(plainText: "Hello world!", htmlText: nil)) + viewModel.context.plainComposerText = .init(string: "Hello world, edited!") + + let deferred = deferFulfillment(viewModel.actions) { action in + guard case let .sendMessage(plainText, _, sentMode, _) = action else { return false } + return plainText == "Hello world, edited!" && sentMode == mode + } + viewModel.process(viewAction: .sendMessage) + try await deferred.fulfill() + } + + @Test + func sendingUnchangedEditAfterCancellingAndEditingAgainSendsTheEdit() async throws { + // Editing a different message must reset the remembered original content. + viewModel.context.composerFormattingEnabled = false + viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID("first"), type: .default))) + viewModel.process(timelineAction: .setText(plainText: "Hello world!", htmlText: nil)) + viewModel.process(viewAction: .cancelEdit) + + let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("second"), type: .default) + viewModel.process(timelineAction: .setMode(mode: mode)) + viewModel.process(timelineAction: .setText(plainText: "Something else", htmlText: nil)) + viewModel.context.plainComposerText = .init(string: "Hello world!") + + let deferred = deferFulfillment(viewModel.actions) { action in + guard case let .sendMessage(plainText, _, sentMode, _) = action else { return false } + return plainText == "Hello world!" && sentMode == mode + } + viewModel.process(viewAction: .sendMessage) + try await deferred.fulfill() + } + @Test func restoreVolatileDraftWhenClearing() async { await waitForConfirmation("Volatile draft loaded and cleared", expectedCount: 2) { confirmation in @@ -802,6 +903,18 @@ final class ComposerToolbarViewModelTests { // MARK: - Helpers + private func collectSentMessages() -> CurrentValueSubject<[ComposerToolbarViewModelAction], Never> { + let subject = CurrentValueSubject<[ComposerToolbarViewModelAction], Never>([]) + viewModel.actions + .filter { action in + guard case .sendMessage = action else { return false } + return true + } + .sink { subject.value.append($0) } + .store(in: &cancellables) + return subject + } + private func setUpViewModel(initialText: String? = nil, loadDraftClosure: (() async -> Result)? = nil) { wysiwygViewModel = WysiwygComposerViewModel() completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init())