Add intentional mentions (#1843)

* Add intentional mentions
This commit is contained in:
Jorge Martin Espinosa 2023-11-21 17:34:00 +01:00 committed by GitHub
parent 80f833111c
commit a52d78c3b3
9 changed files with 159 additions and 16 deletions

1
changelog.d/1591.feature Normal file
View file

@ -0,0 +1 @@
Add intentional mentions to messages.

View file

@ -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.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
@ -327,15 +328,25 @@ class MessageComposerPresenter @Inject constructor(
richTextEditorState: RichTextEditorState, richTextEditorState: RichTextEditorState,
) = launch { ) = launch {
val capturedMode = messageComposerContext.composerMode 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 // Reset composer right away
richTextEditorState.setHtml("") richTextEditorState.setHtml("")
updateComposerMode(MessageComposerMode.Normal) updateComposerMode(MessageComposerMode.Normal)
when (capturedMode) { 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 -> { is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId 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() is MessageComposerMode.Quote -> TODO()
@ -343,6 +354,7 @@ class MessageComposerPresenter @Inject constructor(
capturedMode.eventId, capturedMode.eventId,
message.markdown, message.markdown,
message.html, message.html,
mentions
) )
} }
analyticsService.capture( analyticsService.capture(

View file

@ -26,12 +26,12 @@ import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer 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.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl 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.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter 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.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher 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.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.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.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE 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 io.mockk.mockk
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.internal.immutableListOf import okhttp3.internal.immutableListOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import uniffi.wysiwyg_composer.MentionsState
import java.io.File import java.io.File
@Suppress("LargeClass") @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<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0): MessageComposerState { private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0): MessageComposerState {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode) state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount) skipItems(skipCount)

View file

@ -90,13 +90,13 @@ interface MatrixRoom : Closeable {
suspend fun userAvatarUrl(userId: UserId): Result<String?> suspend fun userAvatarUrl(userId: UserId): Result<String?>
suspend fun sendMessage(body: String, htmlBody: String?): Result<Unit> suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result<Unit> suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun enterSpecialMode(eventId: EventId?): Result<Unit> suspend fun enterSpecialMode(eventId: EventId?): Result<Unit>
suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result<Unit> suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit> suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>

View file

@ -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
}

View file

@ -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<Mention>.map(): Mentions {
val hasAtRoom = any { it is Mention.AtRoom }
val userIds = filterIsInstance<Mention.User>().map { it.userId }
return Mentions(userIds, hasAtRoom)
}

View file

@ -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.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState 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.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.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType 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<Unit> = withContext(roomDispatcher) { override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(roomDispatcher) {
messageEventContentFromParts(body, htmlBody).use { content -> messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content ->
runCatching { runCatching {
innerRoom.send(content) innerRoom.send(content)
} }
} }
} }
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result<Unit> = override suspend fun editMessage(
originalEventId: EventId?,
transactionId: TransactionId?,
body: String,
htmlBody: String?,
mentions: List<Mention>,
): Result<Unit> =
withContext(roomDispatcher) { withContext(roomDispatcher) {
if (originalEventId != null) { if (originalEventId != null) {
runCatching { runCatching {
val editedEvent = specialModeEventTimelineItem ?: innerRoom.getEventTimelineItemByEventId(originalEventId.value) val editedEvent = specialModeEventTimelineItem ?: innerRoom.getEventTimelineItemByEventId(originalEventId.value)
editedEvent.use { editedEvent.use {
innerRoom.edit( innerRoom.edit(
newContent = messageEventContentFromParts(body, htmlBody), newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()),
editItem = it, editItem = it,
) )
} }
@ -289,11 +296,11 @@ class RustMatrixRoom(
} }
} }
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result<Unit> = withContext(roomDispatcher) { override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(roomDispatcher) {
runCatching { runCatching {
val inReplyTo = specialModeEventTimelineItem ?: innerRoom.getEventTimelineItemByEventId(eventId.value) val inReplyTo = specialModeEventTimelineItem ?: innerRoom.getEventTimelineItemByEventId(eventId.value)
inReplyTo.use { eventTimelineItem -> inReplyTo.use { eventTimelineItem ->
innerRoom.sendReply(messageEventContentFromParts(body, htmlBody), eventTimelineItem) innerRoom.sendReply(messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), eventTimelineItem)
} }
specialModeEventTimelineItem = null specialModeEventTimelineItem = null
} }

View file

@ -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.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState 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.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.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode 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 generateWidgetWebViewUrlResult = Result.success("https://call.element.io")
private var getWidgetDriverResult: Result<MatrixWidgetDriver> = Result.success(FakeWidgetDriver()) private var getWidgetDriverResult: Result<MatrixWidgetDriver> = Result.success(FakeWidgetDriver())
private var canUserTriggerRoomNotificationResult: Result<Boolean> = Result.success(true) private var canUserTriggerRoomNotificationResult: Result<Boolean> = Result.success(true)
var sendMessageMentions = emptyList<Mention>()
val editMessageCalls = mutableListOf<Pair<String, String?>>() val editMessageCalls = mutableListOf<Pair<String, String?>>()
var sendMediaCount = 0 var sendMediaCount = 0
@ -190,7 +192,8 @@ class FakeMatrixRoom(
userAvatarUrlResult userAvatarUrlResult
} }
override suspend fun sendMessage(body: String, htmlBody: String?) = simulateLongTask { override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>) = simulateLongTask {
sendMessageMentions = mentions
Result.success(Unit) Result.success(Unit)
} }
@ -219,7 +222,14 @@ class FakeMatrixRoom(
return cancelSendResult return cancelSendResult
} }
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result<Unit> { override suspend fun editMessage(
originalEventId: EventId?,
transactionId: TransactionId?,
body: String,
htmlBody: String?,
mentions: List<Mention>
): Result<Unit> {
sendMessageMentions = mentions
editMessageCalls += body to htmlBody editMessageCalls += body to htmlBody
return Result.success(Unit) return Result.success(Unit)
} }
@ -231,7 +241,8 @@ class FakeMatrixRoom(
return Result.success(Unit) return Result.success(Unit)
} }
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result<Unit> { override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> {
sendMessageMentions = mentions
replyMessageParameter = body to htmlBody replyMessageParameter = body to htmlBody
return Result.success(Unit) return Result.success(Unit)
} }

View file

@ -34,7 +34,7 @@ dependencies {
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils) implementation(projects.libraries.uiUtils)
implementation(libs.matrix.richtexteditor) api(libs.matrix.richtexteditor)
api(libs.matrix.richtexteditor.compose) api(libs.matrix.richtexteditor.compose)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)