diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 7bd6bd1867..d0e50d114c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -60,6 +60,7 @@ import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.draft.ComposerDraft import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.timeline.TimelineException import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.map @@ -436,7 +437,14 @@ class MessageComposerPresenter @Inject constructor( val eventId = capturedMode.eventId val transactionId = capturedMode.transactionId timelineController.invokeOnCurrentTimeline { + // First try to edit the message in the current timeline editMessage(eventId, transactionId, message.markdown, message.html, message.mentions) + .onFailure { cause -> + if (cause is TimelineException.EventNotFound && eventId != null) { + // if the event is not found in the timeline, try to edit the message directly + room.editMessage(eventId, message.markdown, message.html, message.mentions) + } + } } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index b944ea3d95..dcb09fb7c8 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.draft.ComposerDraft import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType +import io.element.android.libraries.matrix.api.timeline.TimelineException import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -417,6 +418,67 @@ class MessageComposerPresenterTest { } } + @Test + fun `present - edit sent message event not found`() = runTest { + val timelineEditMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> + Result.failure(TimelineException.EventNotFound) + } + val timeline = FakeTimeline().apply { + this.editMessageLambda = timelineEditMessageLambda + } + val roomEditMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List -> + Result.success(Unit) + } + val fakeMatrixRoom = FakeMatrixRoom( + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) } + ).apply { + this.editMessageLambda = roomEditMessageLambda + } + val presenter = createPresenter( + this, + fakeMatrixRoom, + ) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + remember(state, state.textEditorState.messageHtml()) { state } + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") + val mode = anEditMode() + initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + val withMessageState = awaitItem() + assertThat(withMessageState.mode).isEqualTo(mode) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) + val withEditedMessageState = awaitItem() + assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) + skipItems(1) + val messageSentState = awaitItem() + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") + + advanceUntilIdle() + + assert(timelineEditMessageLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID), value(null), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any()) + + assert(roomEditMessageLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any()) + + assertThat(analyticsService.capturedEvents).containsExactly( + Composer( + inThread = false, + isEditing = true, + isReply = false, + messageType = Composer.MessageType.Text, + ) + ) + } + } + @Test fun `present - edit not sent message`() = runTest { val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 926df6ae21..378ada5b16 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -126,6 +126,8 @@ interface MatrixRoom : Closeable { suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result + suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result + suspend fun sendImage( file: File, thumbnailFile: File?, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt index e3970619cd..ba44de0ba3 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt @@ -18,4 +18,5 @@ package io.element.android.libraries.matrix.api.timeline sealed class TimelineException : Exception() { data object CannotPaginate : TimelineException() + data object EventNotFound : TimelineException() } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 0a09ea005e..b594bba5e4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsMapper import io.element.android.libraries.matrix.impl.timeline.RustTimeline import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType +import io.element.android.libraries.matrix.impl.util.MessageEventContent import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl @@ -324,6 +325,14 @@ class RustMatrixRoom( } } + override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result = withContext(roomDispatcher) { + runCatching { + MessageEventContent.from(body, htmlBody, mentions).use { newContent -> + innerRoom.edit(eventId.value, newContent) + } + } + } + override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result { return liveTimeline.sendMessage(body, htmlBody, mentions) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 00c79bf687..b57efc5603 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -42,7 +42,6 @@ import io.element.android.libraries.matrix.impl.media.toMSC3246range import io.element.android.libraries.matrix.impl.poll.toInner import io.element.android.libraries.matrix.impl.room.RoomContentForwarder import io.element.android.libraries.matrix.impl.room.location.toInner -import io.element.android.libraries.matrix.impl.room.map import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper @@ -51,6 +50,7 @@ import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIn import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper +import io.element.android.libraries.matrix.impl.util.MessageEventContent import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher @@ -70,12 +70,10 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.EventTimelineItem import org.matrix.rustcomponents.sdk.FormattedBody import org.matrix.rustcomponents.sdk.MessageFormat -import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle -import org.matrix.rustcomponents.sdk.messageEventContentFromHtml -import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown import org.matrix.rustcomponents.sdk.use import timber.log.Timber import uniffi.matrix_sdk_ui.LiveBackPaginationStatus @@ -266,7 +264,7 @@ class RustTimeline( } override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result = withContext(dispatcher) { - messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content -> + MessageEventContent.from(body, htmlBody, mentions).use { content -> runCatching { inner.send(content) } @@ -275,20 +273,8 @@ class RustTimeline( override suspend fun redactEvent(eventId: EventId?, transactionId: TransactionId?, reason: String?): Result = withContext(dispatcher) { runCatching { - when { - eventId != null -> { - inner.getEventTimelineItemByEventId(eventId.value).use { - inner.redactEvent(item = it, reason = reason) - } - } - transactionId != null -> { - inner.getEventTimelineItemByTransactionId(transactionId.value).use { - inner.redactEvent(item = it, reason = reason) - } - } - else -> { - error("Either eventId or transactionId must be non-null") - } + getEventTimelineItem(eventId, transactionId).use { item -> + inner.redactEvent(item = item, reason = reason) } } } @@ -302,26 +288,11 @@ class RustTimeline( ): Result = withContext(dispatcher) { runCatching { - when { - originalEventId != null -> { - inner.getEventTimelineItemByEventId(originalEventId.value).use { - inner.edit( - newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), - item = it, - ) - } - } - transactionId != null -> { - inner.getEventTimelineItemByTransactionId(transactionId.value).use { - inner.edit( - newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), - item = it, - ) - } - } - else -> { - error("Either originalEventId or transactionId must be non null") - } + getEventTimelineItem(originalEventId, transactionId).use { item -> + inner.edit( + newContent = MessageEventContent.from(body, htmlBody, mentions), + item = item, + ) } } } @@ -334,7 +305,7 @@ class RustTimeline( fromNotification: Boolean, ): Result = withContext(dispatcher) { runCatching { - val msg = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()) + val msg = MessageEventContent.from(body, htmlBody, mentions) inner.sendReply(msg, eventId.value) } } @@ -361,6 +332,20 @@ class RustTimeline( } } + @Throws + private suspend fun getEventTimelineItem(eventId: EventId?, transactionId: TransactionId?): EventTimelineItem { + return try { + when { + eventId != null -> inner.getEventTimelineItemByEventId(eventId.value) + transactionId != null -> inner.getEventTimelineItemByTransactionId(transactionId.value) + else -> error("Either eventId or transactionId must be non-null") + } + } catch (e: Exception) { + Timber.e(e, "Failed to get event timeline item") + throw TimelineException.EventNotFound + } + } + override suspend fun sendVideo( file: File, thumbnailFile: File?, @@ -517,13 +502,6 @@ class RustTimeline( ) } - private fun messageEventContentFromParts(body: String, htmlBody: String?): RoomMessageEventContentWithoutRelation = - if (htmlBody != null) { - messageEventContentFromHtml(body, htmlBody) - } else { - messageEventContentFromMarkdown(body) - } - private fun sendAttachment(files: List, handle: () -> SendAttachmentJoinHandle): Result { return runCatching { MediaUploadHandlerImpl(files, handle()) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt new file mode 100644 index 0000000000..e1728bb528 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl.util + +import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.impl.room.map +import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation +import org.matrix.rustcomponents.sdk.messageEventContentFromHtml +import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown + +/** + * Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions. + */ +object MessageEventContent { + fun from(body: String, htmlBody: String?, mentions: List): RoomMessageEventContentWithoutRelation { + return if (htmlBody != null) { + messageEventContentFromHtml(body, htmlBody) + } else { + messageEventContentFromMarkdown(body) + }.withMentions(mentions.map()) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 21c50e51a4..cfd0267516 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -212,6 +212,11 @@ class FakeMatrixRoom( return updateUserRoleResult() } + var editMessageLambda: (EventId, String, String?, List) -> Result = { _, _, _, _ -> lambdaError() } + override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result { + return editMessageLambda(eventId, body, htmlBody, mentions) + } + override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List) = simulateLongTask { sendMessageResult(body, htmlBody, mentions) }