From a52d78c3b3ca1323ddfeb6ed8b4028099cb34bb0 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 21 Nov 2023 17:34:00 +0100 Subject: [PATCH] Add intentional mentions (#1843) * Add intentional mentions --- changelog.d/1591.feature | 1 + .../MessageComposerPresenter.kt | 16 ++++- .../MessageComposerPresenterTest.kt | 66 ++++++++++++++++++- .../libraries/matrix/api/room/MatrixRoom.kt | 6 +- .../libraries/matrix/api/room/Mention.kt | 22 +++++++ .../libraries/matrix/impl/room/Mention.kt | 26 ++++++++ .../matrix/impl/room/RustMatrixRoom.kt | 19 ++++-- .../matrix/test/room/FakeMatrixRoom.kt | 17 ++++- libraries/textcomposer/impl/build.gradle.kts | 2 +- 9 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 changelog.d/1591.feature create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/Mention.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt diff --git a/changelog.d/1591.feature b/changelog.d/1591.feature new file mode 100644 index 0000000000..54f1402cf8 --- /dev/null +++ b/changelog.d/1591.feature @@ -0,0 +1 @@ +Add intentional mentions to messages. 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 3a952efb69..dd2537ff18 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 @@ -47,6 +47,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaSender @@ -327,15 +328,25 @@ class MessageComposerPresenter @Inject constructor( richTextEditorState: RichTextEditorState, ) = launch { val capturedMode = messageComposerContext.composerMode + val mentions = richTextEditorState.mentionsState?.let { state -> + buildList { + if (state.hasAtRoomMention) { + add(Mention.AtRoom) + } + for (userId in state.userIds) { + add(Mention.User(userId)) + } + } + }.orEmpty() // Reset composer right away richTextEditorState.setHtml("") updateComposerMode(MessageComposerMode.Normal) when (capturedMode) { - is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html) + is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html, mentions = mentions) is MessageComposerMode.Edit -> { val eventId = capturedMode.eventId val transactionId = capturedMode.transactionId - room.editMessage(eventId, transactionId, message.markdown, message.html) + room.editMessage(eventId, transactionId, message.markdown, message.html, mentions) } is MessageComposerMode.Quote -> TODO() @@ -343,6 +354,7 @@ class MessageComposerPresenter @Inject constructor( capturedMode.eventId, message.markdown, message.html, + mentions ) } analyticsService.capture( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index ef58792552..a48f55d120 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -26,12 +26,12 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerState -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +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.user.CurrentSessionIdHolder import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE @@ -79,10 +80,12 @@ import io.element.android.tests.testutils.waitForPredicate import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okhttp3.internal.immutableListOf import org.junit.Rule import org.junit.Test +import uniffi.wysiwyg_composer.MentionsState import java.io.File @Suppress("LargeClass") @@ -835,6 +838,67 @@ class MessageComposerPresenterTest { } } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - send messages with intentional mentions`() = runTest { + val room = FakeMatrixRoom() + val presenter = createPresenter(room = room, coroutineScope = this) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + + // Check intentional mentions on message sent + val mentionUser1 = listOf(A_USER_ID.value) + initialState.richTextEditorState.mentionsState = MentionsState( + userIds = mentionUser1, + roomIds = emptyList(), + roomAliases = emptyList(), + hasAtRoomMention = false + ) + initialState.richTextEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + + advanceUntilIdle() + + assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID.value))) + + // Check intentional mentions on reply sent + initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode())) + val mentionUser2 = listOf(A_USER_ID_2.value) + awaitItem().richTextEditorState.mentionsState = MentionsState( + userIds = mentionUser2, + roomIds = emptyList(), + roomAliases = emptyList(), + hasAtRoomMention = false + ) + + initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + advanceUntilIdle() + + assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_2.value))) + + // Check intentional mentions on edit message + skipItems(1) + initialState.eventSink(MessageComposerEvents.SetMode(anEditMode())) + val mentionUser3 = listOf(A_USER_ID_3.value) + awaitItem().richTextEditorState.mentionsState = MentionsState( + userIds = mentionUser3, + roomIds = emptyList(), + roomAliases = emptyList(), + hasAtRoomMention = false + ) + + initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + advanceUntilIdle() + + assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_3.value))) + + skipItems(1) + } + } + private suspend fun ReceiveTurbine.backToNormalMode(state: MessageComposerState, skipCount: Int = 0): MessageComposerState { state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode) skipItems(skipCount) 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 1f2de7720f..e9ad35dafa 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 @@ -90,13 +90,13 @@ interface MatrixRoom : Closeable { suspend fun userAvatarUrl(userId: UserId): Result - suspend fun sendMessage(body: String, htmlBody: String?): Result + suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result - suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result + suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?, mentions: List): Result suspend fun enterSpecialMode(eventId: EventId?): Result - suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result + suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result suspend fun redactEvent(eventId: EventId, reason: String? = null): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/Mention.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/Mention.kt new file mode 100644 index 0000000000..30aba3c3c0 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/Mention.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 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 + * + * http://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.api.room + +sealed interface Mention { + data class User(val userId: String): Mention + data object AtRoom: Mention +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt new file mode 100644 index 0000000000..463496ffe7 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 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 + * + * http://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.room + +import io.element.android.libraries.matrix.api.room.Mention +import org.matrix.rustcomponents.sdk.Mentions + +fun List.map(): Mentions { + val hasAtRoom = any { it is Mention.AtRoom } + val userIds = filterIsInstance().map { it.userId } + return Mentions(userIds, hasAtRoom) +} 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 ab7317590e..35a61c1a1d 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 @@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState +import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.location.AssetType @@ -250,22 +251,28 @@ class RustMatrixRoom( } } - override suspend fun sendMessage(body: String, htmlBody: String?): Result = withContext(roomDispatcher) { - messageEventContentFromParts(body, htmlBody).use { content -> + override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result = withContext(roomDispatcher) { + messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content -> runCatching { innerRoom.send(content) } } } - override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result = + override suspend fun editMessage( + originalEventId: EventId?, + transactionId: TransactionId?, + body: String, + htmlBody: String?, + mentions: List, + ): Result = withContext(roomDispatcher) { if (originalEventId != null) { runCatching { val editedEvent = specialModeEventTimelineItem ?: innerRoom.getEventTimelineItemByEventId(originalEventId.value) editedEvent.use { innerRoom.edit( - newContent = messageEventContentFromParts(body, htmlBody), + newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), editItem = it, ) } @@ -289,11 +296,11 @@ class RustMatrixRoom( } } - override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result = withContext(roomDispatcher) { + override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result = withContext(roomDispatcher) { runCatching { val inReplyTo = specialModeEventTimelineItem ?: innerRoom.getEventTimelineItemByEventId(eventId.value) inReplyTo.use { eventTimelineItem -> - innerRoom.sendReply(messageEventContentFromParts(body, htmlBody), eventTimelineItem) + innerRoom.sendReply(messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), eventTimelineItem) } specialModeEventTimelineItem = null } 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 244a769895..a041263e2c 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 @@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState +import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomNotificationMode @@ -108,6 +109,7 @@ class FakeMatrixRoom( private var generateWidgetWebViewUrlResult = Result.success("https://call.element.io") private var getWidgetDriverResult: Result = Result.success(FakeWidgetDriver()) private var canUserTriggerRoomNotificationResult: Result = Result.success(true) + var sendMessageMentions = emptyList() val editMessageCalls = mutableListOf>() var sendMediaCount = 0 @@ -190,7 +192,8 @@ class FakeMatrixRoom( userAvatarUrlResult } - override suspend fun sendMessage(body: String, htmlBody: String?) = simulateLongTask { + override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List) = simulateLongTask { + sendMessageMentions = mentions Result.success(Unit) } @@ -219,7 +222,14 @@ class FakeMatrixRoom( return cancelSendResult } - override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result { + override suspend fun editMessage( + originalEventId: EventId?, + transactionId: TransactionId?, + body: String, + htmlBody: String?, + mentions: List + ): Result { + sendMessageMentions = mentions editMessageCalls += body to htmlBody return Result.success(Unit) } @@ -231,7 +241,8 @@ class FakeMatrixRoom( return Result.success(Unit) } - override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result { + override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result { + sendMessageMentions = mentions replyMessageParameter = body to htmlBody return Result.success(Unit) } diff --git a/libraries/textcomposer/impl/build.gradle.kts b/libraries/textcomposer/impl/build.gradle.kts index 99f910d317..6cce2b855d 100644 --- a/libraries/textcomposer/impl/build.gradle.kts +++ b/libraries/textcomposer/impl/build.gradle.kts @@ -34,7 +34,7 @@ dependencies { implementation(projects.libraries.testtags) implementation(projects.libraries.uiUtils) - implementation(libs.matrix.richtexteditor) + api(libs.matrix.richtexteditor) api(libs.matrix.richtexteditor.compose) ksp(libs.showkase.processor)