Cancel message edit if content remains unchanged

This commit is contained in:
Ghazi Tozri
2026-09-15 22:52:04 +02:00
committed by Stefan Ceriu
parent de13fa5438
commit 5741290471
2 changed files with 150 additions and 7 deletions
@@ -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<Void, Never>?
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
@@ -19,6 +19,7 @@ final class ComposerToolbarViewModelTests {
private var viewModel: ComposerToolbarViewModel!
private var completionSuggestionServiceMock: CompletionSuggestionServiceMock!
private var draftServiceMock: ComposerDraftServiceMock!
private var cancellables = Set<AnyCancellable>()
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 <strong>world</strong>!"))
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<ComposerDraftProxy?, ComposerDraftServiceError>)? = nil) {
wysiwygViewModel = WysiwygComposerViewModel()
completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init())