Repository: element-hq/element-x-android (language: kotlin)

Issue:
Mentions: @room mention pill
From element-x-ios created by [Velin92](https://github.com/Velin92): vector-im/element-x-ios#1829  

This is a special case, but the string @room should be interpreted as a special mention case and display a pill with probably its own different design.
Also it should display the room avatar
It does not need to have any tapping or link behaviour however


Discussion hints:
nan

Pull request: Integrate mentions in the composer
<!-- Please read [CONTRIBUTING.md](https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
 
## Type of change

- [x] Feature
- [ ] Bugfix
- [ ] Technical
- [ ] Other :

## Content

- Integrates a new version of the RTE library with better support for mentions.
- Sets a callback in the mention suggestion selection view to actually insert the selection.
- Creates `MentionSpanProvider` to provide the RTE composer with the custom `ReplacementSpan` for mentions.

## Motivation and context

Closes #1453 

## Screenshots / GIFs

|Light|Dark|
|-|-|
|<img width="567" alt="image" src="https://github.com/vector-im/element-x-android/assets/480955/c3718506-3db2-43aa-b0ed-4776fe1f0ba2">|<img width="565" alt="image" src="https://github.com/vector-im/element-x-android/assets/480955/d18671cd-e3c7-49ea-a541-3f35c4d76938">|

## Tests

<!-- Explain how you tested your development -->

- Open a room, type `@` in the composer.
- Select something from the list of suggestions.
- A mention pill should be added to the editor and when sending, it'll be recognised as an actual mention.

## Tested devices

- [x] Physical
- [ ] Emulator
- OS version(s): 11

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 23
- [x] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [x] Pull request is based on the develop branch
- [x] Pull request includes a new file under ./changelog.d. See https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md#changelog
- [x] Pull request includes screenshots or videos if containing UI changes
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [x] You've made a self review of your PR


Test command: ./gradlew :libraries:textcomposer:impl:testDebugUnitTest --tests "io.element.android.libraries.textcomposer.impl.mentions.MentionSpanProviderTest" :features:messages:impl:testDebugUnitTest --tests "io.element.android.features.messages.textcomposer.MessageComposerPresenterTest"

Fix patch:
diff --git a/changelog.d/1453.feature b/changelog.d/1453.feature
new file mode 100644
index 00000000000..86cc90ad74f
--- /dev/null
+++ b/changelog.d/1453.feature
@@ -0,0 +1,1 @@
+Add support for typing mentions in the message composer.
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index c9063f2a791..dab910f8ee9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -349,12 +350,12 @@ private fun MessagesViewContent(
 
         // This key is used to force the sheet to be remeasured when the content changes.
         // Any state change that should trigger a height size should be added to the list of remembered values here.
-        val sheetResizeContentKey = remember(
-            state.composerState.mode.relatedEventId,
+        val sheetResizeContentKey = remember { mutableIntStateOf(0) }
+        LaunchedEffect(
             state.composerState.richTextEditorState.lineCount,
-            state.composerState.memberSuggestions.size
+            state.composerState.showTextFormatting,
         ) {
-            Random.nextInt()
+            sheetResizeContentKey.intValue = Random.nextInt()
         }
 
         ExpandableBottomSheetScaffold(
@@ -390,7 +391,7 @@ private fun MessagesViewContent(
                     state = state,
                 )
             },
-            sheetContentKey = sheetResizeContentKey,
+            sheetContentKey = sheetResizeContentKey.intValue,
             sheetTonalElevation = 0.dp,
             sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp,
         )
@@ -406,7 +407,8 @@ private fun MessagesViewComposerBottomSheetContents(
     if (state.userHasPermissionToSendMessage) {
         Column(modifier = modifier.fillMaxWidth()) {
             MentionSuggestionsPickerView(
-                modifier = Modifier.heightIn(max = 230.dp)
+                modifier = Modifier
+                    .heightIn(max = 230.dp)
                     // Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
                     .nestedScroll(object : NestedScrollConnection {
                         override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
@@ -418,7 +420,7 @@ private fun MessagesViewComposerBottomSheetContents(
                 roomAvatarData = state.roomAvatar.dataOrNull(),
                 memberSuggestions = state.composerState.memberSuggestions,
                 onSuggestionSelected = {
-                    // TODO pass the selected suggestion to the RTE so it can be inserted as a pill
+                    state.composerState.eventSink(MessageComposerEvents.InsertMention(it))
                 }
             )
             MessageComposerView(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt
new file mode 100644
index 00000000000..b2977bd5084
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.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.features.messages.impl.mentions
+
+import androidx.compose.runtime.Immutable
+import io.element.android.libraries.matrix.api.room.RoomMember
+
+@Immutable
+sealed interface MentionSuggestion {
+    data object Room : MentionSuggestion
+    data class Member(val roomMember: RoomMember) : MentionSuggestion
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt
index 6b937a872ea..779398b0208 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt
@@ -31,7 +31,6 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.element.android.features.messages.impl.R
-import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion
 import io.element.android.libraries.designsystem.components.avatar.Avatar
 import io.element.android.libraries.designsystem.components.avatar.AvatarData
 import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -52,8 +51,8 @@ fun MentionSuggestionsPickerView(
     roomId: RoomId,
     roomName: String?,
     roomAvatarData: AvatarData?,
-    memberSuggestions: ImmutableList<RoomMemberSuggestion>,
-    onSuggestionSelected: (RoomMemberSuggestion) -> Unit,
+    memberSuggestions: ImmutableList<MentionSuggestion>,
+    onSuggestionSelected: (MentionSuggestion) -> Unit,
     modifier: Modifier = Modifier,
 ) {
     LazyColumn(
@@ -63,8 +62,8 @@ fun MentionSuggestionsPickerView(
             memberSuggestions,
             key = { suggestion ->
                 when (suggestion) {
-                    is RoomMemberSuggestion.Room -> "@room"
-                    is RoomMemberSuggestion.Member -> suggestion.roomMember.userId.value
+                    is MentionSuggestion.Room -> "@room"
+                    is MentionSuggestion.Member -> suggestion.roomMember.userId.value
                 }
             }
         ) {
@@ -85,18 +84,18 @@ fun MentionSuggestionsPickerView(
 
 @Composable
 private fun RoomMemberSuggestionItemView(
-    memberSuggestion: RoomMemberSuggestion,
+    memberSuggestion: MentionSuggestion,
     roomId: String,
     roomName: String?,
     roomAvatar: AvatarData?,
-    onSuggestionSelected: (RoomMemberSuggestion) -> Unit,
+    onSuggestionSelected: (MentionSuggestion) -> Unit,
     modifier: Modifier = Modifier,
 ) {
     Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
         val avatarSize = AvatarSize.TimelineRoom
         val avatarData = when (memberSuggestion) {
-            is RoomMemberSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
-            is RoomMemberSuggestion.Member -> AvatarData(
+            is MentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
+            is MentionSuggestion.Member -> AvatarData(
                 memberSuggestion.roomMember.userId.value,
                 memberSuggestion.roomMember.displayName,
                 memberSuggestion.roomMember.avatarUrl,
@@ -104,13 +103,13 @@ private fun RoomMemberSuggestionItemView(
             )
         }
         val title = when (memberSuggestion) {
-            is RoomMemberSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
-            is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.displayName
+            is MentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
+            is MentionSuggestion.Member -> memberSuggestion.roomMember.displayName
         }
 
         val subtitle = when (memberSuggestion) {
-            is RoomMemberSuggestion.Room -> "@room"
-            is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.userId.value
+            is MentionSuggestion.Room -> "@room"
+            is MentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
         }
 
         Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp))
@@ -159,9 +158,9 @@ internal fun MentionSuggestionsPickerView_Preview() {
             roomName = "Room",
             roomAvatarData = null,
             memberSuggestions = persistentListOf(
-                RoomMemberSuggestion.Room,
-                RoomMemberSuggestion.Member(roomMember),
-                RoomMemberSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
+                MentionSuggestion.Room,
+                MentionSuggestion.Member(roomMember),
+                MentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
             ),
             onSuggestionSelected = {}
         )
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt
index 1f4e2b53768..d2c3e6e518a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt
@@ -16,7 +16,7 @@
 
 package io.element.android.features.messages.impl.mentions
 
-import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion
+import io.element.android.libraries.core.data.filterUpTo
 import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
 import io.element.android.libraries.matrix.api.room.RoomMember
@@ -46,10 +46,8 @@ object MentionSuggestionsProcessor {
         roomMembersState: MatrixRoomMembersState,
         currentUserId: UserId,
         canSendRoomMention: suspend () -> Boolean,
-    ): List<RoomMemberSuggestion> {
+    ): List<MentionSuggestion> {
         val members = roomMembersState.roomMembers()
-            // Take the first MAX_BATCH_ITEMS only
-            ?.take(MAX_BATCH_ITEMS)
         return when {
             members.isNullOrEmpty() || suggestion == null -> {
                 // Clear suggestions
@@ -61,7 +59,7 @@ object MentionSuggestionsProcessor {
                         // Replace suggestions
                         val matchingMembers = getMemberSuggestions(
                             query = suggestion.text,
-                            roomMembers = roomMembersState.roomMembers(),
+                            roomMembers = members,
                             currentUserId = currentUserId,
                             canSendRoomMention = canSendRoomMention()
                         )
@@ -81,7 +79,7 @@ object MentionSuggestionsProcessor {
         roomMembers: List<RoomMember>?,
         currentUserId: UserId,
         canSendRoomMention: Boolean,
-    ): List<RoomMemberSuggestion> {
+    ): List<MentionSuggestion> {
         return if (roomMembers.isNullOrEmpty()) {
             emptyList()
         } else {
@@ -95,14 +93,14 @@ object MentionSuggestionsProcessor {
             }
 
             val matchingMembers = roomMembers
-                // Search only in joined members, exclude the current user
-                .filter { member ->
+                // Search only in joined members, up to MAX_BATCH_ITEMS, exclude the current user
+                .filterUpTo(MAX_BATCH_ITEMS) { member ->
                     isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
                 }
-                .map(RoomMemberSuggestion::Member)
+                .map(MentionSuggestion::Member)
 
             if ("room".contains(query) && canSendRoomMention) {
-                listOf(RoomMemberSuggestion.Room) + matchingMembers
+                listOf(MentionSuggestion.Room) + matchingMembers
             } else {
                 matchingMembers
             }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
index 97c2e7015d0..c8ce20b6b9e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
@@ -17,6 +17,7 @@
 package io.element.android.features.messages.impl.messagecomposer
 
 import androidx.compose.runtime.Immutable
+import io.element.android.features.messages.impl.mentions.MentionSuggestion
 import io.element.android.libraries.textcomposer.model.Message
 import io.element.android.libraries.textcomposer.model.MessageComposerMode
 import io.element.android.libraries.textcomposer.model.Suggestion
@@ -41,4 +42,5 @@ sealed interface MessageComposerEvents {
     data object CancelSendAttachment : MessageComposerEvents
     data class Error(val error: Throwable) : MessageComposerEvents
     data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
+    data class InsertMention(val mention: MentionSuggestion) : MessageComposerEvents
 }
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 c4b631e4df6..3a952efb692 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
@@ -20,7 +20,6 @@ import android.Manifest
 import android.annotation.SuppressLint
 import android.net.Uri
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.getValue
@@ -36,6 +35,7 @@ import im.vector.app.features.analytics.plan.Composer
 import io.element.android.features.messages.impl.attachments.Attachment
 import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
 import io.element.android.features.messages.impl.media.local.LocalMediaFactory
+import io.element.android.features.messages.impl.mentions.MentionSuggestion
 import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
 import io.element.android.libraries.architecture.Presenter
 import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -45,8 +45,8 @@ import io.element.android.libraries.di.SingleIn
 import io.element.android.libraries.featureflag.api.FeatureFlagService
 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.RoomMember
 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
@@ -67,6 +67,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import javax.inject.Inject
@@ -87,7 +89,7 @@ class MessageComposerPresenter @Inject constructor(
     private val messageComposerContext: MessageComposerContextImpl,
     private val richTextEditorStateFactory: RichTextEditorStateFactory,
     private val currentSessionIdHolder: CurrentSessionIdHolder,
-    permissionsPresenterFactory: PermissionsPresenter.Factory
+    permissionsPresenterFactory: PermissionsPresenter.Factory,
 ) : Presenter<MessageComposerState> {
 
     private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
@@ -173,7 +175,7 @@ class MessageComposerPresenter @Inject constructor(
             }
         }
 
-        val memberSuggestions = remember { mutableStateListOf<RoomMemberSuggestion>() }
+        val memberSuggestions = remember { mutableStateListOf<MentionSuggestion>() }
         LaunchedEffect(isMentionsEnabled) {
             if (!isMentionsEnabled) return@LaunchedEffect
             val currentUserId = currentSessionIdHolder.current
@@ -184,8 +186,11 @@ class MessageComposerPresenter @Inject constructor(
                 return !roomIsDm && userCanSendAtRoom
             }
 
-            suggestionSearchTrigger
-                .debounce(0.5.seconds)
+            // This will trigger a search immediately when `@` is typed
+            val mentionStartTrigger = suggestionSearchTrigger.filter { it?.text.isNullOrEmpty() }
+            // This will start a search when the user changes the text after the `@` with a debounce to prevent too much wasted work
+            val mentionCompletionTrigger = suggestionSearchTrigger.filter { !it?.text.isNullOrEmpty() }.debounce(0.3.seconds)
+            merge(mentionStartTrigger, mentionCompletionTrigger)
                 .combine(room.membersStateFlow) { suggestion, roomMembersState ->
                     memberSuggestions.clear()
                     val result = MentionSuggestionsProcessor.process(
@@ -284,6 +289,20 @@ class MessageComposerPresenter @Inject constructor(
                 is MessageComposerEvents.SuggestionReceived -> {
                     suggestionSearchTrigger.value = event.suggestion
                 }
+                is MessageComposerEvents.InsertMention -> {
+                    localCoroutineScope.launch {
+                        when (val mention = event.mention) {
+                            is MentionSuggestion.Room -> {
+                                richTextEditorState.insertAtRoomMentionAtSuggestion()
+                            }
+                            is MentionSuggestion.Member -> {
+                                val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
+                                val link = PermalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
+                                richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
+                            }
+                        }
+                    }
+                }
             }
         }
 
@@ -297,6 +316,7 @@ class MessageComposerPresenter @Inject constructor(
             canCreatePoll = canCreatePoll.value,
             attachmentsState = attachmentsState.value,
             memberSuggestions = memberSuggestions.toPersistentList(),
+            currentUserId = currentSessionIdHolder.current,
             eventSink = { handleEvents(it) }
         )
     }
@@ -410,8 +430,3 @@ class MessageComposerPresenter @Inject constructor(
         }
 }
 
-@Immutable
-sealed interface RoomMemberSuggestion {
-    data object Room : RoomMemberSuggestion
-    data class Member(val roomMember: RoomMember) : RoomMemberSuggestion
-}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
index 6a9d963d181..09eb51477f5 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
@@ -19,6 +19,8 @@ package io.element.android.features.messages.impl.messagecomposer
 import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Stable
 import io.element.android.features.messages.impl.attachments.Attachment
+import io.element.android.features.messages.impl.mentions.MentionSuggestion
+import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.textcomposer.model.MessageComposerMode
 import io.element.android.wysiwyg.compose.RichTextEditorState
 import kotlinx.collections.immutable.ImmutableList
@@ -33,7 +35,8 @@ data class MessageComposerState(
     val canShareLocation: Boolean,
     val canCreatePoll: Boolean,
     val attachmentsState: AttachmentsState,
-    val memberSuggestions: ImmutableList<RoomMemberSuggestion>,
+    val memberSuggestions: ImmutableList<MentionSuggestion>,
+    val currentUserId: UserId,
     val eventSink: (MessageComposerEvents) -> Unit,
 ) {
     val hasFocus: Boolean = richTextEditorState.hasFocus
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
index ac936b2118b..a542cb772d8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
@@ -17,6 +17,8 @@
 package io.element.android.features.messages.impl.messagecomposer
 
 import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.messages.impl.mentions.MentionSuggestion
+import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.textcomposer.model.MessageComposerMode
 import io.element.android.wysiwyg.compose.RichTextEditorState
 import kotlinx.collections.immutable.ImmutableList
@@ -38,7 +40,7 @@ fun aMessageComposerState(
     canShareLocation: Boolean = true,
     canCreatePoll: Boolean = true,
     attachmentsState: AttachmentsState = AttachmentsState.None,
-    memberSuggestions: ImmutableList<RoomMemberSuggestion> = persistentListOf(),
+    memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
 ) = MessageComposerState(
     richTextEditorState = composerState,
     isFullScreen = isFullScreen,
@@ -49,5 +51,6 @@ fun aMessageComposerState(
     canCreatePoll = canCreatePoll,
     attachmentsState = attachmentsState,
     memberSuggestions = memberSuggestions,
+    currentUserId = UserId("@alice:localhost"),
     eventSink = {},
 )
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
index b988d545a6a..28e40a910f1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.height
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.tooling.preview.PreviewParameter
 import androidx.compose.ui.unit.dp
 import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
@@ -32,9 +33,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
 import io.element.android.libraries.designsystem.preview.PreviewsDayNight
 import io.element.android.libraries.textcomposer.TextComposer
 import io.element.android.libraries.textcomposer.model.Message
-import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
 import io.element.android.libraries.textcomposer.model.Suggestion
 import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
+import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
 import kotlinx.coroutines.launch
 
 @Composable
@@ -46,6 +47,7 @@ internal fun MessageComposerView(
     enableVoiceMessages: Boolean,
     modifier: Modifier = Modifier,
 ) {
+    val view = LocalView.current
     fun sendMessage(message: Message) {
         state.eventSink(MessageComposerEvents.SendMessage(message))
     }
@@ -59,6 +61,7 @@ internal fun MessageComposerView(
     }
 
     fun onDismissTextFormatting() {
+        view.clearFocus()
         state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = false))
     }
 
@@ -113,6 +116,7 @@ internal fun MessageComposerView(
         onDeleteVoiceMessage = onDeleteVoiceMessage,
         onSuggestionReceived = ::onSuggestionReceived,
         onError = ::onError,
+        currentUserId = state.currentUserId,
     )
 }
 
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/FilterUpTo.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/FilterUpTo.kt
new file mode 100644
index 00000000000..d3e2cc3e786
--- /dev/null
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/FilterUpTo.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.core.data
+
+/**
+ * Returns a list containing first [count] elements matching the given [predicate].
+ * If the list contains less elements matching the [predicate], then all of them are returned.
+ *
+ * @param T the type of elements contained in the list.
+ * @param count the maximum number of elements to take.
+ * @param predicate the predicate used to match elements.
+ * @return a list containing first [count] elements matching the given [predicate].
+ */
+inline fun <T> Iterable<T>.filterUpTo(count: Int, predicate: (T) -> Boolean): List<T> {
+    val result = mutableListOf<T>()
+    for (element in this) {
+        if (predicate(element)) {
+            result.add(element)
+            if (result.size == count) {
+                break
+            }
+        }
+    }
+    return result
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
index e85abb396b0..c860239d6aa 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
@@ -108,6 +108,36 @@ val SemanticColors.pinDigitBg
         Color(0xFF26282D)
     }
 
+val SemanticColors.currentUserMentionPillText
+    get() =  if (isLight) {
+        // We want LightDesignTokens.colorGreen1100
+        Color(0xff005c45)
+    } else {
+        // We want DarkDesignTokens.colorGreen1100
+        Color(0xff1fc090)
+    }
+
+val SemanticColors.currentUserMentionPillBackground
+    get() =  if (isLight) {
+        // We want LightDesignTokens.colorGreenAlpha400
+        Color(0x3b07b661)
+    } else {
+        // We want DarkDesignTokens.colorGreenAlpha500
+        Color(0xff003d29)
+    }
+
+val SemanticColors.mentionPillText
+    get() = textPrimary
+
+val SemanticColors.mentionPillBackground
+    get() =  if (isLight) {
+        // We want LightDesignTokens.colorGray400
+        Color(0x1f052e61)
+    } else {
+        // We want DarkDesignTokens.colorGray500
+        Color(0x26f4f7fa)
+    }
+
 @PreviewsDayNight
 @Composable
 internal fun ColorAliasesPreview() = ElementPreview {
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt
index ba9cdc1e80f..4a89d05276a 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt
@@ -133,13 +133,15 @@ object PermalinkParser {
         }
 
     private fun String.getViaParameters(): List<String> {
-        return UrlQuerySanitizer(this)
-            .parameterList
-            .filter {
-                it.mParameter == "via"
-            }
-            .map {
-                URLDecoder.decode(it.mValue, "UTF-8")
-            }
+        return runCatching {
+            UrlQuerySanitizer(this)
+                .parameterList
+                .filter {
+                    it.mParameter == "via"
+                }
+                .map {
+                    URLDecoder.decode(it.mValue, "UTF-8")
+                }
+        }.getOrDefault(emptyList())
     }
 }
diff --git a/libraries/textcomposer/impl/build.gradle.kts b/libraries/textcomposer/impl/build.gradle.kts
index db97a967870..99f910d317d 100644
--- a/libraries/textcomposer/impl/build.gradle.kts
+++ b/libraries/textcomposer/impl/build.gradle.kts
@@ -42,4 +42,7 @@ dependencies {
     testImplementation(libs.test.junit)
     testImplementation(libs.test.truth)
     testImplementation(libs.coroutines.test)
+    testImplementation(libs.test.robolectric)
+    testImplementation(projects.libraries.matrix.test)
+    testImplementation(projects.tests.testutils)
 }
diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
index 93e552768a9..75dbc6c1c00 100644
--- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
+++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
@@ -44,6 +44,7 @@ import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
@@ -57,6 +58,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
 import io.element.android.libraries.designsystem.utils.CommonDrawables
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.TransactionId
+import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.matrix.api.media.MediaSource
 import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
 import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
@@ -73,6 +75,7 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteBu
 import io.element.android.libraries.textcomposer.components.VoiceMessagePreview
 import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
 import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
+import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
 import io.element.android.libraries.textcomposer.model.Message
 import io.element.android.libraries.textcomposer.model.MessageComposerMode
 import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
@@ -81,8 +84,10 @@ import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
 import io.element.android.libraries.textcomposer.model.VoiceMessageState
 import io.element.android.libraries.theme.ElementTheme
 import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.wysiwyg.compose.PillStyle
 import io.element.android.wysiwyg.compose.RichTextEditor
 import io.element.android.wysiwyg.compose.RichTextEditorState
+import io.element.android.wysiwyg.display.TextDisplay
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 import uniffi.wysiwyg_composer.MenuAction
@@ -95,6 +100,7 @@ fun TextComposer(
     composerMode: MessageComposerMode,
     enableTextFormatting: Boolean,
     enableVoiceMessages: Boolean,
+    currentUserId: UserId,
     modifier: Modifier = Modifier,
     showTextFormatting: Boolean = false,
     subcomposing: Boolean = false,
@@ -143,6 +149,7 @@ fun TextComposer(
 
     val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) {
         @Composable {
+            val mentionSpanProvider = rememberMentionSpanProvider(currentUserId)
             TextInput(
                 state = state,
                 subcomposing = subcomposing,
@@ -153,6 +160,8 @@ fun TextComposer(
                 },
                 composerMode = composerMode,
                 onResetComposerMode = onResetComposerMode,
+                resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) },
+                resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) },
                 onError = onError,
             )
         }
@@ -385,6 +394,8 @@ private fun TextInput(
     placeholder: String,
     composerMode: MessageComposerMode,
     onResetComposerMode: () -> Unit,
+    resolveRoomMentionDisplay: () -> TextDisplay,
+    resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
     modifier: Modifier = Modifier,
     onError: (Throwable) -> Unit = {},
 ) {
@@ -432,7 +443,11 @@ private fun TextInput(
                     .fillMaxWidth(),
                 style = ElementRichTextEditorStyle.create(
                     hasFocus = state.hasFocus
+                ).copy(
+                    pill = PillStyle(Color.Red)
                 ),
+                resolveMentionDisplay = resolveMentionDisplay,
+                resolveRoomMentionDisplay = resolveRoomMentionDisplay,
                 onError = onError
             )
         }
@@ -584,6 +599,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
                 onResetComposerMode = {},
                 enableTextFormatting = true,
                 enableVoiceMessages = true,
+                currentUserId = UserId("@alice:localhost"),
             )
         }, {
         TextComposer(
@@ -594,6 +610,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
             onResetComposerMode = {},
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     }, {
         TextComposer(
@@ -607,6 +624,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
             onResetComposerMode = {},
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     }, {
         TextComposer(
@@ -617,6 +635,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
             onResetComposerMode = {},
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     })
     )
@@ -633,6 +652,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
             composerMode = MessageComposerMode.Normal,
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     }, {
         TextComposer(
@@ -642,6 +662,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
             composerMode = MessageComposerMode.Normal,
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     }, {
         TextComposer(
@@ -651,6 +672,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
             composerMode = MessageComposerMode.Normal,
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     }))
 }
@@ -667,6 +689,7 @@ internal fun TextComposerEditPreview() = ElementPreview {
             onResetComposerMode = {},
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     }))
 }
@@ -691,6 +714,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
             onResetComposerMode = {},
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     },
         {
@@ -710,6 +734,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
                 onResetComposerMode = {},
                 enableTextFormatting = true,
                 enableVoiceMessages = true,
+                currentUserId = UserId("@alice:localhost")
             )
         }, {
         TextComposer(
@@ -731,6 +756,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
             onResetComposerMode = {},
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     }, {
         TextComposer(
@@ -752,6 +778,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
             onResetComposerMode = {},
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     }, {
         TextComposer(
@@ -773,6 +800,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
             onResetComposerMode = {},
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     }, {
         TextComposer(
@@ -794,6 +822,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
             onResetComposerMode = {},
             enableTextFormatting = true,
             enableVoiceMessages = true,
+            currentUserId = UserId("@alice:localhost")
         )
     })
     )
@@ -813,6 +842,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
         onResetComposerMode = {},
         enableTextFormatting = true,
         enableVoiceMessages = true,
+        currentUserId = UserId("@alice:localhost")
     )
     PreviewColumn(items = persistentListOf({
         VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, createFakeWaveform()))
diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt
new file mode 100644
index 00000000000..609a050abf2
--- /dev/null
+++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.textcomposer.mentions
+
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RectF
+import android.text.style.ReplacementSpan
+import kotlin.math.roundToInt
+
+class MentionSpan(
+    val backgroundColor: Int,
+    val textColor: Int,
+) : ReplacementSpan() {
+
+    override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
+        return paint.measureText(text, start, end).roundToInt() + 40
+    }
+
+    override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
+        val textSize = paint.measureText(text, start, end)
+        val rect = RectF(x, top.toFloat(), x + textSize + 40, bottom.toFloat())
+        paint.color = backgroundColor
+        canvas.drawRoundRect(rect, rect.height() / 2, rect.height() / 2, paint)
+        paint.color = textColor
+        canvas.drawText(text!!, start, end, x + 20, y.toFloat(), paint)
+    }
+}
diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt
new file mode 100644
index 00000000000..77674e12547
--- /dev/null
+++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt
@@ -0,0 +1,116 @@
+/*
+ * 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.textcomposer.mentions
+
+import android.graphics.Color
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.text.buildSpannedString
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.currentUserMentionPillBackground
+import io.element.android.libraries.designsystem.theme.currentUserMentionPillText
+import io.element.android.libraries.designsystem.theme.mentionPillBackground
+import io.element.android.libraries.designsystem.theme.mentionPillText
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.theme.ElementTheme
+
+@Stable
+class MentionSpanProvider(
+    private val currentSessionId: SessionId,
+    private var currentUserTextColor: Int = 0,
+    private var currentUserBackgroundColor: Int = Color.WHITE,
+    private var otherTextColor: Int = 0,
+    private var otherBackgroundColor: Int = Color.WHITE,
+) {
+
+    @Suppress("ComposableNaming")
+    @Composable
+    internal fun setup() {
+        currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb()
+        currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb()
+        otherTextColor = ElementTheme.colors.mentionPillText.toArgb()
+        otherBackgroundColor = ElementTheme.colors.mentionPillBackground.toArgb()
+    }
+
+    fun getMentionSpanFor(text: String, url: String): MentionSpan {
+        val permalinkData = PermalinkParser.parse(url)
+        return when {
+            permalinkData is PermalinkData.UserLink -> {
+                val isCurrentUser = permalinkData.userId == currentSessionId.value
+                MentionSpan(
+                    backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
+                    textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
+                )
+            }
+            text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
+                MentionSpan(
+                    backgroundColor = otherBackgroundColor,
+                    textColor = otherTextColor,
+                )
+            }
+            else -> {
+                MentionSpan(
+                    backgroundColor = otherBackgroundColor,
+                    textColor = otherTextColor,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+fun rememberMentionSpanProvider(currentUserId: SessionId): MentionSpanProvider {
+    val provider = remember(currentUserId) {
+        MentionSpanProvider(currentUserId)
+    }
+    provider.setup()
+    return provider
+}
+
+@PreviewsDayNight
+@Composable
+internal fun MentionSpanPreview() {
+    val provider = rememberMentionSpanProvider(SessionId("@me:matrix.org"))
+    ElementPreview {
+        provider.setup()
+
+        val textColor = ElementTheme.colors.textPrimary.toArgb()
+        val mentionSpan = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org")
+        val mentionSpan2 = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
+        AndroidView(factory = { context ->
+            TextView(context).apply {
+                layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+                text = buildSpannedString {
+                    append("This is a ")
+                    append("@mention", mentionSpan, 0)
+                    append(" to the current user and this is a ")
+                    append("@mention", mentionSpan2, 0)
+                    append(" to other user")
+                }
+                setTextColor(textColor)
+            }
+        })
+    }
+}


Test patch:
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 f05c209085f..ef587925524 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
@@ -31,7 +31,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
 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.messagecomposer.RoomMemberSuggestion
+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
@@ -754,19 +754,19 @@ class MessageComposerPresenterTest {
             // An empty suggestion returns the room and joined members that are not the current user
             initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
             assertThat(awaitItem().memberSuggestions)
-                .containsExactly(RoomMemberSuggestion.Room, RoomMemberSuggestion.Member(bob), RoomMemberSuggestion.Member(david))
+                .containsExactly(MentionSuggestion.Room, MentionSuggestion.Member(bob), MentionSuggestion.Member(david))
 
             // A suggestion containing a part of "room" will also return the room mention
             initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo")))
-            assertThat(awaitItem().memberSuggestions).containsExactly(RoomMemberSuggestion.Room)
+            assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Room)
 
             // A non-empty suggestion will return those joined members whose user id matches it
             initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob")))
-            assertThat(awaitItem().memberSuggestions).containsExactly(RoomMemberSuggestion.Member(bob))
+            assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(bob))
 
             // A non-empty suggestion will return those joined members whose display name matches it
             initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave")))
-            assertThat(awaitItem().memberSuggestions).containsExactly(RoomMemberSuggestion.Member(david))
+            assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(david))
 
             // If the suggestion isn't a mention, no suggestions are returned
             initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
@@ -776,7 +776,7 @@ class MessageComposerPresenterTest {
             room.givenCanTriggerRoomNotification(Result.success(false))
             initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
             assertThat(awaitItem().memberSuggestions)
-                .containsExactly(RoomMemberSuggestion.Member(bob), RoomMemberSuggestion.Member(david))
+                .containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david))
 
             // If room is a DM, `RoomMemberSuggestion.Room` is not returned
             room.givenCanTriggerRoomNotification(Result.success(true))
@@ -813,8 +813,25 @@ class MessageComposerPresenterTest {
 
             // An empty suggestion returns the joined members that are not the current user, but not the room
             initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
+            skipItems(1)
             assertThat(awaitItem().memberSuggestions)
-                .containsExactly(RoomMemberSuggestion.Member(bob), RoomMemberSuggestion.Member(david))
+                .containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david))
+        }
+    }
+
+    @Test
+    fun `present - insertMention`() = runTest {
+        val presenter = createPresenter(this)
+        moleculeFlow(RecompositionMode.Immediate) {
+            presenter.present()
+        }.test {
+            skipItems(1)
+            val initialState = awaitItem()
+            initialState.richTextEditorState.setHtml("Hey @bo")
+            initialState.eventSink(MessageComposerEvents.InsertMention(MentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2))))
+
+            assertThat(initialState.richTextEditorState.messageHtml)
+                .isEqualTo("Hey <a href='https://matrix.to/#/${A_USER_ID_2.value}'>${A_USER_ID_2.value}</a>")
         }
     }
 
diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt
new file mode 100644
index 00000000000..748a06bb2c6
--- /dev/null
+++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.textcomposer.impl.mentions
+
+import android.graphics.Color
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
+import io.element.android.tests.testutils.WarmUpRule
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class MentionSpanProviderTest {
+
+    @JvmField @Rule
+    val warmUpRule = WarmUpRule()
+
+    private val myUserColor = Color.RED
+    private val otherColor = Color.BLUE
+    private val currentUserId = A_SESSION_ID
+
+    private val mentionSpanProvider = MentionSpanProvider(
+        currentSessionId = currentUserId,
+        currentUserBackgroundColor = myUserColor,
+        currentUserTextColor = myUserColor,
+        otherBackgroundColor = otherColor,
+        otherTextColor = otherColor,
+    )
+
+    @Test
+    fun `getting mention span for current user should return a MentionSpan with custom colors`() {
+        val mentionSpan = mentionSpanProvider.getMentionSpanFor("me", "https://matrix.to/#/${currentUserId.value}")
+        assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor)
+        assertThat(mentionSpan.textColor).isEqualTo(myUserColor)
+    }
+
+    @Test
+    fun `getting mention span for other user should return a MentionSpan with normal colors`() {
+        val mentionSpan = mentionSpanProvider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
+        assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
+        assertThat(mentionSpan.textColor).isEqualTo(otherColor)
+    }
+
+    @Test
+    fun `getting mention span for @room should return a MentionSpan with normal colors`() {
+        val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
+        assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
+        assertThat(mentionSpan.textColor).isEqualTo(otherColor)
+    }
+}
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Day-18_18_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Day-18_18_null,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..87bfdadf602
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Day-18_18_null,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a70e858c4935324773d885cdc80268550af9ded8bfa2c7e5e6b916f2d4b85f4b
+size 18110
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Night-18_19_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Night-18_19_null,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..2b93b209178
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Night-18_19_null,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3c0a05c8f8245af9d46da170ce3062f20c10b77f7b64be1432747a14b98c5c0e
+size 16198