mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Cancel message edit if content remains unchanged
This commit is contained in:
committed by
Stefan Ceriu
parent
de13fa5438
commit
5741290471
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user