mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-06 20:38:41 +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 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>?
|
private var replyLoadingTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(initialText: String? = nil,
|
init(initialText: String? = nil,
|
||||||
@@ -192,6 +207,12 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
case .recordVoiceMessage:
|
case .recordVoiceMessage:
|
||||||
MXLog.warning("Ignoring send action while recording a voice message.")
|
MXLog.warning("Ignoring send action while recording a voice message.")
|
||||||
default:
|
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 {
|
if context.composerFormattingEnabled {
|
||||||
actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown,
|
actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown,
|
||||||
html: wysiwygViewModel.content.html,
|
html: wysiwygViewModel.content.html,
|
||||||
@@ -206,13 +227,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
case .cancelReply:
|
case .cancelReply:
|
||||||
set(mode: .default)
|
set(mode: .default)
|
||||||
case .cancelEdit:
|
case .cancelEdit:
|
||||||
if let draft = draftService.loadVolatileDraft() {
|
cancelEdit()
|
||||||
handleLoadDraft(draft)
|
|
||||||
draftService.clearVolatileDraft()
|
|
||||||
} else {
|
|
||||||
set(text: "")
|
|
||||||
set(mode: .default)
|
|
||||||
}
|
|
||||||
case .attach(let attachment):
|
case .attach(let attachment):
|
||||||
state.bindings.composerFocused = false
|
state.bindings.composerFocused = false
|
||||||
actionsSubject.send(.attach(attachment))
|
actionsSubject.send(.attach(attachment))
|
||||||
@@ -265,6 +280,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
} else {
|
} else {
|
||||||
set(text: plainText)
|
set(text: plainText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if state.composerMode.isEdit {
|
||||||
|
originalEditContent = currentContent
|
||||||
|
}
|
||||||
case .setFocus:
|
case .setFocus:
|
||||||
state.bindings.composerFocused = true
|
state.bindings.composerFocused = true
|
||||||
case .removeFocus:
|
case .removeFocus:
|
||||||
@@ -453,6 +472,16 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
intentionalMentions: .init(userIDs: plainComposerContent.mentionedUserIDs, atRoom: plainComposerContent.containsAtRoomMention)))
|
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) {
|
private func processVoiceMessageAction(_ action: ComposerToolbarVoiceMessageAction) {
|
||||||
switch action {
|
switch action {
|
||||||
case .startRecording:
|
case .startRecording:
|
||||||
@@ -576,6 +605,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
guard mode != state.composerMode else { return }
|
guard mode != state.composerMode else { return }
|
||||||
|
|
||||||
state.composerMode = mode
|
state.composerMode = mode
|
||||||
|
originalEditContent = nil
|
||||||
switch mode {
|
switch mode {
|
||||||
case .default:
|
case .default:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ final class ComposerToolbarViewModelTests {
|
|||||||
private var viewModel: ComposerToolbarViewModel!
|
private var viewModel: ComposerToolbarViewModel!
|
||||||
private var completionSuggestionServiceMock: CompletionSuggestionServiceMock!
|
private var completionSuggestionServiceMock: CompletionSuggestionServiceMock!
|
||||||
private var draftServiceMock: ComposerDraftServiceMock!
|
private var draftServiceMock: ComposerDraftServiceMock!
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
setUpViewModel()
|
setUpViewModel()
|
||||||
@@ -546,6 +547,106 @@ final class ComposerToolbarViewModelTests {
|
|||||||
#expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world"))
|
#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
|
@Test
|
||||||
func restoreVolatileDraftWhenClearing() async {
|
func restoreVolatileDraftWhenClearing() async {
|
||||||
await waitForConfirmation("Volatile draft loaded and cleared", expectedCount: 2) { confirmation in
|
await waitForConfirmation("Volatile draft loaded and cleared", expectedCount: 2) { confirmation in
|
||||||
@@ -802,6 +903,18 @@ final class ComposerToolbarViewModelTests {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// 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) {
|
private func setUpViewModel(initialText: String? = nil, loadDraftClosure: (() async -> Result<ComposerDraftProxy?, ComposerDraftServiceError>)? = nil) {
|
||||||
wysiwygViewModel = WysiwygComposerViewModel()
|
wysiwygViewModel = WysiwygComposerViewModel()
|
||||||
completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init())
|
completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init())
|
||||||
|
|||||||
Reference in New Issue
Block a user